11523 lines
441 KiB
HTML
11523 lines
441 KiB
HTML
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="theme-color" content="#101419">
|
||
<title>Nostr推荐</title>
|
||
<script>
|
||
window.WEBHOME_PAGE_OPENED_AT = Date.now();
|
||
if (window.fongmiBridge) document.documentElement.classList.add("fm-native");
|
||
(function () {
|
||
var protoList = [
|
||
window.Element && Element.prototype,
|
||
window.Document && Document.prototype,
|
||
window.DocumentFragment && DocumentFragment.prototype
|
||
];
|
||
for (var p = 0; p < protoList.length; p++) {
|
||
var proto = protoList[p];
|
||
if (!proto) continue;
|
||
if (proto.replaceChildren) continue;
|
||
proto.replaceChildren = function () {
|
||
while (this.firstChild) this.removeChild(this.firstChild);
|
||
for (var i = 0; i < arguments.length; i++) {
|
||
var child = arguments[i];
|
||
this.appendChild(child && child.nodeType ? child : document.createTextNode(String(child)));
|
||
}
|
||
};
|
||
}
|
||
if (window.Element && !Element.prototype.matches) {
|
||
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
|
||
}
|
||
if (window.Element && !Element.prototype.closest) {
|
||
Element.prototype.closest = function (selector) {
|
||
var el = this;
|
||
while (el && el.nodeType === 1) {
|
||
if (el.matches && el.matches(selector)) return el;
|
||
el = el.parentElement || el.parentNode;
|
||
}
|
||
return null;
|
||
};
|
||
}
|
||
if (!Object.values) {
|
||
Object.values = function (obj) {
|
||
return Object.keys(obj || {}).map(function (key) { return obj[key]; });
|
||
};
|
||
}
|
||
if (!Object.entries) {
|
||
Object.entries = function (obj) {
|
||
return Object.keys(obj || {}).map(function (key) { return [key, obj[key]]; });
|
||
};
|
||
}
|
||
if (!Array.from) {
|
||
Array.from = function (value, mapFn, thisArg) {
|
||
var result = [];
|
||
if (value == null) return result;
|
||
var iterator = typeof Symbol !== "undefined" && value[Symbol.iterator];
|
||
var i = 0;
|
||
if (typeof iterator === "function") {
|
||
var step;
|
||
var iter = iterator.call(value);
|
||
while (!(step = iter.next()).done) {
|
||
result.push(mapFn ? mapFn.call(thisArg, step.value, i++) : step.value);
|
||
}
|
||
return result;
|
||
}
|
||
var length = Number(value.length) || 0;
|
||
for (; i < length; i++) {
|
||
result.push(mapFn ? mapFn.call(thisArg, value[i], i) : value[i]);
|
||
}
|
||
return result;
|
||
};
|
||
}
|
||
if (window.NodeList && !NodeList.prototype.forEach) {
|
||
NodeList.prototype.forEach = Array.prototype.forEach;
|
||
}
|
||
if (window.HTMLCollection && !HTMLCollection.prototype.forEach) {
|
||
HTMLCollection.prototype.forEach = Array.prototype.forEach;
|
||
}
|
||
if (!Array.prototype.includes) {
|
||
Array.prototype.includes = function (value, fromIndex) {
|
||
return this.indexOf(value, fromIndex || 0) !== -1;
|
||
};
|
||
}
|
||
if (!Array.prototype.flat) {
|
||
Array.prototype.flat = function (depth) {
|
||
var maxDepth = depth == null ? 1 : Number(depth) || 0;
|
||
var flatten = function (items, level) {
|
||
var acc = [];
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
if (Array.isArray(item) && level < maxDepth) {
|
||
var nested = flatten(item, level + 1);
|
||
for (var n = 0; n < nested.length; n++) acc.push(nested[n]);
|
||
} else {
|
||
acc.push(item);
|
||
}
|
||
}
|
||
return acc;
|
||
};
|
||
return flatten(this, 0);
|
||
};
|
||
}
|
||
if (!Array.prototype.flatMap) {
|
||
Array.prototype.flatMap = function (callback, thisArg) {
|
||
return this.map(callback, thisArg).flat();
|
||
};
|
||
}
|
||
if (!String.prototype.includes) {
|
||
String.prototype.includes = function (search, start) {
|
||
return this.indexOf(search, start || 0) !== -1;
|
||
};
|
||
}
|
||
if (!String.prototype.startsWith) {
|
||
String.prototype.startsWith = function (search, start) {
|
||
return this.substr(start || 0, search.length) === search;
|
||
};
|
||
}
|
||
if (!String.prototype.endsWith) {
|
||
String.prototype.endsWith = function (search, length) {
|
||
var value = String(this);
|
||
var end = length == null ? value.length : Math.min(Number(length) || 0, value.length);
|
||
return value.substring(end - search.length, end) === search;
|
||
};
|
||
}
|
||
if (window.Promise && !Promise.prototype.finally) {
|
||
Promise.prototype.finally = function (callback) {
|
||
var P = this.constructor;
|
||
return this.then(function (value) {
|
||
return P.resolve(callback()).then(function () { return value; });
|
||
}, function (reason) {
|
||
return P.resolve(callback()).then(function () { throw reason; });
|
||
});
|
||
};
|
||
}
|
||
})();
|
||
</script>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: transparent;
|
||
--panel-rgb: 76, 88, 98;
|
||
--panel: rgba(var(--panel-rgb), .42);
|
||
--panel-soft: rgba(var(--panel-rgb), .28);
|
||
--panel-strong: rgba(var(--panel-rgb), .62);
|
||
--panel-2: rgba(var(--panel-rgb), .46);
|
||
--text: #f4f7fb;
|
||
--muted: rgba(226, 234, 242, .72);
|
||
--line: rgba(255, 255, 255, .2);
|
||
--line-strong: rgba(255, 255, 255, .34);
|
||
--control: rgba(34, 43, 51, .42);
|
||
--control-active: rgba(48, 61, 70, .58);
|
||
--accent: rgba(245, 249, 252, .86);
|
||
--accent-2: rgba(221, 232, 241, .66);
|
||
--focus-line: rgba(168, 218, 255, .72);
|
||
--focus-line-soft: rgba(168, 218, 255, .42);
|
||
--focus-glow: rgba(96, 165, 250, .24);
|
||
--focus-surface: rgba(255, 255, 255, .12);
|
||
--ok: #9fd8ad;
|
||
--danger: #f09d9d;
|
||
--shadow: 0 16px 42px rgba(0, 0, 0, .24);
|
||
--radius: 8px;
|
||
--fm-web-height: 100dvh;
|
||
--fm-safe-bottom: 20px;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html { -webkit-tap-highlight-color: transparent; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-height: var(--fm-web-height);
|
||
background: transparent;
|
||
color: var(--text);
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
html:not(.fm-native),
|
||
html:not(.fm-native) body {
|
||
background: transparent;
|
||
}
|
||
|
||
html.fm-native,
|
||
html.fm-native body {
|
||
background: transparent;
|
||
}
|
||
|
||
body {
|
||
overflow-x: hidden;
|
||
letter-spacing: 0;
|
||
position: relative;
|
||
overscroll-behavior-x: none;
|
||
}
|
||
|
||
button, input, textarea, select {
|
||
font: inherit;
|
||
color: inherit;
|
||
}
|
||
|
||
button {
|
||
border: 0;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
margin: 0;
|
||
padding: 0;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
text-align: inherit;
|
||
}
|
||
|
||
img {
|
||
display: block;
|
||
max-width: 100%;
|
||
background: transparent;
|
||
}
|
||
|
||
.app {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
min-height: var(--fm-web-height);
|
||
overflow-x: hidden;
|
||
padding: max(12px, env(safe-area-inset-top)) 14px calc(18px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
|
||
background: transparent;
|
||
}
|
||
|
||
/* 详情页激活期间:主页内容完全隐藏,resize 时也不会透出 */
|
||
body.detail-active .app,
|
||
body.detail-closing .app {
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
/* 防止 resize 触发重排时内容短暂出现 */
|
||
opacity: 0;
|
||
}
|
||
|
||
body.detail-active .back-top {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
body.episode-active #detailSheet {
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 42px;
|
||
height: 42px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: var(--radius);
|
||
background: var(--control);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
|
||
/* backdrop-filter 已移到需要的具体场景(详情页/浮层),避免每个按钮都创建合成层 */
|
||
}
|
||
|
||
.icon {
|
||
width: 19px;
|
||
height: 19px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 2.2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.pill {
|
||
width: fit-content;
|
||
color: var(--text);
|
||
background: var(--control-active);
|
||
border: 1px solid var(--line);
|
||
padding: 5px 9px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 有「继续观看」时,三个按钮沿用「无继续观看」页的统一样式:
|
||
flex 自适应排列 + 20px 圆角胶囊 + 42px 高(继承 .actions / .btn 基础样式),
|
||
只保留 nowrap 防止「继续观看」断行;不再用 grid 把按钮拉伸成整行大方块。 */
|
||
.actions.has-continue .btn {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.actions.has-continue #detailContinueBtn {
|
||
color: #fff;
|
||
background:
|
||
radial-gradient(circle at 24% 20%, rgba(255, 255, 255, .34), rgba(255, 255, 255, 0) 34%),
|
||
linear-gradient(135deg, #12d7ff 0%, #0b91ff 58%, #0867f7 100%);
|
||
border-color: rgba(255, 255, 255, .16);
|
||
box-shadow: 0 16px 30px rgba(0, 128, 255, .28), inset 0 1px 0 rgba(255, 255, 255, .26);
|
||
font-weight: 800;
|
||
text-shadow: none;
|
||
}
|
||
|
||
.actions.has-continue #detailSearchBtn,
|
||
.actions.has-continue #panSearchBtn {
|
||
/* 实际背景被 Liquid-Glass 的 var(--lg-base)!important 接管成深色玻璃,
|
||
故文字用白色才可读(原 #111827 深色字会与深玻璃糊在一起看不清)。 */
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, .98);
|
||
border-color: rgba(255, 255, 255, .72);
|
||
box-shadow: 0 14px 28px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .72);
|
||
font-weight: 800;
|
||
text-shadow: none;
|
||
}
|
||
|
||
.btn {
|
||
min-height: 42px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
padding: 0 14px;
|
||
border-radius: var(--radius);
|
||
background: var(--control);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
font-weight: 760;
|
||
text-decoration: none;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
|
||
}
|
||
|
||
.btn.primary {
|
||
color: var(--text);
|
||
background: var(--control-active);
|
||
border-color: var(--line-strong);
|
||
}
|
||
|
||
.btn.blue {
|
||
background: var(--control-active);
|
||
color: var(--text);
|
||
border-color: var(--line-strong);
|
||
}
|
||
|
||
.btn.danger {
|
||
color: #fff0f0;
|
||
background: rgba(240, 157, 157, .2);
|
||
border-color: rgba(255, 220, 220, .24);
|
||
}
|
||
|
||
.search {
|
||
width: 100%;
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 10px;
|
||
margin: 14px 0 8px;
|
||
position: relative;
|
||
z-index: 20;
|
||
}
|
||
|
||
.search.suggesting {
|
||
z-index: 95;
|
||
}
|
||
|
||
.suggest-panel {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: calc(100% + 8px);
|
||
transform: none;
|
||
z-index: 200;
|
||
display: none;
|
||
max-height: min(60vh, 500px);
|
||
overflow-y: auto;
|
||
padding: 6px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, .10);
|
||
background: rgba(8, 13, 22, .88);
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, .55), inset 0 1px 0 rgba(255, 255, 255, .08);
|
||
backdrop-filter: blur(20px) saturate(150%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.suggest-panel.open { display: flex; flex-direction: column; gap: 2px; }
|
||
.suggest-panel::-webkit-scrollbar { display: none; }
|
||
|
||
/* TV 端:保持跟随搜索框(absolute),不居中 */
|
||
html.tv-mode .suggest-panel {
|
||
position: absolute !important;
|
||
left: 0 !important;
|
||
right: 0 !important;
|
||
top: calc(100% + 8px) !important;
|
||
transform: none !important;
|
||
}
|
||
|
||
.suggest-item {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-height: 44px;
|
||
padding: 8px 12px;
|
||
border-radius: 11px;
|
||
color: var(--text);
|
||
text-align: left;
|
||
background: transparent;
|
||
border: none;
|
||
transition: background .12s ease;
|
||
}
|
||
|
||
.suggest-item:first-child {
|
||
background: rgba(96, 165, 250, .10);
|
||
}
|
||
|
||
.suggest-item:hover,
|
||
.suggest-item:focus {
|
||
background: rgba(255, 255, 255, .07);
|
||
outline: none;
|
||
}
|
||
|
||
.suggest-item:first-child:hover,
|
||
.suggest-item:first-child:focus {
|
||
background: rgba(96, 165, 250, .16);
|
||
}
|
||
|
||
/* 左侧图标块 */
|
||
.suggest-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
flex-shrink: 0;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, .08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.suggest-item:first-child .suggest-icon {
|
||
background: rgba(96, 165, 250, .18);
|
||
}
|
||
|
||
/* 热搜图标橙色 */
|
||
.suggest-icon-hot {
|
||
background: rgba(251, 146, 60, .15) !important;
|
||
color: #fb923c;
|
||
}
|
||
|
||
.suggest-item b {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
/* 关键词高亮 */
|
||
.suggest-item mark {
|
||
background: none;
|
||
color: #60a5fa;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.suggest-item span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.field {
|
||
width: 100%;
|
||
min-height: 44px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--line);
|
||
background: var(--control);
|
||
color: var(--text);
|
||
padding: 0 12px;
|
||
outline: none;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
|
||
}
|
||
|
||
textarea.field {
|
||
min-height: 74px;
|
||
padding: 10px 12px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.field::placeholder { color: rgba(226, 234, 242, .5); }
|
||
|
||
/* 搜索框单独胶囊圆角 */
|
||
#searchInput { border-radius: 999px; }
|
||
|
||
/* 搜索框聚焦光晕 */
|
||
#searchInput:focus {
|
||
border-color: var(--focus-line);
|
||
box-shadow: 0 0 0 3px var(--focus-glow);
|
||
transition: border-color .16s ease, box-shadow .16s ease;
|
||
}
|
||
|
||
|
||
/* ══════════════════════════════════════
|
||
导航栏:一体长条设计(参考图样式)
|
||
══════════════════════════════════════ */
|
||
/* .chips 用于 panTabs / seasonTabs 等内部 chip 列表,保持原有宽度行为 */
|
||
.chips {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
overflow-x: auto;
|
||
padding: 3px 0 8px;
|
||
overscroll-behavior-x: contain;
|
||
position: relative;
|
||
z-index: 18;
|
||
background: transparent;
|
||
margin-bottom: -4px;
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
}
|
||
.chips::-webkit-scrollbar { display: none; }
|
||
|
||
/* 主导航栏 backdrop-filter via CSS class — TV 端可被 html.tv-mode * 覆盖 */
|
||
#chips {
|
||
backdrop-filter: blur(18px) saturate(1.1);
|
||
-webkit-backdrop-filter: blur(18px) saturate(1.1);
|
||
}
|
||
|
||
/* 主导航栏 #chips:宽度由内容决定,不撑满 */
|
||
#chips {
|
||
width: auto !important;
|
||
min-width: unset !important;
|
||
max-width: none !important;
|
||
display: inline-flex !important;
|
||
overflow-x: visible !important;
|
||
padding: 0 !important;
|
||
margin-bottom: 0 !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
/* chips-wrapper 负责滚动容器 */
|
||
#chips-wrapper {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
overflow-y: visible; /* 允许 chip outline 在纵向溢出 */
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
position: relative;
|
||
z-index: 18;
|
||
margin-top: 16px;
|
||
margin-bottom: 4px;
|
||
/* 上下 padding 足够大,让 outline + outline-offset 有空间显示,不被裁剪 */
|
||
padding: 8px 4px 12px;
|
||
}
|
||
#chips-wrapper::-webkit-scrollbar { display: none; }
|
||
|
||
#chips-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
width: max-content;
|
||
min-width: 100%;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 999px;
|
||
padding: 3px 4px;
|
||
gap: 2px;
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
box-shadow: none;
|
||
/* 允许子 chip 的 outline 焦点环溢出容器,否则会被裁剪 */
|
||
overflow: visible;
|
||
}
|
||
|
||
.chip {
|
||
flex: 0 0 auto;
|
||
min-height: 34px;
|
||
padding: 0 14px;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
border: none;
|
||
color: rgba(255,255,255,.58);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
text-shadow: none;
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
position: relative;
|
||
/* overflow visible 允许焦点 outline 溢出,手机/TV 都不被容器裁剪 */
|
||
overflow: visible;
|
||
transition: color .2s ease, background .2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chip:not(.active)::before { display: none; }
|
||
|
||
.chip.active {
|
||
background: rgba(255,255,255,.16);
|
||
color: rgba(255,255,255,.98);
|
||
border: none;
|
||
box-shadow:
|
||
0 1px 8px rgba(0,0,0,.28),
|
||
inset 0 1px 0 rgba(255,255,255,.18),
|
||
inset 0 -1px 0 rgba(0,0,0,.12);
|
||
font-weight: 760;
|
||
}
|
||
|
||
.chip:hover:not(.active) {
|
||
color: rgba(255,255,255,.80);
|
||
background: rgba(255,255,255,.06);
|
||
}
|
||
|
||
.chip:active {
|
||
transform: scale(0.95);
|
||
opacity: .85;
|
||
transition: transform .08s ease, opacity .08s ease;
|
||
}
|
||
|
||
/* chip 图标 */
|
||
.chip-icon {
|
||
width: 15px;
|
||
height: 15px;
|
||
flex-shrink: 0;
|
||
opacity: .75;
|
||
}
|
||
.chip.active .chip-icon { opacity: 1; }
|
||
|
||
/* 搜索按钮在最右边 */
|
||
.chip-search-btn {
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.section {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
margin-top: 24px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#recommendSection {
|
||
overflow: visible;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
position: relative;
|
||
overflow: visible;
|
||
}
|
||
|
||
.section h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.section small {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.section-actions {
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.mini-icon-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: var(--radius);
|
||
background: var(--control);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
backdrop-filter: blur(12px) saturate(1.05);
|
||
}
|
||
|
||
.mini-icon-btn .icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.stack {
|
||
display: block;
|
||
gap: 18px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.list-block {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.list-block:first-child,
|
||
.list-panel:first-child {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.subsection-head {
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.subsection-head h4 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.subsection-head span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rail {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
display: flex;
|
||
gap: 12px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
overscroll-behavior-x: contain;
|
||
scroll-snap-type: x proximity;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
touch-action: pan-x pan-y;
|
||
padding: 2px 0 5px;
|
||
}
|
||
|
||
.media-grid {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.infinite-sentinel {
|
||
width: 100%;
|
||
height: 1px;
|
||
pointer-events: none;
|
||
overflow-anchor: none;
|
||
}
|
||
|
||
.card {
|
||
flex: 0 0 142px;
|
||
min-width: 0;
|
||
max-width: 172px;
|
||
position: relative;
|
||
border-radius: var(--radius);
|
||
background: var(--panel-soft);
|
||
border: 1px solid var(--line);
|
||
overflow: hidden;
|
||
padding: 0;
|
||
text-align: left;
|
||
color: var(--text);
|
||
scroll-snap-align: start;
|
||
contain: layout style;
|
||
}
|
||
|
||
.media-grid .card {
|
||
width: 100%;
|
||
max-width: none;
|
||
flex: none;
|
||
}
|
||
|
||
.poster {
|
||
aspect-ratio: 2 / 3;
|
||
width: 100%;
|
||
object-fit: cover;
|
||
background: linear-gradient(110deg,
|
||
rgba(255,255,255,.04) 0%,
|
||
rgba(255,255,255,.09) 45%,
|
||
rgba(255,255,255,.04) 100%);
|
||
background-size: 200% 100%;
|
||
}
|
||
.poster.loaded {
|
||
background: transparent;
|
||
}
|
||
|
||
.card > .poster {
|
||
width: calc(100% + 2px);
|
||
max-width: calc(100% + 2px);
|
||
margin: -1px -1px 0;
|
||
}
|
||
|
||
.card-body {
|
||
padding: 10px;
|
||
background: rgba(18,24,38,.15);
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 12px;
|
||
font-weight: 760;
|
||
line-height: 1.2;
|
||
min-height: 15px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .42);
|
||
}
|
||
|
||
.rating-badge {
|
||
position: absolute;
|
||
right: 7px;
|
||
top: 7px;
|
||
min-width: 34px;
|
||
height: 24px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 999px;
|
||
background: rgba(6, 9, 12, .48);
|
||
border: 1px solid rgba(255, 255, 255, .18);
|
||
color: rgba(255, 255, 255, .94);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.card-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.card-title-row .card-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.rating-badge-inline {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 28px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 193, 7, .18);
|
||
border: 1px solid rgba(255, 193, 7, .36);
|
||
color: rgba(255, 220, 80, .95);
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
/* ── 长按菜单 ── */
|
||
.lp-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 9998;
|
||
background: transparent;
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: none;
|
||
}
|
||
|
||
.card-long-press-menu {
|
||
position: fixed;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 6px;
|
||
background: rgba(28, 36, 44, .92);
|
||
border: 1px solid var(--line-strong);
|
||
border-radius: 14px;
|
||
backdrop-filter: blur(24px) saturate(1.2);
|
||
-webkit-backdrop-filter: blur(24px) saturate(1.2);
|
||
box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.3);
|
||
min-width: 148px;
|
||
transform-origin: top center;
|
||
animation: lp-in .15s cubic-bezier(.34,1.56,.64,1) both;
|
||
}
|
||
|
||
@keyframes lp-in {
|
||
from { opacity: 0; transform: scale(.88) translateY(-6px); }
|
||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||
}
|
||
|
||
.card-long-press-menu .lp-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 11px 14px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
-webkit-tap-highlight-color: transparent;
|
||
transition: background .12s;
|
||
}
|
||
|
||
.card-long-press-menu .lp-btn:active {
|
||
background: rgba(255,255,255,.1);
|
||
}
|
||
|
||
.card-long-press-menu .lp-btn svg {
|
||
flex-shrink: 0;
|
||
opacity: .88;
|
||
}
|
||
|
||
.card-long-press-menu .lp-divider {
|
||
height: 1px;
|
||
background: var(--line);
|
||
margin: 2px 6px;
|
||
}
|
||
|
||
/* ── 详情徽标:位于影视名上方 ── */
|
||
.detail-logo-wrap {
|
||
display: none; /* JS sets to "flex" when logo available */
|
||
position: relative;
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
align-items: flex-start;
|
||
justify-content: flex-start;
|
||
max-width: 60vw;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.detail-logo-img {
|
||
max-height: 76px;
|
||
max-width: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
object-position: left bottom;
|
||
filter: drop-shadow(0 4px 16px rgba(0,0,0,.72)) drop-shadow(0 1px 4px rgba(0,0,0,.5));
|
||
opacity: 0;
|
||
transition: opacity .35s ease .1s;
|
||
}
|
||
|
||
.detail-logo-img.loaded {
|
||
opacity: 1;
|
||
}
|
||
|
||
@media (min-width: 600px) {
|
||
.detail-logo-wrap { max-width: 50vw; }
|
||
.detail-logo-img { max-height: 96px; }
|
||
}
|
||
|
||
.card-meta {
|
||
margin-top: 6px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
|
||
}
|
||
|
||
.card-meta:empty {
|
||
display: none;
|
||
}
|
||
|
||
.card.recent-card {
|
||
background: rgba(var(--panel-rgb), .26);
|
||
}
|
||
|
||
.card.recent-card > .poster {
|
||
width: calc(100% + 2px);
|
||
max-width: calc(100% + 2px);
|
||
margin: -1px;
|
||
}
|
||
|
||
.recent-card .card-body {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 2;
|
||
padding: 28px 9px 9px;
|
||
border-top: 0;
|
||
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(5, 7, 9, .72) 46%, rgba(5, 7, 9, .88));
|
||
}
|
||
|
||
.recent-card .card-title,
|
||
.recent-card .card-meta,
|
||
.recent-badge {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.recent-card .card-title {
|
||
min-height: 0;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.recent-card .card-meta {
|
||
max-width: 100%;
|
||
margin-top: 4px;
|
||
color: rgba(238, 244, 250, .74);
|
||
font-size: 11px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.recent-badge {
|
||
position: absolute;
|
||
top: 7px;
|
||
right: 7px;
|
||
z-index: 2;
|
||
max-width: calc(100% - 14px);
|
||
min-height: 22px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0 7px;
|
||
border-radius: 999px;
|
||
background: rgba(6, 9, 12, .46);
|
||
border: 1px solid rgba(255, 255, 255, .16);
|
||
color: rgba(255, 255, 255, .9);
|
||
font-size: 11px;
|
||
font-weight: 780;
|
||
line-height: 1;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .48);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.recent-progress {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 3;
|
||
height: 3px;
|
||
background: rgba(255, 255, 255, .2);
|
||
}
|
||
|
||
.recent-progress span {
|
||
display: block;
|
||
width: var(--recent-progress, 0%);
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, .82);
|
||
}
|
||
|
||
.card.has-people .card-body {
|
||
padding-right: 42px;
|
||
}
|
||
|
||
.card-people {
|
||
position: absolute;
|
||
right: 10px;
|
||
bottom: 10px;
|
||
color: rgba(226, 234, 242, .64);
|
||
font-size: 11px;
|
||
font-weight: 760;
|
||
line-height: 1;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .46);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.empty {
|
||
min-height: 92px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 14px;
|
||
border-radius: var(--radius);
|
||
border: 1px dashed rgba(255, 255, 255, .18);
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, .03);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.metric {
|
||
flex: 0 0 210px;
|
||
max-width: 260px;
|
||
min-height: 82px;
|
||
padding: 12px;
|
||
border-radius: var(--radius);
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.metric b {
|
||
display: block;
|
||
font-size: 20px;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.metric span {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.sheet {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 60;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
min-height: var(--fm-web-height);
|
||
background: transparent;
|
||
backdrop-filter: none;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: 0 14px calc(22px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
/* Full-screen blurred cover art backdrop for detail sheet */
|
||
/* ── Emby-style hero background ──
|
||
真实 div(#detailHeroBg)替代 ::before,JS 可直接操控 filter ── */
|
||
.sheet::before {
|
||
display: none; /* 废弃,改用 #detailHeroBg 真实元素 */
|
||
}
|
||
|
||
.detail-hero-bg {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: -2;
|
||
background-size: cover;
|
||
background-position: center 10%;
|
||
filter: brightness(0.80) saturate(1.05);
|
||
opacity: 0;
|
||
transition: opacity 0.45s cubic-bezier(.25,.8,.30,1),
|
||
filter 0.18s ease;
|
||
pointer-events: none;
|
||
will-change: opacity, filter;
|
||
}
|
||
|
||
#detailSheet.active .detail-hero-bg {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* sheet-closing:整体淡出 sheet */
|
||
#detailSheet.sheet-closing .detail-hero-bg {
|
||
opacity: 0 !important;
|
||
}
|
||
|
||
/* Scrim removed — no darkening overlay, blur only */
|
||
.sheet::after { display: none; }
|
||
|
||
/* Blur overlay on lower half — gives text area a frosted glass feel */
|
||
.sheet-blur-layer {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: -1;
|
||
pointer-events: none;
|
||
backdrop-filter: blur(18px) saturate(1.05);
|
||
-webkit-backdrop-filter: blur(18px) saturate(1.05);
|
||
/* mask: clear at top, blurry from 45% down */
|
||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 71%, black 81%);
|
||
mask-image: linear-gradient(180deg, transparent 0%, transparent 71%, black 81%);
|
||
}
|
||
|
||
.sheet.active .sheet-blur-layer {
|
||
display: block;
|
||
}
|
||
|
||
.sheet.active { display: block; }
|
||
|
||
/* 双重保险:无论 JS inline style 如何,sheet 没有 active/closing 类时强制隐藏
|
||
防止 resize / orientation change 时 sheet 意外漏出到主页面 */
|
||
.sheet:not(.active):not(.sheet-closing) {
|
||
display: none !important;
|
||
visibility: hidden !important;
|
||
pointer-events: none !important;
|
||
}
|
||
|
||
/* 关闭淡出:整个 sheet 整体透明度过渡,避免内容和背景不同步 */
|
||
.sheet.sheet-closing {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.22s ease !important;
|
||
}
|
||
|
||
.detail-cover {
|
||
display: none !important;
|
||
}
|
||
|
||
/* TV 端保持 detail-cover */
|
||
html.tv-mode .detail-cover { display: block; }
|
||
|
||
/* TV / 大屏详情页:显示海报轮播区域 */
|
||
#detailSheet.detail-large .detail-cover {
|
||
display: block !important;
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
max-height: 45vh;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: rgba(255,255,255,.05);
|
||
margin-bottom: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-cover.loading::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(255,255,255,.06);
|
||
}
|
||
|
||
.detail-cover img {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
opacity: 0;
|
||
transition: none;
|
||
}
|
||
|
||
.detail-cover img.active {
|
||
opacity: 1;
|
||
filter: none;
|
||
}
|
||
|
||
.detail-cover.poster-mode {
|
||
background: rgba(var(--panel-rgb), .38);
|
||
}
|
||
|
||
.detail-cover.poster-mode::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: -18px;
|
||
background-image: var(--detail-cover-bg, none);
|
||
background-size: cover;
|
||
background-position: center;
|
||
filter: blur(18px) saturate(1.08);
|
||
opacity: .42;
|
||
transform: none;
|
||
}
|
||
|
||
|
||
.detail-cover.poster-mode img {
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
z-index: 1;
|
||
}
|
||
|
||
.detail-info {
|
||
padding: 0 2px 12px;
|
||
}
|
||
|
||
.detail-info h2 {
|
||
margin: 0 0 8px;
|
||
font-size: clamp(22px, 6vw, 32px);
|
||
line-height: 1.12;
|
||
}
|
||
|
||
.detail-info p {
|
||
margin: 0;
|
||
color: rgba(244, 247, 251, .82);
|
||
line-height: 1.5;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 演员简介字体与影视简介保持一致 */
|
||
.person-info p {
|
||
margin: 0;
|
||
color: rgba(244, 247, 251, .82);
|
||
line-height: 1.5;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* ── 可折叠简介 ── */
|
||
.collapsible-overview { margin: 0; }
|
||
|
||
.overview-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
touch-action: manipulation;
|
||
-webkit-tap-highlight-color: transparent;
|
||
transition: color .15s ease;
|
||
}
|
||
|
||
.overview-toggle:hover { color: var(--text); }
|
||
|
||
.overview-toggle-icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 2.2;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
transition: transform .22s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.collapsible-overview.open .overview-toggle-icon {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.overview-body {
|
||
overflow: hidden;
|
||
max-height: 0;
|
||
opacity: 0;
|
||
transition: max-height .3s cubic-bezier(.4,0,.2,1), opacity .25s ease, margin-top .3s ease;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.collapsible-overview.open .overview-body {
|
||
max-height: 600px;
|
||
opacity: 1;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin: 8px 0 14px;
|
||
}
|
||
|
||
.meta-pill {
|
||
padding: 5px 8px;
|
||
border-radius: 999px;
|
||
color: rgba(226, 234, 242, .72);
|
||
background: rgba(18, 24, 38, .15);
|
||
border: 1px solid rgba(255, 255, 255, .10);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 语义配色:年份偏蓝,类型偏紫,时长偏青,评分金色 */
|
||
.meta-pill.pill-year { color: #7cc4fa; border-color: rgba(124,196,250,.28); background: rgba(124,196,250,.10); }
|
||
.meta-pill.pill-genre { color: #e0cfff; border-color: rgba(224,207,255,.32); background: rgba(196,167,247,.14); }
|
||
.meta-pill.pill-runtime { color: #67d9c4; border-color: rgba(103,217,196,.25); background: rgba(103,217,196,.09); }
|
||
.meta-pill.pill-score { color: #fbbf24; border-color: rgba(251,191,36,.30); background: rgba(251,191,36,.10); }
|
||
|
||
.detail-block {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.detail-block h3 {
|
||
margin: 0 0 10px;
|
||
font-size: 17px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.episode-card {
|
||
flex: 0 0 230px;
|
||
max-width: 260px;
|
||
min-height: 138px;
|
||
padding: 11px;
|
||
border-radius: var(--radius);
|
||
background: rgba(18,24,38,.15);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
text-align: left;
|
||
scroll-snap-align: start;
|
||
}
|
||
|
||
.episode-card.has-still {
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.episode-card.has-still .episode-body {
|
||
padding: 10px;
|
||
}
|
||
|
||
.episode-still {
|
||
width: calc(100% + 2px);
|
||
max-width: calc(100% + 2px);
|
||
margin: -1px -1px 0;
|
||
aspect-ratio: 16 / 9;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.episode-card b,
|
||
.person-card b {
|
||
display: block;
|
||
font-size: 14px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.episode-card span,
|
||
.person-card span {
|
||
display: block;
|
||
margin-top: 6px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.episode-card p {
|
||
margin: 8px 0 0;
|
||
color: rgba(244, 247, 251, .78);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 4;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.person-card {
|
||
flex: 0 0 auto;
|
||
width: auto;
|
||
max-width: none;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-radius: 999px;
|
||
background: rgba(18,24,38,.15);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
padding: 6px 14px 6px 6px;
|
||
text-align: left;
|
||
scroll-snap-align: start;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.person-card img {
|
||
width: 44px;
|
||
height: 44px;
|
||
min-width: 44px;
|
||
aspect-ratio: 1 / 1;
|
||
object-fit: cover;
|
||
border-radius: 50%;
|
||
margin: 0;
|
||
flex-shrink: 0;
|
||
background: rgba(255,255,255,.07);
|
||
}
|
||
|
||
.person-card div {
|
||
padding: 0;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.person-card b {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 760;
|
||
line-height: 1.2;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 130px;
|
||
}
|
||
|
||
.person-card span {
|
||
display: block;
|
||
margin-top: 0;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 130px;
|
||
}
|
||
|
||
.person-card .person-en-name {
|
||
font-size: 10px;
|
||
color: rgba(244,247,251,.38);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 130px;
|
||
}
|
||
|
||
.sync-panel {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border-radius: var(--radius);
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.hint {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.mono {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: max(18px, calc(var(--fm-safe-bottom) + env(safe-area-inset-bottom)));
|
||
z-index: 90;
|
||
transform: translateX(-50%) translateY(24px);
|
||
max-width: calc(100vw - 28px);
|
||
padding: 10px 12px;
|
||
border-radius: var(--radius);
|
||
background: rgba(6, 8, 10, .86);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
box-shadow: var(--shadow);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: .18s ease;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.toast.show {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
|
||
.back-top {
|
||
position: fixed;
|
||
right: max(14px, env(safe-area-inset-right));
|
||
bottom: max(76px, calc(58px + var(--fm-safe-bottom) + env(safe-area-inset-bottom)));
|
||
z-index: 55;
|
||
width: 44px;
|
||
height: 44px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: var(--radius);
|
||
background: rgba(34, 43, 51, .68);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, .24);
|
||
backdrop-filter: blur(12px) saturate(1.05);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
transform: translateY(10px);
|
||
transition: opacity .16s ease, transform .16s ease, visibility .16s ease;
|
||
}
|
||
|
||
.back-top,
|
||
.back-top.show {
|
||
display: none !important;
|
||
opacity: 0 !important;
|
||
visibility: hidden !important;
|
||
pointer-events: none !important;
|
||
}
|
||
|
||
/* back-top 不参与液态玻璃高光层,避免 ::before 溢出产生幽灵点 */
|
||
.back-top {
|
||
overflow: hidden !important;
|
||
}
|
||
.back-top::before {
|
||
display: none !important;
|
||
}
|
||
|
||
.connection-dock {
|
||
position: relative;
|
||
z-index: 2000;
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
justify-self: end;
|
||
margin-left: auto;
|
||
margin: 0;
|
||
border-radius: var(--radius);
|
||
border: none;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
backdrop-filter: none;
|
||
overflow: visible;
|
||
}
|
||
|
||
.connection-toggle {
|
||
width: 42px;
|
||
height: 42px;
|
||
min-height: 42px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: var(--text);
|
||
text-align: center;
|
||
}
|
||
|
||
.connection-title {
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.connection-arrow {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
transition: transform .16s ease;
|
||
}
|
||
|
||
.connection-dock.open .connection-arrow {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.connection-body {
|
||
display: none;
|
||
position: absolute;
|
||
right: 0;
|
||
top: calc(100% + 8px);
|
||
z-index: 1000;
|
||
width: min(calc(var(--fm-web-width, 100vw) - 28px), 420px);
|
||
max-width: calc(var(--fm-web-width, 100vw) - 28px);
|
||
margin: 0;
|
||
padding: 11px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: rgba(34, 43, 51, .72);
|
||
box-shadow: 0 14px 36px rgba(0, 0, 0, .24);
|
||
backdrop-filter: blur(14px) saturate(1.05);
|
||
transform: translateX(var(--connection-shift, 0px));
|
||
}
|
||
|
||
.connection-body.open {
|
||
display: block;
|
||
}
|
||
|
||
.connection-grid {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.connection-item {
|
||
display: grid;
|
||
grid-template-columns: minmax(56px, 74px) minmax(0, 1fr);
|
||
gap: 8px;
|
||
align-items: baseline;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.connection-item b {
|
||
color: var(--muted);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.connection-item span {
|
||
min-width: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.connection-body .actions {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.connection-body .btn {
|
||
min-width: 0;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pan-config {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid rgba(255, 255, 255, .12);
|
||
}
|
||
|
||
.pan-config label {
|
||
display: grid;
|
||
gap: 5px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.pan-config .field {
|
||
min-height: 38px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.pan-disk-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
|
||
gap: 6px;
|
||
}
|
||
|
||
.pan-disk-grid label {
|
||
min-width: 0;
|
||
min-height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 8px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid rgba(255, 255, 255, .14);
|
||
background: rgba(255, 255, 255, .04);
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
font-weight: 720;
|
||
}
|
||
|
||
.pan-disk-grid input {
|
||
width: 14px;
|
||
height: 14px;
|
||
accent-color: rgba(244, 247, 251, .82);
|
||
}
|
||
|
||
.pan-disk-grid label:focus-within {
|
||
background: rgba(70, 85, 96, .72);
|
||
border-color: var(--focus-line);
|
||
color: var(--text);
|
||
}
|
||
|
||
.panel-editing {
|
||
background: rgba(24, 36, 46, .88) !important;
|
||
border-color: rgba(184, 226, 255, .9) !important;
|
||
}
|
||
|
||
.pan-search-block {
|
||
display: none;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.pan-search-block.active {
|
||
display: block;
|
||
}
|
||
|
||
.pan-result-list {
|
||
display: grid;
|
||
gap: 9px;
|
||
max-height: min(calc(var(--fm-web-height) * .52), 560px);
|
||
min-height: 92px;
|
||
overflow-y: auto;
|
||
overscroll-behavior-y: contain;
|
||
padding-right: 2px;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.pan-result-list::-webkit-scrollbar { display: none; }
|
||
|
||
.pan-tabs {
|
||
margin: 4px 0 12px;
|
||
padding: 6px 4px;
|
||
}
|
||
|
||
.pan-tabs:empty { display: none; }
|
||
|
||
.pan-tabs .chip {
|
||
min-height: 30px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
position: relative;
|
||
z-index: 1;
|
||
overflow: visible; /* 允许焦点环溢出 */
|
||
}
|
||
|
||
.pan-tabs .chip.active {
|
||
z-index: 2; /* 浮在相邻 chip 上方,防止边框被遮 */
|
||
}
|
||
|
||
.pan-result-item {
|
||
width: 100%;
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 7px;
|
||
padding: 11px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid rgba(255,255,255,.12);
|
||
background: rgba(10,14,22,0.62) !important;
|
||
color: var(--text);
|
||
text-align: left;
|
||
backdrop-filter: blur(14px) saturate(1.05);
|
||
-webkit-backdrop-filter: blur(14px) saturate(1.05);
|
||
}
|
||
|
||
.pan-result-item.is-bad {
|
||
opacity: .58;
|
||
}
|
||
|
||
.pan-result-title {
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.pan-result-title span:first-child {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pan-result-meta,
|
||
.pan-result-url {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.pan-result-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pan-tag.quality {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
line-height: 1;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #e8c97a;
|
||
border: 1px solid rgba(232,201,122,.3);
|
||
background: rgba(232,201,122,.08);
|
||
}
|
||
.pan-tag.quality.dv { color: #b8a4f8; border-color: rgba(184,164,248,.35); background: rgba(184,164,248,.12); letter-spacing: .02em; }
|
||
.pan-tag.quality.hdrp { color: #f4a8d0; border-color: rgba(244,168,208,.3); background: rgba(244,168,208,.1); }
|
||
.pan-tag.quality.fhd { color: #7ec8e3; border-color: rgba(126,200,227,.3); background: rgba(126,200,227,.1); }
|
||
.pan-tag.quality.hd { color: #82c98a; border-color: rgba(130,201,138,.28); background: rgba(130,201,138,.09); }
|
||
.pan-tag.quality.sd { color: rgba(240,237,232,.4); border-color: rgba(255,255,255,.1); background: rgba(255,255,255,.04); }
|
||
|
||
.pan-result-url {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pan-health {
|
||
flex: 0 0 auto;
|
||
display: inline-block;
|
||
width: .55rem;
|
||
height: .55rem;
|
||
border-radius: 999px;
|
||
background: #94a3b8;
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, .18);
|
||
}
|
||
|
||
.pan-health.ok { background: #22c55e; }
|
||
.pan-health.bad { background: #ef4444; }
|
||
.pan-health.locked { background: #f59e0b; }
|
||
.pan-health.pending {
|
||
background: #60a5fa;
|
||
animation: pulse-dot 1.1s ease-in-out infinite;
|
||
}
|
||
|
||
.pan-health.uncertain,
|
||
.pan-health.unsupported { background: #94a3b8; }
|
||
|
||
@keyframes pulse-dot {
|
||
0%, 100% { opacity: .45; transform: scale(.92); }
|
||
50% { opacity: 1; transform: scale(1.08); }
|
||
}
|
||
|
||
.dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 999px;
|
||
background: var(--muted);
|
||
}
|
||
|
||
/* 状态按钮里的 dot 加呼吸灯动画 — 方案 A 同心光晕扩散 */
|
||
.connection-toggle .dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
position: relative;
|
||
animation: none;
|
||
}
|
||
.connection-toggle .dot::before,
|
||
.connection-toggle .dot::after {
|
||
content: '';
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%) scale(0);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.connection-toggle .dot.ok {
|
||
background: #4ade80;
|
||
animation: dot-glow-core 2s ease-in-out infinite;
|
||
}
|
||
.connection-toggle .dot.ok::before {
|
||
width: 18px; height: 18px;
|
||
background: rgba(74, 222, 128, .22);
|
||
animation: dot-glow-ring1 2s ease-out infinite;
|
||
}
|
||
.connection-toggle .dot.ok::after {
|
||
width: 28px; height: 28px;
|
||
background: rgba(74, 222, 128, .10);
|
||
animation: dot-glow-ring2 2s ease-out .15s infinite;
|
||
}
|
||
.connection-toggle .dot.warn {
|
||
background: #fbbf24;
|
||
animation: dot-glow-core 2s ease-in-out infinite;
|
||
}
|
||
.connection-toggle .dot.warn::before {
|
||
width: 18px; height: 18px;
|
||
background: rgba(251, 191, 36, .22);
|
||
animation: dot-glow-ring1 2s ease-out infinite;
|
||
}
|
||
.connection-toggle .dot.warn::after {
|
||
width: 28px; height: 28px;
|
||
background: rgba(251, 191, 36, .10);
|
||
animation: dot-glow-ring2 2s ease-out .15s infinite;
|
||
}
|
||
.connection-toggle .dot.bad {
|
||
background: #fb7185;
|
||
animation: dot-glow-core 2s ease-in-out infinite;
|
||
}
|
||
.connection-toggle .dot.bad::before {
|
||
width: 18px; height: 18px;
|
||
background: rgba(251, 113, 133, .22);
|
||
animation: dot-glow-ring1 2s ease-out infinite;
|
||
}
|
||
.connection-toggle .dot.bad::after {
|
||
width: 28px; height: 28px;
|
||
background: rgba(251, 113, 133, .10);
|
||
animation: dot-glow-ring2 2s ease-out .15s infinite;
|
||
}
|
||
@keyframes dot-glow-core {
|
||
0%, 100% { opacity: .7; transform: scale(.88); }
|
||
50% { opacity: 1; transform: scale(1.12); }
|
||
}
|
||
@keyframes dot-glow-ring1 {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(.5); }
|
||
60% { opacity: 1; }
|
||
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
|
||
}
|
||
@keyframes dot-glow-ring2 {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(.5); }
|
||
60% { opacity: .7; }
|
||
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
|
||
}
|
||
@keyframes pulse-dot-glow {
|
||
0%, 100% { opacity: .55; transform: scale(.9); box-shadow: 0 0 3px 1px rgba(226,234,242,.25); }
|
||
50% { opacity: 1; transform: scale(1.1); box-shadow: 0 0 8px 3px rgba(226,234,242,.55), 0 0 16px 5px rgba(226,234,242,.18); }
|
||
}
|
||
|
||
.dot.ok { background: var(--ok); }
|
||
.dot.warn { background: #ffcc4d; }
|
||
.dot.bad { background: var(--danger); }
|
||
|
||
.image-viewer {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 100;
|
||
min-height: var(--fm-web-height);
|
||
background: var(--panel-strong);
|
||
backdrop-filter: blur(8px) saturate(1.05);
|
||
padding: max(14px, env(safe-area-inset-top)) 14px calc(18px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.image-viewer.episode-mode {
|
||
background: transparent;
|
||
backdrop-filter: none;
|
||
}
|
||
|
||
.image-viewer.active {
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
gap: 14px;
|
||
}
|
||
|
||
.image-content {
|
||
min-width: 0;
|
||
min-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.image-content::-webkit-scrollbar { display: none; }
|
||
|
||
.image-content img {
|
||
align-self: center;
|
||
justify-self: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
max-height: calc(var(--fm-web-height) - 86px - var(--fm-safe-bottom));
|
||
object-fit: contain;
|
||
background: transparent;
|
||
}
|
||
|
||
.episode-view {
|
||
width: min(860px, 100%);
|
||
min-width: 0;
|
||
align-self: start;
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
padding-bottom: 20px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--line);
|
||
background: var(--panel);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.episode-view img {
|
||
width: 100%;
|
||
height: auto;
|
||
max-height: min(48vh, 460px);
|
||
aspect-ratio: 16 / 9;
|
||
object-fit: cover;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--line);
|
||
background: rgba(0, 0, 0, .18);
|
||
}
|
||
|
||
.episode-view h3 {
|
||
margin: 0;
|
||
font-size: 19px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.episode-view span {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.episode-view p {
|
||
margin: 0;
|
||
color: rgba(244, 247, 251, .9);
|
||
font-size: 15px;
|
||
line-height: 1.65;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.focusable:focus,
|
||
button:focus,
|
||
input:focus,
|
||
textarea:focus {
|
||
outline: none;
|
||
border-color: var(--focus-line);
|
||
box-shadow: none;
|
||
z-index: 5;
|
||
transition: border-color .06s ease, background-color .06s ease, opacity .06s ease;
|
||
}
|
||
|
||
/* TV 模式焦点环更醒目,大屏远距可见 */
|
||
html.tv-mode .focusable:focus,
|
||
html.tv-mode button:focus {
|
||
box-shadow: 0 0 0 3px rgba(255,255,255,.95), 0 0 0 6px rgba(60,140,255,.85) !important;
|
||
border-color: rgba(255, 255, 255, .98) !important;
|
||
outline: none !important;
|
||
}
|
||
|
||
|
||
|
||
html.tv-mode .chip:focus,
|
||
html.tv-mode .btn:focus,
|
||
html.tv-mode .icon-btn:focus,
|
||
html.tv-mode .mini-icon-btn:focus {
|
||
box-shadow: 0 0 0 2px rgba(255,255,255,.90), 0 0 0 5px rgba(60,140,255,.80) !important;
|
||
border-color: rgba(255, 255, 255, .95) !important;
|
||
}
|
||
|
||
/* ── TV 性能优化:禁用动画、简化渲染,消除1-2秒操作延迟 ── */
|
||
/* 注意:焦点呼吸 glow 动画豁免,不在禁用范围内 */
|
||
html.tv-mode *:not(:focus),
|
||
html.tv-mode *:not(:focus)::before,
|
||
html.tv-mode *:not(:focus)::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.08s !important;
|
||
will-change: auto !important;
|
||
}
|
||
/* 焦点元素本身:恢复呼吸 glow 动画 */
|
||
html.tv-mode :focus {
|
||
animation-duration: 1.5s !important;
|
||
animation-iteration-count: infinite !important;
|
||
transition-duration: 0.08s !important;
|
||
}
|
||
|
||
/* TV 彻底禁用所有 backdrop-filter(最大性能杀手)*/
|
||
html.tv-mode * {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 禁用所有伪元素动画(包括呼吸光晕)*/
|
||
html.tv-mode *::before,
|
||
html.tv-mode *::after {
|
||
animation: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
/* TV 模式:关闭高斯模糊和粒子,但保留背景轮播图 */
|
||
html.tv-mode .sheet-blur-layer {
|
||
display: none !important;
|
||
}
|
||
/* hero-bg TV 端 brightness 也由 JS 控制,初始正常亮度 */
|
||
html.tv-mode .detail-hero-bg {
|
||
filter: brightness(0.80) saturate(1.05) !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 模式 card 不需要 will-change transform,避免过多合成层 */
|
||
html.tv-mode .card {
|
||
will-change: auto !important;
|
||
}
|
||
|
||
/* TV 模式 chip 栏:导航栏适配 */
|
||
html.tv-mode #chips-wrapper nav,
|
||
html.tv-mode #chips {
|
||
min-width: unset !important;
|
||
width: 100% !important;
|
||
gap: 4px !important;
|
||
padding-bottom: 12px !important;
|
||
}
|
||
|
||
.focusable:not(.back-top):focus,
|
||
button:not(.back-top):focus,
|
||
input:focus,
|
||
textarea:focus {
|
||
position: relative;
|
||
}
|
||
|
||
.back-top:focus {
|
||
position: fixed;
|
||
z-index: 55;
|
||
}
|
||
|
||
/* 手机端返回顶部按钮更大更易点 */
|
||
@media (max-width: 719px) {
|
||
.back-top {
|
||
width: 48px;
|
||
height: 48px;
|
||
bottom: calc(20px + var(--fm-safe-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||
right: 16px;
|
||
}
|
||
}
|
||
|
||
.card:focus,
|
||
.episode-card:focus,
|
||
/* 演职员表卡片:手机/平板不显示任何选中状态 */
|
||
.person-card:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.card:focus img,
|
||
.episode-card:focus img {
|
||
filter: brightness(1.08) saturate(1.06);
|
||
}
|
||
|
||
/* 手机端 touch active 效果 */
|
||
.card:active {
|
||
transform: scale(0.97);
|
||
opacity: .88;
|
||
transition: transform .08s ease, opacity .08s ease;
|
||
}
|
||
|
||
.pan-result-item:focus {
|
||
outline: 2px solid rgba(168, 218, 255, .86);
|
||
outline-offset: -2px;
|
||
border-color: rgba(188, 228, 255, .9);
|
||
background: rgba(82, 100, 112, .86);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.pan-result-item:focus .pan-result-title {
|
||
color: #fff;
|
||
}
|
||
|
||
/* chip 焦点背景只在键盘/遥控器导航时显示(focus-visible),
|
||
手机触摸滑动后不会残留灰色背景 */
|
||
.chip:focus-visible,
|
||
.btn:focus,
|
||
.mini-icon-btn:focus,
|
||
.connection-toggle:focus {
|
||
transform: translateY(-1px);
|
||
background: rgba(70, 85, 96, .72);
|
||
border-color: var(--focus-line);
|
||
}
|
||
|
||
/* 非 focus-visible(触摸)时 chip 不显示焦点背景 */
|
||
.chip:focus:not(:focus-visible) {
|
||
background: transparent;
|
||
border-color: transparent;
|
||
transform: none;
|
||
outline: none;
|
||
}
|
||
|
||
.chip.active:focus-visible {
|
||
background: rgba(82, 98, 108, .78);
|
||
border-color: rgba(184, 226, 255, .8);
|
||
}
|
||
|
||
.chip.active:focus:not(:focus-visible) {
|
||
background: rgba(255,255,255,.14);
|
||
border-color: transparent;
|
||
transform: none;
|
||
outline: none;
|
||
}
|
||
|
||
input.focusable:focus,
|
||
textarea.focusable:focus,
|
||
.field:focus {
|
||
transform: none;
|
||
background: rgba(28, 38, 46, .72);
|
||
border-color: var(--focus-line);
|
||
box-shadow: 0 0 0 2px rgba(116,184,255,.25);
|
||
animation: none !important;
|
||
}
|
||
|
||
.suggest-item:focus {
|
||
background: rgba(60, 76, 90, .82);
|
||
border-color: var(--focus-line);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.image-content:focus {
|
||
border-radius: var(--radius);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.focusable:active,
|
||
button:active {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
@media (min-width: 720px) {
|
||
.app,
|
||
.sheet {
|
||
padding-left: 34px;
|
||
padding-right: 34px;
|
||
}
|
||
|
||
.grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
.rail {
|
||
gap: 14px;
|
||
}
|
||
|
||
.card {
|
||
flex-basis: 176px;
|
||
}
|
||
|
||
.media-grid {
|
||
gap: 14px;
|
||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1180px) {
|
||
.app,
|
||
.sheet {
|
||
padding-left: 56px;
|
||
padding-right: 56px;
|
||
}
|
||
|
||
.grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
.media-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(156px, 1fr));
|
||
}
|
||
}
|
||
|
||
/* Hero bg cross-fade during cover switch */
|
||
.sheet.hero-bg-swap .detail-hero-bg {
|
||
opacity: 0.08 !important;
|
||
transition: opacity 0.12s ease !important;
|
||
}
|
||
</style>
|
||
<style id="modern-ui-overrides">
|
||
:root{
|
||
--bg: transparent;
|
||
--bg2: transparent;
|
||
--panel:rgba(18,24,38,.52);
|
||
--panel-soft:rgba(22,30,48,.36);
|
||
--line:rgba(255,255,255,.08);
|
||
--line-strong:rgba(255,255,255,.18);
|
||
--text:#f5f7fb;
|
||
--muted:#98a5bc;
|
||
--accent:#57a6ff;
|
||
--accent2:#8fbcff;
|
||
--focus:#74b8ff;
|
||
--shadow:0 18px 60px rgba(0,0,0,.45);
|
||
--radius:14px;
|
||
}
|
||
|
||
html,body{
|
||
background: transparent !important;
|
||
}
|
||
|
||
.suggest-item,.search-item{
|
||
background: rgba(255,255,255,0.04) !important;
|
||
border: 1px solid rgba(255,255,255,0.07) !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* Aurora背景容器GPU提升 */
|
||
|
||
/* 解除头部的隔离限制,并提高整个头部的层级 */
|
||
.section-head {
|
||
position: relative;
|
||
z-index: 999 !important;
|
||
}
|
||
|
||
/* 减淡搜索总层透明度 */
|
||
.search-overlay,
|
||
.search-mask,
|
||
.search-result-wrap{
|
||
background: rgba(22,28,38,0.58) !important; /* 从0.72减淡约20% */
|
||
}
|
||
|
||
.suggest-panel,
|
||
.search-overlay::after,
|
||
.search-result-wrap::after{
|
||
background: rgba(18,22,30,0.22); /* 从0.28减淡约20% */
|
||
}
|
||
|
||
/* ── 详情页布局:内容下移,类 Emby 效果 ── */
|
||
#detailSheet.sheet {
|
||
display: none;
|
||
flex-direction: column;
|
||
justify-content: flex-start;
|
||
padding-top: 0 !important;
|
||
}
|
||
#detailSheet.sheet.active {
|
||
display: flex;
|
||
}
|
||
|
||
/* TV / 大屏详情页:侧边栏布局,左边封面右边信息 */
|
||
#detailSheet.detail-large.sheet {
|
||
display: flex !important;
|
||
flex-direction: row !important;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
padding: max(16px, env(safe-area-inset-top)) 40px 40px !important;
|
||
gap: 32px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
#detailSheet.detail-large .detail-spacer {
|
||
display: none !important;
|
||
}
|
||
|
||
/* 返回按钮:小尺寸,不撑满全宽 */
|
||
#detailSheet.detail-large #closeDetailBtn {
|
||
width: auto !important;
|
||
flex: 0 0 auto !important;
|
||
}
|
||
|
||
/* 大屏模式:剧照框同样隐藏,只用全屏背景轮播 */
|
||
#detailSheet.detail-large .detail-cover {
|
||
display: none !important;
|
||
}
|
||
|
||
/* info 占满整行 */
|
||
#detailSheet.detail-large .detail-info {
|
||
flex: 0 0 100%;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 0 0 16px;
|
||
}
|
||
|
||
/* actions 和 detail-block 都换行,占满整行 */
|
||
#detailSheet.detail-large .actions,
|
||
#detailSheet.detail-large .detail-block {
|
||
flex: 0 0 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
#detailSheet.detail-large ~ * { flex-basis: 100%; }
|
||
|
||
/* 返回按钮:与其他操作按钮统一样式,位于 actions 行 */
|
||
#closeDetailBtn {
|
||
position: relative;
|
||
top: auto;
|
||
align-self: auto;
|
||
z-index: auto;
|
||
flex-shrink: 0;
|
||
margin: 0;
|
||
}
|
||
/* ══════════════════════════════════════════════════════
|
||
详情页沉浸全屏 — Toolbar 隐藏后背景图延伸到状态栏
|
||
══════════════════════════════════════════════════════ */
|
||
|
||
/* 沉浸模式:detail-hero-bg 已是 inset:0,天然覆盖全屏 */
|
||
/* 非沉浸模式(浏览器预览 / Toolbar 可见):正常间距 */
|
||
html.detail-immersive .detail-spacer {
|
||
flex: 1 0 38vh;
|
||
min-height: 38vh;
|
||
}
|
||
|
||
/* 确保 sheet 在沉浸模式下从屏幕最顶端开始 */
|
||
html.detail-immersive #detailSheet.sheet {
|
||
top: 0 !important;
|
||
padding-top: 0 !important;
|
||
}
|
||
|
||
/* detail-spacer 在沉浸模式下略微缩小,给内容更多空间 */
|
||
html.detail-immersive #detailSheet.sheet {
|
||
padding-bottom: calc(32px + var(--fm-safe-bottom, 20px) + env(safe-area-inset-bottom, 0px)) !important;
|
||
}
|
||
|
||
/* 背景图片在沉浸模式下覆盖底部导航栏区域 */
|
||
html.detail-immersive .detail-hero-bg {
|
||
bottom: 0 !important;
|
||
}
|
||
|
||
|
||
/* Transparent spacer that fills upper half so content sits in lower half */
|
||
.detail-spacer {
|
||
flex: 1 0 42vh;
|
||
min-height: 42vh;
|
||
pointer-events: none;
|
||
position: relative;
|
||
}
|
||
|
||
/* ── 轮播图随滚动渐变高斯模糊 ── */
|
||
/* detailSheet hero bg 已改用 #detailHeroBg 真实元素,见 .detail-hero-bg */
|
||
|
||
/* sheet-blur-layer 也跟着模糊,加深沉浸感 */
|
||
#detailSheet .sheet-blur-layer {
|
||
transition: backdrop-filter 0.38s ease, -webkit-backdrop-filter 0.38s ease, opacity 0.38s ease;
|
||
}
|
||
</style>
|
||
<script>
|
||
window.TMDB_API_KEY = "d7040155454e7fdf547c4d889ebbcca7";
|
||
</script>
|
||
<style id="ios26-liquid-glass-fix">
|
||
/* ── iOS 26 Liquid Glass — 核心变量 ── */
|
||
:root {
|
||
--lg-base: rgba(255,255,255,.08);
|
||
--lg-border: rgba(255,255,255,.14);
|
||
--lg-border-inner: rgba(255,255,255,.22);
|
||
--lg-blur: blur(16px) saturate(140%) brightness(1.03);
|
||
--lg-blur-heavy: blur(22px) saturate(160%) brightness(1.05);
|
||
--lg-shadow: 0 4px 24px rgba(0,0,0,.22), 0 1px 0 rgba(255,255,255,.12) inset;
|
||
--lg-shadow-raised: 0 8px 32px rgba(0,0,0,.30), 0 1.5px 0 rgba(255,255,255,.18) inset;
|
||
--lg-radius-btn: 20px;
|
||
--lg-radius-chip: 999px;
|
||
--lg-transition: all .18s cubic-bezier(.25,.8,.25,1);
|
||
}
|
||
|
||
|
||
.app,
|
||
.section,
|
||
.search,
|
||
.connection-dock,
|
||
.sync-panel,
|
||
.metric {
|
||
background:transparent !important;
|
||
box-shadow:none !important;
|
||
}
|
||
|
||
.app{
|
||
position:relative;
|
||
z-index:1;
|
||
}
|
||
|
||
/* ── Liquid Glass 按钮基础层 ── */
|
||
.btn,
|
||
.icon-btn,
|
||
.mini-icon-btn,
|
||
.connection-toggle,
|
||
.back-top {
|
||
position: relative;
|
||
/* overflow visible:允许 outline 焦点环在父容器外显示 */
|
||
overflow: visible;
|
||
border-radius: var(--lg-radius-btn) !important;
|
||
background: var(--lg-base) !important;
|
||
border: 1px solid var(--lg-border) !important;
|
||
backdrop-filter: var(--lg-blur) !important;
|
||
-webkit-backdrop-filter: var(--lg-blur) !important;
|
||
box-shadow: var(--lg-shadow) !important;
|
||
transition: var(--lg-transition) !important;
|
||
isolation: isolate;
|
||
}
|
||
|
||
/* 状态按钮单独圆形 */
|
||
.connection-toggle {
|
||
border-radius: 999px !important;
|
||
}
|
||
|
||
/* chip 导航栏:新一体长条设计,透明底 + active 高光 */
|
||
.chip {
|
||
position: relative;
|
||
/* overflow visible:允许 outline 焦点环溢出,不被 #chips-bar 裁剪 */
|
||
overflow: visible;
|
||
border-radius: 999px !important;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
box-shadow: none !important;
|
||
transition: color .18s ease, background .18s ease !important;
|
||
isolation: isolate;
|
||
}
|
||
/* ── chip.active 呼吸光晕(已合并到下方主规则)── */
|
||
|
||
/* 内高光条 — 顶部玻璃折射感 */
|
||
.btn::before,
|
||
.icon-btn::before,
|
||
.mini-icon-btn::before,
|
||
.connection-toggle::before,
|
||
.back-top::before {
|
||
content: "" !important;
|
||
display: block !important;
|
||
position: absolute !important;
|
||
inset: 0 !important;
|
||
border-radius: inherit !important;
|
||
background: linear-gradient(
|
||
160deg,
|
||
rgba(255,255,255,.18) 0%,
|
||
rgba(255,255,255,.06) 38%,
|
||
transparent 60%
|
||
) !important;
|
||
pointer-events: none !important;
|
||
z-index: 1 !important;
|
||
overflow: hidden !important; /* 高光层自身裁剪,不依赖父容器 overflow */
|
||
}
|
||
|
||
/* 确保内容在高光层之上 */
|
||
.btn > *,
|
||
.icon-btn > *,
|
||
.mini-icon-btn > *,
|
||
.connection-toggle > *,
|
||
.back-top > * {
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
/* Hover — 玻璃微微提亮 */
|
||
.btn:hover,
|
||
.icon-btn:hover,
|
||
.mini-icon-btn:hover,
|
||
.connection-toggle:hover,
|
||
.back-top:hover {
|
||
background: rgba(255,255,255,.13) !important;
|
||
border-color: rgba(255,255,255,.22) !important;
|
||
box-shadow: var(--lg-shadow-raised) !important;
|
||
transform: translateY(-1px) !important;
|
||
}
|
||
|
||
/* Focus / Active — 下压弹动 */
|
||
.btn:focus,
|
||
.icon-btn:focus,
|
||
.mini-icon-btn:focus,
|
||
.chip:focus,
|
||
.connection-toggle:focus,
|
||
.back-top:focus {
|
||
background: rgba(255,255,255,.16) !important;
|
||
border-color: rgba(140,200,255,.55) !important;
|
||
box-shadow: 0 0 0 2px rgba(100,180,255,.30), var(--lg-shadow-raised) !important;
|
||
transform: translateY(-1px) !important;
|
||
outline: none !important;
|
||
}
|
||
|
||
.btn:active,
|
||
.icon-btn:active,
|
||
.mini-icon-btn:active,
|
||
.chip:active,
|
||
.connection-toggle:active,
|
||
.back-top:active {
|
||
background: rgba(255,255,255,.06) !important;
|
||
box-shadow: var(--lg-shadow) !important;
|
||
transform: translateY(1px) scale(.98) !important;
|
||
transition: all .08s ease !important;
|
||
}
|
||
|
||
/* 主要/选中态 — 淡玻璃白,不再深蓝色 */
|
||
.btn.primary,
|
||
.btn.blue {
|
||
background: rgba(80,160,255,.16) !important;
|
||
border-color: rgba(120,190,255,.32) !important;
|
||
box-shadow: 0 4px 20px rgba(80,140,255,.20), 0 1px 0 rgba(255,255,255,.18) inset !important;
|
||
}
|
||
|
||
.btn.primary:hover,
|
||
.btn.blue:hover {
|
||
background: rgba(80,160,255,.24) !important;
|
||
border-color: rgba(140,200,255,.42) !important;
|
||
}
|
||
|
||
/* chip 选中态 — 与状态按钮同款毛玻璃,只加亮边框区分 */
|
||
.chip.active {
|
||
background: rgba(255,255,255,.14) !important;
|
||
border-color: rgba(255,255,255,.38) !important;
|
||
box-shadow: 0 4px 18px rgba(0,0,0,.22), 0 1.5px 0 rgba(255,255,255,.22) inset !important;
|
||
color: #fff !important;
|
||
position: relative !important;
|
||
overflow: hidden !important;
|
||
}
|
||
|
||
/* 呼吸内光晕 — 径向渐变从中心向外,opacity 呼吸 */
|
||
.chip.active::before {
|
||
content: "" !important;
|
||
display: block !important;
|
||
position: absolute !important;
|
||
inset: 0 !important;
|
||
border-radius: inherit !important;
|
||
background: radial-gradient(ellipse at 50% 120%,
|
||
rgba(140,200,255,.70) 0%,
|
||
rgba(160,110,255,.45) 35%,
|
||
transparent 68%
|
||
) !important;
|
||
pointer-events: none !important;
|
||
z-index: 0 !important;
|
||
/* 静态基线可见:即便动画在 WebView 首帧尚未起步(合成层未提升),
|
||
光晕也已点亮,避免「首次加载是暗色块、点击后才正常」的问题。 */
|
||
opacity: .9 !important;
|
||
animation: chip-inner-breathe 2s ease-in-out infinite !important;
|
||
animation-delay: -1s !important;
|
||
}
|
||
|
||
@keyframes chip-inner-breathe {
|
||
0% { opacity: .62; transform: scaleY(.94); }
|
||
50% { opacity: 1; transform: scaleY(1); }
|
||
100% { opacity: .62; transform: scaleY(.94); }
|
||
}
|
||
|
||
/* chip 文字在光晕层上方 */
|
||
.chip.active > * {
|
||
position: relative !important;
|
||
z-index: 1 !important;
|
||
}
|
||
|
||
/* TV 端统一在下方 tv-mode 块中禁用,此处不重复 */
|
||
|
||
.chip.active:hover {
|
||
background: rgba(255,255,255,.20) !important;
|
||
border-color: rgba(255,255,255,.50) !important;
|
||
}
|
||
|
||
|
||
/* danger 按钮 */
|
||
.btn.danger {
|
||
background: rgba(255,80,80,.12) !important;
|
||
border-color: rgba(255,160,160,.24) !important;
|
||
}
|
||
|
||
/* ── 焦点效果(静态,无呼吸动画)── */
|
||
/* 触摸不显示chip焦点背景,只有键盘/遥控器(focus-visible)才显示 */
|
||
.chip:focus:not(:focus-visible),
|
||
.chip.active:focus:not(:focus-visible) {
|
||
outline: none !important;
|
||
border-color: transparent !important;
|
||
transform: none !important;
|
||
background: transparent !important;
|
||
box-shadow: none !important;
|
||
}
|
||
.chip.active:focus:not(:focus-visible) {
|
||
background: rgba(255,255,255,.14) !important;
|
||
}
|
||
.chip:focus-visible {
|
||
outline: none !important;
|
||
border-color: rgba(255,255,255,.5) !important;
|
||
transform: none !important;
|
||
background: rgba(70,85,96,.72) !important;
|
||
}
|
||
.chip.active:focus-visible {
|
||
background: rgba(82,98,108,.82) !important;
|
||
}
|
||
|
||
html.tv-mode .chip:focus {
|
||
background: rgba(255,255,255,.18) !important;
|
||
color: #fff !important;
|
||
transform: none !important;
|
||
border-color: rgba(255,255,255,.5) !important;
|
||
}
|
||
|
||
html.tv-mode .focusable:focus,
|
||
html.tv-mode button:focus,
|
||
html.tv-mode input:focus,
|
||
html.tv-mode .btn:focus {
|
||
outline: none !important;
|
||
border-color: rgba(255,255,255,.3) !important;
|
||
}
|
||
|
||
html.tv-mode .card:focus:not(.person-card) {
|
||
outline: none !important;
|
||
border-color: rgba(255,255,255,.2) !important;
|
||
transform: translateY(-4px) scale(1.04) !important;
|
||
}
|
||
|
||
html.tv-mode .person-card:focus {
|
||
outline: none !important;
|
||
border-color: rgba(255,255,255,.2) !important;
|
||
transform: translateY(-4px) scale(1.04) !important;
|
||
}
|
||
|
||
@media (hover:none),(pointer:coarse) {
|
||
button:focus:not(.chip):not(.field):not(.person-card),
|
||
.focusable:focus:not(.chip):not(.field):not(.person-card),
|
||
.btn:focus:not(.person-card) {
|
||
outline: none !important;
|
||
border-color: rgba(255,255,255,.3) !important;
|
||
}
|
||
}
|
||
|
||
.pan-result-item:focus {
|
||
outline: none !important;
|
||
border-color: transparent !important;
|
||
background: rgba(82,100,112,.86) !important;
|
||
}
|
||
|
||
/* ── 搜索结果激活时:虚化主页其他内容 ── */
|
||
html.search-active #chips-wrapper,
|
||
html.search-active #chips,
|
||
html.search-active #recommendSection,
|
||
html.search-active #listStack,
|
||
html.search-active .back-top {
|
||
filter: blur(6px) brightness(0.5);
|
||
pointer-events: none;
|
||
user-select: none;
|
||
transition: filter .28s ease, opacity .28s ease;
|
||
}
|
||
|
||
/* 聚焦搜索框时:主页内容高斯模糊 + 禁止滚动 */
|
||
html.search-focused #chips-wrapper,
|
||
html.search-focused #chips,
|
||
html.search-focused #recommendSection,
|
||
html.search-focused #listStack,
|
||
html.search-focused #searchSection,
|
||
html.search-focused .back-top {
|
||
filter: blur(8px) brightness(0.45);
|
||
pointer-events: none;
|
||
user-select: none;
|
||
transition: filter .25s ease;
|
||
}
|
||
|
||
html.search-focused body {
|
||
overflow: hidden;
|
||
touch-action: none;
|
||
}
|
||
|
||
/* 搜索框聚焦时:全屏蒙层,吞掉对背景内容的点击/滑动,
|
||
点击该蒙层会退出搜索聚焦态(document click 逻辑) */
|
||
#searchFocusMask {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 90;
|
||
background: transparent;
|
||
touch-action: none;
|
||
}
|
||
|
||
html.search-focused #searchFocusMask {
|
||
display: block;
|
||
}
|
||
|
||
/* 搜索栏本身要保持在蒙层之上,可继续输入/操作 */
|
||
html.search-focused .search-bar-row {
|
||
z-index: 96;
|
||
}
|
||
html.search-active #searchSection {
|
||
position: relative;
|
||
z-index: 10;
|
||
padding-bottom: 65px;
|
||
}
|
||
/* sentinel 已通过 JS display:none 处理,这里加保险 */
|
||
html.search-active #infiniteSentinel {
|
||
display: none !important;
|
||
}
|
||
|
||
/* ── 搜索列表样式 ── */
|
||
#searchRail {
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
gap: 10px !important;
|
||
overflow: visible !important;
|
||
padding: 2px 0 12px !important;
|
||
scroll-snap-type: none !important;
|
||
}
|
||
|
||
.search-list-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
width: 100%;
|
||
padding: 14px 12px;
|
||
border-radius: 18px;
|
||
background: rgba(255,255,255,.06);
|
||
border: 1px solid rgba(255,255,255,.08);
|
||
box-shadow: 0 2px 12px rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.06);
|
||
text-align: left;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
transition: background .16s ease, transform .18s ease, box-shadow .18s ease;
|
||
box-sizing: border-box;
|
||
}
|
||
.search-list-item:hover,
|
||
.search-list-item:focus {
|
||
background: rgba(255,255,255,.10);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.30), inset 0 1px 0 rgba(255,255,255,.10);
|
||
outline: none;
|
||
}
|
||
.search-list-item:active {
|
||
background: rgba(255,255,255,.05);
|
||
transform: translateY(0);
|
||
}
|
||
|
||
/* 右侧箭头图标 */
|
||
.sli-arrow {
|
||
flex-shrink: 0;
|
||
align-self: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
opacity: .25;
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* 类型 / 状态徽章 */
|
||
.sli-badge {
|
||
display: inline-block;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 2px 7px;
|
||
border-radius: 6px;
|
||
margin-top: 7px;
|
||
letter-spacing: .03em;
|
||
background: rgba(96, 165, 250, .14);
|
||
color: #60a5fa;
|
||
border: .5px solid rgba(96, 165, 250, .28);
|
||
}
|
||
.sli-badge.green {
|
||
background: rgba(74, 222, 128, .12);
|
||
color: #4ade80;
|
||
border-color: rgba(74, 222, 128, .26);
|
||
}
|
||
|
||
.sli-poster {
|
||
width: 72px;
|
||
min-width: 72px;
|
||
height: 108px;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
background: rgba(255,255,255,.06);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 右侧内容区:纯竖向堆叠,每行独占一行,间距用 margin-top 控制 */
|
||
.sli-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: block;
|
||
}
|
||
|
||
.sli-title {
|
||
display: block;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
line-height: 1.35;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
margin: 0;
|
||
}
|
||
|
||
.sli-meta {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #fff;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
margin-top: 4px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* 星评行:flex 横排,严格左对齐 */
|
||
.sli-stars {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.star {
|
||
font-style: normal;
|
||
letter-spacing: 1px;
|
||
font-size: 15px;
|
||
line-height: 1;
|
||
}
|
||
.star-full { color: #f5a623; }
|
||
.star-half { color: #f5a623; font-size: 13px; }
|
||
.star-empty { color: rgba(255,255,255,.25); }
|
||
|
||
.sli-rating {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #f5a623;
|
||
line-height: 1;
|
||
}
|
||
|
||
.sli-overview {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
color: #fff;
|
||
line-height: 1.6;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
html.tv-mode .chip::before,
|
||
html.tv-mode .chip.active::before,
|
||
html.tv-mode .btn::before,
|
||
html.tv-mode .icon-btn::before {
|
||
animation: none !important;
|
||
background: none !important;
|
||
display: none !important;
|
||
}
|
||
|
||
/* TV 模式过渡只保留焦点环,其余禁用以减少重绘 */
|
||
html.tv-mode .chip,
|
||
html.tv-mode .btn,
|
||
html.tv-mode .card {
|
||
transition: box-shadow 0.06s ease, border-color 0.06s ease !important;
|
||
}
|
||
|
||
/* ── TV 综合性能加固:禁用所有合成层、will-change ── */
|
||
html.tv-mode .btn,
|
||
html.tv-mode .icon-btn,
|
||
html.tv-mode .mini-icon-btn,
|
||
html.tv-mode .connection-toggle,
|
||
html.tv-mode .back-top {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
will-change: auto !important;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.28) !important;
|
||
}
|
||
|
||
/* TV 导航栏:去掉 blur,保留半透明效果 */
|
||
html.tv-mode #chips,
|
||
html.tv-mode #chips-bar,
|
||
html.tv-mode .chips {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 盘搜结果:去掉 backdrop-filter */
|
||
html.tv-mode .pan-result-item {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 搜索建议面板:去掉 blur */
|
||
html.tv-mode .suggest-panel {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 连接面板:去掉 blur */
|
||
html.tv-mode .connection-body {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV rating badge:去掉 blur */
|
||
html.tv-mode .rating-badge,
|
||
html.tv-mode .recent-badge {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 搜索模糊效果:TV 上禁用 filter blur(昂贵) */
|
||
html.tv-mode.search-active #chips-wrapper,
|
||
html.tv-mode.search-active #chips,
|
||
html.tv-mode.search-active #recommendSection,
|
||
html.tv-mode.search-active #listStack,
|
||
html.tv-mode.search-active .back-top {
|
||
filter: none !important;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
/* TV hero-bg:关闭 will-change 避免提前合成 */
|
||
html.tv-mode .detail-hero-bg {
|
||
will-change: auto !important;
|
||
}
|
||
|
||
/* TV card focus:用 outline 替代 transform+scale 减少重排 */
|
||
html.tv-mode .card:focus:not(.person-card) {
|
||
transform: none !important;
|
||
outline: 2px solid rgba(255,255,255,.7) !important;
|
||
outline-offset: 2px !important;
|
||
border-color: rgba(255,255,255,.4) !important;
|
||
}
|
||
|
||
html.tv-mode .person-card:focus {
|
||
transform: none !important;
|
||
outline: 2px solid rgba(255,255,255,.7) !important;
|
||
outline-offset: 2px !important;
|
||
border-color: rgba(255,255,255,.4) !important;
|
||
}
|
||
|
||
/* TV mini-icon-btn 去掉 backdrop-filter */
|
||
html.tv-mode .mini-icon-btn {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 卡片长按菜单:去掉 blur */
|
||
html.tv-mode .card-long-press-menu {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV toast:去掉背景模糊 */
|
||
html.tv-mode .toast {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* TV 详情页 sheet-blur-layer 已通过上方规则 display:none */
|
||
|
||
</style>
|
||
|
||
<!-- ══════════════════════════════════════════════════════════════
|
||
TV 端终极性能修复(必须在所有样式块之后,确保覆盖 liquid-glass !important)
|
||
根本原因:ios26-liquid-glass-fix 块对 .btn/.icon-btn 等施加了
|
||
backdrop-filter !important,此块在主 style 之后,优先级更高;
|
||
本块紧跟其后,用相同或更高优先级彻底清除所有 TV 端性能杀手。
|
||
手机端完全不受影响(所有规则均在 html.tv-mode 前缀下)。
|
||
══════════════════════════════════════════════════════════════ -->
|
||
<style id="tv-perf-final">
|
||
|
||
/* ── 1. 彻底禁用所有 backdrop-filter / filter(GPU 合成层最大杀手)── */
|
||
html.tv-mode *,
|
||
html.tv-mode *::before,
|
||
html.tv-mode *::after {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
will-change: auto !important;
|
||
}
|
||
|
||
/* ── 2. 彻底禁用所有非焦点元素的动画和过渡 ── */
|
||
html.tv-mode *:not(:focus),
|
||
html.tv-mode *:not(:focus)::before,
|
||
html.tv-mode *:not(:focus)::after {
|
||
animation: none !important;
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.06s !important;
|
||
}
|
||
|
||
/* ── 3. 焦点元素只保留边框/阴影过渡,取消其他 ── */
|
||
html.tv-mode *:focus,
|
||
html.tv-mode *:focus::before,
|
||
html.tv-mode *:focus::after {
|
||
animation: none !important;
|
||
transition: box-shadow 0.06s ease, border-color 0.06s ease, outline-color 0.06s ease !important;
|
||
will-change: auto !important;
|
||
}
|
||
|
||
/* ── 4. chip.active::before 呼吸动画:彻底隐藏伪元素(display:none 比 animation:none 更彻底)── */
|
||
html.tv-mode .chip::before,
|
||
html.tv-mode .chip.active::before,
|
||
html.tv-mode .chip::after,
|
||
html.tv-mode .chip.active::after {
|
||
display: none !important;
|
||
content: none !important;
|
||
animation: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
/* ── 5. liquid-glass 给 .btn/.icon-btn 等施加的 backdrop-filter 覆盖 ── */
|
||
html.tv-mode .btn,
|
||
html.tv-mode .icon-btn,
|
||
html.tv-mode .mini-icon-btn,
|
||
html.tv-mode .connection-toggle,
|
||
html.tv-mode .back-top,
|
||
html.tv-mode .chip,
|
||
html.tv-mode .chip.active {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
will-change: auto !important;
|
||
/* 用简单 transition 替代 liquid-glass 的 all .18s */
|
||
transition: box-shadow 0.06s ease, border-color 0.06s ease !important;
|
||
/* TV Android WebView 中文字体 baseline 偏上,补偿对齐 */
|
||
padding-top: 3px !important;
|
||
padding-bottom: 0px !important;
|
||
line-height: 1 !important;
|
||
}
|
||
|
||
/* liquid-glass 高光伪元素(::before)在 TV 端完全隐藏 */
|
||
html.tv-mode .btn::before,
|
||
html.tv-mode .icon-btn::before,
|
||
html.tv-mode .mini-icon-btn::before,
|
||
html.tv-mode .connection-toggle::before,
|
||
html.tv-mode .back-top::before {
|
||
display: none !important;
|
||
content: none !important;
|
||
animation: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
/* ── 6. 详情页高斯模糊层:TV 端彻底关闭 ── */
|
||
html.tv-mode .sheet-blur-layer,
|
||
html.tv-mode #detailSheet .sheet-blur-layer {
|
||
display: none !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
/* ── 7. detail-hero-bg:TV 端关闭 will-change + transition,只留 filter ── */
|
||
html.tv-mode .detail-hero-bg {
|
||
will-change: auto !important;
|
||
transition: opacity 0.3s ease !important; /* 只保留 opacity 过渡,去掉 filter 过渡避免滚动时每帧合成 */
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* ── 8. 搜索激活时的 filter blur:TV 端用 opacity 替代 ── */
|
||
html.tv-mode.search-active #chips-wrapper,
|
||
html.tv-mode.search-active #chips,
|
||
html.tv-mode.search-active #recommendSection,
|
||
html.tv-mode.search-active #listStack,
|
||
html.tv-mode.search-active .back-top {
|
||
filter: none !important;
|
||
opacity: 0.35 !important;
|
||
transition: opacity 0.1s ease !important;
|
||
}
|
||
|
||
/* ── 9. pan-result-item、suggest-panel 等去掉所有 blur ── */
|
||
html.tv-mode .pan-result-item,
|
||
html.tv-mode .suggest-panel,
|
||
html.tv-mode .connection-body,
|
||
html.tv-mode .rating-badge,
|
||
html.tv-mode .recent-badge,
|
||
html.tv-mode .card-long-press-menu,
|
||
html.tv-mode .toast,
|
||
html.tv-mode .image-viewer {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
/* ── 10. card focus:纯 outline,禁止 transform(会触发重排)── */
|
||
html.tv-mode .card:focus,
|
||
html.tv-mode .card:focus:not(.person-card),
|
||
html.tv-mode .person-card:focus {
|
||
transform: none !important;
|
||
outline: 3px solid rgba(255,255,255,.85) !important;
|
||
outline-offset: 2px !important;
|
||
border-color: rgba(255,255,255,.5) !important;
|
||
box-shadow: 0 0 0 5px rgba(60,140,255,.5) !important;
|
||
transition: outline-color 0.06s ease, border-color 0.06s ease !important;
|
||
}
|
||
|
||
/* ── 11. 禁用所有 CSS 动画关键帧在 TV 上的运行(pulse-dot、lp-in 等)── */
|
||
html.tv-mode {
|
||
--lg-transition: none !important;
|
||
}
|
||
|
||
/* ── 12. poster 骨架屏动画(background-size 动画)在 TV 端禁用 ── */
|
||
html.tv-mode .poster:not(.loaded) {
|
||
animation: none !important;
|
||
background: rgba(255,255,255,.06) !important;
|
||
}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<main class="app" id="home">
|
||
|
||
|
||
<div class="search-focus-mask" id="searchFocusMask" aria-hidden="true"></div>
|
||
|
||
<div class="search-bar-row" style="display:flex;align-items:center;gap:8px;position:relative;margin-top:12px;">
|
||
<aside class="connection-dock" id="connectionDock" style="flex-shrink:0;position:relative">
|
||
<button class="connection-toggle focusable" id="connectionToggle" type="button" aria-expanded="false" aria-controls="connectionBody">
|
||
<span class="dot warn" id="connectionDot"></span>
|
||
</button>
|
||
<div class="connection-body" id="connectionBody" aria-hidden="true">
|
||
<div class="connection-grid">
|
||
<div class="connection-item"><b>App SDK</b><span id="statusSdk">检测中</span></div>
|
||
<div class="connection-item"><b>TMDB</b><span id="statusTmdb">等待请求</span></div>
|
||
<div class="connection-item"><b>Nostr</b><span id="statusNostr">等待连接</span></div>
|
||
<div class="connection-item"><b>盘搜</b><span id="statusPan">未搜索</span></div>
|
||
<div class="connection-item"><b>发布</b><span id="statusPublish">暂无发布</span></div>
|
||
<div class="connection-item"><b>身份</b><span class="mono" id="statusIdentity">未就绪</span></div>
|
||
<div class="connection-item"><b>Relays</b><span id="statusRelays">未连接</span></div>
|
||
</div>
|
||
<div class="pan-config">
|
||
<label for="panBaseInput">盘搜地址
|
||
<input class="field focusable" id="panBaseInput" type="url" inputmode="url" placeholder="https://so.252035.xyz" readonly>
|
||
</label>
|
||
<label for="panChannelsInput">TG频道
|
||
<textarea class="field focusable" id="panChannelsInput" rows="2" placeholder="可选,多个频道用逗号或换行分隔" readonly></textarea>
|
||
</label>
|
||
<label for="panUserInput">账号
|
||
<input class="field focusable" id="panUserInput" type="text" autocomplete="username" placeholder="未启用认证可留空" readonly>
|
||
</label>
|
||
<label for="panPassInput">密码
|
||
<input class="field focusable" id="panPassInput" type="password" autocomplete="current-password" placeholder="未启用认证可留空" readonly>
|
||
</label>
|
||
<div class="pan-disk-grid" id="panDiskGrid"></div>
|
||
<button class="btn focusable" id="savePanConfigBtn" type="button">保存盘搜</button>
|
||
</div>
|
||
<div class="actions" style="margin-top:10px">
|
||
<button class="btn focusable" id="syncBtn" type="button">同步身份</button>
|
||
<button class="btn danger focusable" id="deleteDataBtn" type="button">删除数据</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
<form class="search" id="searchForm" autocomplete="off" style="flex:1;min-width:0;margin:0">
|
||
<input class="field focusable" id="searchInput" type="search" inputmode="search" enterkeyhint="search" placeholder="搜索关键词" aria-autocomplete="list" aria-controls="suggestPanel" readonly>
|
||
<button class="btn blue focusable" type="submit" aria-label="搜索" style="width:42px;height:42px;min-height:42px;flex-shrink:0;padding:0;">
|
||
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||
</button>
|
||
</form>
|
||
<!-- 联想/热搜面板:作为 search-bar-row 的直接子元素定位,使其铺满整行宽度
|
||
(与下方内容左对齐、视觉居中),而不是只贴着输入框右侧 -->
|
||
<div class="suggest-panel" id="suggestPanel" role="listbox"></div>
|
||
</div>
|
||
|
||
<div id="chips-wrapper">
|
||
<nav class="chips" id="chips" style="background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.10);border-radius:999px;padding:3px 4px;gap:2px;box-shadow:0 2px 16px rgba(0,0,0,.22),inset 0 1px 0 rgba(255,255,255,.08)"></nav>
|
||
</div>
|
||
|
||
<section class="section" id="searchSection" style="display:none">
|
||
<div class="section-head">
|
||
<h3>搜索结果</h3>
|
||
</div>
|
||
<div class="rail" id="searchRail"></div>
|
||
</section>
|
||
|
||
<section class="section" id="recommendSection">
|
||
<div class="section-head" style="display:none">
|
||
<h3>推荐</h3>
|
||
</div>
|
||
<div class="media-grid" id="recommendRail"></div>
|
||
</section>
|
||
|
||
<section class="section" id="listSection">
|
||
<div class="stack" id="listStack"></div>
|
||
</section>
|
||
|
||
<div class="infinite-sentinel" id="infiniteSentinel" aria-hidden="true"></div>
|
||
|
||
</main>
|
||
|
||
<section class="sheet" id="detailSheet" aria-hidden="true">
|
||
<div class="detail-hero-bg" id="detailHeroBg"></div>
|
||
<div class="sheet-blur-layer"></div>
|
||
<div class="detail-spacer"></div>
|
||
<div class="detail-cover">
|
||
<img id="detailImage" alt="">
|
||
</div>
|
||
<div class="detail-info">
|
||
<div class="detail-logo-wrap" id="detailLogoWrap" style="display:none">
|
||
<img id="detailLogoImg" class="detail-logo-img" alt="">
|
||
</div>
|
||
<div class="detail-title-row">
|
||
<h2 id="detailTitle"></h2>
|
||
<span class="detail-title-meta" id="detailTitleMeta"></span>
|
||
</div>
|
||
<div class="detail-meta" id="detailMeta"></div>
|
||
<div class="collapsible-overview open" id="detailOverviewWrap">
|
||
<button class="overview-toggle" id="detailOverviewToggle" type="button" aria-expanded="true">
|
||
<svg class="overview-toggle-icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
||
<span id="detailOverviewLabel">简介</span>
|
||
</button>
|
||
<div class="overview-body">
|
||
<p id="detailText"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn primary focusable" id="detailContinueBtn" type="button" style="display:none" aria-hidden="true">
|
||
<svg class="icon" viewBox="0 0 24 24"><path d="M8 5v14l11-7Z"/></svg>
|
||
<span id="detailContinueText">继续观看</span>
|
||
</button>
|
||
<button class="btn primary focusable" id="detailSearchBtn" type="button">
|
||
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||
<span id="detailSearchText">搜索播放</span>
|
||
</button>
|
||
<button class="btn focusable" id="panSearchBtn" type="button">
|
||
<svg class="icon" viewBox="0 0 24 24"><path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/></svg>
|
||
盘搜
|
||
</button>
|
||
<button class="btn focusable" id="closeDetailBtn" type="button" aria-label="返回" style="display:none">
|
||
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||
返回
|
||
</button>
|
||
</div>
|
||
<div class="detail-block pan-search-block" id="panSearchBlock" style="display:none">
|
||
<div class="subsection-head">
|
||
<h4>盘搜资源</h4>
|
||
<span id="panSearchHint"></span>
|
||
</div>
|
||
<div class="chips pan-tabs" id="panTabs"></div>
|
||
<div class="pan-result-list" id="panResultList"></div>
|
||
</div>
|
||
<div class="detail-block" id="seasonBlock" style="display:none">
|
||
<h3>分季 / 分集</h3>
|
||
<div class="chips" id="seasonTabs"></div>
|
||
<div class="rail" id="episodeRail"></div>
|
||
</div>
|
||
<div class="detail-block" id="castBlock" style="display:none">
|
||
<h3>演职员表</h3>
|
||
<div class="rail" id="castRail"></div>
|
||
</div>
|
||
<div class="detail-block" id="personWorkBlock" style="display:none">
|
||
<h3 id="personWorkTitle">简介</h3>
|
||
<div id="personInfo"></div>
|
||
<div class="rail" id="personWorkRail"></div>
|
||
</div>
|
||
<div class="detail-block" id="recommendBlock" style="display:none">
|
||
<h3>相关推荐</h3>
|
||
<div class="rail" id="recommendWorkRail"></div>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<section class="sheet" id="syncSheet" aria-hidden="true">
|
||
<button class="btn focusable" id="closeSyncBtn" type="button">
|
||
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||
返回
|
||
</button>
|
||
<div class="sync-panel" style="margin-top:14px">
|
||
<h3 style="margin:0">Nostr 同步身份</h3>
|
||
<p class="hint">页面会自动生成本机身份并把有效观看偏好签名发布到 relay。手机和电脑如果想同步同一个用户的推荐,请导入同一个 nsec。只看全网热度时不需要导入。</p>
|
||
<div>
|
||
<label class="hint" for="nsecInput">nsec 私钥</label>
|
||
<textarea class="field focusable mono" id="nsecInput" placeholder="nsec1..."></textarea>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn primary focusable" id="saveNsecBtn" type="button">导入身份</button>
|
||
<button class="btn focusable" id="newNsecBtn" type="button">生成新身份</button>
|
||
</div>
|
||
<p class="hint mono" id="identityText">身份未就绪</p>
|
||
<p class="hint" id="relayText">relay 未连接</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="image-viewer" id="imageViewer" aria-hidden="true">
|
||
<button class="btn focusable" id="closeImageBtn" type="button">
|
||
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||
返回
|
||
</button>
|
||
<div class="image-content focusable" id="imageContent" tabindex="0">
|
||
<img id="viewerImage" alt="">
|
||
<div class="episode-view" id="episodeViewer" style="display:none"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
<button class="back-top focusable" id="backTopBtn" type="button" aria-label="返回顶部">
|
||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
|
||
</button>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.js"></script>
|
||
<script>
|
||
window.WEBHOME_CONFIG = {
|
||
siteKey: "",
|
||
tmdb: {
|
||
apiKey: "d7040155454e7fdf547c4d889ebbcca7",
|
||
apiBase: "https://api.tmdb.org/3",
|
||
language: "zh-CN",
|
||
imageBase: "https://images.tmdb.org/t/p/w342",
|
||
backdropBase: "https://images.tmdb.org/t/p/w1920_and_h800_multi_faces",
|
||
lists: [
|
||
{
|
||
id: "all",
|
||
title: "推荐",
|
||
hint: "近期国内外最新上线",
|
||
mediaType: "all",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+7", vote_count_gte: "2" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "2" } },
|
||
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-60", "primary_release_date_lte": "today+3", vote_count_gte: "5", language: "zh-CN" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US|GB", without_genres: "10764,10767,10766,16", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "5" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ko", with_origin_country: "KR", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ja", with_origin_country: "JP", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } }
|
||
]
|
||
},
|
||
{
|
||
id: "cn-tv",
|
||
title: "华语剧",
|
||
hint: "大陆长篇电视剧 · 最新热播",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } }
|
||
]
|
||
},
|
||
{
|
||
id: "movie-cn",
|
||
title: "华语电影",
|
||
hint: "华语新片·热门·高口碑",
|
||
mediaType: "movie",
|
||
sources: [
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-180", "primary_release_date_lte": "today+30", vote_count_gte: "1", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "2", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-365", "primary_release_date_lte": "today", vote_count_gte: "5", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "vote_average.desc", "primary_release_date_gte": "today-730", "primary_release_date_lte": "today", vote_count_gte: "30", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "cn", sort_by: "popularity.desc", "primary_release_date_gte": "today-365", "primary_release_date_lte": "today", vote_count_gte: "3", language: "zh-CN" } }
|
||
]
|
||
},
|
||
{
|
||
id: "movie-foreign",
|
||
title: "国外电影",
|
||
hint: "全球新片·近三月热播",
|
||
mediaType: "movie",
|
||
excludeOriginalLanguage: ["zh", "cn"],
|
||
sources: [
|
||
{ endpoint: "movie/now_playing", mediaType: "movie", params: { region: "US", language: "zh-CN" } },
|
||
{ endpoint: "movie/upcoming", mediaType: "movie", params: { region: "US", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+30", vote_count_gte: "10", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "30", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "ko", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "8", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "ja", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "8", language: "zh-CN" } }
|
||
]
|
||
},
|
||
{
|
||
id: "jp-kr-tv",
|
||
title: "日韩剧",
|
||
hint: "韩国·日本最新剧集",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{
|
||
endpoint: "discover/tv",
|
||
mediaType: "tv",
|
||
params: {
|
||
with_original_language: "ko",
|
||
sort_by: "first_air_date.desc",
|
||
first_air_date_gte: "today-120",
|
||
first_air_date_lte: "today+60",
|
||
include_null_first_air_dates: "false",
|
||
without_genres: "16,99"
|
||
}
|
||
},
|
||
{
|
||
endpoint: "discover/tv",
|
||
mediaType: "tv",
|
||
params: {
|
||
with_original_language: "ja",
|
||
sort_by: "first_air_date.desc",
|
||
first_air_date_gte: "today-120",
|
||
first_air_date_lte: "today+60",
|
||
include_null_first_air_dates: "false",
|
||
without_genres: "16,99"
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: "hk-tw-tv",
|
||
title: "港台剧",
|
||
hint: "香港·台湾最新剧集",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "TW", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-365", "first_air_date_lte": "today+14" } }
|
||
]
|
||
},
|
||
{
|
||
id: "us-tv",
|
||
title: "欧美剧",
|
||
hint: "Netflix·HBO·Apple TV+·Disney+ 最新",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "GB", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+14", vote_count_gte: "10" } }
|
||
]
|
||
},
|
||
{
|
||
id: "variety-cn",
|
||
title: "国内综艺",
|
||
hint: "内地热门综艺节目",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "HK", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "TW", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } }
|
||
]
|
||
},
|
||
{
|
||
id: "variety-global",
|
||
title: "国外综艺",
|
||
hint: "海外综艺·真人秀",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_origin_country: "US", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_origin_country: "KR", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_origin_country: "US", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_origin_country: "KR", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } }
|
||
]
|
||
},
|
||
{
|
||
id: "anime",
|
||
title: "动画",
|
||
hint: "国内外最新动画",
|
||
mediaType: "tv",
|
||
sources: [
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "ja", with_origin_country: "JP", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "zh", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-120", "primary_release_date_lte": "today+30" } }
|
||
]
|
||
},
|
||
{
|
||
id: "documentary",
|
||
title: "纪录片",
|
||
hint: "全球最热门纪录片",
|
||
mediaType: "all",
|
||
sources: [
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "50", language: "zh-CN" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "20", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", with_original_language: "zh", sort_by: "vote_average.desc", vote_count_gte: "20", language: "zh-CN" } }
|
||
]
|
||
},
|
||
{
|
||
id: "concert",
|
||
title: "演唱会",
|
||
hint: "演唱会·音乐现场·Live",
|
||
mediaType: "movie",
|
||
sources: [
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", sort_by: "popularity.desc", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+30", language: "zh-CN" } },
|
||
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "10402", with_keywords: "156205", sort_by: "vote_average.desc", vote_count_gte: "5", language: "zh-CN" } },
|
||
{ endpoint: "discover/tv", mediaType: "tv", params: { with_keywords: "156205", sort_by: "popularity.desc", language: "zh-CN" } }
|
||
]
|
||
}
|
||
]
|
||
},
|
||
nostr: {
|
||
kind: 30078,
|
||
tag: "fish2018-home-v1",
|
||
eventsKey: "fish2018_home_v1_events",
|
||
nsecKey: "fish2018_home_v1_nsec",
|
||
relays: [
|
||
"wss://relay-sgp.signedbyme.com",
|
||
"wss://relay.lovelana.org",
|
||
"wss://relay.nostr.moe",
|
||
"wss://nostr.spacecitynode.com",
|
||
// "ws://139.162.37.202",
|
||
// "wss://nos.lol",
|
||
// "wss://relay.snort.social",
|
||
// "wss://nostr.mom",
|
||
// "wss://relay.primal.net",
|
||
// "wss://nostr-01.yakihonne.com",
|
||
// "wss://nostr-02.yakihonne.com"
|
||
]
|
||
},
|
||
pan: {
|
||
apiBase: "https://so.252035.xyz",
|
||
cacheKey: "fish2018_home_v1_pan_config",
|
||
diskTypes: ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile", "pikpak", "guangya", "magnet", "ed2k"],
|
||
checkableDiskTypes: ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"],
|
||
pollIntervals: [4500, 9000]
|
||
}
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const PAN_DISK_TYPES = [
|
||
{ id: "quark", name: "夸克" },
|
||
{ id: "aliyun", name: "阿里" },
|
||
{ id: "baidu", name: "百度" },
|
||
{ id: "uc", name: "UC" },
|
||
{ id: "tianyi", name: "天翼" },
|
||
{ id: "xunlei", name: "迅雷" },
|
||
{ id: "123", name: "123" },
|
||
{ id: "115", name: "115" },
|
||
{ id: "mobile", name: "移动" },
|
||
{ id: "pikpak", name: "PikPak" },
|
||
{ id: "guangya", name: "光鸭" },
|
||
{ id: "magnet", name: "磁力" },
|
||
{ id: "ed2k", name: "电驴" }
|
||
];
|
||
const PAN_CHECKABLE_DISK_TYPES = new Set(window.WEBHOME_CONFIG.pan.checkableDiskTypes || ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"]);
|
||
const PAN_HEALTH_PRIORITY = { ok: 0, locked: 1, pending: 2, idle: 3, unsupported: 4, uncertain: 5, bad: 6 };
|
||
// 画质关键词优先级(score 越高排越前):
|
||
// DV/HDR10+ > HDR10/HLG > 4K/2160P > 高码率 > 杜比全景声 > 1080P > 720P > SDR
|
||
const PAN_QUALITY_KEYWORDS = [
|
||
{ re: /dv|dolby.?vision|杜比视界/i, score: 10, label: "DolbyVision", cls: "dv" },
|
||
{ re: /hdr10\+|hdr10plus/i, score: 9, label: "HDR10+", cls: "hdrp" },
|
||
{ re: /hdr(?!10\+)|hlg|hdr10(?!\+)/i, score: 8, label: "HDR" },
|
||
{ re: /4k|2160p|uhd/i, score: 7, label: "4K" },
|
||
{ re: /高码|高码率/i, score: 6, label: "高码" },
|
||
{ re: /杜比(?!视界)|atmos/i, score: 5, label: "杜比" },
|
||
{ re: /1080p|1080i|全高清|fhd/i, score: 4, label: "1080P", cls: "fhd" },
|
||
{ re: /720p/i, score: 3, label: "720P", cls: "hd" },
|
||
{ re: /480p|576p/i, score: 2, label: "SD", cls: "sd" },
|
||
{ re: /sdr/i, score: 1, label: "SDR" },
|
||
];
|
||
function panQualityInfo(item) {
|
||
const title = String((item && item.title) || "");
|
||
let count = 0, score = 0;
|
||
const tags = [];
|
||
PAN_QUALITY_KEYWORDS.forEach((q) => {
|
||
if (q.re.test(title)) { count++; score += q.score; tags.push({ label: q.label, cls: q.cls || null }); }
|
||
});
|
||
return { count, score, tags };
|
||
}
|
||
const PAGE_OPENED_AT = window.WEBHOME_PAGE_OPENED_AT || Date.now();
|
||
const GRID_INITIAL_ROWS = 12;
|
||
const GRID_APPEND_ROWS = 8;
|
||
const RECENT_UI_TTL_MS = 30000;
|
||
const state = {
|
||
site: null,
|
||
config: null,
|
||
identity: null,
|
||
selected: null,
|
||
detail: null,
|
||
activeList: "all",
|
||
catalog: {},
|
||
catalogPage: {},
|
||
recent: { items: [], loading: false, loaded: false, error: "", refreshedAt: 0 },
|
||
gridRender: {},
|
||
loadingMore: false,
|
||
chipFocusTimer: 0,
|
||
railScroll: {},
|
||
searchItems: [],
|
||
suggestions: { keyword: "", items: [], loading: false, timer: 0, seq: 0, controller: null },
|
||
hot: { db: null, dbPromise: null, idb: false, ready: false, items: [], media: new Map(), users: new Map(), ingestQueue: Promise.resolve(), refreshTimer: 0 },
|
||
relay: { connected: 0, published: 0, total: 0, lastOk: 0, lastDone: 0, statuses: {}, subscribeStarted: false, subscribeDone: 0, subscribeFinished: {}, backfillBusy: {}, backfillTimers: {}, backfillCandidates: {}, primaryBackfillRelay: "", fallbackReadyAt: 0, fallbackTimer: 0, fallbackPrefetchTimer: 0 },
|
||
status: {
|
||
sdk: "检测中",
|
||
tmdb: "等待请求",
|
||
nostr: "等待连接",
|
||
pan: "未搜索",
|
||
publish: "暂无发布",
|
||
identity: "未就绪"
|
||
},
|
||
deleteState: { loaded: false, users: {} },
|
||
pan: { config: null, configDirty: false, loading: false, keyword: "", activeType: "", results: [], health: {}, pending: {}, queued: new Map(), inFlight: new Set(), observer: null, flushTimer: 0, pollTimers: [], pollRound: 0, viewToken: "", renderKeys: "", tabKeys: "", checkEnabled: false, focusKey: "", focusMode: "", playbackReturn: null, isPlaying: false },
|
||
watch: { item: null, timer: 0, bestMs: 0, durationMs: 0, lastAt: 0, published: false, intentAction: "" },
|
||
detailCover: { images: [], index: 0, timer: 0, token: 0, swipe: null },
|
||
deviceMode: "unknown",
|
||
detailReturn: null,
|
||
focusReturnEl: null,
|
||
remoteInitialFocused: false,
|
||
renderTimer: 0,
|
||
homeContentTimer: 0,
|
||
homeContentSeq: 0,
|
||
activeGridAppendTimer: 0,
|
||
activeGridAppendId: "",
|
||
scrollingUntil: 0,
|
||
infiniteObserver: null,
|
||
scrollLoadTimer: 0,
|
||
resume: { timer: 0, lastAt: 0 }
|
||
};
|
||
|
||
const WATCH_HEAT_MS = 10 * 60 * 1000;
|
||
const FALLBACK_PREFETCH_MS = 1000;
|
||
const FALLBACK_SHOW_MS = 1500;
|
||
const HOT_WINDOW_DAYS = 90;
|
||
const HOT_DAY_SECONDS = 24 * 60 * 60;
|
||
const HOT_WINDOW_SECONDS = HOT_WINDOW_DAYS * 24 * 60 * 60;
|
||
const HOT_WINDOW_MS = HOT_WINDOW_SECONDS * 1000;
|
||
const HOT_DB_NAME = "fish2018_home_v1_hot_compact_index";
|
||
const HOT_DB_VERSION = 1;
|
||
const HOT_VECTOR_D = "heat:user:90d:v2";
|
||
const HOT_VECTOR_VERSION = 5;
|
||
const HOT_USER_VECTOR_LIMIT = 300;
|
||
const HOT_TITLE_LIMIT = 60;
|
||
const HOT_POSTER_LIMIT = 96;
|
||
const HOT_PAGE_LIMIT = 1000;
|
||
const HOT_SUBSCRIBE_LIMIT = 500;
|
||
const HOT_RECENT_PAGES_PER_RELAY = 6;
|
||
const HOT_HISTORY_PAGES_PER_RELAY = 10;
|
||
const HOT_BACKFILL_IDLE_MS = 650;
|
||
const HOT_BACKFILL_RETRY_MS = 30000;
|
||
const HOT_BACKFILL_RETRY_MAX_MS = 5 * 60 * 1000;
|
||
const HOT_BACKUP_WINDOW_SECONDS = 7 * HOT_DAY_SECONDS;
|
||
const HOT_REFRESH_IDLE_MS = 260;
|
||
const HOT_REFRESH_BACKFILL_MS = 900;
|
||
const HOT_PRUNE_BATCH = 1000;
|
||
const HOT_RENDER_LIMIT = 1000;
|
||
const UI_SNAPSHOT_TTL_MS = 2 * 60 * 60 * 1000;
|
||
const PAN_PLAYBACK_RETURN_TTL_MS = 10 * 60 * 1000;
|
||
const DELETE_REPUBLISH_BLOCK_MS = 24 * 60 * 60 * 1000;
|
||
const DELETE_TOMBSTONE_TTL_MS = HOT_WINDOW_MS + HOT_DAY_SECONDS * 1000;
|
||
|
||
let nativeSdkWait = null;
|
||
|
||
function waitForNativeSdk(timeout) {
|
||
if (window.fm || !window.fongmiBridge) return Promise.resolve(!!window.fm);
|
||
if (nativeSdkWait) return nativeSdkWait;
|
||
nativeSdkWait = new Promise((resolve) => {
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
window.removeEventListener("fmsdk", finish);
|
||
nativeSdkWait = null;
|
||
resolve(!!window.fm);
|
||
};
|
||
window.addEventListener("fmsdk", finish);
|
||
setTimeout(finish, timeout == null ? 1500 : timeout);
|
||
});
|
||
return nativeSdkWait;
|
||
}
|
||
|
||
function sdk() {
|
||
if (window.fm) {
|
||
const pan = window.fm.pan || {};
|
||
return Object.assign({}, window.fm, { pan, check: pan.check || window.fm.check });
|
||
}
|
||
const check = async (items) => ({ results: (items || []).map((item) => ({ type: item.type, url: item.url, normalized_url: item.url, state: "idle", summary: "浏览器预览不检测", cache_hit: false, checked_at: Date.now(), expires_at: Date.now() + 300000 })) });
|
||
const play = async (payload) => {
|
||
const item = payload || {};
|
||
const url = addPanPassword(String(item.url || ""), item.password || "", { diskType: item.type });
|
||
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||
};
|
||
return {
|
||
req: browserRequest,
|
||
res: (url) => url,
|
||
play: async (url, title) => window.open(url, "_blank", "noopener,noreferrer"),
|
||
search: async (kw) => toast("原生搜索:" + kw),
|
||
openLive: async () => toast("请在 App 中打开直播"),
|
||
openKeep: async () => toast("请在 App 中打开收藏"),
|
||
history: async () => [],
|
||
vod: async (siteKey, vodId, title) => toast("原生播放:" + (title || vodId || siteKey || "")),
|
||
pan: { check, play },
|
||
check,
|
||
ui: { setToolbar: async () => {} },
|
||
cache: {
|
||
get: async (key) => localStorage.getItem("fm_" + key) || "",
|
||
set: async (key, value) => localStorage.setItem("fm_" + key, value),
|
||
del: async (key) => localStorage.removeItem("fm_" + key)
|
||
},
|
||
site: async () => ({ key: "browser", name: "浏览器预览" }),
|
||
config: async () => ({ id: "browser", url: location.href, driveCheck: false }),
|
||
device: async () => ({ type: 1 })
|
||
};
|
||
}
|
||
|
||
function isTvMode() {
|
||
return document.documentElement.classList.contains("tv-mode");
|
||
}
|
||
|
||
function isNativeMobileClient() {
|
||
const client = window.fongmiClient || {};
|
||
if (client.isMobile === true) return true;
|
||
if (String(client.mode || "").toLowerCase() === "mobile") return true;
|
||
return false;
|
||
}
|
||
|
||
function isNativeLeanbackClient() {
|
||
const client = window.fongmiClient || {};
|
||
if (client.isLeanback === true) return true;
|
||
if (String(client.mode || "").toLowerCase() === "leanback") return true;
|
||
return false;
|
||
}
|
||
|
||
function setTvMode(tv) {
|
||
document.documentElement.classList.toggle("tv-mode", !!tv);
|
||
syncNativeToolbarForRoute();
|
||
}
|
||
|
||
function setNativeToolbarVisible(visible, force) {
|
||
// 手机端详情页:隐藏/恢复原生 Toolbar,实现沉浸全屏
|
||
if (!force && !isTvMode() && !isNativeMobileClient()) return;
|
||
try {
|
||
const ui = sdk().ui || {};
|
||
if (ui.setToolbar) ui.setToolbar(visible);
|
||
// 同步 CSS 变量:Toolbar 隐藏时给顶部腾出状态栏空间
|
||
if (!visible) {
|
||
document.documentElement.classList.add("detail-immersive");
|
||
} else {
|
||
document.documentElement.classList.remove("detail-immersive");
|
||
}
|
||
} catch (e) {
|
||
// 降级:即使 SDK 不支持也应用 CSS 沉浸效果
|
||
if (!visible) {
|
||
document.documentElement.classList.add("detail-immersive");
|
||
} else {
|
||
document.documentElement.classList.remove("detail-immersive");
|
||
}
|
||
}
|
||
}
|
||
|
||
function syncNativeToolbarForRoute() {
|
||
// 沉浸(全屏)状态是粘滞的,由 _fsOn 决定,不再随路由自动恢复 Toolbar
|
||
_fsSetToolbar(!_fsOn);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// 全屏(沉浸)模型 — 参考 Eclipse
|
||
// 规则:
|
||
// · TV 端:首页加载不全屏;进入详情/子页面后全屏。
|
||
// · 移动端:加载即全屏(含首页)。
|
||
// · 退出全屏的唯一方式 = 已在主页时再按一次返回;
|
||
// 其它任何返回只关闭当前层、保持全屏。
|
||
// ══════════════════════════════════════════════════════
|
||
let _fsOn = false; // 当前是否处于全屏(Toolbar 隐藏)
|
||
function _fsSetToolbar(show) {
|
||
try { const ui = sdk().ui || {}; if (ui.setToolbar) ui.setToolbar(show); } catch (e) {}
|
||
}
|
||
function _applyFs(on) {
|
||
_fsOn = !!on;
|
||
_fsSetToolbar(!on); // 全屏 = 隐藏 Toolbar
|
||
}
|
||
function _enterFullscreen() { if (!_fsOn) _applyFs(true); } // 进入子页面时调用
|
||
function _exitFullscreen() { if (_fsOn) _applyFs(false); } // 仅主页返回时调用
|
||
// 启动时按设备类型决定首页初始全屏状态
|
||
function _initFsForHome() {
|
||
if (isNativeLeanbackClient() || isTvMode()) _applyFs(false); // TV:首页不全屏
|
||
else _applyFs(true); // 移动端:首页即全屏
|
||
}
|
||
// 是否有二级 sheet 处于打开状态(详情 / 同步 / 图片)
|
||
function _anySheetActive() {
|
||
return (
|
||
($("detailSheet") && $("detailSheet").classList.contains("active")) ||
|
||
($("syncSheet") && $("syncSheet").classList.contains("active")) ||
|
||
($("imageViewer") && $("imageViewer").classList.contains("active"))
|
||
);
|
||
}
|
||
|
||
async function detectDeviceMode() {
|
||
try {
|
||
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1500);
|
||
const api = sdk();
|
||
if (isNativeLeanbackClient()) {
|
||
state.deviceMode = "leanback";
|
||
setTvMode(true);
|
||
return true;
|
||
}
|
||
if (isNativeMobileClient()) state.deviceMode = "mobile";
|
||
if (!api.device) {
|
||
setNativeToolbarVisible(true, true);
|
||
document.documentElement.classList.remove("tv-mode");
|
||
return false;
|
||
}
|
||
const device = await api.device();
|
||
const mode = String(device && device.mode || "").toLowerCase();
|
||
const tv = Number(device && device.type) === 0 || mode === "leanback";
|
||
const nativeClient = !!(window.fm || window.fongmiBridge || window.fongmiClient);
|
||
state.deviceMode = tv ? "leanback" : nativeClient && (Number(device && device.type) === 1 || mode === "mobile") ? "mobile" : "browser";
|
||
setTvMode(tv);
|
||
return tv;
|
||
} catch (e) {
|
||
setNativeToolbarVisible(true, true);
|
||
document.documentElement.classList.remove("tv-mode");
|
||
state.deviceMode = isNativeMobileClient() ? "mobile" : "browser";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function cacheKey(type) {
|
||
if (type === "events") return window.WEBHOME_CONFIG.nostr.eventsKey || "fish2018_home_v1_events";
|
||
if (type === "nsec") return window.WEBHOME_CONFIG.nostr.nsecKey || "fish2018_home_v1_nsec";
|
||
if (type === "pan") return window.WEBHOME_CONFIG.pan.cacheKey || "fish2018_home_v1_pan_config";
|
||
if (type === "ui") return "fish2018_home_v1_ui_snapshot";
|
||
if (type === "deleteState") return "fish2018_home_v1_delete_state";
|
||
return type;
|
||
}
|
||
|
||
function defaultPanConfig() {
|
||
return {
|
||
apiBase: window.WEBHOME_CONFIG.pan.apiBase || "https://so.252035.xyz",
|
||
diskTypes: (window.WEBHOME_CONFIG.pan.diskTypes || []).slice(),
|
||
username: "",
|
||
password: "",
|
||
token: "",
|
||
tokenExpiresAt: 0,
|
||
channels: []
|
||
};
|
||
}
|
||
|
||
function normalizePanBase(value) {
|
||
let base = String(value || "").trim() || defaultPanConfig().apiBase;
|
||
if (!/^https?:\/\//i.test(base)) base = "https://" + base;
|
||
return base.replace(/\/+$/, "");
|
||
}
|
||
|
||
function normalizePanDiskType(type) {
|
||
const value = String(type || "").trim().toLowerCase();
|
||
if (value === "ali" || value === "alipan") return "aliyun";
|
||
if (value === "123pan") return "123";
|
||
if (value === "139" || value === "caiyun") return "mobile";
|
||
if (value === "pikpakdrive") return "pikpak";
|
||
if (value === "guangyapan") return "guangya";
|
||
if (value === "magnetlink" || value === "magnet:?" || value === "bt") return "magnet";
|
||
if (value === "ed2k:") return "ed2k";
|
||
if (value === "other") return "others";
|
||
return value;
|
||
}
|
||
|
||
function normalizePanChannels(value) {
|
||
const list = Array.isArray(value) ? value : String(value || "").split(/[\n,,\s]+/);
|
||
return Array.from(new Set(list.map((item) => String(item || "").trim()).filter(Boolean)));
|
||
}
|
||
|
||
function isPanCheckSupported(item) {
|
||
return !!(item && PAN_CHECKABLE_DISK_TYPES.has(normalizePanDiskType(item.diskType)));
|
||
}
|
||
|
||
function canCheckPanLinks() {
|
||
const pan = sdk().pan || {};
|
||
return !!(state.pan.checkEnabled && pan.check);
|
||
}
|
||
|
||
function sanitizePanConfig(value) {
|
||
const fallback = defaultPanConfig();
|
||
const raw = value && typeof value === "object" ? value : {};
|
||
const allowed = new Set(PAN_DISK_TYPES.map((item) => item.id));
|
||
const disks = Array.isArray(raw.diskTypes) ? raw.diskTypes.map(normalizePanDiskType).filter((item) => allowed.has(item)) : fallback.diskTypes;
|
||
return {
|
||
apiBase: normalizePanBase(raw.apiBase || fallback.apiBase),
|
||
diskTypes: Array.from(new Set(disks.length ? disks : fallback.diskTypes)),
|
||
username: String(raw.username || "").trim(),
|
||
password: String(raw.password || ""),
|
||
token: String(raw.token || ""),
|
||
tokenExpiresAt: Number(raw.tokenExpiresAt || 0),
|
||
channels: normalizePanChannels(raw.channels || raw.tgChannels || raw.channel)
|
||
};
|
||
}
|
||
|
||
async function initPanConfig(options) {
|
||
options = options || {};
|
||
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
|
||
if (options.preserveDirty && state.pan.configDirty) return;
|
||
let saved = null;
|
||
try { saved = safeJson(await sdk().cache.get(cacheKey("pan")), null); } catch (e) { saved = null; }
|
||
state.pan.config = sanitizePanConfig(saved);
|
||
state.pan.configDirty = false;
|
||
renderPanConfigControls();
|
||
}
|
||
|
||
async function savePanConfig() {
|
||
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1500);
|
||
if (window.fongmiBridge && !window.fm) throw new Error("App SDK 未就绪,请稍后再保存");
|
||
state.pan.config = sanitizePanConfig(readPanConfigControls());
|
||
await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config));
|
||
renderPanConfigControls();
|
||
state.pan.configDirty = false;
|
||
setPanStatus("配置已保存");
|
||
closeConnectionPanel();
|
||
toast("盘搜配置已保存");
|
||
}
|
||
|
||
function readPanConfigControls() {
|
||
const checked = Array.from(document.querySelectorAll("#panDiskGrid input:checked")).map((input) => input.value);
|
||
return {
|
||
apiBase: $("panBaseInput") ? $("panBaseInput").value : "",
|
||
username: $("panUserInput") ? $("panUserInput").value : "",
|
||
password: $("panPassInput") ? $("panPassInput").value : "",
|
||
token: state.pan.config && state.pan.config.token || "",
|
||
tokenExpiresAt: state.pan.config && state.pan.config.tokenExpiresAt || 0,
|
||
diskTypes: checked,
|
||
channels: normalizePanChannels($("panChannelsInput") ? $("panChannelsInput").value : "")
|
||
};
|
||
}
|
||
|
||
function renderPanConfigControls() {
|
||
const config = state.pan.config || defaultPanConfig();
|
||
if ($("panBaseInput")) {
|
||
$("panBaseInput").value = config.apiBase;
|
||
disablePanelTextEditing($("panBaseInput"));
|
||
}
|
||
if ($("panChannelsInput")) {
|
||
$("panChannelsInput").value = (config.channels || []).join("\n");
|
||
disablePanelTextEditing($("panChannelsInput"));
|
||
}
|
||
if ($("panUserInput")) {
|
||
$("panUserInput").value = config.username || "";
|
||
disablePanelTextEditing($("panUserInput"));
|
||
}
|
||
if ($("panPassInput")) {
|
||
$("panPassInput").value = config.password || "";
|
||
disablePanelTextEditing($("panPassInput"));
|
||
}
|
||
const grid = $("panDiskGrid");
|
||
if (!grid) return;
|
||
const selected = new Set(config.diskTypes || []);
|
||
grid.replaceChildren(...PAN_DISK_TYPES.map((disk) => {
|
||
const label = document.createElement("label");
|
||
label.innerHTML = `<input class="focusable" type="checkbox" value="${escapeAttr(disk.id)}" ${selected.has(disk.id) ? "checked" : ""}><span>${escapeHtml(disk.name)}</span>`;
|
||
return label;
|
||
}));
|
||
}
|
||
|
||
function markPanConfigDirty() {
|
||
state.pan.configDirty = true;
|
||
}
|
||
|
||
function panDiskName(type) {
|
||
const normalized = normalizePanDiskType(type);
|
||
const item = PAN_DISK_TYPES.find((disk) => disk.id === normalized);
|
||
return item ? item.name : normalized || "网盘";
|
||
}
|
||
|
||
function panApi(path) {
|
||
const base = normalizePanBase((state.pan.config || defaultPanConfig()).apiBase);
|
||
if (base.endsWith("/api") && path.startsWith("/api/")) return base + path.slice(4);
|
||
return base + path;
|
||
}
|
||
|
||
function panSearchDiskTypes(config) {
|
||
const types = ((config && config.diskTypes) || []).map(normalizePanDiskType).filter(Boolean);
|
||
return Array.from(new Set(types));
|
||
}
|
||
|
||
function panSearchPayload(keyword, config) {
|
||
const payload = {
|
||
kw: keyword,
|
||
res: "merge",
|
||
src: "all",
|
||
cloud_types: panSearchDiskTypes(config)
|
||
};
|
||
const channels = normalizePanChannels(config && config.channels || []);
|
||
if (channels.length) payload.channels = channels;
|
||
return payload;
|
||
}
|
||
|
||
async function persistPanConfigQuietly() {
|
||
try { await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config || defaultPanConfig())); } catch (e) {}
|
||
}
|
||
|
||
async function ensurePanAuthHeaders() {
|
||
const config = state.pan.config || defaultPanConfig();
|
||
if (!config.username || !config.password) return {};
|
||
if (config.token && Number(config.tokenExpiresAt || 0) * 1000 > Date.now() + 60000) return { Authorization: "Bearer " + config.token };
|
||
const response = await postJson(panApi("/api/auth/login"), { username: config.username, password: config.password }, 12, {});
|
||
const data = response && response.data ? response.data : response;
|
||
const token = data && data.token;
|
||
if (!token) throw new Error(response && (response.error || response.message) || "盘搜认证失败");
|
||
state.pan.config = sanitizePanConfig(Object.assign({}, config, { token, tokenExpiresAt: data.expires_at || 0 }));
|
||
await persistPanConfigQuietly();
|
||
return { Authorization: "Bearer " + token };
|
||
}
|
||
|
||
function openHotDb() {
|
||
if (!("indexedDB" in window)) return Promise.resolve(null);
|
||
if (state.hot.db) return Promise.resolve(state.hot.db);
|
||
if (state.hot.dbPromise) return state.hot.dbPromise;
|
||
state.hot.dbPromise = new Promise((resolve) => {
|
||
const req = indexedDB.open(HOT_DB_NAME, HOT_DB_VERSION);
|
||
req.onupgradeneeded = () => {
|
||
const db = req.result;
|
||
db.createObjectStore("media", { keyPath: "m" });
|
||
const store = db.createObjectStore("userVector", { keyPath: "u" });
|
||
store.createIndex("u", "u", { unique: false });
|
||
store.createIndex("e", "e", { unique: false });
|
||
store.createIndex("x", "x", { unique: false });
|
||
db.createObjectStore("relayCursor", { keyPath: "relay" });
|
||
};
|
||
req.onsuccess = () => {
|
||
state.hot.db = req.result;
|
||
state.hot.dbPromise = null;
|
||
state.hot.idb = true;
|
||
resolve(state.hot.db);
|
||
};
|
||
req.onerror = () => {
|
||
state.hot.dbPromise = null;
|
||
resolve(null);
|
||
};
|
||
req.onblocked = () => setStatus("nostr", "本地索引升级等待");
|
||
});
|
||
return state.hot.dbPromise;
|
||
}
|
||
|
||
function hotWindowStart() {
|
||
return Math.floor((Date.now() - HOT_WINDOW_MS) / 1000);
|
||
}
|
||
|
||
function hotBackupWindowStart() {
|
||
return Math.max(hotWindowStart(), hotNow() - HOT_BACKUP_WINDOW_SECONDS);
|
||
}
|
||
|
||
function hotNow() {
|
||
return Math.floor(Date.now() / 1000);
|
||
}
|
||
|
||
function hotToday() {
|
||
return Math.floor(hotNow() / HOT_DAY_SECONDS);
|
||
}
|
||
|
||
function hotExpiresAt(createdAt) {
|
||
return Number(createdAt || hotNow()) + HOT_WINDOW_SECONDS;
|
||
}
|
||
|
||
function hotExpiresDay(expiresAt) {
|
||
return Math.ceil(Number(expiresAt || hotNow()) / HOT_DAY_SECONDS);
|
||
}
|
||
|
||
function hotUserKey(pubkey) {
|
||
return String(pubkey || "").slice(0, 32);
|
||
}
|
||
|
||
function normalizeDeleteState(raw) {
|
||
const now = Date.now();
|
||
const users = {};
|
||
Object.entries(raw && raw.users || {}).forEach(([pubkey, marker]) => {
|
||
const key = String(pubkey || "").trim();
|
||
if (!key || !marker || typeof marker !== "object") return;
|
||
const expiresAt = Number(marker.expiresAt || 0);
|
||
if (expiresAt && expiresAt <= now) return;
|
||
users[key] = {
|
||
cutoff: Math.max(0, Number(marker.cutoff || 0)),
|
||
blockPublishUntil: Math.max(0, Number(marker.blockPublishUntil || 0)),
|
||
expiresAt: expiresAt || now + DELETE_TOMBSTONE_TTL_MS,
|
||
updatedAt: Math.max(0, Number(marker.updatedAt || 0))
|
||
};
|
||
});
|
||
return { loaded: true, users };
|
||
}
|
||
|
||
async function ensureDeleteStateLoaded() {
|
||
if (state.deleteState.loaded) return state.deleteState;
|
||
let saved = null;
|
||
try { saved = safeJson(await sdk().cache.get(cacheKey("deleteState")), null); } catch (e) { saved = null; }
|
||
state.deleteState = normalizeDeleteState(saved);
|
||
return state.deleteState;
|
||
}
|
||
|
||
async function persistDeleteState() {
|
||
await sdk().cache.set(cacheKey("deleteState"), JSON.stringify({ users: state.deleteState.users || {} }));
|
||
}
|
||
|
||
async function markIdentityDeletedLocally(identity) {
|
||
if (!identity || !identity.pubkey) return null;
|
||
await ensureDeleteStateLoaded();
|
||
const now = Date.now();
|
||
const marker = {
|
||
cutoff: hotNow() + 1,
|
||
blockPublishUntil: now + DELETE_REPUBLISH_BLOCK_MS,
|
||
expiresAt: now + DELETE_TOMBSTONE_TTL_MS,
|
||
updatedAt: now
|
||
};
|
||
state.deleteState.users[identity.pubkey] = marker;
|
||
await persistDeleteState().catch(() => {});
|
||
return marker;
|
||
}
|
||
|
||
function localDeleteMarker(pubkey) {
|
||
const marker = state.deleteState.users && state.deleteState.users[String(pubkey || "")];
|
||
if (!marker) return null;
|
||
if (Number(marker.expiresAt || 0) && Number(marker.expiresAt || 0) <= Date.now()) {
|
||
delete state.deleteState.users[String(pubkey || "")];
|
||
persistDeleteState().catch(() => {});
|
||
return null;
|
||
}
|
||
return marker;
|
||
}
|
||
|
||
function isLocallyDeletedPreferenceEvent(event) {
|
||
const pubkey = eventUserKey(event);
|
||
const marker = localDeleteMarker(pubkey);
|
||
if (!marker) return false;
|
||
return Number(event && event.created_at || 0) <= Number(marker.cutoff || 0);
|
||
}
|
||
|
||
async function isPreferencePublishBlocked() {
|
||
await ensureDeleteStateLoaded();
|
||
const pubkey = state.identity && state.identity.pubkey;
|
||
const marker = localDeleteMarker(pubkey);
|
||
return !!(marker && Number(marker.blockPublishUntil || 0) > Date.now());
|
||
}
|
||
|
||
function eventExpiration(event) {
|
||
const tag = (event && event.tags || []).find((item) => item && item[0] === "expiration");
|
||
const value = tag ? Number(tag[1]) : 0;
|
||
return Number.isFinite(value) ? value : 0;
|
||
}
|
||
|
||
function eventExpired(event) {
|
||
const expiration = eventExpiration(event);
|
||
return expiration > 0 && expiration <= hotNow();
|
||
}
|
||
|
||
function idbRequest(req) {
|
||
return new Promise((resolve, reject) => {
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
function idbDone(tx) {
|
||
return new Promise((resolve, reject) => {
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
tx.onabort = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
async function hotStoreGet(storeName, key) {
|
||
const db = await openHotDb();
|
||
if (!db) return null;
|
||
return idbRequest(db.transaction(storeName, "readonly").objectStore(storeName).get(key)).catch(() => null);
|
||
}
|
||
|
||
async function hotLoadIndex() {
|
||
const db = await openHotDb();
|
||
if (!db) {
|
||
state.hot.ready = true;
|
||
return;
|
||
}
|
||
try {
|
||
const tx = db.transaction("media", "readonly");
|
||
const media = await idbRequest(tx.objectStore("media").getAll());
|
||
state.hot.media = new Map((media || []).filter((item) => item && item.m).map((item) => [item.m, item]));
|
||
state.hot.items = buildHotItemsFromIndex();
|
||
hotPruneExpired().catch(() => {});
|
||
state.hot.ready = true;
|
||
} catch (e) {
|
||
state.hot.ready = true;
|
||
}
|
||
scheduleRender();
|
||
}
|
||
|
||
async function hotIngestEvent(event) {
|
||
const changed = await hotIngestEvents([event]);
|
||
if (changed) hotRefreshItems();
|
||
return changed;
|
||
}
|
||
|
||
function hotIngestEvents(events) {
|
||
const batch = Array.isArray(events) ? events.slice() : [];
|
||
const run = () => hotIngestEventsNow(batch).catch(() => false);
|
||
state.hot.ingestQueue = state.hot.ingestQueue.then(run, run);
|
||
return state.hot.ingestQueue;
|
||
}
|
||
|
||
async function hotIngestEventsNow(events) {
|
||
await ensureDeleteStateLoaded();
|
||
const incoming = new Map();
|
||
for (const event of events || []) {
|
||
const vector = heatVectorFromEvent(event);
|
||
if (!vector) continue;
|
||
const old = incoming.get(vector.u);
|
||
if (!old || hotIsNewerVector(vector, old)) incoming.set(vector.u, vector);
|
||
}
|
||
if (!incoming.size) return false;
|
||
const vectors = Array.from(incoming.values());
|
||
const db = await openHotDb();
|
||
const existing = await hotGetVectors(vectors.map((vector) => vector.u), db);
|
||
const changedMedia = new Set();
|
||
const vectorWrites = [];
|
||
let changed = false;
|
||
for (const vector of vectors) {
|
||
const old = existing.get(vector.u) || (!db ? state.hot.users.get(vector.u) : null);
|
||
if (old && !hotIsNewerVector(vector, old)) continue;
|
||
if (!old && !vector.i.length) continue;
|
||
const mediaKeys = hotApplyVectorDiffToState(old, vector);
|
||
mediaKeys.forEach((key) => changedMedia.add(key));
|
||
vectorWrites.push(vector);
|
||
hotCacheVector(hotPersistVector(vector), db);
|
||
changed = true;
|
||
}
|
||
if (!changed) return false;
|
||
if (db) {
|
||
const tx = db.transaction(["media", "userVector"], "readwrite");
|
||
const mediaStore = tx.objectStore("media");
|
||
const vectorStore = tx.objectStore("userVector");
|
||
changedMedia.forEach((mediaKey) => {
|
||
const media = state.hot.media.get(mediaKey);
|
||
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
|
||
else mediaStore.delete(mediaKey);
|
||
});
|
||
vectorWrites.forEach((vector) => vectorStore.put(hotPersistVector(vector)));
|
||
await idbDone(tx).catch(() => {});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function heatVectorFromEvent(event) {
|
||
if (eventExpired(event) || getD(event) !== HOT_VECTOR_D) return null;
|
||
if (isLocallyDeletedPreferenceEvent(event)) return null;
|
||
const content = eventContent(event);
|
||
if (!content || content.v !== HOT_VECTOR_VERSION) return null;
|
||
const pubkey = eventUserKey(event);
|
||
if (!pubkey || pubkey === "local") return null;
|
||
const userKey = hotUserKey(pubkey);
|
||
if (!userKey) return null;
|
||
const createdAt = Number(event.created_at || 0);
|
||
if (!Number.isFinite(createdAt) || createdAt <= 0) return null;
|
||
const expiresAt = eventExpiration(event) || hotExpiresAt(createdAt);
|
||
const expiresDay = hotExpiresDay(expiresAt);
|
||
if (expiresDay <= hotToday()) return null;
|
||
const wireItems = normalizeWireVectorItems(content.i || [], createdAt);
|
||
const items = wireItems.map(hotStoredVectorItem).filter(Boolean);
|
||
return {
|
||
u: userKey,
|
||
ts: createdAt,
|
||
id: event.id || "",
|
||
e: expiresDay,
|
||
x: hotVectorNextPruneDay(items, expiresDay),
|
||
i: items,
|
||
meta: wireItems
|
||
};
|
||
}
|
||
|
||
function normalizeWireVectorItems(items, createdAt) {
|
||
const map = new Map();
|
||
const fallbackDay = Math.floor(Number(createdAt || hotNow()) / HOT_DAY_SECONDS);
|
||
(Array.isArray(items) ? items : []).forEach((raw) => {
|
||
const item = hotNormalizeWireItem(raw, fallbackDay, false);
|
||
if (!item) return;
|
||
const key = hotVectorItemMediaKey(item);
|
||
const old = map.get(key);
|
||
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
|
||
});
|
||
return Array.from(map.values())
|
||
.sort((a, b) => hotVectorItemDay(b) - hotVectorItemDay(a) || hotVectorItemMediaKey(a).localeCompare(hotVectorItemMediaKey(b)))
|
||
.slice(0, HOT_USER_VECTOR_LIMIT);
|
||
}
|
||
|
||
function normalizeStoredVectorItems(items, createdAt) {
|
||
const map = new Map();
|
||
const fallbackDay = Math.floor(Number(createdAt || hotNow()) / HOT_DAY_SECONDS);
|
||
(Array.isArray(items) ? items : []).forEach((raw) => {
|
||
const item = hotNormalizeStoredItem(raw, fallbackDay, false);
|
||
if (!item) return;
|
||
const key = hotVectorItemMediaKey(item);
|
||
const old = map.get(key);
|
||
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
|
||
});
|
||
return Array.from(map.values())
|
||
.sort((a, b) => hotVectorItemDay(b) - hotVectorItemDay(a) || hotVectorItemMediaKey(a).localeCompare(hotVectorItemMediaKey(b)))
|
||
.slice(0, HOT_USER_VECTOR_LIMIT);
|
||
}
|
||
|
||
function hotNormalizeWireItem(raw, fallbackDay, keepExpired) {
|
||
const item = hotNormalizeVectorParts(raw, fallbackDay, keepExpired);
|
||
if (!item) return null;
|
||
const title = String(item.title || "").trim().slice(0, HOT_TITLE_LIMIT);
|
||
const poster = hotCompactPoster(item.poster || "").slice(0, HOT_POSTER_LIMIT);
|
||
if (!title || !poster) return null;
|
||
return [item.typeCode, item.tmdbId, item.day, title, poster];
|
||
}
|
||
|
||
function hotNormalizeStoredItem(raw, fallbackDay, keepExpired) {
|
||
const item = hotNormalizeVectorParts(raw, fallbackDay, keepExpired);
|
||
return item ? [item.typeCode, item.tmdbId, item.day] : null;
|
||
}
|
||
|
||
function hotNormalizeVectorParts(raw, fallbackDay, keepExpired) {
|
||
let mediaType = "";
|
||
let tmdbId = "";
|
||
let day = fallbackDay;
|
||
let title = "";
|
||
let poster = "";
|
||
if (Array.isArray(raw)) {
|
||
mediaType = String(raw[0] || "");
|
||
tmdbId = raw[1];
|
||
day = Number(raw[2] || fallbackDay);
|
||
title = String(raw[3] || "");
|
||
poster = String(raw[4] || "");
|
||
} else if (raw && typeof raw === "object") {
|
||
mediaType = String(raw.mt || raw.mediaType || raw.c || "");
|
||
tmdbId = raw.tid || raw.tmdbId || raw.id || "";
|
||
day = Number(raw.d || raw.day || fallbackDay);
|
||
title = String(raw.t || raw.title || "");
|
||
poster = String(raw.p || raw.pic || raw.poster || "");
|
||
}
|
||
const typeCode = hotMediaTypeCode(mediaType);
|
||
if (!typeCode || tmdbId === "" || tmdbId === null || tmdbId === undefined || !Number.isFinite(day)) return null;
|
||
const idNumber = Number(tmdbId);
|
||
tmdbId = Number.isInteger(idNumber) && idNumber > 0 ? idNumber : String(tmdbId);
|
||
day = Math.floor(day);
|
||
if (!keepExpired && day < hotMinItemDay()) return null;
|
||
if (day > hotToday()) day = hotToday();
|
||
return { typeCode, tmdbId, day, title, poster };
|
||
}
|
||
|
||
function hotStoredVectorItem(item) {
|
||
return hotNormalizeStoredItem(item, hotToday(), true);
|
||
}
|
||
|
||
function hotPersistVector(vector) {
|
||
return {
|
||
u: vector.u,
|
||
ts: vector.ts,
|
||
id: vector.id || "",
|
||
e: vector.e,
|
||
x: vector.x,
|
||
i: normalizeStoredVectorItems(vector.i || [], vector.ts || hotNow())
|
||
};
|
||
}
|
||
|
||
function hotMediaTypeCode(mediaType) {
|
||
const value = String(mediaType || "");
|
||
if (value === "t" || value === "tv") return "t";
|
||
if (value === "m" || value === "movie") return "m";
|
||
return "";
|
||
}
|
||
|
||
function hotMediaTypeFromCode(code) {
|
||
const value = String(code || "");
|
||
if (value === "t") return "tv";
|
||
if (value === "m") return "movie";
|
||
return value === "tv" || value === "movie" ? value : "";
|
||
}
|
||
|
||
function hotCompactPoster(url) {
|
||
const value = String(url || "").trim();
|
||
if (!value) return "";
|
||
if (value.startsWith("/")) return value;
|
||
const bases = [window.WEBHOME_CONFIG.tmdb.imageBase, window.WEBHOME_CONFIG.tmdb.backdropBase].filter(Boolean);
|
||
for (const base of bases) {
|
||
if (value.startsWith(base)) return value.slice(base.length) || "";
|
||
}
|
||
try {
|
||
const parsed = new URL(value);
|
||
if (parsed.hostname === "images.tmdb.org") return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || parsed.pathname;
|
||
} catch (e) {}
|
||
return value;
|
||
}
|
||
|
||
function hotPosterUrl(path) {
|
||
const value = String(path || "").trim();
|
||
if (!value) return "";
|
||
if (/^https?:\/\//i.test(value)) return value;
|
||
return value.startsWith("/") ? imageUrl(value, false) : value;
|
||
}
|
||
|
||
function hotMinItemDay() {
|
||
return hotToday() - HOT_WINDOW_DAYS + 1;
|
||
}
|
||
|
||
function hotVectorNextPruneDay(items, expiresDay) {
|
||
let day = Number(expiresDay || hotToday() + HOT_WINDOW_DAYS);
|
||
(items || []).forEach((item) => {
|
||
day = Math.min(day, hotVectorItemDay(item) + HOT_WINDOW_DAYS);
|
||
});
|
||
return Math.max(1, Math.floor(day || hotToday() + HOT_WINDOW_DAYS));
|
||
}
|
||
|
||
function hotVectorItemMediaKey(item) {
|
||
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
|
||
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
|
||
return mediaType && tmdbId ? `tmdb:${mediaType}:${tmdbId}` : "";
|
||
}
|
||
|
||
function hotVectorItemDay(item) {
|
||
return Math.floor(Number(item && item[2] || 0));
|
||
}
|
||
|
||
function hotVectorItemLatest(item) {
|
||
return hotVectorItemDay(item) * HOT_DAY_SECONDS;
|
||
}
|
||
|
||
function hotVectorItemMedia(item) {
|
||
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
|
||
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
|
||
return {
|
||
m: hotVectorItemMediaKey(item),
|
||
t: item && item[3] || "",
|
||
mt: mediaType,
|
||
tid: tmdbId,
|
||
p: hotCompactPoster(item && item[4] || "")
|
||
};
|
||
}
|
||
|
||
function hotVectorItemMap(items) {
|
||
const map = new Map();
|
||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||
const key = hotVectorItemMediaKey(item);
|
||
if (!key) return;
|
||
const old = map.get(key);
|
||
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function hotActiveVectorItems(vector) {
|
||
return normalizeStoredVectorItems(vector && vector.i || [], vector && vector.ts || hotNow());
|
||
}
|
||
|
||
function hotVectorHasMedia(vector, mediaKey) {
|
||
if (!vector || !mediaKey) return false;
|
||
return hotVectorItemMap(hotActiveVectorItems(vector)).has(mediaKey);
|
||
}
|
||
|
||
function hotIsNewerVector(next, old) {
|
||
if (!old) return true;
|
||
const nextTs = Number(next && next.ts || 0);
|
||
const oldTs = Number(old && old.ts || 0);
|
||
if (nextTs !== oldTs) return nextTs > oldTs;
|
||
return String(next && next.id || "") > String(old && old.id || "");
|
||
}
|
||
|
||
function hotIsLocalUserKey(userKey) {
|
||
return !!(state.identity && userKey && hotUserKey(state.identity.pubkey) === userKey);
|
||
}
|
||
|
||
function hotCacheVector(vector, db) {
|
||
if (!vector || !vector.u) return;
|
||
if (!db || hotIsLocalUserKey(vector.u)) state.hot.users.set(vector.u, vector);
|
||
else state.hot.users.delete(vector.u);
|
||
}
|
||
|
||
function hotApplyVectorDiffToState(oldVector, newVector) {
|
||
const oldMap = hotVectorItemMap(oldVector && oldVector.i || []);
|
||
const newMap = hotVectorItemMap(newVector && newVector.i || []);
|
||
const metaMap = hotVectorItemMap(newVector && newVector.meta || []);
|
||
const changedMedia = new Set();
|
||
oldMap.forEach((item, mediaKey) => {
|
||
if (newMap.has(mediaKey)) return;
|
||
const media = state.hot.media.get(mediaKey);
|
||
if (!media) return;
|
||
media.c = Math.max(0, Number(media.c || 0) - 1);
|
||
if (media.c > 0) state.hot.media.set(mediaKey, media);
|
||
else state.hot.media.delete(mediaKey);
|
||
changedMedia.add(mediaKey);
|
||
});
|
||
newMap.forEach((item, mediaKey) => {
|
||
const oldMedia = state.hot.media.get(mediaKey);
|
||
const delta = oldMap.has(mediaKey) && oldMedia ? 0 : 1;
|
||
const meta = metaMap.get(mediaKey) || item;
|
||
const media = mergeHotMedia(oldMedia, hotVectorItemMedia(meta), hotVectorItemLatest(item), delta);
|
||
if (media.c > 0 && media.t && media.p) state.hot.media.set(mediaKey, media);
|
||
changedMedia.add(mediaKey);
|
||
});
|
||
return changedMedia;
|
||
}
|
||
|
||
function hotGetVectors(userKeys, db) {
|
||
const uniqueKeys = Array.from(new Set((userKeys || []).filter(Boolean)));
|
||
const map = new Map();
|
||
if (!uniqueKeys.length) return Promise.resolve(map);
|
||
if (!db) {
|
||
uniqueKeys.forEach((key) => {
|
||
const vector = state.hot.users.get(key);
|
||
if (vector) map.set(key, vector);
|
||
});
|
||
return Promise.resolve(map);
|
||
}
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const tx = db.transaction("userVector", "readonly");
|
||
const store = tx.objectStore("userVector");
|
||
uniqueKeys.forEach((key) => {
|
||
const req = store.get(key);
|
||
req.onsuccess = () => {
|
||
if (req.result) map.set(key, req.result);
|
||
};
|
||
});
|
||
tx.oncomplete = () => resolve(map);
|
||
tx.onerror = () => resolve(map);
|
||
tx.onabort = () => resolve(map);
|
||
} catch (e) {
|
||
resolve(map);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function hotGetUserVector(userKey, db) {
|
||
if (!userKey) return null;
|
||
const map = await hotGetVectors([userKey], db || await openHotDb());
|
||
return map.get(userKey) || state.hot.users.get(userKey) || null;
|
||
}
|
||
|
||
async function hotGetMyVector() {
|
||
const identity = state.identity && state.identity.pubkey;
|
||
if (!identity) return null;
|
||
const userKey = hotUserKey(identity);
|
||
const vector = await hotGetUserVector(userKey);
|
||
if (vector) state.hot.users.set(userKey, vector);
|
||
return vector;
|
||
}
|
||
|
||
async function hotLoadMyVector() {
|
||
const vector = await hotGetMyVector();
|
||
renderMetrics();
|
||
return vector;
|
||
}
|
||
|
||
async function hotPruneExpired() {
|
||
const db = await openHotDb();
|
||
if (!db) return;
|
||
let changed = false;
|
||
for (;;) {
|
||
const vectors = await hotGetPrunableVectors(db, hotToday(), HOT_PRUNE_BATCH);
|
||
if (!vectors.length) break;
|
||
const changedMedia = new Set();
|
||
const vectorWrites = [];
|
||
const vectorDeletes = [];
|
||
for (const old of vectors) {
|
||
const expiresDay = Number(old && old.e || 0);
|
||
const nextItems = expiresDay > hotToday() ? normalizeStoredVectorItems(old.i || [], old.ts || hotNow()) : [];
|
||
const next = expiresDay > hotToday()
|
||
? Object.assign({}, old, { i: nextItems, x: hotVectorNextPruneDay(nextItems, expiresDay) })
|
||
: null;
|
||
if (next && hotSameVectorItems(old.i || [], next.i || []) && Number(old.x || 0) === Number(next.x || 0)) continue;
|
||
hotApplyVectorDiffToState(old, next).forEach((key) => changedMedia.add(key));
|
||
if (next) {
|
||
vectorWrites.push(next);
|
||
hotCacheVector(hotPersistVector(next), db);
|
||
} else {
|
||
vectorDeletes.push(old.u);
|
||
state.hot.users.delete(old.u);
|
||
}
|
||
changed = true;
|
||
}
|
||
if (changedMedia.size || vectorWrites.length || vectorDeletes.length) {
|
||
const tx = db.transaction(["media", "userVector"], "readwrite");
|
||
const mediaStore = tx.objectStore("media");
|
||
const vectorStore = tx.objectStore("userVector");
|
||
changedMedia.forEach((mediaKey) => {
|
||
const media = state.hot.media.get(mediaKey);
|
||
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
|
||
else mediaStore.delete(mediaKey);
|
||
});
|
||
vectorWrites.forEach((vector) => vectorStore.put(hotPersistVector(vector)));
|
||
vectorDeletes.forEach((userKey) => vectorStore.delete(userKey));
|
||
await idbDone(tx).catch(() => {});
|
||
}
|
||
if (vectors.length < HOT_PRUNE_BATCH) break;
|
||
}
|
||
if (changed) scheduleHotRefresh();
|
||
}
|
||
|
||
function hotSameVectorItems(a, b) {
|
||
return JSON.stringify(a || []) === JSON.stringify(b || []);
|
||
}
|
||
|
||
function hotGetPrunableVectors(db, expiresDay, limit) {
|
||
return new Promise((resolve) => {
|
||
const vectors = [];
|
||
try {
|
||
const tx = db.transaction("userVector", "readonly");
|
||
const index = tx.objectStore("userVector").index("x");
|
||
const req = index.openCursor(IDBKeyRange.upperBound(expiresDay));
|
||
req.onsuccess = () => {
|
||
const cursor = req.result;
|
||
if (!cursor) return;
|
||
vectors.push(cursor.value);
|
||
if (limit && vectors.length >= limit) return;
|
||
cursor.continue();
|
||
};
|
||
tx.oncomplete = () => resolve(vectors);
|
||
tx.onerror = () => resolve(vectors);
|
||
tx.onabort = () => resolve(vectors);
|
||
} catch (e) {
|
||
resolve(vectors);
|
||
}
|
||
});
|
||
}
|
||
|
||
function hotRefreshItems() {
|
||
clearTimeout(state.hot.refreshTimer);
|
||
state.hot.refreshTimer = 0;
|
||
state.hot.items = buildHotItemsFromIndex();
|
||
}
|
||
|
||
function scheduleHotRefresh(delay) {
|
||
clearTimeout(state.hot.refreshTimer);
|
||
const busy = Object.keys(state.relay.backfillBusy || {}).length > 0;
|
||
const wait = Number.isFinite(delay) ? delay : busy ? HOT_REFRESH_BACKFILL_MS : HOT_REFRESH_IDLE_MS;
|
||
state.hot.refreshTimer = setTimeout(() => {
|
||
state.hot.refreshTimer = 0;
|
||
hotRefreshItems();
|
||
scheduleRender();
|
||
}, wait);
|
||
}
|
||
|
||
function mergeHotMedia(oldMedia, media, latestAt, countDelta) {
|
||
const value = {
|
||
m: media && media.m || oldMedia && oldMedia.m || "",
|
||
t: media && media.t || oldMedia && oldMedia.t || "",
|
||
mt: media && media.mt || oldMedia && oldMedia.mt || "",
|
||
tid: media && media.tid || oldMedia && oldMedia.tid || "",
|
||
p: media && hotCompactPoster(media.p) || oldMedia && oldMedia.p || "",
|
||
c: Math.max(0, Number(oldMedia && oldMedia.c || 0) + Number(countDelta || 0)),
|
||
l: Math.max(Number(oldMedia && oldMedia.l || 0), Number(latestAt || 0))
|
||
};
|
||
return value;
|
||
}
|
||
|
||
function buildHotItemsFromIndex() {
|
||
return Array.from(state.hot.media.values())
|
||
.map((item) => {
|
||
const pic = hotPosterUrl(item.p || "");
|
||
const people = Number(item.c || 0);
|
||
return {
|
||
id: item.m,
|
||
mediaKey: item.m,
|
||
title: item.t || "",
|
||
mediaType: item.mt || "",
|
||
tmdbId: item.tid || "",
|
||
source: "tmdb",
|
||
pic,
|
||
image: pic,
|
||
people,
|
||
count: people,
|
||
latest: item.l || 0,
|
||
lastEventAt: item.l || 0,
|
||
remark: ""
|
||
};
|
||
})
|
||
.filter((item) => item && item.people > 0 && hasPoster(item))
|
||
.sort((a, b) => b.people - a.people || b.lastEventAt - a.lastEventAt)
|
||
.slice(0, HOT_RENDER_LIMIT);
|
||
}
|
||
|
||
async function hotClearIndex(onlyMine) {
|
||
const db = await openHotDb();
|
||
if (!db) {
|
||
state.hot.media.clear();
|
||
state.hot.users.clear();
|
||
state.hot.items = [];
|
||
return;
|
||
}
|
||
if (!onlyMine) {
|
||
const tx = db.transaction(["media", "userVector", "relayCursor"], "readwrite");
|
||
tx.objectStore("media").clear();
|
||
tx.objectStore("userVector").clear();
|
||
tx.objectStore("relayCursor").clear();
|
||
await idbDone(tx).catch(() => {});
|
||
state.hot.media.clear();
|
||
state.hot.users.clear();
|
||
state.hot.items = [];
|
||
return;
|
||
}
|
||
const identity = state.identity && state.identity.pubkey;
|
||
if (!identity) return;
|
||
const mine = await hotGetUserVector(hotUserKey(identity), db);
|
||
if (mine) await hotRemoveVectors([mine]);
|
||
}
|
||
|
||
async function hotRemoveVectors(vectors, options) {
|
||
const rows = (vectors || []).filter((vector) => vector && vector.u);
|
||
if (!rows.length) return;
|
||
const db = await openHotDb();
|
||
const changedMedia = new Set();
|
||
rows.forEach((vector) => {
|
||
hotApplyVectorDiffToState(vector, null).forEach((key) => changedMedia.add(key));
|
||
state.hot.users.delete(vector.u);
|
||
});
|
||
if (db) {
|
||
const tx = db.transaction(["media", "userVector"], "readwrite");
|
||
const mediaStore = tx.objectStore("media");
|
||
const vectorStore = tx.objectStore("userVector");
|
||
changedMedia.forEach((mediaKey) => {
|
||
const media = state.hot.media.get(mediaKey);
|
||
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
|
||
else mediaStore.delete(mediaKey);
|
||
});
|
||
rows.forEach((vector) => vectorStore.delete(vector.u));
|
||
await idbDone(tx).catch(() => {});
|
||
}
|
||
if (!options || options.refresh !== false) scheduleHotRefresh();
|
||
}
|
||
|
||
function setStatus(key, value) {
|
||
state.status[key] = value;
|
||
renderConnection();
|
||
}
|
||
|
||
function setPanStatus(value) {
|
||
state.status.pan = value;
|
||
renderConnection();
|
||
}
|
||
|
||
function setRelayStatus(relay, value) {
|
||
state.relay.statuses[relay] = value;
|
||
renderConnection();
|
||
}
|
||
|
||
function relayFailedAll() {
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
const failed = relays.filter((relay) => {
|
||
const value = state.relay.statuses[relay];
|
||
return value === "失败" || value === "断开";
|
||
}).length;
|
||
return failed >= relays.length;
|
||
}
|
||
|
||
|
||
|
||
|
||
function finishRelaySubscribe(relay, status) {
|
||
if (state.relay.subscribeFinished[relay]) return;
|
||
state.relay.subscribeFinished[relay] = true;
|
||
if (status) setRelayStatus(relay, status);
|
||
state.relay.subscribeDone += 1;
|
||
if (state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length) {
|
||
const primary = choosePrimaryBackfillRelay(true);
|
||
if (primary) scheduleRelayBackfill(primary);
|
||
|
||
}
|
||
if (state.activeList === "all") renderActiveGrid();
|
||
}
|
||
|
||
function renderConnection() {
|
||
if (!$("connectionDot")) return;
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
const relayValues = relays.map((relay) => `${shortRelay(relay)} ${state.relay.statuses[relay] || "等待"}`);
|
||
const connected = Object.values(state.relay.statuses).filter((value) => value === "已连接").length;
|
||
const failed = Object.values(state.relay.statuses).filter((value) => value === "失败" || value === "断开").length;
|
||
const tmdbOk = state.status.tmdb.includes("成功") || state.status.tmdb.includes("完成");
|
||
const nostrOk = connected > 0;
|
||
const bad = state.status.tmdb.includes("失败") || (!nostrOk && failed === relays.length);
|
||
$("statusSdk").textContent = state.status.sdk;
|
||
$("statusTmdb").textContent = state.status.tmdb;
|
||
$("statusNostr").textContent = `${state.status.nostr} (${connected}/${relays.length})`;
|
||
if ($("statusPan")) $("statusPan").textContent = state.status.pan || "未搜索";
|
||
$("statusPublish").textContent = state.status.publish;
|
||
$("statusIdentity").textContent = state.status.identity;
|
||
$("statusRelays").textContent = relayValues.join(" · ");
|
||
$("connectionDot").className = "dot " + (nostrOk ? "ok" : bad ? "bad" : "warn");
|
||
fitConnectionPanel();
|
||
}
|
||
|
||
function fitConnectionPanel() {
|
||
const dock = $("connectionDock");
|
||
const body = $("connectionBody");
|
||
if (!dock || !body) return;
|
||
body.style.setProperty("--connection-shift", "0px");
|
||
if (!dock.classList.contains("open")) return;
|
||
const doFit = () => {
|
||
const dockRect = dock.getBoundingClientRect();
|
||
const viewW = window.innerWidth || document.documentElement.clientWidth || 0;
|
||
const pad = 8;
|
||
const bodyW = Math.min(viewW - pad * 2, 420);
|
||
body.style.setProperty("--connection-shift", "0px");
|
||
const rect = body.getBoundingClientRect();
|
||
let shift = 0;
|
||
if (rect.left < pad) shift = pad - rect.left;
|
||
else if (rect.right > viewW - pad) shift = viewW - pad - rect.right;
|
||
// clamp so panel never goes off left edge
|
||
const maxShift = dockRect.right - pad - bodyW;
|
||
const minShift = pad - dockRect.right + bodyW;
|
||
shift = Math.max(minShift, Math.min(maxShift, shift));
|
||
body.style.setProperty("--connection-shift", `${Math.round(shift)}px`);
|
||
};
|
||
requestAnimationFrame(doFit);
|
||
}
|
||
|
||
function setConnectionPanelOpen(open) {
|
||
const dock = $("connectionDock");
|
||
const body = $("connectionBody");
|
||
const toggle = $("connectionToggle");
|
||
if (!dock || !body) return;
|
||
dock.classList.toggle("open", !!open);
|
||
body.classList.toggle("open", !!open);
|
||
if (toggle) toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||
body.setAttribute("aria-hidden", open ? "false" : "true");
|
||
if (open) disableConnectionTextEditing();
|
||
else if (body.contains(document.activeElement) && toggle) requestAnimationFrame(() => focusRemoteTarget(toggle));
|
||
fitConnectionPanel();
|
||
scheduleUiSnapshotSave();
|
||
}
|
||
|
||
function toggleConnectionPanel(event) {
|
||
if (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
setConnectionPanelOpen(!$("connectionDock").classList.contains("open"));
|
||
}
|
||
|
||
function clearRelayBackfillTimers() {
|
||
Object.values(state.relay.backfillTimers).forEach((timer) => clearTimeout(timer));
|
||
state.relay.backfillTimers = {};
|
||
clearTimeout(state.hot.refreshTimer);
|
||
state.hot.refreshTimer = 0;
|
||
}
|
||
|
||
function closeConnectionPanel() {
|
||
setConnectionPanelOpen(false);
|
||
}
|
||
|
||
function shortRelay(url) {
|
||
return String(url || "").replace(/^wss?:\/\//, "").replace(/\/$/, "");
|
||
}
|
||
|
||
async function browserRequest(url, options) {
|
||
const init = options || {};
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: init.method || "GET",
|
||
headers: init.headers || {},
|
||
body: init.method && init.method !== "GET" && init.method !== "HEAD" ? init.body || "" : undefined,
|
||
credentials: init.credentials === "include" ? "include" : "same-origin"
|
||
});
|
||
const headers = {};
|
||
response.headers.forEach((value, key) => headers[key] = value);
|
||
const body = init.responseType === "json" ? await response.json() : await response.text();
|
||
return { ok: response.ok, status: response.status, url: response.url, headers, body };
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async function requestJson(url, timeout) {
|
||
const response = await sdk().req(url, { responseType: "text", timeout: timeout || 18 });
|
||
if (response && response.error) throw new Error(response.error);
|
||
const body = response && response.body;
|
||
if (typeof body === "string") return JSON.parse(body || "{}");
|
||
return body || {};
|
||
}
|
||
|
||
async function postJson(url, payload, timeout, headers) {
|
||
const response = await sdk().req(url, {
|
||
method: "POST",
|
||
responseType: "text",
|
||
timeout: timeout || 24,
|
||
headers: Object.assign({ "Content-Type": "application/json" }, headers || {}),
|
||
body: JSON.stringify(payload || {})
|
||
});
|
||
if (response && response.error) throw new Error(response.error);
|
||
if (response && response.ok === false) throw new Error("HTTP " + (response.status || 0));
|
||
const body = response && response.body;
|
||
const data = typeof body === "string" ? JSON.parse(body || "{}") : body || {};
|
||
if (data && data.code && Number(data.code) !== 0 && Number(data.code) !== 200) throw new Error(data.message || data.error || ("HTTP " + data.code));
|
||
return data;
|
||
}
|
||
|
||
function today() {
|
||
const date = new Date();
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function tmdbUrl(list, page) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${list.endpoint}`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
Object.entries(list.params || {}).forEach(([key, value]) => {
|
||
const param = key.replace(/_lte$/, ".lte").replace(/_gte$/, ".gte");
|
||
let val = value;
|
||
if (typeof val === "string") {
|
||
// 支持 "today" / "today+30" / "today-90" 动态日期
|
||
val = val.replace(/^today([+-]\d+)?$/, (_, offset) => {
|
||
const d = new Date();
|
||
if (offset) d.setDate(d.getDate() + parseInt(offset, 10));
|
||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||
});
|
||
}
|
||
url.searchParams.set(param, val);
|
||
});
|
||
if (page) url.searchParams.set("page", String(page));
|
||
return url.toString();
|
||
}
|
||
|
||
|
||
function tmdbSearchUrl(keyword) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/search/multi`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
url.searchParams.set("query", keyword);
|
||
url.searchParams.set("include_adult", "false");
|
||
url.searchParams.set("page", "1");
|
||
return url.toString();
|
||
}
|
||
|
||
function suggestUrl(keyword) {
|
||
const url = new URL("https://suggest.video.iqiyi.com/");
|
||
url.searchParams.set("if", "mobile");
|
||
url.searchParams.set("key", keyword);
|
||
return url.toString();
|
||
}
|
||
|
||
function tmdbDetailUrl(item) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${item.mediaType}/${item.tmdbId}`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
url.searchParams.set("append_to_response", item.mediaType === "tv" ? "credits,images,season/1" : "credits,images");
|
||
url.searchParams.set("include_image_language", "zh,null,en");
|
||
return url.toString();
|
||
}
|
||
|
||
function tmdbRecommendationsUrl(item) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${item.mediaType}/${item.tmdbId}/recommendations`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
url.searchParams.set("page", "1");
|
||
return url.toString();
|
||
}
|
||
|
||
function tmdbSeasonUrl(item, seasonNumber) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/tv/${item.tmdbId}/season/${seasonNumber}`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
return url.toString();
|
||
}
|
||
|
||
function tmdbPersonUrl(personId) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/person/${personId}/combined_credits`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
return url.toString();
|
||
}
|
||
|
||
function tmdbPersonDetailUrl(personId) {
|
||
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/person/${personId}`);
|
||
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
|
||
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
|
||
url.searchParams.set("append_to_response", "combined_credits");
|
||
return url.toString();
|
||
}
|
||
|
||
function imageUrl(path, backdrop) {
|
||
if (!path) return "";
|
||
return (backdrop ? window.WEBHOME_CONFIG.tmdb.backdropBase : window.WEBHOME_CONFIG.tmdb.imageBase) + path;
|
||
}
|
||
|
||
// ── 触觉反馈(手机端轻震动)──
|
||
function haptic(type) {
|
||
try {
|
||
if (!navigator.vibrate) return;
|
||
if (type === "light") navigator.vibrate(8);
|
||
else if (type === "medium") navigator.vibrate(18);
|
||
else if (type === "heavy") navigator.vibrate([20, 10, 20]);
|
||
else if (type === "success") navigator.vibrate([10, 40, 10]);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ── 统一更新详情页背景图(替代 CSS 变量 --detail-hero-bg)
|
||
function setHeroBg(src) {
|
||
const heroBg = $("detailHeroBg");
|
||
if (!heroBg) return;
|
||
heroBg.style.backgroundImage = src ? `url("${cssUrl(src)}")` : "none";
|
||
}
|
||
|
||
// ── 直接更新详情页背景亮度+模糊
|
||
// ease 0→1:0=顶部正常亮度,1=完全压暗
|
||
function setHeroBlur(blurPx, ease) {
|
||
const heroBg = $("detailHeroBg");
|
||
if (!heroBg) return;
|
||
const e = (ease !== undefined) ? Math.min(1, Math.max(0, ease)) : Math.min(1, (blurPx || 0) / 20);
|
||
// 顶部 brightness=0.80(降暗20%),压暗到 0.34
|
||
const brightness = (0.80 - e * 0.46).toFixed(3);
|
||
// TV 端禁用 blur(昂贵的 GPU 操作),只保留亮度调节
|
||
if (isTvMode()) {
|
||
heroBg.style.filter = `brightness(${brightness}) saturate(1.05)`;
|
||
} else {
|
||
heroBg.style.filter = `brightness(${brightness}) saturate(1.05) blur(${(blurPx || 0).toFixed(1)}px)`;
|
||
}
|
||
}
|
||
|
||
function tmdbImageUrl(path, size) {
|
||
if (!path) return "";
|
||
const value = String(path || "").trim();
|
||
const imagePath = value.startsWith("/") ? value : tmdbImagePath(value);
|
||
if (!imagePath) return value;
|
||
return `https://images.tmdb.org/t/p/${size || "w342"}${imagePath}`;
|
||
}
|
||
|
||
function tmdbImagePath(url) {
|
||
const value = String(url || "").trim();
|
||
if (!value) return "";
|
||
if (value.startsWith("/")) return value;
|
||
try {
|
||
const parsed = new URL(value);
|
||
if (parsed.hostname !== "images.tmdb.org") return "";
|
||
return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || "";
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function isTmdbImage(url) {
|
||
return !!tmdbImagePath(url);
|
||
}
|
||
|
||
function displayImage(url, options) {
|
||
if (!url) return "";
|
||
const opts = options || {};
|
||
const tmdbPath = tmdbImagePath(url);
|
||
if (tmdbPath) {
|
||
// TV 模式:海报用 w780,backdrop 保持 original
|
||
let size = opts.size || "w342";
|
||
if ((isTvMode() || isNativeLeanbackClient()) && size === "w342") size = "w780";
|
||
if ((isTvMode() || isNativeLeanbackClient()) && size === "w185") size = "w342";
|
||
return tmdbImageUrl(tmdbPath, size);
|
||
}
|
||
return nativeImage(url);
|
||
}
|
||
|
||
function nativeImage(url) {
|
||
if (!url) return "";
|
||
if (isTmdbImage(url)) return url;
|
||
// TV 端 sdk().res() 可能不支持或抛异常,安全降级为直接 URL
|
||
try {
|
||
const result = sdk().res(url, { credentials: "include" });
|
||
return result || url;
|
||
} catch (e) { return url; }
|
||
}
|
||
|
||
// 首屏计数器:每次 renderHomeContent 重置,前 6 张图片用 eager
|
||
let _imgEagerCount = 0;
|
||
function resetImgEagerCount() { _imgEagerCount = 0; }
|
||
|
||
function imageAttrs(src, options) {
|
||
const opts = options || {};
|
||
// TV 端:禁用 lazy loading(Android TV WebView 不支持,图片不会加载)
|
||
// 始终用 eager + high priority,避免 lazy 导致图片永久不加载
|
||
const tvMode = isTvMode() || isNativeLeanbackClient();
|
||
const isEager = tvMode || _imgEagerCount < 6;
|
||
_imgEagerCount++;
|
||
const loading = opts.loading || (isEager ? "eager" : "lazy");
|
||
const fpVal = opts.fetchPriority || (isEager ? "high" : "auto");
|
||
// fetchpriority 在部分 TV WebView 上会触发解析错误,TV 端不输出该属性
|
||
const fetchPriorityAttr = tvMode ? "" : ` fetchpriority="${escapeAttr(fpVal)}"`;
|
||
return `src="${escapeAttr(src)}" loading="${escapeAttr(loading)}" decoding="async"${fetchPriorityAttr}`;
|
||
}
|
||
|
||
function normalizeTmdb(item, list, index) {
|
||
const title = item.title || item.name || item.original_title || item.original_name || "未命名";
|
||
const date = item.release_date || item.first_air_date || "";
|
||
const mediaType = (list.mediaType && list.mediaType !== "all") ? list.mediaType : (item.media_type || list.mediaType || "movie");
|
||
return {
|
||
id: `tmdb:${mediaType}:${item.id}`,
|
||
source: "tmdb",
|
||
tmdbId: String(item.id),
|
||
mediaType,
|
||
listId: list.id || "search",
|
||
listTitle: list.title || "搜索",
|
||
title,
|
||
pic: imageUrl(item.poster_path, false),
|
||
landscape: imageUrl(item.backdrop_path, true),
|
||
image: imageUrl(item.backdrop_path, true) || imageUrl(item.poster_path, false),
|
||
remark: "",
|
||
desc: item.overview || `${list.title || "搜索"} · ${title}`,
|
||
releaseDate: date,
|
||
popularity: item.popularity || 0,
|
||
voteAverage: item.vote_average || 0,
|
||
originalLanguage: item.original_language || "",
|
||
baseRank: index + 1
|
||
};
|
||
}
|
||
|
||
// 列表级原始语言过滤:列表配置了 excludeOriginalLanguage 时,剔除这些语言的条目
|
||
function passesListLangFilter(item, list) {
|
||
const ex = list && list.excludeOriginalLanguage;
|
||
if (!ex || !ex.length) return true;
|
||
return ex.indexOf(item && item.originalLanguage || "") === -1;
|
||
}
|
||
|
||
function historyKeyParts(history) {
|
||
const key = String(history && history.key || "");
|
||
const parts = key.split("@@@");
|
||
return {
|
||
key,
|
||
siteKey: history && history.siteKey || parts[0] || "",
|
||
vodId: history && history.vodId || parts.slice(1).join("@@@") || ""
|
||
};
|
||
}
|
||
|
||
function validHistoryMs(value) {
|
||
const ms = Number(value || 0);
|
||
return Number.isFinite(ms) && ms > 0 && ms < 7 * 24 * 60 * 60 * 1000 ? ms : 0;
|
||
}
|
||
|
||
function isRestorableHistory(history) {
|
||
const parts = historyKeyParts(history);
|
||
const title = String(history && (history.vodName || history.title || history.name) || "").trim();
|
||
if (!parts.siteKey || !parts.vodId || !title) return false;
|
||
if (parts.siteKey === "push_agent") return false;
|
||
if (/^(push|https?|file|magnet|ed2k|thunder|video):/i.test(parts.siteKey)) return false;
|
||
return true;
|
||
}
|
||
|
||
function isExpiredHistory(history) {
|
||
const parts = historyKeyParts(history);
|
||
if (!isRestorableHistory(history)) return true;
|
||
const pic = String(history && (history.vodPic || history.pic || "") || "").trim();
|
||
if (isExpiredPosterSource(pic, parts)) return true;
|
||
const statusText = [history && history.vodRemarks, history && history.remark, history && history.desc, history && history.status, history && history.summary, history && history.episodeUrl]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase();
|
||
if (/(已过期|已失效|链接失效|资源失效|分享已取消|取消分享|文件不存在|内容不存在|资源不存在|不存在|被删除|已删除|违规|notfound|not found|expired|invalid|forbidden|cancelled|canceled|shareexpirederror|sharenotfound|shareinfonotfound|filenotfound|foldernotfound)/i.test(statusText)) return true;
|
||
const title = String(history && (history.vodName || history.title || history.name) || "").trim();
|
||
return /^(已过期|已失效|链接失效|资源失效|文件不存在|内容不存在|资源不存在|不存在)$/.test(title);
|
||
}
|
||
|
||
function isExpiredPosterSource(pic, parts) {
|
||
const value = String(pic || "").trim();
|
||
if (!value) return false;
|
||
const lower = value.slice(0, 500).toLowerCase();
|
||
if (/(已过期|已失效|链接失效|资源失效|expired|notfound|not-found|invalid|forbidden)/i.test(lower)) return true;
|
||
if ((parts && parts.siteKey === "push_agent") && /^data:image\//i.test(value)) return true;
|
||
return /^data:image\/jpeg;base64,\/9j\/4AAQSkZJRgABAQEAYABgAAD\/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQo/i.test(value);
|
||
}
|
||
|
||
function formatHistoryTime(ms) {
|
||
const total = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor(total % 3600 / 60);
|
||
const sec = total % 60;
|
||
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0");
|
||
return m + ":" + String(sec).padStart(2, "0");
|
||
}
|
||
|
||
function historyProgressText(history) {
|
||
const position = validHistoryMs(history && history.position);
|
||
const duration = validHistoryMs(history && history.duration);
|
||
if (duration > 0 && position > 0) return "已看 " + Math.min(99, Math.max(1, Math.round(position / duration * 100))) + "%";
|
||
return position > 0 ? "看到 " + formatHistoryTime(position) : "";
|
||
}
|
||
|
||
function historyProgressPercent(history) {
|
||
const position = validHistoryMs(history && history.position);
|
||
const duration = validHistoryMs(history && history.duration);
|
||
if (duration <= 0 || position <= 0) return 0;
|
||
return Math.min(100, Math.max(1, Math.round(position / duration * 100)));
|
||
}
|
||
|
||
function normalizeHistoryItem(history, index) {
|
||
const parts = historyKeyParts(history);
|
||
const title = String(history && (history.vodName || history.title || history.name) || "最近观看").trim();
|
||
const pic = history && (history.vodPic || history.pic || "") || "";
|
||
const badge = String(history && history.vodRemarks || "").trim();
|
||
const progressText = historyProgressText(history);
|
||
return {
|
||
id: "history:" + (parts.key || index),
|
||
source: "history",
|
||
historyKey: parts.key,
|
||
siteKey: parts.siteKey,
|
||
vodId: parts.vodId,
|
||
title,
|
||
pic,
|
||
image: pic,
|
||
remark: progressText || (badge ? "继续观看" : ""),
|
||
badge,
|
||
progress: historyProgressPercent(history),
|
||
desc: [badge, progressText].filter(Boolean).join(" · ") || title,
|
||
releaseDate: "",
|
||
voteAverage: 0,
|
||
popularity: Number(history && history.createTime || 0),
|
||
baseRank: index + 1,
|
||
createTime: Number(history && history.createTime || 0)
|
||
};
|
||
}
|
||
|
||
function normalizeHistoryList(list) {
|
||
const seen = new Set();
|
||
return (Array.isArray(list) ? list : [])
|
||
.slice()
|
||
.sort((a, b) => Number(b && b.createTime || 0) - Number(a && a.createTime || 0))
|
||
.filter((history) => !isExpiredHistory(history))
|
||
.map(normalizeHistoryItem)
|
||
.filter((item) => {
|
||
const key = item.historyKey || (item.siteKey && item.vodId ? item.siteKey + "@@@" + item.vodId : item.title);
|
||
if (!key || seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function detailHistoryTitleCandidates(item) {
|
||
return [
|
||
item && item.title,
|
||
item && item.name,
|
||
item && item.query,
|
||
item && item.originalTitle,
|
||
item && item.original_name,
|
||
item && item.original_title
|
||
].map(normalizeTitle).filter(Boolean);
|
||
}
|
||
|
||
function detailHistoryVodIdMatchesTmdb(tmdbId, vodId) {
|
||
const id = String(tmdbId || "").trim();
|
||
const value = String(vodId || "").trim();
|
||
if (!id || !value) return false;
|
||
if (value === id) return true;
|
||
if (/tmdb/i.test(value) && value.split(/[^a-z0-9]+/i).includes(id)) return true;
|
||
return false;
|
||
}
|
||
|
||
function detailHistoryMatches(item, history) {
|
||
if (!item || !history || history.source !== "history" || !history.siteKey || !history.vodId) return false;
|
||
const tmdbId = item.tmdbId ? String(item.tmdbId) : item.id && /^tmdb:/i.test(String(item.id)) ? String(item.id).split(":").pop() : "";
|
||
if (detailHistoryVodIdMatchesTmdb(tmdbId, history.vodId)) return true;
|
||
const title = normalizeTitle(history.title || "");
|
||
if (!title) return false;
|
||
return detailHistoryTitleCandidates(item).some((candidate) => {
|
||
if (!candidate) return false;
|
||
if (candidate === title) return true;
|
||
return candidate.length >= 4 && title.length >= 4 && (candidate.includes(title) || title.includes(candidate));
|
||
});
|
||
}
|
||
|
||
function findDetailContinueHistory(item) {
|
||
const items = state.recent.items || [];
|
||
const result = items.find((history) => detailHistoryMatches(item, history)) || null;
|
||
console.log("[继续观看] findDetailContinueHistory:", {
|
||
selected_title: item && item.title,
|
||
selected_tmdbId: item && item.tmdbId,
|
||
recent_count: items.length,
|
||
recent_loaded: state.recent && state.recent.loaded,
|
||
match: result && result.title
|
||
});
|
||
if (!result && items.length > 0) {
|
||
console.log("[继续观看] history items:", items.slice(0, 3).map(h => ({
|
||
title: h.title, siteKey: h.siteKey, vodId: h.vodId, source: h.source
|
||
})));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function updateDetailContinueButton() {
|
||
const button = $("detailContinueBtn");
|
||
const searchText = $("detailSearchText");
|
||
if (!button) return;
|
||
const match = findDetailContinueHistory(state.selected);
|
||
const actions = button.closest(".actions");
|
||
button.__historyItem = match || null;
|
||
button.style.display = match ? "" : "none";
|
||
button.setAttribute("aria-hidden", match ? "false" : "true");
|
||
if (actions) actions.classList.toggle("has-continue", !!match);
|
||
if (searchText) searchText.textContent = match ? "搜索" : "搜索播放";
|
||
}
|
||
|
||
function shouldRefreshRecentList() {
|
||
return !state.recent.loaded || Date.now() - Number(state.recent.refreshedAt || 0) > RECENT_UI_TTL_MS;
|
||
}
|
||
|
||
async function loadRecentList(options) {
|
||
const opts = options || {};
|
||
if (state.recent.loading) return;
|
||
if (state.recent.loaded && !opts.refresh) return;
|
||
state.recent.loading = true;
|
||
state.recent.error = "";
|
||
if (!opts.silent) setStatus("tmdb", "读取最近观看");
|
||
if (state.activeList === "recent") renderActiveGrid();
|
||
try {
|
||
const list = await sdk().history();
|
||
state.recent.items = normalizeHistoryList(list);
|
||
state.recent.loaded = true;
|
||
state.recent.refreshedAt = Date.now();
|
||
if (!opts.silent) setStatus("tmdb", "最近观看 " + state.recent.items.length + " 条");
|
||
} catch (e) {
|
||
state.recent.items = [];
|
||
state.recent.loaded = true;
|
||
state.recent.error = e.message || "unknown";
|
||
if (!opts.silent) setStatus("tmdb", "最近观看读取失败");
|
||
} finally {
|
||
state.recent.loading = false;
|
||
if (state.activeList === "recent") renderActiveGrid();
|
||
if ($("detailSheet") && $("detailSheet").classList.contains("active")) updateDetailContinueButton();
|
||
}
|
||
}
|
||
|
||
async function loadInfo() {
|
||
try {
|
||
await detectDeviceMode();
|
||
_initFsForHome(); // 首页全屏初始化:TV 不全屏 / 移动端全屏
|
||
state.site = await sdk().site();
|
||
state.config = await sdk().config();
|
||
state.pan.checkEnabled = !!(state.config && state.config.driveCheck);
|
||
setStatus("sdk", window.fm ? "App SDK 已连接" : "浏览器预览模式");
|
||
setPanStatus(state.pan.checkEnabled ? "检测已开启" : "检测关闭");
|
||
state.pan.renderKeys = "";
|
||
renderPanResults();
|
||
} catch (e) {
|
||
state.site = { name: "推荐首页" };
|
||
state.pan.checkEnabled = false;
|
||
setStatus("sdk", "SDK 获取失败:" + (e.message || "unknown"));
|
||
}
|
||
}
|
||
|
||
async function loadCatalog() {
|
||
setStatus("tmdb", "等待按需请求");
|
||
loadRecentList({ silent: true }).catch(() => {});
|
||
renderAll({ deferContent: true });
|
||
}
|
||
|
||
|
||
|
||
|
||
async function loadCatalogList(id) {
|
||
const list = getList(id);
|
||
const page = state.catalogPage[id];
|
||
if (!list || page && page.loading) return;
|
||
if (page && page.loaded) return;
|
||
state.catalogPage[id] = { page: 0, total: 1, loading: true, loaded: false };
|
||
setStatus("tmdb", `请求 ${list.title}`);
|
||
if (state.activeList === id) renderActiveGrid();
|
||
// 多数据源混合加载(推荐 tab 用)
|
||
if (list.sources && list.sources.length) {
|
||
try {
|
||
const fetches = list.sources.map((src) => {
|
||
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
|
||
return requestJson(tmdbUrl(srcList, 1), 18)
|
||
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, i)).filter(hasPoster), total: body.total_pages || 1 }))
|
||
.catch(() => ({ items: [], total: 1 }));
|
||
});
|
||
const results = await Promise.all(fetches);
|
||
// 交叉混排:依次从每个来源取一条,循环直到取完
|
||
const mixed = [];
|
||
const iters = results.map((r) => r.items[Symbol.iterator]());
|
||
let anyLeft = true;
|
||
while (anyLeft) {
|
||
anyLeft = false;
|
||
for (const iter of iters) {
|
||
const { value, done } = iter.next();
|
||
if (!done) { mixed.push(value); anyLeft = true; }
|
||
}
|
||
}
|
||
state.catalog[id] = uniqueMedia(mixed.filter((it) => passesListLangFilter(it, list)));
|
||
// 记录多源分页状态,以便继续加载第2页
|
||
const maxTotal = Math.max(...results.map((r) => r.total));
|
||
state.catalogPage[id] = { page: 1, total: maxTotal, loading: false, loaded: true, multiSrc: true };
|
||
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
|
||
} catch (e) {
|
||
state.catalog[id] = [];
|
||
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
|
||
setStatus("tmdb", `${list.title} 加载失败`);
|
||
}
|
||
if (state.activeList === id) renderActiveGrid();
|
||
return;
|
||
}
|
||
try {
|
||
const body = await requestJson(tmdbUrl(list, 1), 18);
|
||
const results = body.results || [];
|
||
state.catalog[id] = uniqueMedia(results.map((item, index) => normalizeTmdb(item, list, index)).filter(hasPoster).filter((it) => passesListLangFilter(it, list)));
|
||
state.catalogPage[id] = { page: 1, total: body.total_pages || 1, loading: false, loaded: true };
|
||
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
|
||
} catch (e) {
|
||
state.catalog[id] = [];
|
||
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
|
||
setStatus("tmdb", `${list.title} 加载失败`);
|
||
}
|
||
if (state.activeList === id) renderActiveGrid();
|
||
}
|
||
|
||
|
||
async function loadMoreCatalog(id) {
|
||
const list = getList(id);
|
||
const page = state.catalogPage[id];
|
||
if (!page || !page.loaded) return loadCatalogList(id);
|
||
if (!list || page.loading || page.page >= page.total) return;
|
||
page.loading = true;
|
||
state.catalogPage[id] = page;
|
||
try {
|
||
const next = page.page + 1;
|
||
// 多源列表:并发请求所有来源的下一页,交叉混排追加
|
||
if (page.multiSrc && list.sources && list.sources.length) {
|
||
const fetches = list.sources.map((src) => {
|
||
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
|
||
return requestJson(tmdbUrl(srcList, next), 18)
|
||
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, (next - 1) * 20 + i)).filter(hasPoster), total: body.total_pages || page.total }))
|
||
.catch(() => ({ items: [], total: page.total }));
|
||
});
|
||
const results = await Promise.all(fetches);
|
||
const mixed = [];
|
||
const iters = results.map((r) => r.items[Symbol.iterator]());
|
||
let anyLeft = true;
|
||
while (anyLeft) {
|
||
anyLeft = false;
|
||
for (const iter of iters) {
|
||
const { value, done } = iter.next();
|
||
if (!done) { mixed.push(value); anyLeft = true; }
|
||
}
|
||
}
|
||
const maxTotal = Math.max(...results.map((r) => r.total));
|
||
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(mixed.filter((it) => passesListLangFilter(it, list))));
|
||
state.catalogPage[id] = { page: next, total: maxTotal, loading: false, loaded: true, multiSrc: true };
|
||
renderActiveGrid();
|
||
return;
|
||
}
|
||
const body = await requestJson(tmdbUrl(list, next), 18);
|
||
const items = (body.results || []).map((item, index) => normalizeTmdb(item, list, (next - 1) * 20 + index)).filter(hasPoster).filter((it) => passesListLangFilter(it, list));
|
||
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(items));
|
||
state.catalogPage[id] = { page: next, total: body.total_pages || page.total || next, loading: false, loaded: true };
|
||
renderActiveGrid();
|
||
} catch (e) {
|
||
page.loading = false;
|
||
}
|
||
}
|
||
|
||
|
||
function loadMoreVisible() {
|
||
if (appendActiveGridBatch()) return;
|
||
if (state.loadingMore || state.activeList === "recent" || state.activeList === "live") return;
|
||
if (state.infiniteObserver) state.infiniteObserver.unobserve($("infiniteSentinel"));
|
||
state.loadingMore = true;
|
||
Promise.resolve(loadMoreCatalog(state.activeList))
|
||
.finally(() => {
|
||
state.loadingMore = false;
|
||
observeInfiniteScroll();
|
||
});
|
||
}
|
||
|
||
async function loadEvents() {
|
||
try {
|
||
await sdk().cache.del(cacheKey("events"));
|
||
} catch (e) {}
|
||
scheduleRender();
|
||
}
|
||
|
||
async function clearLocalEvents(onlyMine) {
|
||
await sdk().cache.del(cacheKey("events"));
|
||
await hotClearIndex(onlyMine);
|
||
scheduleRender();
|
||
}
|
||
|
||
function allItems() {
|
||
return Object.values(state.catalog).flat().filter(hasPoster);
|
||
}
|
||
|
||
function findItem(id) {
|
||
return allItems().concat(state.searchItems).find((item) => item.id === id);
|
||
}
|
||
|
||
function scoreItem(item) {
|
||
return Math.max(0, 18 - item.baseRank) + (item.voteAverage || 0) * 1.2 + Math.min(item.popularity || 0, 200) * .04;
|
||
}
|
||
|
||
function qualifiesHeat(content) {
|
||
return watchedTenMinutes(content) && hasHeatIntent(content);
|
||
}
|
||
|
||
function watchedTenMinutes(content) {
|
||
if (!content || content.action !== "watch") return false;
|
||
const watchMs = Math.max(0, Number(content.watchMs || content.position || 0));
|
||
return watchMs >= WATCH_HEAT_MS;
|
||
}
|
||
|
||
function hasHeatIntent(content) {
|
||
const intent = String(content.intentAction || content.intent || "");
|
||
return intent === "view" || intent === "search" || content.clicked === true || content.searched === true;
|
||
}
|
||
|
||
function eventUserKey(event) {
|
||
if (event.pubkey) return event.pubkey;
|
||
if (event.local && state.identity && state.identity.pubkey) return state.identity.pubkey;
|
||
return "local";
|
||
}
|
||
|
||
function ranked(items) {
|
||
return items.filter(hasPoster).slice().sort((a, b) => scoreItem(b) - scoreItem(a));
|
||
}
|
||
|
||
function uniqueMedia(items) {
|
||
const map = new Map();
|
||
items.filter(hasPoster).forEach((item) => {
|
||
const key = mediaDomKey(item);
|
||
if (key && !map.has(key)) map.set(key, item);
|
||
});
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
function getList(id) {
|
||
return (window.WEBHOME_CONFIG.tmdb.lists || []).find((list) => list.id === id);
|
||
}
|
||
|
||
function visibleTmdbLists() {
|
||
return (window.WEBHOME_CONFIG.tmdb.lists || []).filter((list) => !list.hidden && !(list.mobileHidden && isPhoneViewport()));
|
||
}
|
||
|
||
function isPhoneViewport() {
|
||
const width = Math.min(window.innerWidth || 0, document.documentElement.clientWidth || 0) || window.innerWidth || 0;
|
||
const height = Math.min(window.innerHeight || 0, document.documentElement.clientHeight || 0) || window.innerHeight || 0;
|
||
const coarse = window.matchMedia ? window.matchMedia("(pointer: coarse)").matches : false;
|
||
return width > 0 && width < 720 && (coarse || height > width);
|
||
}
|
||
|
||
function isBrowserPhoneClient() {
|
||
const width = Math.min(window.innerWidth || 0, document.documentElement.clientWidth || 0) || window.innerWidth || 0;
|
||
const coarse = window.matchMedia ? window.matchMedia("(pointer: coarse)").matches : false;
|
||
const ua = String(navigator.userAgent || "");
|
||
const mobileUa = /Android|iPhone|iPod|Mobile/i.test(ua);
|
||
return width > 0 && width < 720 && (coarse || mobileUa);
|
||
}
|
||
|
||
function useLargeDetailLayout() {
|
||
if (state.deviceMode === "mobile" || isNativeMobileClient()) return false;
|
||
if (state.deviceMode === "leanback" || isNativeLeanbackClient()) return true;
|
||
return !isBrowserPhoneClient();
|
||
}
|
||
|
||
function syncDetailLayout() {
|
||
const sheet = $("detailSheet");
|
||
if (!sheet) return false;
|
||
const before = sheet.classList.contains("detail-large");
|
||
const large = useLargeDetailLayout();
|
||
sheet.classList.toggle("detail-large", large);
|
||
// 只有 sheet 处于激活状态时才同步 inline display
|
||
// 防止 resize 时意外恢复已关闭的 sheet(detail 漏出到主页面的根本原因)
|
||
const isOpen = sheet.classList.contains("active") || sheet.classList.contains("sheet-closing");
|
||
if (isOpen && sheet.style.display && sheet.style.display !== "none") {
|
||
sheet.style.display = large ? "flex" : "block";
|
||
}
|
||
return before !== large;
|
||
}
|
||
|
||
function isKnownList(id) {
|
||
return id === "all" || id === "recent" || id === "live" || !!getList(id);
|
||
}
|
||
|
||
function normalizeActiveListForViewport() {
|
||
if (!isKnownList(state.activeList)) state.activeList = "all";
|
||
}
|
||
|
||
function hasPoster(item) {
|
||
return !!(item && item.pic);
|
||
}
|
||
|
||
function renderSearch() {
|
||
const hasResults = state.searchItems.length > 0;
|
||
$("searchSection").style.display = hasResults ? "" : "none";
|
||
document.documentElement.classList.toggle("search-active", hasResults);
|
||
renderSearchList("searchRail", ranked(state.searchItems).slice(0, 18));
|
||
// 搜索激活时:折叠背景元素高度,防止继续往下滚动看到主页内容
|
||
["recommendSection", "listStack", "chips"].forEach((id) => {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
if (hasResults) {
|
||
el.dataset.searchSavedHeight = el.style.cssText || "";
|
||
el.style.maxHeight = "0";
|
||
el.style.overflow = "hidden";
|
||
el.style.marginTop = "0";
|
||
el.style.marginBottom = "0";
|
||
el.style.paddingTop = "0";
|
||
el.style.paddingBottom = "0";
|
||
} else {
|
||
el.style.cssText = el.dataset.searchSavedHeight || "";
|
||
delete el.dataset.searchSavedHeight;
|
||
}
|
||
});
|
||
// 搜索激活时停止无限滚动 sentinel,防止触发主页加载和滚动跳动
|
||
const sentinel = $("infiniteSentinel");
|
||
if (sentinel) {
|
||
if (hasResults) {
|
||
if (state.infiniteObserver) state.infiniteObserver.unobserve(sentinel);
|
||
sentinel.style.display = "none";
|
||
} else {
|
||
sentinel.style.display = "";
|
||
observeInfiniteScroll();
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearSearchResults() {
|
||
if (location.hash === "#search") history.replaceState({ sheet: "home" }, "", location.pathname + location.search);
|
||
state.searchItems = [];
|
||
document.documentElement.classList.remove("search-active");
|
||
document.documentElement.classList.remove("search-focused");
|
||
if ($("searchInput")) { $("searchInput").value = ""; $("searchInput").blur(); }
|
||
hideSearchSuggest();
|
||
renderSearch();
|
||
scheduleUiSnapshotSave();
|
||
}
|
||
|
||
function openSearchFirstResult() {
|
||
const items = ranked(state.searchItems);
|
||
if (!items.length) return;
|
||
openDetail(items[0]);
|
||
}
|
||
|
||
function renderAll(options) {
|
||
const opts = options || {};
|
||
clearTimeout(state.renderTimer);
|
||
state.renderTimer = 0;
|
||
normalizeActiveListForViewport();
|
||
renderChips();
|
||
if (opts.deferContent) scheduleHomeContentRender({ afterPaint: true });
|
||
else renderHomeContent();
|
||
renderMetrics();
|
||
normalizeRails();
|
||
}
|
||
|
||
function renderHomeContent() {
|
||
// 详情页动画期间不渲染主页,防止 resize 时内容透出
|
||
if (document.body.classList.contains("detail-active") ||
|
||
document.body.classList.contains("detail-closing") ||
|
||
state.detailClosing) return;
|
||
$("recommendSection").style.display = "none";
|
||
$("listSection").style.display = "";
|
||
renderLists();
|
||
observeInfiniteScroll();
|
||
requestAnimationFrame(ensureScrollablePage);
|
||
}
|
||
|
||
function scheduleHomeContentRender(options) {
|
||
const opts = options || {};
|
||
const seq = ++state.homeContentSeq;
|
||
if (state.homeContentTimer) {
|
||
cancelAnimationFrame(state.homeContentTimer);
|
||
state.homeContentTimer = 0;
|
||
}
|
||
const run = () => {
|
||
if (seq !== state.homeContentSeq) return;
|
||
state.homeContentTimer = 0;
|
||
renderHomeContent();
|
||
if (typeof opts.after === "function") opts.after();
|
||
};
|
||
if (!opts.afterPaint) {
|
||
run();
|
||
return;
|
||
}
|
||
state.homeContentTimer = requestAnimationFrame(() => {
|
||
if (seq !== state.homeContentSeq) return;
|
||
state.homeContentTimer = requestAnimationFrame(run);
|
||
});
|
||
}
|
||
|
||
function ensureActiveListData(id) {
|
||
if (!id || state.activeList !== id) return;
|
||
if (id === "recent") loadRecentList({ refresh: shouldRefreshRecentList() });
|
||
else if (id !== "live") loadCatalogList(id);
|
||
}
|
||
|
||
function scheduleRender() {
|
||
clearTimeout(state.renderTimer);
|
||
const delay = Date.now() < state.scrollingUntil ? 700 : 160;
|
||
state.renderTimer = setTimeout(() => {
|
||
if (Date.now() < state.scrollingUntil) {
|
||
scheduleRender();
|
||
return;
|
||
}
|
||
renderAll({ deferContent: uiSnapshotRoute() === "home" });
|
||
}, delay);
|
||
}
|
||
|
||
// 分类图标映射
|
||
const CHIP_ICONS = {
|
||
recent: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||
all: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||
movie: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="2" y1="17" x2="7" y2="17"/></svg>`,
|
||
tv: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="8" width="20" height="13" rx="2" ry="2"/><polyline points="9 2 12 5 15 2"/><line x1="8" y1="2" x2="12" y2="5"/><line x1="16" y1="2" x2="12" y2="5"/></svg>`,
|
||
music: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
|
||
photo: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||
live: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="10" x2="2" y2="14"/><line x1="6" y1="6" x2="6" y2="18"/><line x1="10" y1="3" x2="10" y2="21"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="18" y1="11" x2="18" y2="13"/><line x1="22" y1="10" x2="22" y2="14"/></svg>`,
|
||
variety: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||
anime: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`,
|
||
documentary: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
|
||
hot: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c0 0-4 4-4 8a4 4 0 0 0 8 0c0-1.5-.5-3-1.5-4C14 8 13 10 12 10c0 0-2-2-2-4 0 0 1 1 2 1z"/><path d="M12 22c-3.3 0-6-2.7-6-6 0-2 1-4 2.5-5.5C9 12 10.5 13 12 13s3-1 3.5-2.5C17 12 18 14 18 16c0 3.3-2.7 6-6 6z"/></svg>`,
|
||
};
|
||
function getChipIcon(id) {
|
||
if (id === "recent") return CHIP_ICONS.recent;
|
||
if (id === "all") return CHIP_ICONS.all;
|
||
if (id === "now-playing") return CHIP_ICONS.hot;
|
||
if (id.includes("variety")) return CHIP_ICONS.variety;
|
||
if (id === "anime") return CHIP_ICONS.anime;
|
||
if (id === "documentary") return CHIP_ICONS.documentary;
|
||
if (id === "concert" || id.includes("concert") || id.includes("music")) return CHIP_ICONS.music;
|
||
const list = getList(id);
|
||
const mt = (list && list.mediaType) || "";
|
||
if (id.includes("movie") || mt === "movie") return CHIP_ICONS.movie;
|
||
if (id.includes("tv") || id.includes("series") || mt === "tv") return CHIP_ICONS.tv;
|
||
if (id.includes("music")) return CHIP_ICONS.music;
|
||
if (id.includes("photo")) return CHIP_ICONS.photo;
|
||
if (id.includes("live")) return CHIP_ICONS.live;
|
||
return CHIP_ICONS.all;
|
||
}
|
||
|
||
function renderChips() {
|
||
const chips = [{ id: "recent", title: "最近" }, { id: "all", title: "推荐" }]
|
||
.concat(visibleTmdbLists().filter((item) => item.id !== "all").map((item) => ({ id: item.id, title: item.title })));
|
||
const root = $("chips");
|
||
const keys = chips.map((chip) => chip.id + ":" + chip.title).join("\n");
|
||
if (root.dataset.renderKeys !== keys) {
|
||
root.dataset.renderKeys = keys;
|
||
root.replaceChildren(...chips.map((chip) => {
|
||
const button = document.createElement("button");
|
||
button.className = "chip focusable";
|
||
button.type = "button";
|
||
button.dataset.chipId = chip.id;
|
||
button.innerHTML = getChipIcon(chip.id) + `<span>${chip.title}</span>`;
|
||
button.addEventListener("click", () => selectChip(chip.id));
|
||
button.addEventListener("focus", () => {
|
||
scheduleChipFocusSelect(chip.id);
|
||
});
|
||
return button;
|
||
}));
|
||
}
|
||
Array.from(root.children).forEach((button) => button.classList.toggle("active", button.dataset.chipId === state.activeList));
|
||
// 首次加载时,刚插入 DOM 的 .active chip 其 ::before 呼吸光晕在部分 WebView 中
|
||
// 不会立即起步合成,导致看起来是「暗色块」,点击后才点亮。此处对当前选中 chip
|
||
// 做一次极轻量的合成层提升 → 还原,强制浏览器重新计算并启动光晕动画。
|
||
// TV 端 ::before 已被隐藏,无需处理。
|
||
if (!isTvMode()) {
|
||
const activeChip = root.querySelector(".chip.active");
|
||
if (activeChip) {
|
||
requestAnimationFrame(() => {
|
||
activeChip.style.willChange = "transform";
|
||
void activeChip.offsetWidth; // 触发同步重排
|
||
requestAnimationFrame(() => { activeChip.style.willChange = ""; });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
function scheduleChipFocusSelect(id) {
|
||
clearTimeout(state.chipFocusTimer);
|
||
if (!id || state.activeList === id) return;
|
||
state.chipFocusTimer = setTimeout(() => selectChip(id, { keepFocus: true, fromFocus: true }), isTvMode() ? 280 : 45);
|
||
}
|
||
|
||
function selectChip(id, options) {
|
||
const opts = options || {};
|
||
if (!opts.fromFocus) clearTimeout(state.chipFocusTimer);
|
||
if (!id || state.activeList === id) return;
|
||
state.activeList = id;
|
||
renderChips();
|
||
scheduleHomeContentRender({ afterPaint: true, after: () => ensureActiveListData(id) });
|
||
scheduleUiSnapshotSave();
|
||
if (opts.keepFocus) requestAnimationFrame(() => {
|
||
const target = findByDataset($("chips"), "chipId", id);
|
||
if (target) focusRemoteTarget(target);
|
||
});
|
||
}
|
||
|
||
function renderRecommendations() {
|
||
// 推荐 tab 直接走和其他分类一样的 loadCatalogList 逻辑
|
||
const list = getList("all");
|
||
if (!list) return;
|
||
const panel = ensureListPanel("all", "推荐", "全平台热门");
|
||
const grid = panel.querySelector(".media-grid");
|
||
activateListPanel("all");
|
||
const page = state.catalogPage["all"];
|
||
if (page && page.loading) {
|
||
if (!grid.dataset.renderKeys) showGridStatus(grid, "加载中...");
|
||
} else if (page && page.error) {
|
||
showGridStatus(grid, "加载失败");
|
||
} else if (!page || !page.loaded) {
|
||
loadCatalogList("all");
|
||
showGridStatus(grid, "加载中...");
|
||
} else {
|
||
fillGrid(grid, state.catalog["all"] || []);
|
||
// 第一页加载完后立即预取第二页,保证内容足够多
|
||
if (page.loaded && !page.loading && page.page < page.total && (state.catalog["all"] || []).length < 40) {
|
||
setTimeout(() => loadMoreCatalog("all"), 200);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderActiveGrid() {
|
||
if (state.activeList === "all") renderRecommendations();
|
||
else renderLists();
|
||
observeInfiniteScroll();
|
||
requestAnimationFrame(ensureScrollablePage);
|
||
}
|
||
|
||
function renderLists() {
|
||
if (state.activeList === "recent") {
|
||
renderRecentList();
|
||
return;
|
||
}
|
||
if (state.activeList === "live") {
|
||
renderLiveEntry();
|
||
return;
|
||
}
|
||
const lists = visibleTmdbLists().filter((list) => list.id === state.activeList);
|
||
if (!lists.length) {
|
||
activateListPanel("");
|
||
return;
|
||
}
|
||
const list = lists[0];
|
||
const panel = ensureListPanel(list.id, list.title, list.hint || "");
|
||
const grid = panel.querySelector(".media-grid");
|
||
activateListPanel(list.id);
|
||
const page = state.catalogPage[list.id];
|
||
if (page && page.loading) {
|
||
if (!grid.dataset.renderKeys) showGridStatus(grid, "加载中...");
|
||
} else if (page && page.error) {
|
||
showGridStatus(grid, "加载失败");
|
||
} else if (!page || !page.loaded) {
|
||
showGridStatus(grid, "点击后加载片单");
|
||
} else {
|
||
fillGrid(grid, state.catalog[list.id] || []);
|
||
}
|
||
}
|
||
|
||
function renderLiveEntry() {
|
||
const panel = ensureListPanel("live", "直播", "电视台与直播源", { grid: false });
|
||
activateListPanel("live");
|
||
if (panel.dataset.liveReady === "1") return;
|
||
panel.dataset.liveReady = "1";
|
||
const body = document.createElement("div");
|
||
const card = document.createElement("button");
|
||
card.className = "live-entry-card focusable";
|
||
card.type = "button";
|
||
card.innerHTML = `
|
||
<span class="live-icon" aria-hidden="true">
|
||
<svg class="icon" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 19v2"/><path d="m9 3 3 3 3-3"/></svg>
|
||
</span>
|
||
<div class="live-copy">
|
||
<h5>观看直播</h5>
|
||
<p>频道分组、节目单、收藏频道和多线路已接入。</p>
|
||
<div class="live-tags"><span class="live-tag">频道分组</span><span class="live-tag">节目单</span><span class="live-tag">收藏</span></div>
|
||
</div>
|
||
<span class="live-action">继续观看</span>
|
||
`;
|
||
card.addEventListener("click", openLiveHome);
|
||
body.appendChild(card);
|
||
panel.appendChild(body);
|
||
}
|
||
|
||
function renderRecentList() {
|
||
const panel = ensureListPanel("recent", "最近", "继续观看");
|
||
const grid = panel.querySelector(".media-grid");
|
||
activateListPanel("recent");
|
||
if (state.recent.loading && !state.recent.loaded) {
|
||
showGridStatus(grid, "读取最近观看...");
|
||
} else if (state.recent.error) {
|
||
showGridStatus(grid, "最近观看读取失败");
|
||
} else if (!state.recent.loaded) {
|
||
showGridStatus(grid, "点击后读取最近观看");
|
||
} else {
|
||
fillGrid(grid, state.recent.items || []);
|
||
}
|
||
}
|
||
|
||
function ensureListPanel(id, title, hint, options) {
|
||
const stack = $("listStack");
|
||
Array.from(stack.children).forEach((child) => {
|
||
if (!child.classList || !child.classList.contains("list-panel")) child.remove();
|
||
});
|
||
let panel = Array.from(stack.querySelectorAll(".list-panel")).find((item) => item.dataset.listId === id);
|
||
if (panel) return panel;
|
||
panel = document.createElement("div");
|
||
panel.className = "list-block list-panel";
|
||
panel.dataset.listId = id;
|
||
panel.hidden = true;
|
||
panel.innerHTML = `
|
||
<div class="subsection-head" style="display:none">
|
||
<h4></h4>
|
||
<span></span>
|
||
</div>
|
||
`;
|
||
if (!options || options.grid !== false) {
|
||
const grid = document.createElement("div");
|
||
grid.className = "media-grid";
|
||
grid.dataset.listId = id;
|
||
panel.appendChild(grid);
|
||
}
|
||
stack.appendChild(panel);
|
||
return panel;
|
||
}
|
||
|
||
function activateListPanel(id) {
|
||
const stack = $("listStack");
|
||
Array.from(stack.querySelectorAll(".list-panel")).forEach((panel) => {
|
||
panel.hidden = panel.dataset.listId !== id;
|
||
});
|
||
}
|
||
|
||
function renderRail(id, items) {
|
||
const rail = $(id);
|
||
fillRail(rail, items);
|
||
}
|
||
|
||
function renderSearchList(id, items) {
|
||
const rail = $(id);
|
||
if (!rail) return;
|
||
items = items.filter(hasPoster);
|
||
if (!items.length) {
|
||
rail.innerHTML = '<div class="empty">暂无结果</div>';
|
||
return;
|
||
}
|
||
rail.replaceChildren(...items.map((item) => {
|
||
const el = document.createElement("button");
|
||
el.className = "search-list-item focusable";
|
||
el.type = "button";
|
||
const poster = displayImage(item.pic, { size: "w185" });
|
||
const rating = item.voteAverage ? Number(item.voteAverage).toFixed(1) : "";
|
||
const stars = rating ? renderStars(Number(item.voteAverage)) : "";
|
||
const year = item.year || (item.releaseDate || "").slice(0, 4) || "";
|
||
const genre = (item.genres && item.genres[0]) || item.remark || "";
|
||
const mediaLabel = item.mediaType === "movie" ? "电影" : "剧集";
|
||
const meta = [mediaLabel, year, genre].filter(Boolean).join(" · ");
|
||
const overview = (item.overview || item.desc || "").slice(0, 80) + ((item.overview || item.desc || "").length > 80 ? "…" : "");
|
||
const arrowSvg = `<svg class="sli-arrow" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7 10h6M10 7l3 3-3 3" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||
const statusText = detailStatus(item.status || "");
|
||
const isEnded = statusText === "已完结" || (item.totalEpisodes && item.airedEpisodes >= item.totalEpisodes);
|
||
const badgeLabel = isEnded ? "已完结" : (item.mediaType === "movie" ? "电影" : "剧集");
|
||
const badgeClass = isEnded ? "sli-badge green" : "sli-badge";
|
||
el.innerHTML = `
|
||
<img class="sli-poster" alt="" src="${escapeAttr(poster)}" loading="lazy">
|
||
<div class="sli-body">
|
||
<div class="sli-title">${escapeHtml(item.title)}</div>
|
||
<div class="sli-meta">${escapeHtml(meta)}</div>
|
||
${stars ? `<div class="sli-stars">${stars}<span class="sli-rating">${escapeHtml(rating)}</span></div>` : ""}
|
||
${overview ? `<div class="sli-overview">${escapeHtml(overview)}</div>` : ""}
|
||
<span class="${badgeClass}">${badgeLabel}</span>
|
||
</div>
|
||
${arrowSvg}
|
||
`;
|
||
el.addEventListener("click", () => openDetail(item));
|
||
return el;
|
||
}));
|
||
}
|
||
|
||
function renderStars(score) {
|
||
const full = Math.floor(score / 2);
|
||
const half = score % 2 >= 1 ? 1 : 0;
|
||
const empty = 5 - full - half;
|
||
const s = (n, cls) => `<span class="star ${cls}">${"★".repeat(n)}</span>`;
|
||
return (
|
||
(full ? s(full, "star-full") : "") +
|
||
(half ? '<span class="star star-half">½</span>' : "") +
|
||
(empty ? s(empty, "star-empty") : "")
|
||
);
|
||
}
|
||
|
||
function fillRail(rail, items) {
|
||
items = items.filter(hasPoster);
|
||
if (!items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "empty";
|
||
empty.textContent = "暂无内容";
|
||
rail.replaceChildren(empty);
|
||
return;
|
||
}
|
||
const key = rail.dataset.railKey || rail.id || "";
|
||
if (key && rail.dataset.scrollBound) state.railScroll[key] = rail.scrollLeft;
|
||
const savedLeft = key ? state.railScroll[key] || 0 : 0;
|
||
const detailRail = !!($("detailSheet") && $("detailSheet").contains(rail));
|
||
rail.replaceChildren(...items.map((item, index) => mediaCard(item, index, { landscape: detailRail && useLargeDetailLayout() })));
|
||
if (savedLeft) requestAnimationFrame(() => rail.scrollLeft = savedLeft);
|
||
if (!rail.dataset.scrollBound) {
|
||
rail.dataset.scrollBound = "1";
|
||
rail.addEventListener("scroll", () => {
|
||
state.scrollingUntil = Date.now() + 900;
|
||
const railKey = rail.dataset.railKey || rail.id || "";
|
||
if (railKey) state.railScroll[railKey] = rail.scrollLeft;
|
||
}, { passive: true });
|
||
}
|
||
}
|
||
|
||
function fillGrid(grid, items) {
|
||
if (!grid) return;
|
||
const rawItems = Array.isArray(items) ? items : [];
|
||
const gridId = gridRenderId(grid);
|
||
const cached = state.gridRender[gridId];
|
||
if (cached && cached.source === rawItems && cached.sourceLength === rawItems.length && Number(cached.rendered || 0) > 0 && grid.children.length >= Number(cached.rendered || 0)) {
|
||
const need = Math.min(Number(cached.total || 0), initialGridBatchSize(grid));
|
||
if (Number(cached.rendered || 0) >= need) {
|
||
ensureRemoteInitialFocus();
|
||
return;
|
||
}
|
||
}
|
||
items = uniqueMedia(rawItems).filter(hasPoster);
|
||
if (!items.length) {
|
||
showGridStatus(grid, "暂无内容");
|
||
return;
|
||
}
|
||
const keys = items.map(mediaDomKey);
|
||
const renderKeys = items.map(mediaRenderKey);
|
||
const previousKeys = grid.dataset.itemKeys ? grid.dataset.itemKeys.split("\n") : [];
|
||
const previousRenderKeys = grid.dataset.renderKeys ? grid.dataset.renderKeys.split("\n") : [];
|
||
const samePrefix = previousKeys.length > 0 && previousKeys.every((key, index) => key && key === keys[index]);
|
||
let info = state.gridRender[gridId];
|
||
const oldRendered = Number(info && info.rendered || 0);
|
||
const oldTotal = Number(info && info.total || 0);
|
||
const renderedCount = Math.min(oldRendered, previousRenderKeys.length, renderKeys.length);
|
||
const sameRendered = samePrefix && renderedCount > 0 && previousRenderKeys.slice(0, renderedCount).every((key, index) => key === renderKeys[index]);
|
||
if (!info || !samePrefix || !sameRendered) {
|
||
info = state.gridRender[gridId] = { rendered: 0, total: 0, keys: "", items: [] };
|
||
grid.replaceChildren();
|
||
grid.dataset.renderKeys = "";
|
||
}
|
||
grid.dataset.itemKeys = keys.join("\n");
|
||
info.total = items.length;
|
||
info.keys = grid.dataset.itemKeys;
|
||
info.items = items;
|
||
info.source = rawItems;
|
||
info.sourceLength = rawItems.length;
|
||
let target = Math.min(items.length, Math.max(Number(info.rendered || 0), initialGridBatchSize(grid)));
|
||
if (sameRendered && items.length > oldTotal && oldRendered >= oldTotal) target = Math.min(items.length, oldRendered + appendGridBatchSize(grid));
|
||
appendGridItems(grid, items, target);
|
||
ensureRemoteInitialFocus();
|
||
}
|
||
|
||
function showGridStatus(grid, text) {
|
||
if (!grid) return;
|
||
const gridId = gridRenderId(grid);
|
||
delete state.gridRender[gridId];
|
||
grid.dataset.renderKeys = "";
|
||
grid.dataset.itemKeys = "";
|
||
grid.replaceChildren(emptyNode(text));
|
||
}
|
||
|
||
function gridRenderId(grid) {
|
||
return grid && (grid.dataset.listId || grid.id) || "grid";
|
||
}
|
||
|
||
// gridColumns 缓存:key = grid元素id + 视口宽度 + TV模式,避免每次按键触发 getComputedStyle
|
||
var _gridColsCache = {};
|
||
function gridColumns(grid) {
|
||
var cacheKey = (grid ? (grid.dataset.listId || grid.id || "") : "") + "|" + (window.innerWidth | 0) + "|" + (isTvMode() ? "tv" : "m");
|
||
if (_gridColsCache[cacheKey] !== undefined) return _gridColsCache[cacheKey];
|
||
const value = grid ? getComputedStyle(grid).gridTemplateColumns : "";
|
||
const count = value && value !== "none" ? value.split(" ").filter(Boolean).length : 0;
|
||
var result;
|
||
if (count > 0) result = count;
|
||
else if (isTvMode()) result = 5;
|
||
else if ((window.innerWidth || 0) >= 1180) result = 6;
|
||
else if ((window.innerWidth || 0) >= 720) result = 4;
|
||
else result = 3;
|
||
_gridColsCache[cacheKey] = result;
|
||
return result;
|
||
}
|
||
// 视口变化时清除gridColumns缓存
|
||
window.addEventListener("resize", function() { _gridColsCache = {}; }, { passive: true });
|
||
|
||
function initialGridBatchSize(grid) {
|
||
const cols = gridColumns(grid);
|
||
return Math.max(cols * GRID_INITIAL_ROWS, isTvMode() ? 12 : 12);
|
||
}
|
||
|
||
function appendGridBatchSize(grid) {
|
||
const cols = gridColumns(grid);
|
||
return Math.max(cols * GRID_APPEND_ROWS, isTvMode() ? 10 : 6);
|
||
}
|
||
|
||
function appendGridItems(grid, items, target) {
|
||
const gridId = gridRenderId(grid);
|
||
const info = state.gridRender[gridId] || (state.gridRender[gridId] = { rendered: 0, total: items.length, keys: "", items: [] });
|
||
const start = Math.max(0, Number(info.rendered || 0));
|
||
const end = Math.max(start, Math.min(items.length, target));
|
||
info.items = items;
|
||
info.total = items.length;
|
||
if (end <= start) return;
|
||
const fragment = document.createDocumentFragment();
|
||
for (let index = start; index < end; index++) fragment.appendChild(mediaCard(items[index], index));
|
||
grid.appendChild(fragment);
|
||
info.rendered = end;
|
||
grid.dataset.renderKeys = items.slice(0, end).map(mediaRenderKey).join("\n");
|
||
// TV 端:追加完卡片后强制触发 lazy 图片加载(部分 WebView 不自动加载)
|
||
if (isTvMode() || isNativeLeanbackClient()) {
|
||
requestAnimationFrame(function() {
|
||
grid.querySelectorAll("img.poster[loading]").forEach(function(img) {
|
||
if (!img.complete || img.naturalWidth === 0) {
|
||
var src = img.getAttribute("src");
|
||
if (src) { img.removeAttribute("loading"); img.src = src; }
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
function appendGridBatch(grid) {
|
||
if (!grid || grid.closest && grid.closest(".list-panel[hidden]")) return false;
|
||
const gridId = gridRenderId(grid);
|
||
if (gridId === "recommendRail" && state.activeList !== "all") return false;
|
||
if (grid.dataset.listId && grid.dataset.listId !== state.activeList) return false;
|
||
const info = state.gridRender[gridId];
|
||
if (!info || !info.total || Number(info.rendered || 0) >= Number(info.total || 0)) return false;
|
||
const items = Array.isArray(info.items) && info.items.length ? info.items : itemsForGrid(gridId);
|
||
if (!items.length) return false;
|
||
appendGridItems(grid, items, Math.min(items.length, Number(info.rendered || 0) + appendGridBatchSize(grid)));
|
||
return true;
|
||
}
|
||
|
||
function appendActiveGridBatch() {
|
||
const grid = activeMediaGrid();
|
||
if (appendGridBatch(grid)) {
|
||
observeInfiniteScroll();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function canAppendActiveGrid() {
|
||
const grid = activeMediaGrid();
|
||
if (!grid) return false;
|
||
const info = state.gridRender[gridRenderId(grid)];
|
||
return !!(info && Number(info.rendered || 0) < Number(info.total || 0));
|
||
}
|
||
|
||
function activeMediaGrid() {
|
||
if (state.activeList === "all") return $("recommendRail");
|
||
const panel = Array.from($("listStack").querySelectorAll(".list-panel")).find((item) => !item.hidden && item.dataset.listId === state.activeList);
|
||
return panel && panel.querySelector(".media-grid");
|
||
}
|
||
|
||
function itemsForGrid(gridId) {
|
||
if (gridId === "recommendRail" || gridId === "all") {
|
||
return uniqueMedia(state.catalog["all"] || []).filter(hasPoster);
|
||
}
|
||
if (gridId === "recent") return uniqueMedia(state.recent.items || []).filter(hasPoster);
|
||
return uniqueMedia(state.catalog[gridId] || []).filter(hasPoster);
|
||
}
|
||
|
||
function maybeAppendGridForFocus(card) {
|
||
const grid = card && card.closest && card.closest(".media-grid");
|
||
if (!grid) return;
|
||
const info = state.gridRender[gridRenderId(grid)];
|
||
if (!info || Number(info.rendered || 0) >= Number(info.total || 0)) return;
|
||
const cards = Array.from(grid.querySelectorAll(".card"));
|
||
const index = cards.indexOf(card);
|
||
if (index < 0) return;
|
||
const threshold = Math.max(0, Number(info.rendered || 0) - gridColumns(grid) * 2);
|
||
if (index >= threshold) scheduleGridAppend(grid);
|
||
}
|
||
|
||
function scheduleGridAppend(grid) {
|
||
const gridId = gridRenderId(grid);
|
||
if (state.activeGridAppendTimer && state.activeGridAppendId === gridId) return;
|
||
if (state.activeGridAppendTimer) cancelAnimationFrame(state.activeGridAppendTimer);
|
||
state.activeGridAppendId = gridId;
|
||
state.activeGridAppendTimer = requestAnimationFrame(() => {
|
||
state.activeGridAppendTimer = 0;
|
||
state.activeGridAppendId = "";
|
||
if (appendGridBatch(grid)) observeInfiniteScroll();
|
||
});
|
||
}
|
||
|
||
function ensureRemoteInitialFocus() {
|
||
// 详情页激活或正在关闭时不抢焦点到主页元素(会引起滚动跳顶)
|
||
if (document.body.classList.contains("detail-active") || state.detailClosing) return;
|
||
const active = document.activeElement;
|
||
if (state.remoteInitialFocused && isVisibleFocusable(active)) return;
|
||
const target = !state.remoteInitialFocused ? initialHomeFocus() || firstContentFocus() : firstContentFocus();
|
||
if (!target) return;
|
||
state.remoteInitialFocused = true;
|
||
requestAnimationFrame(() => focusRemoteTarget(target));
|
||
}
|
||
|
||
function ensureRemoteActiveFocus() {
|
||
const active = document.activeElement;
|
||
if (isVisibleFocusable(active)) return false;
|
||
if (restoreConnectionPanelFocus()) return true;
|
||
const target = uiSnapshotRoute() === "home" ? initialHomeFocus() || firstContentFocus() : firstContentFocus();
|
||
if (!target) return false;
|
||
focusRemoteTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function focusInitialHomeNow(options) {
|
||
const target = initialHomeFocus();
|
||
focusHomeTopTarget(target, options);
|
||
}
|
||
|
||
function focusCurrentHomeTop(options) {
|
||
const target = currentHomeFocus() || initialHomeFocus();
|
||
focusHomeTopTarget(target, options);
|
||
}
|
||
|
||
function focusHomeTopTarget(target, options) {
|
||
if (!target) return;
|
||
const opts = options || {};
|
||
state.remoteInitialFocused = true;
|
||
if (opts.preventScroll) {
|
||
try {
|
||
target.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
target.focus();
|
||
}
|
||
} else {
|
||
focusRemoteTarget(target);
|
||
}
|
||
}
|
||
|
||
function initialHomeFocus() {
|
||
if (focusScopeRoot() !== document) return null;
|
||
return findByDataset($("chips"), "chipId", "all") || $("chips").querySelector(".chip.active") || $("chips").querySelector(".chip");
|
||
}
|
||
|
||
function currentHomeFocus() {
|
||
if (focusScopeRoot() !== document) return null;
|
||
return findByDataset($("chips"), "chipId", state.activeList) || $("chips").querySelector(".chip.active") || initialHomeFocus();
|
||
}
|
||
|
||
function observeInfiniteScroll() {
|
||
// 搜索激活时停止无限滚动,防止触发主页加载
|
||
if (document.documentElement.classList.contains("search-active")) return;
|
||
const sentinel = $("infiniteSentinel");
|
||
if (!sentinel) return;
|
||
if (!("IntersectionObserver" in window)) return;
|
||
if (!state.infiniteObserver) {
|
||
state.infiniteObserver = new IntersectionObserver((entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) loadMoreVisible();
|
||
}, { root: null, rootMargin: "1800px 0px", threshold: 0 });
|
||
}
|
||
state.infiniteObserver.unobserve(sentinel);
|
||
if (canAppendActiveGrid() || canLoadMore()) state.infiniteObserver.observe(sentinel);
|
||
}
|
||
|
||
function canLoadMore() {
|
||
if (state.loadingMore) return false;
|
||
if (state.activeList === "recent") return false;
|
||
if (state.activeList === "live") return false;
|
||
if (state.activeList === "all") { const p = state.catalogPage["all"]; return !!(p && !p.loading && p.page < p.total); }
|
||
const page = state.catalogPage[state.activeList];
|
||
return !!(page && !page.loading && page.page < page.total);
|
||
}
|
||
|
||
function ensureScrollablePage() {
|
||
// 搜索激活时不触发加载更多,避免跳回顶部
|
||
if (document.documentElement.classList.contains("search-active")) return;
|
||
const doc = document.documentElement;
|
||
if (doc.scrollHeight > window.innerHeight + 500) return;
|
||
loadMoreVisible();
|
||
}
|
||
|
||
function normalizeRails() {
|
||
document.querySelectorAll(".rail").forEach((rail) => {
|
||
rail.style.width = "100%";
|
||
rail.style.maxWidth = "100%";
|
||
rail.style.minWidth = "0";
|
||
});
|
||
}
|
||
|
||
function mediaCard(item, index, options) {
|
||
const opts = options || {};
|
||
const button = document.createElement("button");
|
||
const people = Number(item.people || 0);
|
||
const key = mediaDomKey(item);
|
||
const recent = item.source === "history";
|
||
const landscape = !!opts.landscape;
|
||
button.className = "card focusable" + (people > 0 ? " has-people" : "") + (recent ? " recent-card" : "") + (landscape ? " landscape-card" : "");
|
||
button.type = "button";
|
||
if (key) button.dataset.mediaKey = key;
|
||
if (index !== undefined) button.dataset.cardIndex = String(index); // TV快速路径用
|
||
const rating = item.voteAverage ? Number(item.voteAverage).toFixed(1) : "";
|
||
const poster = displayImage(landscape ? item.landscape || item.image || item.pic : item.pic, { size: landscape ? "original" : "w342" });
|
||
const imageOpt = { loading: index < initialGridBatchSize(null) ? "eager" : "lazy", fetchPriority: index < 8 ? "high" : "" };
|
||
const progress = Math.min(100, Math.max(0, Number(item.progress || 0)));
|
||
button.innerHTML = recent ? `
|
||
<img class="poster" alt="" ${imageAttrs(poster, imageOpt)}>
|
||
${item.badge ? `<span class="recent-badge">${escapeHtml(item.badge)}</span>` : ""}
|
||
<div class="card-body">
|
||
<div class="card-title">${escapeHtml(item.title)}</div>
|
||
<div class="card-meta">${escapeHtml(item.remark)}</div>
|
||
</div>
|
||
${progress > 0 ? `<div class="recent-progress" aria-hidden="true"><span style="--recent-progress:${escapeAttr(progress + "%")}"></span></div>` : ""}
|
||
` : `
|
||
<img class="poster" alt="" ${imageAttrs(poster, imageOpt)} onload="this.classList.add('loaded')" onerror="if(!this.dataset.retried){this.dataset.retried='1';var p=this.getAttribute('src');if(p&&p.indexOf('images.tmdb.org')<0&&p.indexOf('/t/p/')<0){var m=p.match(/\/(t\d|w\d+|original|h\d+)(\/.+)/);if(m)this.src='https://images.tmdb.org/t/p/'+m[1]+m[2];}this.classList.add('loaded');}">
|
||
${people > 0 ? `<span class="card-people">${escapeHtml(people)}人</span>` : ""}
|
||
<div class="card-body">
|
||
<div class="card-title-row">
|
||
<div class="card-title">${escapeHtml(item.title)}</div>
|
||
${rating ? `<span class="rating-badge-inline">${escapeHtml(rating)}</span>` : ""}
|
||
</div>
|
||
<div class="card-meta">${escapeHtml(item.remark)}</div>
|
||
</div>
|
||
`;
|
||
button.addEventListener("click", () => {
|
||
if (item.source === "history") openRecentItem(item);
|
||
else openDetail(item);
|
||
});
|
||
// Long press menu (搜索播放 + 盘搜)
|
||
if (item.source !== "history") {
|
||
let lpTimer = null;
|
||
let lpFired = false;
|
||
let lpStartX = 0, lpStartY = 0;
|
||
|
||
function lpStart(e) {
|
||
lpFired = false;
|
||
const t = e.touches ? e.touches[0] : e;
|
||
lpStartX = t.clientX; lpStartY = t.clientY;
|
||
lpTimer = setTimeout(() => {
|
||
lpFired = true;
|
||
haptic("medium");
|
||
showCardLongPressMenu(item, button);
|
||
}, 480);
|
||
}
|
||
function lpCancel() {
|
||
if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; }
|
||
}
|
||
function lpMove(e) {
|
||
const t = e.touches ? e.touches[0] : e;
|
||
if (Math.abs(t.clientX - lpStartX) > 8 || Math.abs(t.clientY - lpStartY) > 8) lpCancel();
|
||
}
|
||
function lpEnd(e) {
|
||
lpCancel();
|
||
// If long-press fired, swallow this touchend so the click doesn't also fire
|
||
if (lpFired) { e.preventDefault(); e.stopPropagation(); lpFired = false; }
|
||
}
|
||
button.addEventListener("touchstart", lpStart, { passive: true });
|
||
button.addEventListener("touchmove", lpMove, { passive: true });
|
||
button.addEventListener("touchend", lpEnd, { passive: false });
|
||
button.addEventListener("touchcancel", lpCancel, { passive: true });
|
||
button.addEventListener("contextmenu", (e) => { e.preventDefault(); });
|
||
}
|
||
return button;
|
||
}
|
||
|
||
// ── 长按菜单:全局唯一实例管理 ──
|
||
const LongPressMenu = (function() {
|
||
let backdrop = null;
|
||
let menu = null;
|
||
|
||
function destroy() {
|
||
if (backdrop) { backdrop.remove(); backdrop = null; }
|
||
if (menu) { menu.remove(); menu = null; }
|
||
document.body.style.removeProperty("overflow");
|
||
}
|
||
|
||
function makeBtn(svgPath, label, onClick) {
|
||
const btn = document.createElement("button");
|
||
btn.className = "lp-btn focusable";
|
||
btn.type = "button";
|
||
btn.innerHTML = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgPath}</svg>${escapeHtml(label)}`;
|
||
btn.addEventListener("pointerdown", (e) => { e.stopPropagation(); });
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
destroy();
|
||
onClick();
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
return {
|
||
show: function(item, anchorEl) {
|
||
destroy();
|
||
|
||
// Full-screen backdrop to capture all outside taps
|
||
backdrop = document.createElement("div");
|
||
backdrop.className = "lp-backdrop";
|
||
backdrop.addEventListener("touchstart", (e) => { e.preventDefault(); destroy(); }, { passive: false });
|
||
backdrop.addEventListener("mousedown", (e) => { e.preventDefault(); destroy(); });
|
||
document.body.appendChild(backdrop);
|
||
|
||
menu = document.createElement("div");
|
||
menu.id = "cardLongPressMenu";
|
||
menu.className = "card-long-press-menu";
|
||
menu.setAttribute("role", "menu");
|
||
|
||
// 搜索播放 — same icon as detailSearchBtn
|
||
menu.appendChild(makeBtn(
|
||
'<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>',
|
||
"搜索播放",
|
||
() => {
|
||
rememberWatchIntent(item, "search");
|
||
startWatchTracking(item);
|
||
nativeSearch(item.title);
|
||
}
|
||
));
|
||
|
||
menu.appendChild(Object.assign(document.createElement("div"), { className: "lp-divider" }));
|
||
|
||
// 盘搜 — same icon as panSearchBtn
|
||
menu.appendChild(makeBtn(
|
||
'<path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/>',
|
||
"盘搜",
|
||
() => {
|
||
openDetail(item, { keepPan: false });
|
||
setTimeout(() => searchPanResources(item), 300);
|
||
}
|
||
));
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
// Position: prefer above card, fall back below
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
requestAnimationFrame(() => {
|
||
const mw = menu.offsetWidth || 160;
|
||
const mh = menu.offsetHeight || 108;
|
||
let left = rect.left + rect.width / 2 - mw / 2;
|
||
let top = rect.top - mh - 10;
|
||
if (top < 8) top = rect.bottom + 10;
|
||
if (top + mh > window.innerHeight - 8) top = window.innerHeight - mh - 8;
|
||
if (left < 8) left = 8;
|
||
if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
|
||
menu.style.left = left + "px";
|
||
menu.style.top = top + "px";
|
||
});
|
||
},
|
||
destroy
|
||
};
|
||
})();
|
||
|
||
function showCardLongPressMenu(item, anchorEl) {
|
||
LongPressMenu.show(item, anchorEl);
|
||
}
|
||
|
||
async function openRecentItem(item) {
|
||
if (!item || !item.siteKey || !item.vodId) {
|
||
toast("最近观看缺少播放信息");
|
||
return;
|
||
}
|
||
try {
|
||
rememberWatchIntent(item, "view");
|
||
await sdk().vod(item.siteKey, item.vodId, item.title, item.pic);
|
||
startWatchTracking(item);
|
||
} catch (e) {
|
||
toast("打开最近观看失败:" + (e.message || "unknown"));
|
||
}
|
||
}
|
||
|
||
function mediaDomKey(item) {
|
||
if (!item) return "";
|
||
return item.id || (item.tmdbId && item.mediaType ? `tmdb:${item.mediaType}:${item.tmdbId}` : item.title || "");
|
||
}
|
||
|
||
function mediaRenderKey(item) {
|
||
if (!item) return "";
|
||
return [mediaDomKey(item), item.title, item.pic, item.remark, item.voteAverage, item.people, item.progress, item.badge]
|
||
.map((value) => String(value == null ? "" : value).replace(/[\r\n]+/g, " "))
|
||
.join("\t");
|
||
}
|
||
|
||
function mediaHeatKey(item) {
|
||
if (!item) return "";
|
||
const mediaType = item.mediaType || "";
|
||
const tmdbId = item.tmdbId || "";
|
||
if (mediaType && tmdbId) return `tmdb:${mediaType}:${tmdbId}`;
|
||
const title = normalizeTitle(item.title || item.query || "");
|
||
return title ? `title:${title}` : "";
|
||
}
|
||
|
||
|
||
function normalizeSnapshot(item) {
|
||
if (!item || typeof item !== "object") return {};
|
||
return {
|
||
id: item.id || (item.tmdbId && item.mediaType ? `tmdb:${item.mediaType}:${item.tmdbId}` : ""),
|
||
source: item.source || "tmdb",
|
||
tmdbId: item.tmdbId ? String(item.tmdbId) : "",
|
||
mediaType: item.mediaType || "",
|
||
listId: item.listId || "",
|
||
listTitle: item.listTitle || "",
|
||
title: item.title || "",
|
||
pic: item.pic || "",
|
||
landscape: item.landscape || "",
|
||
image: item.image || item.pic || "",
|
||
desc: item.desc || "",
|
||
releaseDate: item.releaseDate || "",
|
||
voteAverage: item.voteAverage || 0,
|
||
popularity: item.popularity || 0,
|
||
baseRank: item.baseRank || 99
|
||
};
|
||
}
|
||
|
||
function normalizeTitle(title) {
|
||
return String(title || "")
|
||
.toLowerCase()
|
||
.replace(/[第][一二三四五六七八九十0-9]+[季部]?/g, "")
|
||
.replace(/\s+/g, "")
|
||
.replace(/[·::,,.。!!??'"“”‘’《》<>【】()[\]{}_-]/g, "")
|
||
.trim();
|
||
}
|
||
|
||
function findMediaByContent(content) {
|
||
const title = normalizeTitle(content.title || content.query || "");
|
||
return allItems().concat(state.searchItems).find((item) => {
|
||
if (content.tmdbId && item.tmdbId && String(content.tmdbId) === String(item.tmdbId)) return true;
|
||
return title && normalizeTitle(item.title) === title;
|
||
});
|
||
}
|
||
|
||
function renderMetrics() {
|
||
const localKey = state.identity ? hotUserKey(state.identity.pubkey) : "";
|
||
const localVector = localKey ? state.hot.users.get(localKey) : null;
|
||
const local = localVector ? hotActiveVectorItems(localVector).length : 0;
|
||
$("identityText").textContent = state.identity ? `npub: ${state.identity.npub}
|
||
nsec: ${state.identity.nsec}` : "身份未就绪";
|
||
$("relayText").textContent = `relay: ${window.WEBHOME_CONFIG.nostr.relays.join(", ")}`;
|
||
setStatus("nostr", `已连接 ${state.relay.connected}/${window.WEBHOME_CONFIG.nostr.relays.length} · 本机 ${local} · 榜单 ${state.hot.items.length}`);
|
||
if (state.identity) state.status.identity = shortKey(state.identity.npub);
|
||
renderConnection();
|
||
}
|
||
|
||
function scheduleSearchSuggest() {
|
||
clearTimeout(state.suggestions.timer);
|
||
const input = $("searchInput");
|
||
const kw = input ? input.value.trim() : "";
|
||
if (!kw) {
|
||
// 输入清空时改为展示热搜词
|
||
if (document.activeElement === input) showHotSuggest();
|
||
else hideSearchSuggest();
|
||
return;
|
||
}
|
||
if (input && input.readOnly) return hideSearchSuggest();
|
||
const seq = ++state.suggestions.seq;
|
||
state.suggestions.timer = setTimeout(() => loadSearchSuggest(kw, seq), 180);
|
||
}
|
||
|
||
async function loadSearchSuggest(keyword, seq) {
|
||
const kw = String(keyword || "").trim();
|
||
if (!kw) return hideSearchSuggest();
|
||
if (seq !== state.suggestions.seq) return;
|
||
if (state.suggestions.keyword === kw && state.suggestions.items.length) {
|
||
if ($("searchInput") && $("searchInput").value.trim() === kw && !$("searchInput").readOnly) renderSearchSuggest();
|
||
return;
|
||
}
|
||
state.suggestions.loading = true;
|
||
try {
|
||
const body = await requestJson(suggestUrl(kw), 8);
|
||
if (seq !== state.suggestions.seq) return;
|
||
if (!$("searchInput") || $("searchInput").value.trim() !== kw || $("searchInput").readOnly) return;
|
||
state.suggestions.keyword = kw;
|
||
state.suggestions.items = normalizeSuggestItems(body).slice(0, 8);
|
||
renderSearchSuggest();
|
||
} catch (e) {
|
||
if (seq === state.suggestions.seq && $("searchInput") && $("searchInput").value.trim() === kw) hideSearchSuggest();
|
||
} finally {
|
||
state.suggestions.loading = false;
|
||
}
|
||
}
|
||
|
||
function normalizeSuggestItems(body) {
|
||
const raw = Array.isArray(body && body.data) ? body.data : Array.isArray(body) ? body : [];
|
||
const seen = new Set();
|
||
const items = [];
|
||
raw.forEach((item) => {
|
||
const title = String(item && (item.name || item.title || item.keyword || item.word) || "").trim();
|
||
if (!title || seen.has(title)) return;
|
||
seen.add(title);
|
||
const type = String(item.cname || item.channel_name || item.type || "").trim();
|
||
const year = item.year ? String(item.year) : "";
|
||
const actor = Array.isArray(item.main_actor) ? item.main_actor.slice(0, 2).join("/") : "";
|
||
items.push({ title, meta: [type, year, actor].filter(Boolean).join(" · ") });
|
||
});
|
||
return items;
|
||
}
|
||
|
||
function pickRandomHotItems(items, poolSize, count) {
|
||
const pool = items.slice(0, Math.min(poolSize, items.length));
|
||
for (let i = pool.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
const tmp = pool[i];
|
||
pool[i] = pool[j];
|
||
pool[j] = tmp;
|
||
}
|
||
return pool.slice(0, count);
|
||
}
|
||
|
||
function showHotSuggest() {
|
||
const panel = $("suggestPanel");
|
||
if (!panel) return;
|
||
// 从热搜池中随机抽取 8 条,每次打开面板都会变化
|
||
const hotItems = pickRandomHotItems(state.hot.items || [], 30, 8);
|
||
if (!hotItems.length) return;
|
||
closeConnectionPanel();
|
||
const fireIconSvg = `<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M10 2c0 0-1 3-1 5 0 1.1.9 2 2 2s2-.9 2-2c0 0 2 2 2 5a5 5 0 01-10 0c0-3 2-6 5-10z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" fill="none"/></svg>`;
|
||
panel.replaceChildren(...hotItems.map((item, idx) => {
|
||
const button = document.createElement("button");
|
||
button.className = "suggest-item focusable";
|
||
button.type = "button";
|
||
button.setAttribute("role", "option");
|
||
const mediaLabel = item.mediaType === "movie" ? "电影" : item.mediaType === "tv" ? "剧集" : "";
|
||
const meta = [mediaLabel, item.people ? item.people + "人在看" : ""].filter(Boolean).join(" · ");
|
||
button.innerHTML = `<span class="suggest-icon suggest-icon-hot">${fireIconSvg}</span><b>${escapeHtml(item.title)}</b><span>${escapeHtml(meta)}</span>`;
|
||
button.addEventListener("click", () => {
|
||
$("searchInput").value = item.title;
|
||
hideSearchSuggest();
|
||
searchTmdb(item.title);
|
||
});
|
||
return button;
|
||
}));
|
||
panel.classList.add("open");
|
||
if ($("searchForm")) $("searchForm").classList.add("suggesting");
|
||
}
|
||
|
||
function renderSearchSuggest() {
|
||
const panel = $("suggestPanel");
|
||
if (!panel) return;
|
||
const items = state.suggestions.items || [];
|
||
if (!items.length) return hideSearchSuggest();
|
||
closeConnectionPanel();
|
||
const kw = (state.suggestions.keyword || "").trim();
|
||
const searchIconSvg = `<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.8"/><path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`;
|
||
panel.replaceChildren(...items.map((item, idx) => {
|
||
const button = document.createElement("button");
|
||
button.className = "suggest-item focusable";
|
||
button.type = "button";
|
||
button.setAttribute("role", "option");
|
||
// 关键词高亮
|
||
let titleHtml = escapeHtml(item.title);
|
||
if (kw) {
|
||
const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
titleHtml = titleHtml.replace(new RegExp(escaped, "gi"), m => `<mark>${m}</mark>`);
|
||
}
|
||
button.innerHTML = `<span class="suggest-icon">${searchIconSvg}</span><b>${titleHtml}</b><span>${escapeHtml(item.meta || "")}</span>`;
|
||
button.addEventListener("click", () => applySearchSuggest(item.title));
|
||
return button;
|
||
}));
|
||
panel.classList.add("open");
|
||
if ($("searchForm")) $("searchForm").classList.add("suggesting");
|
||
}
|
||
|
||
function hideSearchSuggest() {
|
||
clearTimeout(state.suggestions.timer);
|
||
state.suggestions.timer = 0;
|
||
state.suggestions.seq += 1;
|
||
const panel = $("suggestPanel");
|
||
if (panel) {
|
||
panel.classList.remove("open");
|
||
panel.replaceChildren();
|
||
}
|
||
if ($("searchForm")) $("searchForm").classList.remove("suggesting");
|
||
state.suggestions.items = [];
|
||
state.suggestions.keyword = "";
|
||
if (state.suggestions.controller) {
|
||
try { state.suggestions.controller.abort(); } catch (e) {}
|
||
state.suggestions.controller = null;
|
||
}
|
||
}
|
||
|
||
function applySearchSuggest(title) {
|
||
const value = String(title || "").trim();
|
||
if (!value) return;
|
||
$("searchInput").value = value;
|
||
hideSearchSuggest();
|
||
searchTmdb(value);
|
||
}
|
||
|
||
function enableSearchEditing() {
|
||
if ($("searchInput")) $("searchInput").readOnly = false;
|
||
}
|
||
|
||
function disableSearchEditing() {
|
||
if ($("searchInput")) $("searchInput").readOnly = true;
|
||
}
|
||
|
||
function submitSearchInput() {
|
||
const input = $("searchInput");
|
||
if (!input) return;
|
||
enableSearchEditing();
|
||
searchTmdb(input.value);
|
||
}
|
||
|
||
async function searchTmdb(keyword) {
|
||
const kw = keyword.trim();
|
||
if (!kw) return;
|
||
hideSearchSuggest();
|
||
try {
|
||
setStatus("tmdb", `搜索 ${kw}`);
|
||
const body = await requestJson(tmdbSearchUrl(kw), 18);
|
||
const results = (body.results || []).filter((item) => item.media_type === "movie" || item.media_type === "tv");
|
||
state.searchItems = results.map((item, index) => normalizeTmdb(item, { id: "search", title: "搜索", mediaType: item.media_type }, index)).filter(hasPoster).slice(0, 18);
|
||
setStatus("tmdb", `搜索成功 ${state.searchItems.length} 条`);
|
||
renderSearch();
|
||
disableSearchEditing();
|
||
const searchEl = $("searchInput");
|
||
if (searchEl && document.activeElement === searchEl) searchEl.blur();
|
||
// 搜索结果出现时复用「聚焦」已压入的 #search 层;若尚未压入则补一条,
|
||
// 让返回键能整体关闭搜索板块(聚焦态 + 结果列表)一次回到主页。
|
||
if (state.searchItems.length && location.hash !== "#search" && location.hash !== "#detail") {
|
||
history.pushState({ sheet: "search" }, "", "#search");
|
||
_searchHistoryPushed = true;
|
||
}
|
||
scheduleUiSnapshotSave();
|
||
// 保留在搜索结果页,不自动打开详情
|
||
} catch (e) {
|
||
setStatus("tmdb", "搜索失败:" + (e.message || "unknown"));
|
||
toast("搜索失败");
|
||
}
|
||
}
|
||
|
||
// 滚动恢复由我们在 popstate 里手动处理(pushState 后立即还原)
|
||
if ("scrollRestoration" in history) {
|
||
history.scrollRestoration = "manual";
|
||
}
|
||
|
||
// 关闭详情时跨 history.back() 传递主页滚动位置
|
||
let _pendingHomeScrollY = 0;
|
||
// 标记 #pan 条目已推入 history,供 popstate 判断返回方向
|
||
let _panHistoryPushed = false;
|
||
// 标记搜索板块(聚焦态 + 结果页共用同一层 #search)已推入 history
|
||
let _searchHistoryPushed = false;
|
||
|
||
// 主页滚动位置 —— 开详情前保存,关详情后同步恢复
|
||
function openDetail(item, options) {
|
||
const opts = options || {};
|
||
|
||
state.selected = item;
|
||
state.detail = null;
|
||
if (!opts.keepPan) resetPanSearch();
|
||
ensureSheetViewport($("detailSheet"));
|
||
syncDetailLayout();
|
||
document.body.classList.add("detail-active");
|
||
// 浏览器预览模式:直接加沉浸 class(无 Native Toolbar 干扰)
|
||
if (!window.fongmiBridge) document.documentElement.classList.add("detail-immersive");
|
||
$("detailSheet").classList.add("active");
|
||
$("detailSheet").setAttribute("aria-hidden", "false");
|
||
if (isNativeLeanbackClient()) setTvMode(true);
|
||
try {
|
||
renderDetailBase(item);
|
||
clearDetailExtras();
|
||
} catch (e) {
|
||
$("detailTitle").textContent = item && item.title || "详情";
|
||
$("detailText").textContent = "详情渲染失败:" + (e.message || "unknown");
|
||
}
|
||
if (!opts.skipHistory && location.hash !== "#detail") history.pushState({ sheet: "detail" }, "", "#detail");
|
||
if (!opts.restore) rememberWatchIntent(item, "view");
|
||
// 进入详情即全屏:TV 端在此首次进入全屏,移动端通常已是全屏
|
||
document.documentElement.classList.add("detail-immersive");
|
||
_enterFullscreen();
|
||
if (shouldRefreshRecentList()) loadRecentList({ silent: true }).catch(() => {});
|
||
loadDetail(item);
|
||
scheduleUiSnapshotSave();
|
||
if (!opts.restore) setTimeout(() => focusRemoteTarget(detailPrimaryActionButton() || $("closeDetailBtn")), 40);
|
||
}
|
||
|
||
function ensureSheetViewport(sheet, display) {
|
||
if (!sheet) return;
|
||
sheet.style.position = "fixed";
|
||
sheet.style.top = "0";
|
||
sheet.style.right = "0";
|
||
sheet.style.bottom = "0";
|
||
sheet.style.left = "0";
|
||
sheet.style.width = "100%";
|
||
sheet.style.height = "100vh";
|
||
// detail-large 用 flex 布局,不能强制 block
|
||
const useDisplay = display || (sheet.classList.contains("detail-large") ? "flex" : "block");
|
||
sheet.style.display = useDisplay;
|
||
sheet.style.zIndex = sheet.id === "imageViewer" ? "100" : "60";
|
||
}
|
||
|
||
function renderDetailBase(item) {
|
||
resetDetailTextClamp();
|
||
// 每次打开详情页时展开简介
|
||
const ow = $("detailOverviewWrap");
|
||
const ob = $("detailOverviewToggle");
|
||
if (ow) ow.classList.add("open");
|
||
if (ob) ob.setAttribute("aria-expanded", "true");
|
||
// Reset logo
|
||
const logoWrap = $("detailLogoWrap");
|
||
const logoImg = $("detailLogoImg");
|
||
if (logoWrap) logoWrap.style.display = "none";
|
||
if (logoImg) { logoImg.src = ""; logoImg.classList.remove("loaded"); }
|
||
$("detailTitle").textContent = item.title;
|
||
$("detailTitle").style.display = "";
|
||
const fallbackText = item.desc || item.remark || "";
|
||
$("detailText").textContent = fallbackText || "";
|
||
renderDetailMeta(item, state.detail);
|
||
renderDetailTitleMeta(item, state.detail);
|
||
// 先用封面图(item.pic)显示,TMDB数据加载后 renderDetailFull 再换横图
|
||
const posterSrc = item.pic || "";
|
||
setDetailCoverCarousel(
|
||
item.landscape ? [item.landscape] : [],
|
||
posterSrc,
|
||
{ allowPoster: true } // 有海报就先用海报,比空白强
|
||
);
|
||
updateDetailContinueButton();
|
||
scheduleDetailTextClamp();
|
||
}
|
||
|
||
function renderDetailMeta(item, detail) {
|
||
if (isTvMode() && !useLargeDetailLayout()) {
|
||
$("detailMeta").replaceChildren();
|
||
return;
|
||
}
|
||
$("detailMeta").replaceChildren(...detailMeta(item, detail).map((meta) => {
|
||
const span = document.createElement("span");
|
||
const text = meta.text || meta;
|
||
let typeClass = "";
|
||
if (/^\d{4}$/.test(text)) typeClass = " pill-year";
|
||
else if (/分钟|每集/.test(text)) typeClass = " pill-runtime";
|
||
else if (/^\d/.test(text) && /分$/.test(text)) typeClass = " pill-score";
|
||
else if (text && !typeClass) typeClass = " pill-genre";
|
||
span.className = "meta-pill" + (meta.strong ? " strong" : "") + typeClass;
|
||
span.textContent = text;
|
||
return span;
|
||
}));
|
||
}
|
||
|
||
function renderDetailTitleMeta(item, detail) {
|
||
const root = $("detailTitleMeta");
|
||
const items = useLargeDetailLayout()
|
||
? detailTitleMeta(item, detail).filter((meta) => meta.type === "score").slice(0, 1)
|
||
: isTvMode() ? uniqueMeta(detailTitleMeta(item, detail).concat(detailMeta(item, detail))) : detailTitleMeta(item, detail);
|
||
root.replaceChildren(...items.flatMap((meta, index) => {
|
||
const span = document.createElement("span");
|
||
span.className = meta.type || "";
|
||
span.textContent = meta.text || meta;
|
||
if (index === 0) return [span];
|
||
const sep = document.createElement("span");
|
||
sep.className = "sep";
|
||
sep.textContent = "·";
|
||
return [sep, span];
|
||
}));
|
||
}
|
||
|
||
function setDetailCoverCarousel(landscapes, poster, options) {
|
||
const images = uniqueCoverImages(Array.isArray(landscapes) ? landscapes : [landscapes]);
|
||
pauseDetailCoverCarousel();
|
||
state.detailCover.images = images;
|
||
state.detailCover.index = 0;
|
||
state.detailCover.token += 1;
|
||
setDetailCoverImage(images[0] || "", poster, options);
|
||
updateDetailCoverControls();
|
||
if (images.length > 1) {
|
||
const token = state.detailCover.token;
|
||
state.detailCover.timer = setInterval(() => {
|
||
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active') || document.visibilityState === 'hidden') return;
|
||
switchDetailCover(1, { allowPoster: false, keepLoading: false });
|
||
}, 5200);
|
||
}
|
||
}
|
||
|
||
function updateDetailCoverControls() {
|
||
const cover = $("detailImage") && $("detailImage").parentElement;
|
||
if (!cover) return;
|
||
const enabled = state.detailCover.images.length > 1;
|
||
cover.classList.toggle("has-multiple", enabled);
|
||
}
|
||
|
||
function shiftDetailCover(delta) {
|
||
const images = state.detailCover.images || [];
|
||
if (images.length <= 1) return;
|
||
pauseDetailCoverCarousel();
|
||
switchDetailCover(delta, { allowPoster: false, keepLoading: false });
|
||
updateDetailCoverControls();
|
||
}
|
||
|
||
function switchDetailCover(delta, options) {
|
||
const images = state.detailCover.images || [];
|
||
if (images.length <= 1) return;
|
||
const start = Math.max(0, Math.min(Number(state.detailCover.index || 0), images.length - 1));
|
||
const step = delta < 0 ? -1 : 1;
|
||
const token = state.detailCover.token;
|
||
tryPreloadDetailCover(start, step, 0, token, options || {});
|
||
}
|
||
|
||
function tryPreloadDetailCover(current, step, attempts, token, options) {
|
||
const images = state.detailCover.images || [];
|
||
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
|
||
if (!images.length || attempts >= images.length - 1) return;
|
||
const next = (current + step + images.length) % images.length;
|
||
const src = displayImage(images[next], { size: "original" });
|
||
if (!src) return tryPreloadDetailCover(next, step, attempts + 1, token, options);
|
||
// TV 端跳过预加载,直接切换(TV WebView 对 new Image() 预加载处理异常)
|
||
if (isTvMode() || isNativeLeanbackClient()) {
|
||
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
|
||
state.detailCover.index = next;
|
||
setDetailCoverImage(images[next], "", Object.assign({}, options, { preloadedSrc: src }));
|
||
return;
|
||
}
|
||
preloadImage(src, () => {
|
||
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
|
||
state.detailCover.index = next;
|
||
setDetailCoverImage(images[next], "", Object.assign({}, options, { preloadedSrc: src }));
|
||
}, () => tryPreloadDetailCover(next, step, attempts + 1, token, options));
|
||
}
|
||
|
||
function preloadImage(src, onload, onerror) {
|
||
const img = new Image();
|
||
img.onload = onload;
|
||
img.onerror = onerror;
|
||
img.src = src;
|
||
}
|
||
|
||
function restartDetailCoverCarousel() {
|
||
if (!$('detailSheet').classList.contains('active') || state.detailCover.timer || state.detailCover.images.length <= 1) return;
|
||
const images = state.detailCover.images.slice();
|
||
const current = Math.max(0, Math.min(state.detailCover.index, images.length - 1));
|
||
state.detailCover.images = [];
|
||
setDetailCoverCarousel(images.slice(current).concat(images.slice(0, current)), "", { allowPoster: false, keepLoading: false });
|
||
}
|
||
|
||
function setDetailCoverImage(landscape, poster, options) {
|
||
const opts = options || {};
|
||
const cover = $("detailImage").parentElement;
|
||
const image = $("detailImage");
|
||
const land = String(landscape || "").trim();
|
||
const post = String(poster || "").trim();
|
||
const allowPoster = opts.allowPoster !== false;
|
||
const usePoster = allowPoster && (!land || isSameImageSource(land, post));
|
||
const src = usePoster ? post || land : land;
|
||
const bgSrc = src ? displayImage(src, { size: "original" }) : "";
|
||
cover.classList.toggle("loading", !src && opts.keepLoading !== false);
|
||
cover.classList.toggle("poster-mode", !!usePoster && !!src);
|
||
cover.dataset.coverMode = usePoster ? "poster" : "landscape";
|
||
cover.style.setProperty("--detail-cover-bg", bgSrc ? `url("${cssUrl(bgSrc)}")` : "none");
|
||
const sheet = $("detailSheet");
|
||
setHeroBg(bgSrc);
|
||
if (src) {
|
||
const nextSrc = opts.preloadedSrc || bgSrc;
|
||
const applyLoaded = () => {
|
||
image.onload = null;
|
||
image.onerror = null;
|
||
image.src = nextSrc;
|
||
resetDetailCoverFrame(cover);
|
||
image.classList.add("active");
|
||
};
|
||
if (opts.preloadedSrc) {
|
||
applyLoaded();
|
||
return;
|
||
}
|
||
if (image.getAttribute("src") === nextSrc && image.classList.contains("active")) return;
|
||
// TV 端跳过 new Image() 预加载(TV WebView 对 new Image() 处理异常),直接赋值给 img.src
|
||
if (isTvMode() || isNativeLeanbackClient()) {
|
||
applyLoaded();
|
||
return;
|
||
}
|
||
preloadImage(nextSrc, applyLoaded, () => {
|
||
if (!image.getAttribute("src")) image.classList.remove("active");
|
||
});
|
||
} else {
|
||
image.onload = null;
|
||
image.onerror = null;
|
||
image.classList.remove("active");
|
||
image.removeAttribute("src");
|
||
resetDetailCoverFrame(cover);
|
||
}
|
||
}
|
||
|
||
function syncDetailCoverFrame(image) {
|
||
const cover = image && image.parentElement;
|
||
resetDetailCoverFrame(cover);
|
||
}
|
||
|
||
function resetDetailCoverFrame(cover) {
|
||
if (!cover) return;
|
||
cover.style.width = "";
|
||
cover.style.height = "";
|
||
cover.style.minHeight = "";
|
||
cover.style.maxHeight = "";
|
||
}
|
||
|
||
function pauseDetailCoverCarousel() {
|
||
if (state.detailCover.timer) clearInterval(state.detailCover.timer);
|
||
state.detailCover.timer = 0;
|
||
state.detailCover.token += 1;
|
||
}
|
||
|
||
function stopDetailCoverCarousel(clearImage) {
|
||
pauseDetailCoverCarousel();
|
||
state.detailCover.images = [];
|
||
state.detailCover.index = 0;
|
||
updateDetailCoverControls();
|
||
if (clearImage) {
|
||
const image = $("detailImage");
|
||
const cover = image.parentElement;
|
||
image.onload = null;
|
||
image.classList.remove("active");
|
||
image.removeAttribute("src");
|
||
cover.classList.remove("loading", "poster-mode");
|
||
delete cover.dataset.coverMode;
|
||
cover.style.setProperty("--detail-cover-bg", "none");
|
||
setHeroBg("");
|
||
resetDetailCoverFrame(cover);
|
||
}
|
||
}
|
||
|
||
function uniqueCoverImages(items) {
|
||
const seen = new Set();
|
||
return (items || []).map((item) => String(item || "").trim()).filter((item) => {
|
||
const key = tmdbImagePath(item) || item;
|
||
if (!key || seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function isSameImageSource(a, b) {
|
||
const left = tmdbImagePath(a) || String(a || "").trim();
|
||
const right = tmdbImagePath(b) || String(b || "").trim();
|
||
return !!left && !!right && left === right;
|
||
}
|
||
|
||
function cssUrl(value) {
|
||
return String(value || "").replace(/["\\\n\r]/g, "");
|
||
}
|
||
|
||
function detailMeta(item, detail) {
|
||
const data = detail || {};
|
||
const mediaType = item.mediaType === "tv" ? "剧集" : "电影";
|
||
const date = data.first_air_date || data.release_date || item.releaseDate || "";
|
||
const genres = (data.genres || []).map((genre) => genre && genre.name).filter(Boolean).filter((name) => !/science fiction/i.test(name)).slice(0, 2);
|
||
const runtime = detailRuntime(data, item);
|
||
const seasonText = item.mediaType === "tv" && data.number_of_seasons ? `${data.number_of_seasons}季` : "";
|
||
if (useLargeDetailLayout()) {
|
||
const year = String(date || "").slice(0, 4);
|
||
const genreText = genres.length ? genres.slice(0, 3).join(" / ") : mediaType;
|
||
const episodeText = item.mediaType === "tv" ? detailLargeEpisodeLabel(data) : runtime;
|
||
const status = detailStatus(data.status || "");
|
||
return uniqueMeta([year, genreText, episodeText, status && status !== "已完结" ? status : "", runtime && item.mediaType === "tv" ? runtime : ""])
|
||
.slice(0, 6)
|
||
.map((meta) => typeof meta === "string" ? { text: meta } : meta);
|
||
}
|
||
const metas = [
|
||
detailDateLabel(item, date),
|
||
seasonText,
|
||
runtime
|
||
].filter(Boolean);
|
||
genres.forEach((name) => metas.push(name));
|
||
return uniqueMeta(metas).slice(0, 8).map((meta) => typeof meta === "string" ? { text: meta } : meta);
|
||
}
|
||
|
||
function detailTitleMeta(item, detail) {
|
||
const data = detail || {};
|
||
const mediaType = item.mediaType === "tv" ? "电视剧" : "电影";
|
||
const rating = Number(data.vote_average || item.voteAverage || 0);
|
||
const episodeProgress = item.mediaType === "tv" ? detailEpisodeProgress(data) : "";
|
||
const status = detailStatus(data.status || "");
|
||
return [
|
||
rating > 0 ? { text: `${rating.toFixed(1)}分`, type: "score" } : null,
|
||
{ text: mediaType },
|
||
episodeProgress ? { text: episodeProgress } : null,
|
||
status ? { text: status } : null
|
||
].filter(Boolean);
|
||
}
|
||
|
||
function detailRuntime(detail, item) {
|
||
const runtimes = item.mediaType === "tv" ? detail.episode_run_time || [] : detail.runtime ? [detail.runtime] : [];
|
||
const value = runtimes.find((runtime) => Number(runtime) > 0);
|
||
if (!value) return "";
|
||
return item.mediaType === "tv" ? `每集${Number(value)}分钟` : `${Number(value)}分钟`;
|
||
}
|
||
|
||
function detailEpisodeProgress(detail) {
|
||
const total = Number(detail.number_of_episodes || 0);
|
||
const aired = Math.max(Number(detail.last_episode_to_air && detail.last_episode_to_air.episode_number || 0), airedEpisodeFromSeason(detail));
|
||
if (aired && total) return `${aired}/${total}集`;
|
||
if (total) return `共${total}集`;
|
||
if (aired) return `更新至${aired}集`;
|
||
return "";
|
||
}
|
||
|
||
function detailLargeEpisodeLabel(detail) {
|
||
const total = Number(detail && detail.number_of_episodes || 0);
|
||
const aired = Math.max(Number(detail && detail.last_episode_to_air && detail.last_episode_to_air.episode_number || 0), airedEpisodeFromSeason(detail));
|
||
const status = detailStatus(detail && detail.status || "");
|
||
if (total && (status === "已完结" || aired >= total)) return `${total}集全`;
|
||
if (aired && total) return `${aired}/${total}集`;
|
||
if (aired) return `更新至${aired}集`;
|
||
if (total) return `${total}集`;
|
||
return "";
|
||
}
|
||
|
||
function airedEpisodeFromSeason(detail) {
|
||
const season = detail && (detail["season/1"] || detail.season_1);
|
||
const episodes = season && Array.isArray(season.episodes) ? season.episodes : [];
|
||
if (!episodes.length) return 0;
|
||
const todayMs = dateOnlyMs(today());
|
||
return episodes.reduce((max, ep) => {
|
||
const airMs = dateOnlyMs(ep && ep.air_date);
|
||
if (!airMs || airMs > todayMs) return max;
|
||
return Math.max(max, Number(ep.episode_number || 0));
|
||
}, 0);
|
||
}
|
||
|
||
function detailStatus(status) {
|
||
const map = {
|
||
"Returning Series": "更新中",
|
||
"Ended": "已完结",
|
||
"Canceled": "已取消",
|
||
"Cancelled": "已取消",
|
||
"In Production": "制作中",
|
||
"Planned": "计划中",
|
||
"Released": "已上映",
|
||
"Post Production": "后期制作"
|
||
};
|
||
return map[status] || status || "";
|
||
}
|
||
|
||
function detailDateLabel(item, date) {
|
||
if (!date) return "";
|
||
return `${item.mediaType === "tv" ? "首播" : "上映"} ${formatDateCn(date)}`;
|
||
}
|
||
|
||
function formatDateCn(date) {
|
||
const match = String(date || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) return date || "";
|
||
return `${match[1]}年${Number(match[2])}月${Number(match[3])}日`;
|
||
}
|
||
|
||
function dateOnlyMs(date) {
|
||
const match = String(date || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) return 0;
|
||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])).getTime();
|
||
}
|
||
|
||
function uniqueMeta(items) {
|
||
const seen = new Set();
|
||
return items.filter((item) => {
|
||
const text = typeof item === "string" ? item : item && item.text;
|
||
const key = String(text || "").trim();
|
||
if (!key || seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function clearDetailExtras() {
|
||
["seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => $(id).style.display = "none");
|
||
["seasonTabs", "episodeRail", "castRail", "personInfo", "personWorkRail", "recommendWorkRail"].forEach((id) => $(id).replaceChildren());
|
||
}
|
||
|
||
async function loadDetail(item) {
|
||
try {
|
||
const body = await requestJson(tmdbDetailUrl(item), 18);
|
||
if (!body || state.selected !== item) return;
|
||
state.detail = body;
|
||
renderDetailExtras(item, body);
|
||
} catch (e) {
|
||
// 降级:使用本地 item 数据填充详情页
|
||
if (state.selected === item) {
|
||
const fallbackDesc = item.desc || item.remark || item.overview || "";
|
||
if (fallbackDesc) {
|
||
$("detailText").textContent = fallbackDesc;
|
||
scheduleDetailTextClamp();
|
||
}
|
||
renderDetailMeta(item, null);
|
||
renderDetailTitleMeta(item, null);
|
||
normalizeRails();
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderDetailExtras(item, detail) {
|
||
const overview = detail.overview || item.desc || item.remark;
|
||
if (overview) $("detailText").textContent = overview;
|
||
renderDetailMeta(item, detail);
|
||
renderDetailTitleMeta(item, detail);
|
||
scheduleDetailTextClamp();
|
||
renderDetailCoverFromDetail(item, detail);
|
||
renderDetailLogo(detail);
|
||
renderCast(detail.credits && detail.credits.cast || []);
|
||
if (item.mediaType === "tv") renderSeasons(item, detail);
|
||
loadRecommendations(item);
|
||
normalizeRails();
|
||
updateDetailContinueButton();
|
||
updatePostPanFocusState();
|
||
}
|
||
|
||
function bestDetailLogo(detail) {
|
||
const logos = detail && detail.images && detail.images.logos || [];
|
||
if (!logos.length) return "";
|
||
// Prefer: zh first, then null (language-agnostic), then en; highest vote_average
|
||
const scored = logos
|
||
.filter((l) => l && l.file_path)
|
||
.map((l) => {
|
||
const lang = l.iso_639_1 || "";
|
||
const langScore = lang === "zh" ? 3 : lang === "" ? 2 : lang === "en" ? 1 : 0;
|
||
return { path: l.file_path, score: langScore * 1000 + Number(l.vote_average || 0) * 10 + (Number(l.width || 0) / 1000) };
|
||
})
|
||
.sort((a, b) => b.score - a.score);
|
||
return scored.length ? scored[0].path : "";
|
||
}
|
||
|
||
function renderDetailLogo(detail) {
|
||
const wrap = $("detailLogoWrap");
|
||
const img = $("detailLogoImg");
|
||
if (!wrap || !img) return;
|
||
const path = bestDetailLogo(detail);
|
||
if (!path) {
|
||
wrap.style.display = "none";
|
||
img.removeAttribute("src");
|
||
img.classList.remove("loaded");
|
||
return;
|
||
}
|
||
// Use w500 size (logos are typically transparent PNGs, w500 is fine)
|
||
// tmdbImageUrl handles the correct base URL with size in the path
|
||
const src = tmdbImageUrl(path, "w500");
|
||
if (!src) { wrap.style.display = "none"; return; }
|
||
// Use fm.res() to proxy through native http (bypass CORS, send cookies)
|
||
let finalSrc = src;
|
||
try {
|
||
if (typeof fm !== "undefined" && fm && typeof fm.res === "function") {
|
||
finalSrc = fm.res(src);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
img.classList.remove("loaded");
|
||
wrap.style.display = "flex";
|
||
img.onload = () => {
|
||
img.classList.add("loaded");
|
||
const t = $("detailTitle");
|
||
if (t) t.style.display = "none";
|
||
};
|
||
img.onerror = () => {
|
||
wrap.style.display = "none";
|
||
img.classList.remove("loaded");
|
||
const t = $("detailTitle");
|
||
if (t) t.style.display = "";
|
||
};
|
||
img.src = finalSrc;
|
||
}
|
||
|
||
function detailPrimaryActionButton() {
|
||
return isVisibleFocusable($("detailContinueBtn")) ? $("detailContinueBtn") : $("detailSearchBtn");
|
||
}
|
||
|
||
function scheduleDetailTextClamp() {
|
||
requestAnimationFrame(() => requestAnimationFrame(updateDetailTextClamp));
|
||
}
|
||
|
||
function resetDetailTextClamp() {
|
||
const text = $("detailText");
|
||
if (text) {
|
||
text.classList.remove("clamped");
|
||
text.style.removeProperty("--detail-text-max");
|
||
}
|
||
}
|
||
|
||
function measureDetailMoreHeight() { return 0; }
|
||
|
||
function updateDetailTextClamp() {
|
||
// 已移除"更多"按钮,简介始终完整展示
|
||
const text = $("detailText");
|
||
if (text) {
|
||
text.classList.remove("clamped");
|
||
text.style.removeProperty("--detail-text-max");
|
||
}
|
||
}
|
||
|
||
function toggleDetailTextMore() { /* 已废弃 */ }
|
||
|
||
function renderDetailCoverFromDetail(item, detail) {
|
||
const detailLandscapes = bestDetailBackdrops(detail, 8);
|
||
const detailLandscape = detailLandscapes[0] || "";
|
||
const detailPoster = imageUrl(detail.poster_path, false) || item.pic;
|
||
const posterFallback = detailPoster || item.pic || "";
|
||
if (detailLandscapes.length) {
|
||
item.landscape = detailLandscape;
|
||
item.pic = item.pic || posterFallback;
|
||
setDetailCoverCarousel(detailLandscapes, posterFallback, { allowPoster: false });
|
||
} else if (posterFallback) {
|
||
item.landscape = "";
|
||
item.pic = item.pic || posterFallback;
|
||
setDetailCoverCarousel([], posterFallback, { allowPoster: true, keepLoading: false });
|
||
}
|
||
}
|
||
|
||
function bestDetailBackdrops(detail, limit) {
|
||
const candidates = [];
|
||
if (detail && detail.backdrop_path) candidates.push({ file_path: detail.backdrop_path, vote_average: 11, vote_count: Number.MAX_SAFE_INTEGER, width: 1280, height: 720 });
|
||
(detail && detail.images && detail.images.backdrops || []).forEach((img) => {
|
||
if (img && img.file_path) candidates.push(img);
|
||
});
|
||
return candidates
|
||
.filter((img) => img && img.file_path)
|
||
.sort((a, b) => backdropScore(b) - backdropScore(a))
|
||
.slice(0, limit || 8)
|
||
.map((img) => imageUrl(img.file_path, true));
|
||
}
|
||
|
||
function backdropScore(img) {
|
||
const vote = Number(img && img.vote_average || 0);
|
||
const count = Number(img && img.vote_count || 0);
|
||
const width = Number(img && img.width || 0);
|
||
const height = Number(img && img.height || 0);
|
||
const ratio = width > 0 && height > 0 ? width / height : 16 / 9;
|
||
const ratioPenalty = Math.abs(ratio - 16 / 9) * 1.4;
|
||
return vote * 1000 + Math.min(count, 1000) * 2 + Math.min(width, 3840) / 20 - ratioPenalty * 100;
|
||
}
|
||
|
||
function renderCast(cast) {
|
||
const people = cast.filter((person) => person.profile_path).slice(0, 20);
|
||
if (!people.length) return;
|
||
$("castBlock").style.display = "";
|
||
$("castRail").replaceChildren(...people.map((person) => {
|
||
const button = document.createElement("button");
|
||
button.className = "person-card focusable";
|
||
button.type = "button";
|
||
const profile = displayImage(imageUrl(person.profile_path, false), { size: "w185" });
|
||
const zhName = escapeHtml(person.name || "");
|
||
const enName = person.original_name && person.original_name !== person.name
|
||
? escapeHtml(person.original_name) : "";
|
||
const role = escapeHtml(person.character || person.known_for_department || "");
|
||
button.innerHTML = `
|
||
<img alt="${zhName}" ${imageAttrs(profile)}>
|
||
<div>
|
||
<b>${zhName}</b>
|
||
${enName ? `<span class="person-en-name">${enName}</span>` : ""}
|
||
${role ? `<span>${role}</span>` : ""}
|
||
</div>
|
||
`;
|
||
button.addEventListener("click", () => loadPersonWorks(person));
|
||
return button;
|
||
}));
|
||
}
|
||
|
||
function renderSeasons(item, detail) {
|
||
const seasons = (detail.seasons || []).filter((season) => season.season_number > 0);
|
||
if (!seasons.length) return;
|
||
$("seasonBlock").style.display = "";
|
||
const activeEl = document.activeElement;
|
||
const restoreSeason = activeEl && $("seasonTabs").contains(activeEl) && activeEl.dataset.seasonNumber || "";
|
||
$("seasonTabs").replaceChildren(...seasons.map((season, index) => {
|
||
const button = document.createElement("button");
|
||
button.className = "chip focusable" + (index === 0 ? " active" : "");
|
||
button.type = "button";
|
||
button.dataset.seasonNumber = String(season.season_number);
|
||
button.textContent = season.name || "第 " + season.season_number + " 季";
|
||
button.addEventListener("click", () => {
|
||
document.querySelectorAll("#seasonTabs .chip").forEach((el) => el.classList.remove("active"));
|
||
button.classList.add("active");
|
||
loadSeason(item, season.season_number);
|
||
});
|
||
return button;
|
||
}));
|
||
if (restoreSeason) requestAnimationFrame(() => focusRemoteTarget(findByDataset($("seasonTabs"), "seasonNumber", restoreSeason) || $("seasonTabs").querySelector(".chip.active") || $("seasonTabs").querySelector(".chip")));
|
||
const first = detail["season/1"] || null;
|
||
if (first && first.episodes) renderEpisodes(first.episodes);
|
||
else loadSeason(item, seasons[0].season_number);
|
||
}
|
||
|
||
async function loadSeason(item, seasonNumber) {
|
||
try {
|
||
const body = await requestJson(tmdbSeasonUrl(item, seasonNumber), 18);
|
||
renderEpisodes(body.episodes || []);
|
||
} catch (e) {
|
||
$("episodeRail").replaceChildren(emptyNode("分集加载失败"));
|
||
}
|
||
updatePostPanFocusState();
|
||
}
|
||
|
||
function renderEpisodes(episodes) {
|
||
const activeEl = document.activeElement;
|
||
const restoreEpisode = activeEl && $("episodeRail").contains(activeEl) && activeEl.dataset.episodeNumber || "";
|
||
if (!episodes.length) {
|
||
$("episodeRail").replaceChildren(emptyNode("暂无分集信息"));
|
||
return;
|
||
}
|
||
$("episodeRail").replaceChildren(...episodes.map((ep) => {
|
||
const stillSource = ep.still_path ? imageUrl(ep.still_path, true) : "";
|
||
const still = stillSource ? displayImage(stillSource, { size: "w780" }) : "";
|
||
const fullStill = stillSource ? displayImage(stillSource, { size: "w780" }) : "";
|
||
const button = document.createElement("button");
|
||
button.className = "episode-card focusable" + (still ? " has-still" : "");
|
||
button.type = "button";
|
||
button.dataset.episodeNumber = String(ep.episode_number || "");
|
||
button.innerHTML = `
|
||
<span class="episode-badge">第${escapeHtml(ep.episode_number || "?")}集</span>
|
||
${still ? `<img class="episode-still" alt="" ${imageAttrs(still)}>` : ""}
|
||
<div class="episode-body">
|
||
<b>${ep.episode_number}. ${escapeHtml(ep.name || "未命名")}</b>
|
||
<span>${escapeHtml(ep.air_date || "")}</span>
|
||
<p>${escapeHtml(ep.overview || "暂无剧情")}</p>
|
||
</div>
|
||
`;
|
||
button.addEventListener("click", () => playEpisodeViaPan(ep));
|
||
return button;
|
||
}));
|
||
if (restoreEpisode) requestAnimationFrame(() => focusRemoteTarget(findByDataset($("episodeRail"), "episodeNumber", restoreEpisode) || $("episodeRail").querySelector(".episode-card")));
|
||
updatePostPanFocusState();
|
||
}
|
||
|
||
function openEpisodeViewer(ep, still) {
|
||
const title = `${ep.episode_number || ""}${ep.episode_number ? ". " : ""}${ep.name || "未命名"}`;
|
||
const meta = [ep.air_date || "", state.selected && state.selected.title || ""].filter(Boolean).join(" · ");
|
||
const overview = ep.overview || "暂无剧情概要";
|
||
openImage(still || "", { episode: { title, meta, overview, still } });
|
||
}
|
||
|
||
// ── 点击分集 → 直接进播放器播放盘搜匹配到的「当前集」单集资源(优先 115)──
|
||
// 行为:先尝试直接播;匹配不到单集时回退打开盘搜并高亮,不再弹剧照预览页。
|
||
|
||
// 当前选中的季号(用于 SxxEyy 匹配);优先用 episode 自带 season_number。
|
||
function activeSeasonNumber() {
|
||
const el = $("seasonTabs") && $("seasonTabs").querySelector(".chip.active");
|
||
return el ? (Number(el.dataset.seasonNumber) || 0) : 0;
|
||
}
|
||
|
||
// 标题是否为「整季/区间合集」(这类无法定位到单集,单集自动播时排除)
|
||
function panItemIsRangePack(title) {
|
||
const t = String(title || "");
|
||
return /S?\d{1,2}\s*E\d{1,3}\s*[-~~]\s*E?\d{1,3}/i.test(t) // S01E01-E24 / E01-E24
|
||
|| /第\s*\d{1,3}\s*[-~~]\s*\d{1,3}\s*集/.test(t) // 第1-24集
|
||
|| /\bE\d{1,3}\s*[-~~]\s*\d{1,3}\b/i.test(t) // E01-24
|
||
|| /全\s*\d{1,3}\s*集/.test(t) // 全24集
|
||
|| /合集|全集|完结合集|打包/.test(t);
|
||
}
|
||
|
||
// 单条盘搜资源是否精确命中「第 epNo 集」(季号 season)
|
||
function panItemMatchesEpisode(item, season, epNo) {
|
||
const t = String(item && item.title || "");
|
||
if (!t) return false;
|
||
if (panItemIsRangePack(t)) return false; // 区间/整季包不算单集
|
||
const s = Number(season) || 1;
|
||
const n = Number(epNo) || 0;
|
||
if (!n) return false;
|
||
const reSE = new RegExp(`S0*${s}\\s*E0*${n}(?![0-9E])`, "i"); // S01E23
|
||
const reE = new RegExp(`(?:^|[^0-9A-Za-z])E0*${n}(?![0-9])`, "i");// 独立 E23
|
||
const reCN = new RegExp(`第\\s*0*${n}\\s*集(?!\\s*[-~~])`); // 第23集
|
||
return reSE.test(t) || reE.test(t) || reCN.test(t);
|
||
}
|
||
|
||
// 候选排序:115 优先,其次其它单集源,同源按画质分
|
||
function panDiskOrder(item) {
|
||
const order = { "115": 0, "quark": 1, "aliyun": 1, "uc": 1, "123": 1, "tianyi": 1, "mobile": 1, "xunlei": 2, "baidu": 2 };
|
||
return order[normalizePanDiskType(item && item.diskType)] ?? 3;
|
||
}
|
||
function rankPanCandidates(arr) {
|
||
return arr.slice().sort((a, b) => {
|
||
const d = panDiskOrder(a) - panDiskOrder(b);
|
||
if (d) return d;
|
||
return panQualityInfo(b).score - panQualityInfo(a).score;
|
||
});
|
||
}
|
||
|
||
// 「全集/全包」字眼评分:标题越像完整合集分越高(优先选带「全集」字样的)
|
||
function panPackScore(item) {
|
||
const t = String(item && item.title || "");
|
||
let score = 0;
|
||
if (/全\s*\d{1,3}\s*集/.test(t)) score += 6; // 全24集
|
||
if (/全集/.test(t)) score += 5; // 全集
|
||
if (/完结|大结局|完结篇/.test(t)) score += 3; // 完结
|
||
if (/合集|打包/.test(t)) score += 2; // 合集/打包
|
||
if (/S?\d{1,2}\s*E\d{1,3}\s*[-~~]\s*E?\d{1,3}/i.test(t)) score += 2; // S01E01-E24
|
||
if (/第\s*\d{1,3}\s*[-~~]\s*\d{1,3}\s*集/.test(t)) score += 2; // 第1-24集
|
||
return score;
|
||
}
|
||
|
||
// 全集包排序:不限网盘、择优——优先「全集」字眼,其次画质,网盘仅作次要 tiebreak
|
||
function rankPanPacks(arr) {
|
||
return arr.slice().sort((a, b) => {
|
||
const ps = panPackScore(b) - panPackScore(a);
|
||
if (ps) return ps;
|
||
const q = panQualityInfo(b).score - panQualityInfo(a).score;
|
||
if (q) return q;
|
||
return panDiskOrder(a) - panDiskOrder(b);
|
||
});
|
||
}
|
||
|
||
// 标题是否为「本季全集/合集包」(可整包播放,进播放器后由用户自己选集)
|
||
function panItemIsSeasonPack(item, season) {
|
||
const t = String(item && item.title || "");
|
||
if (!panItemIsRangePack(t)) return false;
|
||
const s = Number(season) || 1;
|
||
if (new RegExp(`S0*${s}(?![0-9])`, "i").test(t)) return true; // 明确 S01
|
||
if (new RegExp(`第\\s*0*${s}\\s*季`).test(t)) return true; // 第1季
|
||
const mSe = /S0*(\d+)/i.exec(t);
|
||
if (mSe && Number(mSe[1]) !== s) return false; // 标题指向其它季 → 排除
|
||
const mCn = /第\s*0*(\d+)\s*季/.exec(t);
|
||
if (mCn && Number(mCn[1]) !== s) return false;
|
||
return true; // 无季号的全集 → 视为本季
|
||
}
|
||
|
||
// 收集当前集候选:精确单集(115 优先)+ 本季全集包(不限网盘、按「全集」字眼择优)
|
||
function episodePanCandidates(season, epNo) {
|
||
const singles = [], packs = [];
|
||
(state.pan.results || []).forEach((it) => {
|
||
if (panItemMatchesEpisode(it, season, epNo)) singles.push(it);
|
||
else if (panItemIsSeasonPack(it, season)) packs.push(it);
|
||
});
|
||
return { singles: rankPanCandidates(singles), packs: rankPanPacks(packs) };
|
||
}
|
||
|
||
function panCheckAvailable() {
|
||
const pan = sdk().pan || {};
|
||
return typeof pan.check === "function";
|
||
}
|
||
|
||
// 对少量候选做一次有效性校验,返回 key->state 并写回 state.pan.health
|
||
async function probePanHealth(items) {
|
||
const list = (items || []).filter((it) => it && isPanCheckSupported(it));
|
||
if (!list.length || !panCheckAvailable()) return {};
|
||
try {
|
||
const resp = await sdk().pan.check(list.map((it) => ({ type: it.diskType, url: it.url, password: it.password })));
|
||
const results = (resp && Array.isArray(resp.results)) ? resp.results : [];
|
||
const out = {};
|
||
list.forEach((it, i) => {
|
||
const r = results[i] || {};
|
||
const st = r.state || "uncertain";
|
||
out[it.key] = st;
|
||
state.pan.health[it.key] = { state: st, summary: r.summary || "", checkedAt: r.checked_at || Date.now(), expiresAt: r.expires_at || Date.now() + 300000 };
|
||
});
|
||
return out;
|
||
} catch (e) { return {}; }
|
||
}
|
||
|
||
// 从已排序候选里挑第一条「能播」的。
|
||
// requireOk=true:只取绿灯(ok / 无法检测的盘),灰灯(uncertain/idle)跳过 → 这样灰灯 115 不会卡住,会跳别的盘。
|
||
async function firstUsableCandidate(list, requireOk) {
|
||
let pool = (list || []).filter((it) => getPanHealth(it).state !== "bad");
|
||
if (!pool.length) return null;
|
||
if (panCheckAvailable()) {
|
||
const unprobed = pool.filter((it) => !getPanHealth(it).checkedAt && isPanCheckSupported(it)).slice(0, 12);
|
||
if (unprobed.length) await probePanHealth(unprobed);
|
||
pool = pool.filter((it) => getPanHealth(it).state !== "bad");
|
||
}
|
||
if (requireOk) {
|
||
const ok = pool.filter((it) => {
|
||
const st = getPanHealth(it).state;
|
||
return st === "ok" || st === "unsupported" || !isPanCheckSupported(it); // 绿灯 / 无法检测的盘
|
||
});
|
||
return ok[0] || null;
|
||
}
|
||
return pool[0] || null; // 兜底:非失效的第一条(含灰灯)
|
||
}
|
||
|
||
// 定时解析:5 秒内优先「绿灯」单集;超 5 秒换任意网盘「绿灯」全集;都没有再超时用灰灯兜底。
|
||
async function resolveEpisodePlayableTimed(season, epNo, item, seq) {
|
||
const OK_SINGLE_WINDOW_MS = 5000;
|
||
const HARD_DEADLINE_MS = 12000;
|
||
const start = Date.now();
|
||
const active = () => _episodeLoadSeq === seq && state.selected === item;
|
||
while (active()) {
|
||
const { singles, packs } = episodePanCandidates(season, epNo);
|
||
const elapsed = Date.now() - start;
|
||
// 1) 绿灯单集(5s 窗口内优先)
|
||
const okSingle = await firstUsableCandidate(singles.slice(0, 6), true);
|
||
if (!active()) return null;
|
||
if (okSingle) return { item: okSingle, kind: "single" };
|
||
// 2) 5s 后:绿灯全集(任意网盘,自动跳过灰灯/失效的盘)
|
||
if (elapsed >= OK_SINGLE_WINDOW_MS) {
|
||
const okPack = await firstUsableCandidate(packs.slice(0, 12), true);
|
||
if (!active()) return null;
|
||
if (okPack) return { item: okPack, kind: "pack" };
|
||
}
|
||
// 3) 超时兜底:接受非失效(含灰灯)的单集/全集
|
||
if (elapsed >= HARD_DEADLINE_MS) {
|
||
const anySingle = await firstUsableCandidate(singles.slice(0, 6), false);
|
||
if (anySingle) return { item: anySingle, kind: "single" };
|
||
const anyPack = await firstUsableCandidate(packs.slice(0, 12), false);
|
||
return (active() && anyPack) ? { item: anyPack, kind: "pack" } : null;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function openPanBlockIfNeeded() {
|
||
const block = $("panSearchBlock");
|
||
if (!block || block.classList.contains("active")) return;
|
||
block.classList.add("active");
|
||
block.style.display = "";
|
||
if (location.hash !== "#pan") { history.pushState({ sheet: "pan" }, "", "#pan"); _panHistoryPushed = true; }
|
||
renderPanResults();
|
||
centerPanSearchBlock();
|
||
}
|
||
|
||
// 回退:匹配不到单集 → 打开盘搜、切到 115(若有)、聚焦首条作为高亮,交给用户手动选
|
||
function fallbackHighlightEpisode(item, season, epNo) {
|
||
openPanBlockIfNeeded();
|
||
const has115 = (state.pan.results || []).some((it) => normalizePanDiskType(it.diskType) === "115");
|
||
if (has115 && state.pan.activeType !== "115") selectPanType("115");
|
||
else renderPanResults();
|
||
toast(`未找到第${epNo}集单集资源,已为你打开盘搜`);
|
||
requestAnimationFrame(() => {
|
||
const list = $("panResultList");
|
||
const target = list && list.querySelector(".pan-result-item");
|
||
if (target) {
|
||
try { target.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||
focusRemoteTarget(target);
|
||
}
|
||
});
|
||
}
|
||
|
||
let _episodeLoadSeq = 0;
|
||
|
||
async function playEpisodeViaPan(ep) {
|
||
const item = state.selected;
|
||
if (!item) return;
|
||
const season = Number(ep && ep.season_number) || activeSeasonNumber() || 1;
|
||
const epNo = Number(ep && ep.episode_number) || 0;
|
||
if (!epNo) { toast("缺少集数信息"); return; }
|
||
|
||
const seq = ++_episodeLoadSeq;
|
||
const alive = () => _episodeLoadSeq === seq && state.selected === item;
|
||
toast(`正在加载第${epNo}集…`);
|
||
|
||
// 后台静默盘搜(不弹盘搜页);已是本剧结果则复用
|
||
const sameSeries = state.pan.keyword && panKeyword(item) && state.pan.keyword === panKeyword(item);
|
||
if (!(sameSeries && (state.pan.results || []).length)) {
|
||
searchPanResources(item, { silent: true }).catch(() => {});
|
||
}
|
||
|
||
// 5 秒内优先拿 115 单集;超时换任意网盘能播的全集;拿到即丢给原生播放器(由它转圈加载)
|
||
const resolved = await resolveEpisodePlayableTimed(season, epNo, item, seq);
|
||
if (!alive()) return;
|
||
if (resolved) {
|
||
if (resolved.kind === "single") {
|
||
toast(`播放 第${epNo}集 · ${panDiskName(resolved.item.diskType)}`);
|
||
} else {
|
||
toast(`第${epNo}集已加载本季全集 · ${panDiskName(resolved.item.diskType)},请在播放器中选第${epNo}集`);
|
||
}
|
||
playPanResult(resolved.item); // 原生播放器接管,自行显示加载/缓冲
|
||
return;
|
||
}
|
||
// 实在没有可播资源 → 打开盘搜页让用户手选
|
||
fallbackHighlightEpisode(item, season, epNo);
|
||
}
|
||
|
||
async function loadPersonWorks(person) {
|
||
try {
|
||
$("personWorkTitle").textContent = "简介";
|
||
$("personWorkBlock").style.display = "";
|
||
renderPersonInfo(person, null);
|
||
// 关联作品标题放在简介下方
|
||
const worksTitle = document.createElement("h3");
|
||
worksTitle.id = "personWorksLabel";
|
||
worksTitle.style.marginTop = "16px";
|
||
worksTitle.textContent = "关联作品";
|
||
const rail = $("personWorkRail");
|
||
if (rail && !$("personWorksLabel")) rail.before(worksTitle);
|
||
$("personWorkRail").replaceChildren(emptyNode("加载中..."));
|
||
const body = await requestJson(tmdbPersonDetailUrl(person.id), 18);
|
||
renderPersonInfo(person, body);
|
||
const credits = body.combined_credits || body;
|
||
const works = (credits.cast || []).filter((item) => (item.media_type === "movie" || item.media_type === "tv") && item.poster_path).slice(0, 18).map((value, index) => normalizeTmdb(value, { id: "person", title: "关联作品", mediaType: value.media_type }, index));
|
||
fillRail($("personWorkRail"), works);
|
||
updatePostPanFocusState();
|
||
} catch (e) {
|
||
renderPersonInfo(person, null);
|
||
$("personWorkRail").replaceChildren(emptyNode("关联作品加载失败"));
|
||
updatePostPanFocusState();
|
||
}
|
||
}
|
||
|
||
async function loadRecommendations(item) {
|
||
const block = $("recommendBlock");
|
||
const rail = $("recommendWorkRail");
|
||
if (!block || !rail || !item || !item.tmdbId || !item.mediaType) return;
|
||
try {
|
||
const body = await requestJson(tmdbRecommendationsUrl(item), 18);
|
||
if (state.selected !== item) return;
|
||
const works = (body.results || [])
|
||
.map((value, index) => {
|
||
const mediaType = value.media_type || item.mediaType;
|
||
if ((mediaType !== "movie" && mediaType !== "tv") || !value.poster_path) return null;
|
||
return normalizeTmdb(value, { id: "recommend", title: "相关推荐", mediaType }, index);
|
||
})
|
||
.filter(Boolean)
|
||
.slice(0, 18);
|
||
if (!works.length) {
|
||
block.style.display = "none";
|
||
rail.replaceChildren();
|
||
return;
|
||
}
|
||
block.style.display = "";
|
||
fillRail(rail, works);
|
||
updatePostPanFocusState();
|
||
} catch (e) {
|
||
if (state.selected === item) {
|
||
block.style.display = "none";
|
||
rail.replaceChildren();
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderPersonInfo(person, detail) {
|
||
const root = $("personInfo");
|
||
if (!root) return;
|
||
const biography = detail && detail.biography || "暂无人物简介";
|
||
root.innerHTML = `
|
||
<div class="collapsible-overview" id="personOverviewWrap">
|
||
<button class="overview-toggle" id="personOverviewToggle" type="button" aria-expanded="false">
|
||
<svg class="overview-toggle-icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
||
<span>人物简介</span>
|
||
</button>
|
||
<div class="overview-body">
|
||
<div class="person-info" aria-live="polite">
|
||
<div class="person-info-body">
|
||
<p>${escapeHtml(biography)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const toggle = root.querySelector("#personOverviewToggle");
|
||
const wrap = root.querySelector("#personOverviewWrap");
|
||
if (toggle && wrap) {
|
||
toggle.addEventListener("click", () => {
|
||
const open = wrap.classList.toggle("open");
|
||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||
});
|
||
}
|
||
}
|
||
|
||
function resetPanSearch() {
|
||
resetPanSearchState(true);
|
||
}
|
||
|
||
function resetPanSearchState(hideBlock) {
|
||
if (state.pan.observer) {
|
||
state.pan.observer.disconnect();
|
||
state.pan.observer = null;
|
||
}
|
||
clearTimeout(state.pan.flushTimer);
|
||
state.pan.flushTimer = 0;
|
||
state.pan.pollTimers.forEach((timer) => clearTimeout(timer));
|
||
state.pan.pollTimers = [];
|
||
state.pan.pollRound = 0;
|
||
state.pan.loading = false;
|
||
state.pan.results = [];
|
||
state.pan.activeType = "";
|
||
state.pan.health = {};
|
||
state.pan.pending = {};
|
||
state.pan.queued.clear();
|
||
state.pan.inFlight.clear();
|
||
state.pan.keyword = "";
|
||
state.pan.viewToken = "";
|
||
state.pan.renderKeys = "";
|
||
state.pan.tabKeys = "";
|
||
state.pan.focusKey = "";
|
||
state.pan.focusMode = "";
|
||
state.pan.lastTypeSelect = null;
|
||
state.pan.keepBlockPositionUntil = 0;
|
||
state.pan.playbackReturn = null;
|
||
if (hideBlock && $("panSearchBlock")) {
|
||
$("panSearchBlock").classList.remove("active");
|
||
$("panSearchBlock").style.display = "none";
|
||
}
|
||
if (hideBlock) updatePostPanFocusState();
|
||
if (hideBlock) {
|
||
if ($("panSearchHint")) $("panSearchHint").textContent = "";
|
||
if ($("panTabs")) $("panTabs").replaceChildren();
|
||
if ($("panResultList")) $("panResultList").replaceChildren();
|
||
}
|
||
}
|
||
|
||
function panKeyword(item) {
|
||
return String(item && item.title || "").trim();
|
||
}
|
||
|
||
async function searchPanResources(item, opts) {
|
||
if (!item || state.pan.loading) return;
|
||
const silent = !!(opts && opts.silent);
|
||
const keyword = panKeyword(item);
|
||
if (!keyword) { if (!silent) toast("缺少搜索标题"); return; }
|
||
const hadResults = state.pan.results.length > 0 && $("panSearchBlock") && $("panSearchBlock").classList.contains("active");
|
||
resetPanSearchState(false);
|
||
state.pan.loading = true;
|
||
state.pan.keyword = keyword;
|
||
const token = `pan-${Date.now()}`;
|
||
state.pan.viewToken = token;
|
||
state.pan.results = [];
|
||
state.pan.activeType = "";
|
||
state.pan.health = {};
|
||
state.pan.pending = {};
|
||
state.pan.queued.clear();
|
||
state.pan.inFlight.clear();
|
||
state.pan.focusMode = "tabs";
|
||
state.pan.pollTimers.forEach((timer) => clearTimeout(timer));
|
||
state.pan.pollTimers = [];
|
||
state.pan.pollRound = 0;
|
||
// silent:后台静默搜索,不打开盘搜页、不压 #pan、不抢焦点(用于点分集直连播放)
|
||
if (!silent) {
|
||
if ($("panSearchBlock")) {
|
||
$("panSearchBlock").classList.add("active");
|
||
$("panSearchBlock").style.display = "";
|
||
}
|
||
// 推入盘搜 history 条目,确保返回键从盘搜→详情→主页的顺序
|
||
if (location.hash !== "#pan") {
|
||
history.pushState({ sheet: "pan" }, "", "#pan");
|
||
_panHistoryPushed = true;
|
||
}
|
||
updatePostPanFocusState();
|
||
centerPanSearchBlock();
|
||
if (!hadResults && $("panTabs")) $("panTabs").replaceChildren();
|
||
if (!hadResults && $("panResultList")) $("panResultList").replaceChildren(emptyNode("正在搜索盘搜资源..."));
|
||
$("panSearchHint").textContent = `${keyword} · 搜索中`;
|
||
}
|
||
setPanStatus(`搜索 ${keyword}`);
|
||
try {
|
||
const config = state.pan.config || defaultPanConfig();
|
||
const payload = panSearchPayload(keyword, config);
|
||
const headers = await ensurePanAuthHeaders();
|
||
const body = await postJson(panApi("/api/search"), payload, 28, headers);
|
||
if (state.pan.viewToken !== token || state.selected !== item) return;
|
||
const results = applyPanSearchBody(body, keyword);
|
||
setPanStatus(results.length ? `找到 ${results.length} 条` : "无资源");
|
||
if (!silent) {
|
||
renderPanResults();
|
||
focusFirstPanTab();
|
||
}
|
||
scheduleUiSnapshotSave();
|
||
schedulePanPolling(token, item);
|
||
} catch (e) {
|
||
if (state.pan.viewToken !== token || state.selected !== item) return;
|
||
if (String(e.message || "").includes("401") && state.pan.config) {
|
||
state.pan.config.token = "";
|
||
state.pan.config.tokenExpiresAt = 0;
|
||
await persistPanConfigQuietly();
|
||
}
|
||
state.pan.results = [];
|
||
if (!silent && $("panResultList")) $("panResultList").replaceChildren(emptyNode("盘搜失败:" + (e.message || "unknown")));
|
||
setPanStatus("搜索失败");
|
||
} finally {
|
||
if (state.pan.viewToken === token) state.pan.loading = false;
|
||
}
|
||
}
|
||
|
||
function focusFirstPanResult() {
|
||
const item = $("panResultList") && $("panResultList").querySelector(".pan-result-item");
|
||
if (item) requestAnimationFrame(() => focusRemoteTarget(item));
|
||
}
|
||
|
||
function applyPanSearchBody(body, keyword) {
|
||
const data = body && body.data ? body.data : body;
|
||
const incoming = normalizePanResults(data && data.merged_by_type || {}, keyword);
|
||
const map = new Map(state.pan.results.map((item) => [item.key, item]));
|
||
incoming.forEach((item) => {
|
||
const existing = map.get(item.key);
|
||
if (existing) Object.assign(existing, item);
|
||
else map.set(item.key, item);
|
||
});
|
||
state.pan.results = Array.from(map.values());
|
||
ensureActivePanType();
|
||
scheduleUiSnapshotSave();
|
||
return state.pan.results;
|
||
}
|
||
|
||
function schedulePanPolling(token, item) {
|
||
const intervals = window.WEBHOME_CONFIG.pan.pollIntervals || [];
|
||
intervals.forEach((delay, index) => {
|
||
const timer = setTimeout(() => pollPanResources(token, item, index + 1), delay);
|
||
state.pan.pollTimers.push(timer);
|
||
});
|
||
}
|
||
|
||
async function pollPanResources(token, item, round) {
|
||
if (state.pan.viewToken !== token || state.selected !== item || !state.pan.keyword) return;
|
||
try {
|
||
state.pan.pollRound = round;
|
||
setPanStatus(`更新中 ${round}`);
|
||
const config = state.pan.config || defaultPanConfig();
|
||
const headers = await ensurePanAuthHeaders();
|
||
const body = await postJson(panApi("/api/search"), panSearchPayload(state.pan.keyword, config), 28, headers);
|
||
if (state.pan.viewToken !== token || state.selected !== item) return;
|
||
const before = state.pan.results.length;
|
||
const results = applyPanSearchBody(body, state.pan.keyword);
|
||
const added = results.length - before;
|
||
setPanStatus(added > 0 ? `新增 ${added} 条,共 ${results.length}` : `已更新 ${results.length} 条`);
|
||
renderPanResults();
|
||
scheduleUiSnapshotSave();
|
||
} catch (e) {
|
||
if (String(e.message || "").includes("401") && state.pan.config) {
|
||
state.pan.config.token = "";
|
||
state.pan.config.tokenExpiresAt = 0;
|
||
await persistPanConfigQuietly();
|
||
}
|
||
if (state.pan.viewToken === token) setPanStatus("更新失败");
|
||
}
|
||
}
|
||
|
||
function normalizePanResults(merged, keyword) {
|
||
const config = state.pan.config || defaultPanConfig();
|
||
const enabled = new Set((config.diskTypes || []).map(normalizePanDiskType));
|
||
const seen = new Set();
|
||
const items = [];
|
||
Object.entries(merged || {}).forEach(([type, links]) => {
|
||
const diskType = normalizePanDiskType(type);
|
||
if (!enabled.has(diskType) || !Array.isArray(links)) return;
|
||
links.forEach((link, index) => {
|
||
const url = String(link && (link.url || link.link || link.href) || "").trim();
|
||
if (!url) return;
|
||
const key = `${diskType}|${normalizePanUrl(url)}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
items.push({
|
||
key,
|
||
diskType,
|
||
url,
|
||
password: String(link.password || link.pwd || link.passcode || link.code || "").trim(),
|
||
title: String(link.note || link.name || link.title || link.work_title || keyword || "盘搜资源").trim(),
|
||
source: String(link.source || link.channel || link.plugin || "").trim(),
|
||
datetime: String(link.datetime || link.time || link.created_at || "").trim(),
|
||
normalizedUrl: String(link.normalized_url || link.normalizedUrl || "").trim(),
|
||
index: items.length + index / 1000
|
||
});
|
||
});
|
||
});
|
||
return items;
|
||
}
|
||
|
||
function normalizePanUrl(url) {
|
||
try {
|
||
const parsed = new URL(url);
|
||
parsed.hash = "";
|
||
parsed.hostname = parsed.hostname.toLowerCase();
|
||
return parsed.toString();
|
||
} catch (e) {
|
||
return String(url || "").trim();
|
||
}
|
||
}
|
||
|
||
function panHealthKey(item) {
|
||
return item ? `${item.diskType}|${normalizePanUrl(item.url)}` : "";
|
||
}
|
||
|
||
function getPanHealth(item) {
|
||
const key = panHealthKey(item);
|
||
if (state.pan.pending[key]) return { state: "pending", summary: "检测中" };
|
||
return state.pan.health[key] || { state: "idle", summary: "未检测" };
|
||
}
|
||
|
||
function panHealthPriority(item) {
|
||
const health = getPanHealth(item);
|
||
const value = health && health.state || "idle";
|
||
return PAN_HEALTH_PRIORITY[value] == null ? PAN_HEALTH_PRIORITY.idle : PAN_HEALTH_PRIORITY[value];
|
||
}
|
||
|
||
function panAvailableTypes() {
|
||
const counts = new Map();
|
||
state.pan.results.forEach((item) => {
|
||
const type = normalizePanDiskType(item.diskType);
|
||
if (type) counts.set(type, (counts.get(type) || 0) + 1);
|
||
});
|
||
const known = new Set(PAN_DISK_TYPES.map((item) => item.id));
|
||
const ordered = PAN_DISK_TYPES
|
||
.filter((type) => counts.has(type.id))
|
||
.map((type) => ({ id: type.id, name: type.name, count: counts.get(type.id) || 0 }));
|
||
const extras = Array.from(counts.entries())
|
||
.filter(([id]) => !known.has(id))
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([id, count]) => ({ id, name: panDiskName(id), count }));
|
||
return ordered.concat(extras);
|
||
}
|
||
|
||
function ensureActivePanType() {
|
||
const types = panAvailableTypes();
|
||
if (!types.length) {
|
||
state.pan.activeType = "";
|
||
return "";
|
||
}
|
||
if (!state.pan.activeType || !types.some((type) => type.id === state.pan.activeType)) {
|
||
state.pan.activeType = types[0].id;
|
||
}
|
||
return state.pan.activeType;
|
||
}
|
||
|
||
function rankedPanResults(type) {
|
||
const activeType = type || "";
|
||
const source = state.pan.results.filter((item) => {
|
||
if (activeType && normalizePanDiskType(item.diskType) !== activeType) return false;
|
||
const health = getPanHealth(item);
|
||
if (health && health.state === "bad") return false; // 失效资源直接不显示
|
||
return true;
|
||
});
|
||
return source
|
||
.map((item, index) => ({ item, index, priority: panHealthPriority(item), quality: panQualityInfo(item) }))
|
||
.sort((a, b) =>
|
||
(b.quality.count - a.quality.count) ||
|
||
(b.quality.score - a.quality.score) ||
|
||
(a.priority - b.priority) ||
|
||
(Number(a.item.index || a.index) - Number(b.item.index || b.index)) ||
|
||
(a.index - b.index)
|
||
)
|
||
.map(({ item }) => item);
|
||
}
|
||
|
||
function renderPanTabs() {
|
||
const tabs = $("panTabs");
|
||
if (!tabs) return;
|
||
const types = panAvailableTypes();
|
||
const active = ensureActivePanType();
|
||
const activeEl = document.activeElement;
|
||
const restoreId = activeEl && tabs.contains(activeEl) && getPanTypeFromElement(activeEl) || "";
|
||
const tabKeys = types.map((type) => `${type.id}:${type.count}`).join("\n");
|
||
if (!types.length) {
|
||
state.pan.tabKeys = "";
|
||
tabs.replaceChildren();
|
||
return;
|
||
}
|
||
if (tabKeys === state.pan.tabKeys && tabs.children.length === types.length) {
|
||
Array.from(tabs.children).forEach((button) => {
|
||
const buttonType = getPanTypeFromElement(button);
|
||
const isActive = buttonType === active;
|
||
button.classList.toggle("active", isActive);
|
||
const type = types.find((item) => item.id === buttonType);
|
||
if (type) button.textContent = `${type.name} ${type.count}`;
|
||
});
|
||
if (state.pan.focusMode === "tabs") lockPanFocus(findByDataset(tabs, "panType", active) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip"), "tabs");
|
||
return;
|
||
}
|
||
state.pan.tabKeys = tabKeys;
|
||
tabs.replaceChildren(...types.map((type) => {
|
||
const button = document.createElement("button");
|
||
button.className = "chip focusable" + (type.id === active ? " active" : "");
|
||
button.type = "button";
|
||
button.dataset.panType = type.id;
|
||
button.setAttribute("data-pan-type", type.id);
|
||
button.textContent = `${type.name} ${type.count}`;
|
||
button.addEventListener("focus", () => { state.pan.focusMode = "tabs"; });
|
||
return button;
|
||
}));
|
||
const restoreTarget = restoreId ? findByDataset(tabs, "panType", restoreId) : state.pan.focusMode === "tabs" ? tabs.querySelector(".chip.active") || tabs.querySelector(".chip") : null;
|
||
if (restoreTarget) lockPanFocus(restoreTarget, "tabs");
|
||
}
|
||
|
||
function getPanTypeFromElement(el) {
|
||
return el ? String(el.getAttribute("data-pan-type") || el.dataset && el.dataset.panType || "") : "";
|
||
}
|
||
|
||
function handlePanTabEvent(event) {
|
||
const tabs = $("panTabs");
|
||
if (!tabs) return false;
|
||
const target = event && event.target && closestPanTab(event.target);
|
||
if (!target || !tabs.contains(target)) return false;
|
||
const typeId = getPanTypeFromElement(target);
|
||
if (!typeId) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
selectPanType(typeId);
|
||
return true;
|
||
}
|
||
|
||
function closestPanTab(el) {
|
||
while (el && el !== document && el !== $("panTabs")) {
|
||
if (el.getAttribute && el.getAttribute("data-pan-type")) return el;
|
||
el = el.parentNode;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function selectPanType(typeId) {
|
||
if (!typeId) return;
|
||
const now = Date.now();
|
||
if (state.pan.lastTypeSelect && state.pan.lastTypeSelect.type === typeId && now - state.pan.lastTypeSelect.at < 520) return;
|
||
state.pan.lastTypeSelect = { type: typeId, at: now };
|
||
if (state.pan.activeType === typeId) {
|
||
state.pan.focusMode = "results";
|
||
focusFirstPanResult();
|
||
return;
|
||
}
|
||
const detailTop = $("detailSheet") ? $("detailSheet").scrollTop : 0;
|
||
state.pan.keepBlockPositionUntil = now + 600;
|
||
state.pan.activeType = typeId;
|
||
state.pan.renderKeys = "";
|
||
state.pan.focusMode = "tabs";
|
||
const list = $("panResultList");
|
||
if (list) list.scrollTop = 0;
|
||
updatePanTabActive(typeId);
|
||
renderPanResults();
|
||
if ($("detailSheet")) $("detailSheet").scrollTop = detailTop;
|
||
requestAnimationFrame(() => { if ($("detailSheet")) $("detailSheet").scrollTop = detailTop; });
|
||
lockPanFocus(findByDataset($("panTabs"), "panType", typeId) || $("panTabs").querySelector(".chip.active"), "tabs", { keepBlockPosition: true });
|
||
scheduleUiSnapshotSave();
|
||
}
|
||
|
||
function updatePanTabActive(typeId) {
|
||
const tabs = $("panTabs");
|
||
if (!tabs) return;
|
||
Array.from(tabs.querySelectorAll("[data-pan-type]")).forEach((button) => {
|
||
button.classList.toggle("active", getPanTypeFromElement(button) === typeId);
|
||
});
|
||
}
|
||
|
||
function focusFirstPanTab() {
|
||
const target = $("panTabs") && ($("panTabs").querySelector(".chip.active") || $("panTabs").querySelector(".chip"));
|
||
if (target) {
|
||
centerPanSearchBlock();
|
||
lockPanFocus(target, "tabs");
|
||
}
|
||
}
|
||
|
||
function isPanSearchActive() {
|
||
const block = $("panSearchBlock");
|
||
return !!(block && block.classList.contains("active") && block.style.display !== "none");
|
||
}
|
||
|
||
function updatePostPanFocusState() {
|
||
const panActive = isPanSearchActive();
|
||
["seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => {
|
||
const block = $(id);
|
||
if (!block) return;
|
||
block.setAttribute("aria-hidden", panActive ? "true" : "false");
|
||
block.querySelectorAll(".focusable,button,input,textarea").forEach((el) => {
|
||
if (panActive) {
|
||
if (!el.dataset.panFocusLocked) {
|
||
el.dataset.panFocusLocked = "1";
|
||
el.dataset.panPrevTabindex = el.hasAttribute("tabindex") ? el.getAttribute("tabindex") || "" : "__none__";
|
||
}
|
||
el.setAttribute("tabindex", "-1");
|
||
} else if (el.dataset.panFocusLocked) {
|
||
const previous = el.dataset.panPrevTabindex;
|
||
if (previous && previous !== "__none__") el.setAttribute("tabindex", previous);
|
||
else el.removeAttribute("tabindex");
|
||
delete el.dataset.panFocusLocked;
|
||
delete el.dataset.panPrevTabindex;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderPanResults() {
|
||
const list = $("panResultList");
|
||
if (!list) return;
|
||
renderPanTabs();
|
||
const active = ensureActivePanType();
|
||
const items = rankedPanResults(active);
|
||
const total = state.pan.results.length;
|
||
const activeName = active ? panDiskName(active) : "";
|
||
$("panSearchHint").textContent = state.pan.keyword ? `${state.pan.keyword} · 共 ${total} 条${activeName ? " · " + activeName + " " + items.length : ""}` : "";
|
||
if (!total) {
|
||
state.pan.renderKeys = "";
|
||
list.replaceChildren(emptyNode("资源搜索中..."));
|
||
return;
|
||
}
|
||
if (!items.length) {
|
||
state.pan.renderKeys = "";
|
||
list.replaceChildren(emptyNode("当前网盘暂无资源"));
|
||
return;
|
||
}
|
||
const keys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
|
||
const activeEl = document.activeElement;
|
||
const shouldRestore = !!(activeEl && list.contains(activeEl)) || state.pan.focusMode === "results";
|
||
const restoreKey = shouldRestore && activeEl.dataset.panKey || state.pan.focusKey || "";
|
||
list.setAttribute("tabindex", "-1");
|
||
list.setAttribute("role", "listbox");
|
||
if (patchPanResultList(list, items, active, shouldRestore, restoreKey)) return;
|
||
if (keys === state.pan.renderKeys && list.children.length === items.length) {
|
||
restorePanResultFocus(list, restoreKey, shouldRestore);
|
||
return;
|
||
}
|
||
state.pan.renderKeys = keys;
|
||
list.replaceChildren(...items.map(panResultNode));
|
||
observePanVisibleItems();
|
||
restorePanResultFocus(list, restoreKey, shouldRestore);
|
||
}
|
||
|
||
function patchPanResultList(list, items, active, shouldRestore, restoreKey) {
|
||
const existing = Array.from(list.querySelectorAll(".pan-result-item"));
|
||
if (!existing.length || existing.some((node) => !items.some((item) => item.key === node.dataset.panKey))) return false;
|
||
const nodes = new Map(existing.map((node) => [node.dataset.panKey, node]));
|
||
const ordered = items.map((item) => {
|
||
const node = nodes.get(item.key);
|
||
if (node) {
|
||
updatePanResultNode(node, item);
|
||
return node;
|
||
}
|
||
return panResultNode(item);
|
||
});
|
||
let reference = list.firstChild;
|
||
ordered.forEach((node) => {
|
||
if (node === reference) {
|
||
reference = reference.nextSibling;
|
||
return;
|
||
}
|
||
list.insertBefore(node, reference);
|
||
});
|
||
state.pan.renderKeys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
|
||
observePanVisibleItems();
|
||
restorePanResultFocus(list, restoreKey, shouldRestore);
|
||
return true;
|
||
}
|
||
|
||
function updatePanResultNode(button, item) {
|
||
const health = getPanHealth(item);
|
||
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
|
||
button.dataset.panKey = item.key;
|
||
button.innerHTML = panResultHtml(item, health);
|
||
}
|
||
|
||
function restorePanResultFocus(list, key, shouldRestore) {
|
||
if (!shouldRestore || !list) return;
|
||
const target = key ? findByDataset(list, "panKey", key) : null;
|
||
const fallback = list.querySelector(".pan-result-item");
|
||
lockPanFocus(target || fallback, "results", { keepBlockPosition: true, once: true });
|
||
}
|
||
|
||
function lockPanFocus(target, mode, options) {
|
||
if (!target) return;
|
||
const opts = options || {};
|
||
const apply = () => {
|
||
if (!$("panSearchBlock") || $("panSearchBlock").style.display === "none") return;
|
||
if (mode && state.pan.focusMode && state.pan.focusMode !== mode) return;
|
||
const active = document.activeElement;
|
||
const inPan = active && ($("panTabs").contains(active) || $("panResultList").contains(active));
|
||
if (inPan && mode && state.pan.focusMode !== mode) return;
|
||
if (inPan && active === target) return;
|
||
if (!isVisibleFocusable(target)) return;
|
||
state.pan.focusMode = mode || state.pan.focusMode;
|
||
focusPanTarget(target, opts);
|
||
};
|
||
requestAnimationFrame(apply);
|
||
if (opts.once) return;
|
||
setTimeout(apply, 80);
|
||
setTimeout(apply, 220);
|
||
}
|
||
|
||
function focusPanTarget(target, options) {
|
||
if (!target) return;
|
||
const opts = options || {};
|
||
const list = $("panResultList");
|
||
const tabs = $("panTabs");
|
||
const keepBlockPosition = opts.keepBlockPosition || Date.now() < Number(state.pan.keepBlockPositionUntil || 0);
|
||
if (tabs && tabs.contains(target) && !keepBlockPosition) centerPanSearchBlock();
|
||
try {
|
||
target.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
target.focus();
|
||
}
|
||
if (list && list.contains(target)) {
|
||
keepPanResultItemVisible(target, list);
|
||
if (!opts.keepBlockPosition) ensurePanListViewport();
|
||
return;
|
||
}
|
||
if (tabs && tabs.contains(target)) return;
|
||
const rect = target.getBoundingClientRect();
|
||
const height = window.innerHeight || document.documentElement.clientHeight || 0;
|
||
if (height && (rect.top < 48 || rect.bottom > height - 48)) {
|
||
try { target.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e) { target.scrollIntoView(false); }
|
||
}
|
||
}
|
||
|
||
function centerPanSearchBlock() {
|
||
const block = $("panSearchBlock");
|
||
const detail = $("detailSheet");
|
||
if (!block || !detail || !detail.classList.contains("active")) return;
|
||
try {
|
||
block.scrollIntoView({ block: "center", inline: "nearest" });
|
||
} catch (e) {
|
||
const blockRect = block.getBoundingClientRect();
|
||
const detailRect = detail.getBoundingClientRect();
|
||
const height = detail.clientHeight || window.innerHeight || 0;
|
||
if (!height) return;
|
||
detail.scrollTop += blockRect.top - detailRect.top - Math.round((height - Math.min(blockRect.height, height * .72)) / 2);
|
||
}
|
||
}
|
||
|
||
function ensurePanListViewport() {
|
||
const list = $("panResultList");
|
||
const detail = $("detailSheet");
|
||
if (!list || !detail || !detail.classList.contains("active")) return;
|
||
const listRect = list.getBoundingClientRect();
|
||
const detailRect = detail.getBoundingClientRect();
|
||
const height = detail.clientHeight || window.innerHeight || 0;
|
||
if (!height) return;
|
||
const topLimit = detailRect.top + Math.max(34, height * .1);
|
||
const bottomLimit = detailRect.top + height - Math.max(50, height * .1);
|
||
if (listRect.top < topLimit) detail.scrollTop += listRect.top - topLimit;
|
||
else if (listRect.bottom > bottomLimit && listRect.top > topLimit) detail.scrollTop += Math.min(listRect.top - topLimit, listRect.bottom - bottomLimit);
|
||
}
|
||
|
||
function keepPanResultItemVisible(target, list) {
|
||
const itemRect = target.getBoundingClientRect();
|
||
const listRect = list.getBoundingClientRect();
|
||
const topDelta = itemRect.top - listRect.top;
|
||
const bottomDelta = itemRect.bottom - listRect.bottom;
|
||
if (topDelta < 0) list.scrollTop += topDelta;
|
||
else if (bottomDelta > 0) list.scrollTop += bottomDelta;
|
||
}
|
||
|
||
function findByDataset(root, name, value) {
|
||
if (!root) return null;
|
||
return Array.from(root.querySelectorAll("[data-" + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + "]"))
|
||
.find((el) => String(el.dataset[name] || "") === String(value || "")) || null;
|
||
}
|
||
|
||
function panResultNode(item) {
|
||
const health = getPanHealth(item);
|
||
const button = document.createElement("button");
|
||
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
|
||
button.type = "button";
|
||
button.dataset.panKey = item.key;
|
||
button.innerHTML = panResultHtml(item, health);
|
||
button.addEventListener("focus", () => { state.pan.focusKey = item.key; state.pan.focusMode = "results"; });
|
||
button.addEventListener("click", () => playPanResult(item));
|
||
return button;
|
||
}
|
||
|
||
function panResultHtml(item, health) {
|
||
const quality = panQualityInfo(item);
|
||
// 合并规则:
|
||
// 1. DolbyVision + 杜比全景声同时命中 → 合并为「双杜比」
|
||
// 2. 4K + 1080P 同时命中 → 只保留 4K,去掉 1080P
|
||
const _hasDV = quality.tags.some(t => t.cls === "dv");
|
||
const _hasAtmos = quality.tags.some(t => t.label === "杜比");
|
||
const _has4K = quality.tags.some(t => t.label === "4K");
|
||
let _tags = quality.tags;
|
||
if (_hasDV && _hasAtmos)
|
||
_tags = [{ label: "双杜比", cls: "dv" }, ..._tags.filter(t => t.cls !== "dv" && t.label !== "杜比")];
|
||
if (_has4K)
|
||
_tags = _tags.filter(t => t.label !== "1080P");
|
||
const qualityTags = _tags.map((t) => `<span class="pan-tag quality${t.cls ? " " + t.cls : ""}">${escapeHtml(t.label)}</span>`).join("");
|
||
return `
|
||
<div class="pan-result-title">
|
||
<span>${escapeHtml(item.title || state.pan.keyword || "盘搜资源")}</span>
|
||
${panHealthIndicatorHtml(item, health)}
|
||
</div>
|
||
${qualityTags ? `<div class="pan-result-meta">${qualityTags}</div>` : ""}
|
||
`;
|
||
}
|
||
|
||
async function rememberPanPlaybackReturn(item) {
|
||
const detail = $("detailSheet");
|
||
const list = $("panResultList");
|
||
const key = item && item.key || state.pan.focusKey || "";
|
||
state.pan.playbackReturn = {
|
||
key,
|
||
activeType: state.pan.activeType || "",
|
||
detailScrollTop: detail ? Math.round(detail.scrollTop || 0) : 0,
|
||
listScrollTop: list ? Math.round(list.scrollTop || 0) : 0,
|
||
at: Date.now()
|
||
};
|
||
state.pan.focusKey = key;
|
||
state.pan.focusMode = "results";
|
||
await saveUiSnapshotNow();
|
||
}
|
||
|
||
function restorePanPlaybackReturn(options) {
|
||
const opts = options || {};
|
||
const saved = state.pan.playbackReturn;
|
||
if (!saved) return false;
|
||
if (Date.now() - Number(saved.at || 0) > PAN_PLAYBACK_RETURN_TTL_MS) {
|
||
state.pan.playbackReturn = null;
|
||
return false;
|
||
}
|
||
const detail = $("detailSheet");
|
||
const block = $("panSearchBlock");
|
||
if (!detail || !detail.classList.contains("active") || !block || !block.classList.contains("active")) return false;
|
||
if (saved.activeType) state.pan.activeType = saved.activeType;
|
||
state.pan.focusKey = saved.key || state.pan.focusKey || "";
|
||
state.pan.focusMode = "results";
|
||
if (!opts.skipRender) renderPanResults();
|
||
const apply = () => {
|
||
const currentDetail = $("detailSheet");
|
||
const currentList = $("panResultList");
|
||
if (currentDetail) currentDetail.scrollTop = Number(saved.detailScrollTop || 0);
|
||
if (currentList) currentList.scrollTop = Number(saved.listScrollTop || 0);
|
||
const target = saved.key ? findByDataset(currentList, "panKey", saved.key) : null;
|
||
const fallback = currentList && currentList.querySelector(".pan-result-item");
|
||
if (target || fallback) lockPanFocus(target || fallback, "results");
|
||
};
|
||
requestAnimationFrame(apply);
|
||
setTimeout(apply, 80);
|
||
setTimeout(apply, 240);
|
||
state.pan.playbackReturn = null;
|
||
scheduleUiSnapshotSave();
|
||
return true;
|
||
}
|
||
|
||
function panHealthLabel(state) {
|
||
return ({ ok: "链接有效", bad: "链接失效", locked: "需要提取码", unsupported: "暂不支持检测", uncertain: "检测结果不确定", pending: "检测中", idle: "未检测" })[state] || "未检测";
|
||
}
|
||
|
||
function panHealthIndicatorHtml(item, health) {
|
||
if (!canCheckPanLinks() || !isPanCheckSupported(item)) return "";
|
||
const stateValue = health && health.state || "idle";
|
||
const title = health && health.summary ? `${panHealthLabel(stateValue)}:${health.summary}` : panHealthLabel(stateValue);
|
||
return `<span class="pan-health ${escapeAttr(stateValue)}" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}"></span>`;
|
||
}
|
||
|
||
function observePanVisibleItems() {
|
||
const list = $("panResultList");
|
||
if (state.pan.observer) state.pan.observer.disconnect();
|
||
if (!canCheckPanLinks()) return;
|
||
if (!list || !rankedPanResults(state.pan.activeType).some(isPanCheckSupported) || !("IntersectionObserver" in window)) {
|
||
queueAllVisiblePanFallback();
|
||
return;
|
||
}
|
||
state.pan.observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (!entry.isIntersecting || entry.intersectionRatio < 0.25) return;
|
||
const key = entry.target.dataset.panKey;
|
||
const item = state.pan.results.find((value) => value.key === key);
|
||
queuePanCheck(item);
|
||
});
|
||
}, { root: list, threshold: [0.25, 0.6] });
|
||
list.querySelectorAll(".pan-result-item").forEach((node) => state.pan.observer.observe(node));
|
||
scheduleVisiblePanCheck();
|
||
}
|
||
|
||
function scheduleVisiblePanCheck() {
|
||
clearTimeout(scheduleVisiblePanCheck.timer);
|
||
scheduleVisiblePanCheck.timer = setTimeout(queueVisiblePanChecks, 90);
|
||
}
|
||
|
||
function queueVisiblePanChecks() {
|
||
if (!canCheckPanLinks()) return;
|
||
const list = $("panResultList");
|
||
if (!list) return;
|
||
const listRect = list.getBoundingClientRect();
|
||
const bottomLimit = listRect.bottom + Math.max(80, listRect.height * .35);
|
||
Array.from(list.querySelectorAll(".pan-result-item")).some((node) => {
|
||
const rect = node.getBoundingClientRect();
|
||
if (rect.bottom < listRect.top || rect.top > bottomLimit) return false;
|
||
const item = state.pan.results.find((value) => value.key === node.dataset.panKey);
|
||
queuePanCheck(item);
|
||
return state.pan.queued.size + state.pan.inFlight.size >= 10;
|
||
});
|
||
}
|
||
|
||
function queueAllVisiblePanFallback() {
|
||
if (!canCheckPanLinks()) return;
|
||
queueVisiblePanChecks();
|
||
}
|
||
|
||
function queuePanCheck(item) {
|
||
if (!item || !canCheckPanLinks() || !isPanCheckSupported(item)) return;
|
||
const key = panHealthKey(item);
|
||
if (!key || state.pan.health[key] || state.pan.pending[key] || state.pan.queued.has(key) || state.pan.inFlight.has(key)) return;
|
||
state.pan.queued.set(key, item);
|
||
schedulePanCheckFlush();
|
||
}
|
||
|
||
function schedulePanCheckFlush() {
|
||
if (state.pan.flushTimer) return;
|
||
state.pan.flushTimer = setTimeout(() => {
|
||
state.pan.flushTimer = 0;
|
||
flushPanCheckQueue();
|
||
}, 180);
|
||
}
|
||
|
||
async function flushPanCheckQueue() {
|
||
if (!canCheckPanLinks()) {
|
||
state.pan.queued.clear();
|
||
return;
|
||
}
|
||
if (!state.pan.queued.size) return;
|
||
const entries = Array.from(state.pan.queued.entries()).slice(0, 10);
|
||
entries.forEach(([key]) => state.pan.queued.delete(key));
|
||
entries.forEach(([key]) => {
|
||
state.pan.pending[key] = true;
|
||
state.pan.inFlight.add(key);
|
||
});
|
||
renderPanResults();
|
||
try {
|
||
const response = await sdk().pan.check(entries.map(([, item]) => ({ type: item.diskType, url: item.url, password: item.password })));
|
||
const results = response && Array.isArray(response.results) ? response.results : [];
|
||
entries.forEach(([originalKey, fallback], index) => {
|
||
const result = results[index] || {};
|
||
const normalizedUrl = String(result.normalized_url || result.normalizedUrl || "").trim();
|
||
if (normalizedUrl) fallback.normalizedUrl = normalizedUrl;
|
||
const health = {
|
||
state: result.state || "uncertain",
|
||
summary: result.summary || "",
|
||
checkedAt: result.checked_at || Date.now(),
|
||
expiresAt: result.expires_at || Date.now() + 300000
|
||
};
|
||
state.pan.health[originalKey] = health;
|
||
const resultKey = panHealthKey({ diskType: normalizePanDiskType(result.type || fallback.diskType), url: result.url || fallback.url });
|
||
if (resultKey && resultKey !== originalKey) state.pan.health[resultKey] = health;
|
||
});
|
||
setPanStatus(`已检测 ${checkedPanCount()}/${checkablePanCount()}`);
|
||
} catch (e) {
|
||
entries.forEach(([, item]) => {
|
||
state.pan.health[panHealthKey(item)] = { state: "uncertain", summary: e.message || "检测失败", checkedAt: Date.now(), expiresAt: Date.now() + 300000 };
|
||
});
|
||
setPanStatus("检测不可用");
|
||
} finally {
|
||
entries.forEach(([key]) => {
|
||
delete state.pan.pending[key];
|
||
state.pan.inFlight.delete(key);
|
||
});
|
||
renderPanResults();
|
||
scheduleVisiblePanCheck();
|
||
if (state.pan.queued.size) schedulePanCheckFlush();
|
||
}
|
||
}
|
||
|
||
function checkedPanCount() {
|
||
return state.pan.results.filter((item) => isPanCheckSupported(item) && !!state.pan.health[panHealthKey(item)]).length;
|
||
}
|
||
|
||
function checkablePanCount() {
|
||
return state.pan.results.filter(isPanCheckSupported).length;
|
||
}
|
||
|
||
function panPasswordParam(item, url) {
|
||
const type = normalizePanDiskType(item && item.diskType);
|
||
const target = String(url || "").toLowerCase();
|
||
if (type === "115" || /(^|\/\/)([^/]*\.)?(115|115cdn|anxia)\./i.test(target)) return "password";
|
||
return "pwd";
|
||
}
|
||
|
||
function hasPanPassword(url) {
|
||
return /(?:[?&#])(pwd|password|passcode|code)=/i.test(String(url || ""));
|
||
}
|
||
|
||
function addPanPassword(url, password, item) {
|
||
let target = String(url || "").trim();
|
||
const code = String(password || "").trim();
|
||
if (!target || !code || hasPanPassword(target)) return target;
|
||
if (/^(magnet:|ed2k:\/\/)/i.test(target)) return target;
|
||
const param = panPasswordParam(item, target);
|
||
try {
|
||
const parsed = new URL(target);
|
||
parsed.searchParams.set(param, code);
|
||
return parsed.toString();
|
||
} catch (e) {
|
||
const hashIndex = target.indexOf("#");
|
||
const base = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
|
||
const hash = hashIndex >= 0 ? target.slice(hashIndex) : "";
|
||
const sep = base.includes("?") ? (base.endsWith("?") || base.endsWith("&") ? "" : "&") : "?";
|
||
return base + sep + param + "=" + encodeURIComponent(code) + hash;
|
||
}
|
||
}
|
||
|
||
function buildPanPlayPayload(item) {
|
||
let url = String(item && (item.normalizedUrl || item.normalized_url || item.url) || "").trim();
|
||
if (!url) return null;
|
||
if (/^push:\/\//i.test(url)) url = url.replace(/^push:\/\//i, "");
|
||
return {
|
||
type: normalizePanDiskType(item && item.diskType),
|
||
url,
|
||
password: item && item.password || "",
|
||
title: item && (item.title || state.pan.keyword || item.url) || ""
|
||
};
|
||
}
|
||
|
||
async function playPanResult(item) {
|
||
const payload = buildPanPlayPayload(item);
|
||
if (!payload) return;
|
||
await rememberPanPlaybackReturn(item);
|
||
if (state.selected) {
|
||
rememberWatchIntent(state.selected, "search");
|
||
startWatchTracking(state.selected);
|
||
}
|
||
try {
|
||
const pan = sdk().pan || {};
|
||
if (!pan.play) throw new Error("当前 App 不支持 pan.play");
|
||
// 用 localStorage 持久化标记,WebView 被挂起后 JS 内存会丢失
|
||
// 但 localStorage 会保留,visibilitychange 恢复时可以读到
|
||
localStorage.setItem("fm_pan_playing", "1");
|
||
state.pan.isPlaying = true;
|
||
pan.play(payload); // 不 await,原生接管后 Promise 永不 resolve
|
||
} catch (e) {
|
||
localStorage.removeItem("fm_pan_playing");
|
||
state.pan.isPlaying = false;
|
||
toast("播放失败:" + (e.message || "unknown"));
|
||
}
|
||
}
|
||
|
||
function emptyNode(text) {
|
||
const node = document.createElement("div");
|
||
node.className = "empty";
|
||
node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function closeDetail(fromPopState) {
|
||
const sheet = $("detailSheet");
|
||
if (!sheet) return;
|
||
// 防止动画期间重复触发
|
||
if (sheet.classList.contains("sheet-closing")) return;
|
||
|
||
// 停止轮播和盘搜
|
||
stopDetailCoverCarousel(true);
|
||
resetPanSearch();
|
||
// 关闭详情保持全屏(不恢复 Toolbar);退出全屏只在「已在主页时再按返回」处理
|
||
document.documentElement.classList.remove("detail-immersive");
|
||
|
||
// 清零模糊 / 隐藏徽标
|
||
setHeroBlur(0, 0);
|
||
sheet.style.setProperty("--cover-blur-pct", "0");
|
||
delete sheet.dataset.blurLocked;
|
||
const logoWrap = $("detailLogoWrap");
|
||
const logoImg = $("detailLogoImg");
|
||
if (logoWrap) logoWrap.style.display = "none";
|
||
if (logoImg) logoImg.classList.remove("loaded");
|
||
|
||
// 动画结束后执行真正的状态清除
|
||
const FADE = 240;
|
||
// 若盘搜打开时 hash 是 #pan,先收敛到 #detail,再走统一的 needBack 路径
|
||
if (!fromPopState && location.hash === "#pan") {
|
||
history.replaceState({ sheet: "detail" }, "", "#detail");
|
||
}
|
||
// 关闭详情时需要回退 history:从 #detail pop 回主页哨兵
|
||
const needBack = !fromPopState && location.hash === "#detail";
|
||
|
||
// 触发整体淡出动画(sheet 整体 opacity 0,22ms)
|
||
sheet.classList.add("sheet-closing");
|
||
state.detailClosing = true;
|
||
document.body.classList.add("detail-closing");
|
||
|
||
// fromPopState 路径:history.back() 已发生,立即补哨兵防止动画期间再按返回退出 App
|
||
if (!needBack) {
|
||
ensureHomeHistoryEntry();
|
||
}
|
||
setTimeout(() => {
|
||
state.detailClosing = false;
|
||
document.body.classList.remove("detail-closing");
|
||
sheet.classList.remove("active");
|
||
sheet.classList.remove("sheet-closing");
|
||
sheet.classList.remove("detail-large");
|
||
sheet.style.display = "";
|
||
sheet.setAttribute("aria-hidden", "true");
|
||
// 此时 sheet 已不可见,再移除 detail-active 并恢复滚动
|
||
document.body.classList.remove("detail-active");
|
||
document.body.classList.remove("episode-active");
|
||
state.detailReturn = null;
|
||
scheduleUiSnapshotSave();
|
||
if (needBack) {
|
||
// 保存当前主页滚动位置,history.back() 后 popstate 会重置它
|
||
_pendingHomeScrollY = Math.round(window.scrollY || document.documentElement.scrollTop || 0);
|
||
history.back();
|
||
} else {
|
||
// 系统返回键路径:popstate 里保存的位置,动画结束后还原
|
||
if (_pendingHomeScrollY > 0) {
|
||
const _sy = _pendingHomeScrollY;
|
||
_pendingHomeScrollY = 0;
|
||
window.scrollTo(0, _sy);
|
||
document.documentElement.scrollTop = _sy;
|
||
document.body.scrollTop = _sy;
|
||
}
|
||
}
|
||
// 延迟清除背景图(避免闪一帧)
|
||
setTimeout(() => {
|
||
if (!sheet.classList.contains("active")) setHeroBg("");
|
||
}, 80);
|
||
}, FADE);
|
||
}
|
||
|
||
function openSync(options) {
|
||
const opts = options || {};
|
||
ensureSheetViewport($("syncSheet"));
|
||
$("syncSheet").classList.add("active");
|
||
$("syncSheet").setAttribute("aria-hidden", "false");
|
||
$("nsecInput").value = state.identity ? state.identity.nsec : "";
|
||
if (!opts.skipHistory && location.hash !== "#sync") history.pushState({ sheet: "sync" }, "", "#sync");
|
||
scheduleUiSnapshotSave();
|
||
if (!opts.restore) setTimeout(() => focusRemoteTarget($("saveNsecBtn")), 40);
|
||
}
|
||
|
||
function closeSync(fromPopState) {
|
||
const sheet = $("syncSheet");
|
||
sheet.classList.remove("active");
|
||
sheet.style.display = "";
|
||
sheet.setAttribute("aria-hidden", "true");
|
||
scheduleUiSnapshotSave();
|
||
if (!fromPopState && location.hash === "#sync") history.back();
|
||
}
|
||
|
||
function openImage(url, options) {
|
||
const opts = options || {};
|
||
const episode = opts.episode || null;
|
||
if (!url && !episode) return;
|
||
if (!opts.restore) rememberFocusReturn();
|
||
const image = $("viewerImage");
|
||
const episodeBox = $("episodeViewer");
|
||
if (episode) {
|
||
document.body.classList.add("episode-active");
|
||
$("imageViewer").classList.add("episode-mode");
|
||
image.style.display = "none";
|
||
image.removeAttribute("src");
|
||
const still = episode.still || url || "";
|
||
episodeBox.style.display = "grid";
|
||
episodeBox.innerHTML = `
|
||
${still ? `<img alt="" ${imageAttrs(still)}>` : ""}
|
||
<div>
|
||
<h3>${escapeHtml(episode.title || "分集剧情")}</h3>
|
||
${episode.meta ? `<span>${escapeHtml(episode.meta)}</span>` : ""}
|
||
</div>
|
||
<p>${escapeHtml(episode.overview || "暂无剧情概要")}</p>
|
||
`;
|
||
} else {
|
||
document.body.classList.remove("episode-active");
|
||
$("imageViewer").classList.remove("episode-mode");
|
||
episodeBox.style.display = "none";
|
||
episodeBox.replaceChildren();
|
||
image.style.display = "";
|
||
image.src = url;
|
||
}
|
||
ensureSheetViewport($("imageViewer"), "grid");
|
||
$("imageViewer").classList.add("active");
|
||
$("imageViewer").setAttribute("aria-hidden", "false");
|
||
if (!opts.skipHistory) history.pushState({ sheet: "image" }, "", "#image");
|
||
scheduleUiSnapshotSave();
|
||
if (!opts.restore) setTimeout(() => focusRemoteTarget(episode ? $("imageContent") : $("closeImageBtn")), 40);
|
||
}
|
||
|
||
function closeImage(fromPopState) {
|
||
const viewer = $("imageViewer");
|
||
document.body.classList.remove("episode-active");
|
||
viewer.classList.remove("active");
|
||
viewer.classList.remove("episode-mode");
|
||
viewer.style.display = "";
|
||
viewer.setAttribute("aria-hidden", "true");
|
||
$("viewerImage").removeAttribute("src");
|
||
$("viewerImage").style.display = "";
|
||
$("episodeViewer").style.display = "none";
|
||
$("episodeViewer").replaceChildren();
|
||
if (!fromPopState && location.hash === "#image") history.back();
|
||
scheduleUiSnapshotSave();
|
||
restoreFocusReturn($("detailSheet") && $("detailSheet").classList.contains("active") ? $("closeDetailBtn") : null);
|
||
}
|
||
|
||
async function nativeSearch(title) {
|
||
if (!title) return;
|
||
rememberDetailReturn($("detailSearchBtn"));
|
||
await sdk().search(title, { direct: true });
|
||
toast("已发起原生搜索");
|
||
}
|
||
|
||
async function openLiveHome() {
|
||
try {
|
||
const api = sdk();
|
||
if (window.fm && api.openLive) {
|
||
await api.openLive();
|
||
toast("已打开直播");
|
||
} else if (api.openLive) {
|
||
await api.openLive();
|
||
} else if (window.fongmi && window.fongmi.app && window.fongmi.app.openLive) {
|
||
await window.fongmi.app.openLive();
|
||
toast("已打开直播");
|
||
} else {
|
||
toast("当前环境不支持打开直播");
|
||
}
|
||
} catch (e) {
|
||
toast("打开直播失败:" + (e.message || "unknown"));
|
||
}
|
||
}
|
||
|
||
async function openDetailContinueHistory() {
|
||
const button = $("detailContinueBtn");
|
||
const history = button && button.__historyItem || findDetailContinueHistory(state.selected);
|
||
if (!state.selected || !history || !history.siteKey || !history.vodId) {
|
||
toast("没有可继续观看的记录");
|
||
updateDetailContinueButton();
|
||
return;
|
||
}
|
||
try {
|
||
rememberDetailReturn(button);
|
||
rememberWatchIntent(state.selected, "view");
|
||
startWatchTracking(state.selected);
|
||
await sdk().vod(history.siteKey, history.vodId, history.title || state.selected.title, history.pic || state.selected.pic);
|
||
} catch (e) {
|
||
stopWatchTracking(false);
|
||
toast("继续观看失败:" + (e.message || "unknown"));
|
||
}
|
||
}
|
||
|
||
function rememberDetailReturn(target) {
|
||
const detail = $("detailSheet");
|
||
if (!detail || !detail.classList.contains("active")) return;
|
||
state.detailReturn = {
|
||
scrollTop: Math.round(detail.scrollTop || 0),
|
||
targetId: target && target.id || document.activeElement && document.activeElement.id || "detailSearchBtn",
|
||
at: Date.now()
|
||
};
|
||
saveUiSnapshotNow();
|
||
}
|
||
|
||
function restoreDetailReturn() {
|
||
const saved = state.detailReturn;
|
||
if (!saved) return false;
|
||
if (Date.now() - Number(saved.at || 0) > 10 * 60 * 1000) {
|
||
state.detailReturn = null;
|
||
return false;
|
||
}
|
||
const detail = $("detailSheet");
|
||
if (!detail || !detail.classList.contains("active")) return false;
|
||
const apply = () => {
|
||
if ($("detailSheet")) $("detailSheet").scrollTop = Number(saved.scrollTop || 0);
|
||
const target = saved.targetId && $(saved.targetId) || $("detailSearchBtn") || $("closeDetailBtn");
|
||
if (target) focusRemoteTarget(target);
|
||
};
|
||
requestAnimationFrame(apply);
|
||
setTimeout(apply, 80);
|
||
setTimeout(apply, 220);
|
||
state.detailReturn = null;
|
||
scheduleUiSnapshotSave();
|
||
return true;
|
||
}
|
||
|
||
function rememberWatchIntent(item, action) {
|
||
if (!item) return;
|
||
state.watch = {
|
||
item: normalizeSnapshot(item),
|
||
timer: state.watch.timer,
|
||
bestMs: state.watch.bestMs || 0,
|
||
durationMs: state.watch.durationMs || 0,
|
||
lastAt: Date.now(),
|
||
published: false,
|
||
intentAction: action || "view"
|
||
};
|
||
savePendingWatch();
|
||
}
|
||
|
||
function startWatchTracking(item) {
|
||
if (!item || !window.fm) return;
|
||
state.watch = {
|
||
item: normalizeSnapshot(item),
|
||
timer: state.watch.timer,
|
||
bestMs: 0,
|
||
durationMs: 0,
|
||
lastAt: Date.now(),
|
||
published: false,
|
||
intentAction: state.watch.intentAction || ""
|
||
};
|
||
savePendingWatch();
|
||
if (!state.watch.timer) state.watch.timer = setInterval(sampleWatchStatus, 12000);
|
||
sampleWatchStatus();
|
||
}
|
||
|
||
async function savePendingWatch() {
|
||
const watch = state.watch;
|
||
if (!watch || !watch.item) return;
|
||
try {
|
||
await sdk().cache.set(cacheKey("pendingWatch"), JSON.stringify({
|
||
item: watch.item,
|
||
intentAction: watch.intentAction,
|
||
startedAt: watch.lastAt || Date.now(),
|
||
published: !!watch.published
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadPendingWatch() {
|
||
if (state.watch && state.watch.item) return state.watch;
|
||
try {
|
||
const text = await sdk().cache.get(cacheKey("pendingWatch"));
|
||
const data = text ? JSON.parse(text) : null;
|
||
if (!data || !data.item) return null;
|
||
state.watch = {
|
||
item: normalizeSnapshot(data.item),
|
||
timer: state.watch.timer,
|
||
bestMs: 0,
|
||
durationMs: 0,
|
||
lastAt: data.startedAt || Date.now(),
|
||
published: !!data.published,
|
||
intentAction: data.intentAction || "search"
|
||
};
|
||
return state.watch;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function clearPendingWatch() {
|
||
try { await sdk().cache.del(cacheKey("pendingWatch")); } catch (e) {}
|
||
}
|
||
|
||
async function sampleWatchStatus() {
|
||
const watch = state.watch;
|
||
if (!watch || !watch.item || !window.fm) return;
|
||
try {
|
||
const status = await fm.stat();
|
||
const position = Math.max(0, Number(status && status.position || 0));
|
||
const duration = Math.max(0, Number(status && status.duration || 0));
|
||
if (duration > 0) watch.durationMs = duration;
|
||
if (position > watch.bestMs) watch.bestMs = position;
|
||
const completion = watch.durationMs > 0 ? watch.bestMs / watch.durationMs : 0;
|
||
if (watch.bestMs >= WATCH_HEAT_MS && hasHeatIntent(watch) && !watch.published) {
|
||
if (await hasPublishedHeat(watch.item)) {
|
||
watch.published = true;
|
||
await savePendingWatch();
|
||
await clearPendingWatch();
|
||
return;
|
||
}
|
||
watch.published = true;
|
||
savePendingWatch();
|
||
await recordPreference(watch.item, "watch", {
|
||
watchMs: Math.round(watch.bestMs),
|
||
durationMs: Math.round(watch.durationMs),
|
||
completion: Number(Math.min(1, completion).toFixed(3)),
|
||
intentAction: watch.intentAction,
|
||
playState: status && status.state,
|
||
playTitle: status && status.title || ""
|
||
});
|
||
await clearPendingWatch();
|
||
}
|
||
if (completion >= .92 && watch.bestMs > 0) stopWatchTracking(false);
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function settleWatchFromHistory() {
|
||
const watch = await loadPendingWatch();
|
||
if (!watch || !watch.item || watch.published) return;
|
||
if (await hasPublishedHeat(watch.item)) {
|
||
watch.published = true;
|
||
await savePendingWatch();
|
||
await clearPendingWatch();
|
||
return;
|
||
}
|
||
try {
|
||
const list = await sdk().history();
|
||
const history = findWatchHistory(watch.item, Array.isArray(list) ? list : []);
|
||
if (!history) return;
|
||
const position = Math.max(0, Number(history.position || 0));
|
||
const duration = Math.max(0, Number(history.duration || 0));
|
||
if (position < WATCH_HEAT_MS || !hasHeatIntent(watch)) return;
|
||
if (await hasPublishedHeat(watch.item)) {
|
||
await clearPendingWatch();
|
||
return;
|
||
}
|
||
watch.bestMs = position;
|
||
watch.durationMs = duration;
|
||
watch.published = true;
|
||
await savePendingWatch();
|
||
await recordPreference(watch.item, "watch", {
|
||
watchMs: Math.round(position),
|
||
durationMs: Math.round(duration),
|
||
completion: duration > 0 ? Number(Math.min(1, position / duration).toFixed(3)) : 0,
|
||
intentAction: watch.intentAction,
|
||
historyKey: history.key || "",
|
||
siteKey: history.siteKey || "",
|
||
vodId: history.vodId || "",
|
||
playTitle: history.vodName || ""
|
||
});
|
||
await clearPendingWatch();
|
||
toast("已同步观看偏好");
|
||
} catch (e) {}
|
||
}
|
||
|
||
function scheduleHistorySettlement() {
|
||
settleWatchFromHistory();
|
||
setTimeout(settleWatchFromHistory, 1200);
|
||
setTimeout(settleWatchFromHistory, 4000);
|
||
}
|
||
|
||
function findWatchHistory(item, list) {
|
||
const title = normalizeTitle(item.title || "");
|
||
const tmdbId = item.tmdbId ? String(item.tmdbId) : "";
|
||
return list
|
||
.filter((history) => history && Number(history.position || 0) > 0)
|
||
.filter((history) => !state.watch.lastAt || Number(history.createTime || 0) >= state.watch.lastAt - 30000)
|
||
.sort((a, b) => Number(b.createTime || 0) - Number(a.createTime || 0))
|
||
.find((history) => {
|
||
const name = normalizeTitle(history.vodName || "");
|
||
if (title && name && (title === name || title.includes(name) || name.includes(title))) return true;
|
||
if (tmdbId && String(history.vodId || "").includes(tmdbId)) return true;
|
||
return false;
|
||
});
|
||
}
|
||
|
||
async function hasPublishedHeat(item) {
|
||
const identity = state.identity && state.identity.pubkey;
|
||
if (!identity || !item) return false;
|
||
const mediaKeyValue = mediaHeatKey(item);
|
||
if (!mediaKeyValue) return false;
|
||
const vector = await hotGetMyVector();
|
||
return hotVectorHasMedia(vector, mediaKeyValue);
|
||
}
|
||
|
||
function stopWatchTracking(flush) {
|
||
const watch = state.watch;
|
||
if (flush && watch && watch.item && watch.bestMs >= WATCH_HEAT_MS && hasHeatIntent(watch) && !watch.published) {
|
||
const completion = watch.durationMs > 0 ? watch.bestMs / watch.durationMs : 0;
|
||
trackPreference(watch.item, "watch", {
|
||
watchMs: Math.round(watch.bestMs),
|
||
durationMs: Math.round(watch.durationMs),
|
||
completion: Number(Math.min(1, completion).toFixed(3)),
|
||
intentAction: watch.intentAction
|
||
});
|
||
}
|
||
if (watch && watch.timer) clearInterval(watch.timer);
|
||
state.watch = { item: null, timer: 0, bestMs: 0, durationMs: 0, lastAt: 0, published: false, intentAction: "" };
|
||
}
|
||
|
||
function trackPreference(item, action, extra) {
|
||
recordPreference(item, action, extra).catch(() => {});
|
||
}
|
||
|
||
async function recordPreference(item, action, extra) {
|
||
if (action !== "watch") return;
|
||
if (await isPreferencePublishBlocked()) {
|
||
setStatus("publish", "删除保护中,暂停发布");
|
||
return;
|
||
}
|
||
if (action === "watch" && await hasPublishedHeat(item)) return;
|
||
const event = await createPreferenceEvent(item, action, extra || {});
|
||
if (!event) return;
|
||
event.local = true;
|
||
await hotIngestEvent(event);
|
||
scheduleRender();
|
||
publishEvent(event);
|
||
}
|
||
|
||
async function createPreferenceEvent(item, action, extra) {
|
||
const identity = await ensureIdentity();
|
||
if (!identity) return null;
|
||
if (action === "watch" && Math.max(0, Number(extra.watchMs || extra.position || 0)) < WATCH_HEAT_MS) return null;
|
||
const snapshot = normalizeSnapshot(item);
|
||
const currentItem = hotVectorItemFromSnapshot(snapshot, hotToday());
|
||
if (!currentItem) return null;
|
||
const oldVector = await hotGetMyVector();
|
||
const createdAt = Math.max(hotNow(), Number(oldVector && oldVector.ts || 0) + 1);
|
||
const expiresAt = hotExpiresAt(createdAt);
|
||
const items = hotWireItemsForPublish(oldVector, currentItem, createdAt);
|
||
const content = { v: HOT_VECTOR_VERSION, i: items };
|
||
const tags = [
|
||
["d", HOT_VECTOR_D],
|
||
["t", window.WEBHOME_CONFIG.nostr.tag],
|
||
["app", "fongmi-webhome"],
|
||
["expiration", String(expiresAt)]
|
||
];
|
||
const draft = { kind: window.WEBHOME_CONFIG.nostr.kind, created_at: createdAt, tags, content: JSON.stringify(content) };
|
||
if (identity && identity.secret && window.NostrTools) return window.NostrTools.finalizeEvent(draft, identity.secret);
|
||
if (window.nostr && window.nostr.signEvent) return await window.nostr.signEvent(draft);
|
||
return Object.assign({ id: HOT_VECTOR_D, pubkey: "" }, draft);
|
||
}
|
||
|
||
function hotVectorItemFromSnapshot(snapshot, day) {
|
||
if (!snapshot || (snapshot.mediaType !== "movie" && snapshot.mediaType !== "tv") || !snapshot.tmdbId) return null;
|
||
return hotNormalizeWireItem([
|
||
hotMediaTypeCode(snapshot.mediaType),
|
||
Number(snapshot.tmdbId) || String(snapshot.tmdbId),
|
||
day,
|
||
snapshot.title || "",
|
||
snapshot.pic || snapshot.image || ""
|
||
], day, false);
|
||
}
|
||
|
||
function hotWireItemsForPublish(oldVector, currentItem, createdAt) {
|
||
const map = new Map();
|
||
(oldVector && oldVector.i || []).forEach((item) => {
|
||
const wire = hotWireItemFromStored(item);
|
||
if (wire) map.set(hotVectorItemMediaKey(wire), wire);
|
||
});
|
||
map.set(hotVectorItemMediaKey(currentItem), currentItem);
|
||
return normalizeWireVectorItems(Array.from(map.values()), createdAt);
|
||
}
|
||
|
||
function hotWireItemFromStored(item) {
|
||
const mediaKey = hotVectorItemMediaKey(item);
|
||
const media = mediaKey ? state.hot.media.get(mediaKey) : null;
|
||
if (!media || !media.t || !media.p) return null;
|
||
return hotNormalizeWireItem([
|
||
hotMediaTypeCode(media.mt),
|
||
Number(media.tid) || media.tid,
|
||
hotVectorItemDay(item),
|
||
media.t,
|
||
media.p
|
||
], hotToday(), false);
|
||
}
|
||
|
||
async function ensureIdentity() {
|
||
if (state.identity) return state.identity;
|
||
await waitForNostrTools();
|
||
if (!window.NostrTools) return null;
|
||
let nsec = await sdk().cache.get(cacheKey("nsec"));
|
||
if (!nsec) {
|
||
nsec = window.NostrTools.nip19.nsecEncode(window.NostrTools.generateSecretKey());
|
||
await sdk().cache.set(cacheKey("nsec"), nsec);
|
||
}
|
||
return setIdentity(nsec);
|
||
}
|
||
|
||
async function setIdentity(nsec) {
|
||
await waitForNostrTools();
|
||
if (!window.NostrTools) throw new Error("nostr-tools 加载失败");
|
||
const decoded = window.NostrTools.nip19.decode(nsec.trim());
|
||
if (decoded.type !== "nsec") throw new Error("请输入 nsec 私钥");
|
||
const secret = decoded.data;
|
||
const pubkey = window.NostrTools.getPublicKey(secret);
|
||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||
state.identity = { nsec: nsec.trim(), secret, pubkey, npub };
|
||
await sdk().cache.set(cacheKey("nsec"), state.identity.nsec);
|
||
await hotLoadMyVector().catch(() => null);
|
||
setStatus("identity", shortKey(npub));
|
||
renderMetrics();
|
||
return state.identity;
|
||
}
|
||
|
||
function waitForNostrTools() {
|
||
if (window.NostrTools) return Promise.resolve();
|
||
return new Promise((resolve) => {
|
||
let tries = 0;
|
||
const timer = setInterval(() => {
|
||
if (window.NostrTools || tries++ > 40) {
|
||
clearInterval(timer);
|
||
if (!window.NostrTools) setStatus("identity", "nostr-tools 加载失败");
|
||
resolve();
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
function subscribeNostr() {
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
state.relay.subscribeStarted = true;
|
||
state.relay.subscribeDone = 0;
|
||
state.relay.subscribeFinished = {};
|
||
setStatus("nostr", "连接中");
|
||
relays.forEach((relay, index) => {
|
||
try {
|
||
setRelayStatus(relay, "连接中");
|
||
const ws = new WebSocket(relay);
|
||
const subId = "fongmi_pref_" + index + "_" + Date.now();
|
||
let backfillStarted = false;
|
||
let seenEvents = 0;
|
||
let eventQueue = [];
|
||
let flushTimer = 0;
|
||
const flushQueue = () => {
|
||
clearTimeout(flushTimer);
|
||
flushTimer = 0;
|
||
const batch = eventQueue;
|
||
eventQueue = [];
|
||
if (!batch.length) return;
|
||
hotIngestEvents(batch).then((changed) => {
|
||
if (!changed) return;
|
||
scheduleHotRefresh(HOT_REFRESH_IDLE_MS);
|
||
});
|
||
};
|
||
const startBackfill = () => {
|
||
if (backfillStarted) return;
|
||
backfillStarted = true;
|
||
state.relay.backfillCandidates[relay] = Math.max(Number(state.relay.backfillCandidates[relay] || 0), seenEvents);
|
||
choosePrimaryBackfillRelay(false);
|
||
syncRelayBackfill(relay).catch(() => {});
|
||
};
|
||
const timer = setTimeout(() => {
|
||
if (state.relay.statuses[relay] === "已连接") startBackfill();
|
||
finishRelaySubscribe(relay, state.relay.statuses[relay] === "已连接" ? "" : "失败");
|
||
try { ws.close(); } catch (e) {}
|
||
}, 8000);
|
||
ws.onopen = () => {
|
||
state.relay.connected += 1;
|
||
setRelayStatus(relay, "已连接");
|
||
setStatus("nostr", "已连接");
|
||
renderMetrics();
|
||
ws.send(JSON.stringify(["REQ", subId, {
|
||
kinds: [window.WEBHOME_CONFIG.nostr.kind],
|
||
"#t": [window.WEBHOME_CONFIG.nostr.tag],
|
||
"#d": [HOT_VECTOR_D],
|
||
since: hotBackupWindowStart(),
|
||
limit: HOT_SUBSCRIBE_LIMIT
|
||
}]));
|
||
};
|
||
ws.onmessage = async (message) => {
|
||
const data = safeJson(message.data, []);
|
||
if (data[0] === "EOSE") {
|
||
clearTimeout(timer);
|
||
flushQueue();
|
||
finishRelaySubscribe(relay, "");
|
||
startBackfill();
|
||
try {
|
||
ws.send(JSON.stringify(["CLOSE", subId]));
|
||
ws.close();
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
if (data[0] !== "EVENT" || !data[2]) return;
|
||
seenEvents += 1;
|
||
eventQueue.push(data[2]);
|
||
if (!flushTimer) flushTimer = setTimeout(flushQueue, 100);
|
||
};
|
||
ws.onclose = () => {
|
||
clearTimeout(timer);
|
||
flushQueue();
|
||
if (state.relay.statuses[relay] !== "已连接") finishRelaySubscribe(relay, "断开");
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
flushQueue();
|
||
finishRelaySubscribe(relay, "失败");
|
||
if (!Object.values(state.relay.statuses).includes("已连接")) setStatus("nostr", "连接失败");
|
||
};
|
||
} catch (e) {
|
||
finishRelaySubscribe(relay, "失败");
|
||
}
|
||
});
|
||
}
|
||
|
||
function queryRelayHotPage(relay, filter, timeout) {
|
||
return new Promise((resolve) => {
|
||
const events = [];
|
||
let settled = false;
|
||
const done = (complete) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
events.complete = !!complete;
|
||
resolve(events);
|
||
};
|
||
try {
|
||
const ws = new WebSocket(relay);
|
||
const subId = "fongmi_hot_" + Date.now() + "_" + Math.random().toString(16).slice(2);
|
||
const timer = setTimeout(() => {
|
||
try { ws.close(); } catch (e) {}
|
||
done(false);
|
||
}, timeout || 9000);
|
||
ws.onopen = () => ws.send(JSON.stringify(["REQ", subId, filter]));
|
||
ws.onmessage = (message) => {
|
||
const data = safeJson(message.data, []);
|
||
if (data[0] === "EVENT" && data[2]) events.push(data[2]);
|
||
if (data[0] === "EOSE") {
|
||
clearTimeout(timer);
|
||
try {
|
||
ws.send(JSON.stringify(["CLOSE", subId]));
|
||
ws.close();
|
||
} catch (e) {}
|
||
done(true);
|
||
}
|
||
};
|
||
ws.onclose = () => {
|
||
clearTimeout(timer);
|
||
done(false);
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
done(false);
|
||
};
|
||
} catch (e) {
|
||
done(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function syncRelayBackfill(relay) {
|
||
if (state.relay.backfillBusy[relay]) return;
|
||
state.relay.backfillBusy[relay] = true;
|
||
let cursor = null;
|
||
try {
|
||
const db = await openHotDb();
|
||
if (!db) return;
|
||
const primary = selectPrimaryBackfillRelay(relay);
|
||
const isPrimary = primary === relay;
|
||
const since = isPrimary ? hotWindowStart() : hotBackupWindowStart();
|
||
const now = hotNow();
|
||
cursor = normalizeRelayCursor(await hotStoreGet("relayCursor", relay), relay, since, now);
|
||
if (cursor.nextRetryAt && cursor.nextRetryAt > now) return;
|
||
let changed = false;
|
||
changed = await syncRelayRecent(relay, cursor, since, now) || changed;
|
||
if (isPrimary && !cursor.recentUntil) changed = await syncRelayHistory(relay, cursor, since, now) || changed;
|
||
if (!isPrimary) {
|
||
cursor.historyDone = true;
|
||
cursor.historyUntil = since - 1;
|
||
}
|
||
cursor.updatedAt = hotNow();
|
||
cursor.primary = isPrimary;
|
||
const tx = db.transaction("relayCursor", "readwrite");
|
||
tx.objectStore("relayCursor").put(cursor);
|
||
await idbDone(tx).catch(() => {});
|
||
if (changed) {
|
||
scheduleHotRefresh(HOT_REFRESH_BACKFILL_MS);
|
||
setStatus("nostr", `已同步 ${shortRelay(relay)}${isPrimary ? " 主" : " 近7天"}`);
|
||
}
|
||
} finally {
|
||
delete state.relay.backfillBusy[relay];
|
||
if (cursor && !relayBackfillDone(cursor)) scheduleRelayBackfill(relay, relayBackfillDelay(cursor));
|
||
}
|
||
}
|
||
|
||
function selectPrimaryBackfillRelay(relay) {
|
||
const primary = choosePrimaryBackfillRelay(state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length);
|
||
if (primary && primary !== relay && !state.relay.backfillBusy[primary]) scheduleRelayBackfill(primary);
|
||
return primary;
|
||
}
|
||
|
||
function choosePrimaryBackfillRelay(force) {
|
||
if (state.relay.primaryBackfillRelay) return state.relay.primaryBackfillRelay;
|
||
if (!force) return "";
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
let best = "";
|
||
let bestScore = -1;
|
||
relays.forEach((relay) => {
|
||
const connected = state.relay.statuses[relay] === "已连接";
|
||
const score = Number(state.relay.backfillCandidates[relay] || 0);
|
||
if (!connected && !score) return;
|
||
if (score > bestScore) {
|
||
best = relay;
|
||
bestScore = score;
|
||
}
|
||
});
|
||
if (best) state.relay.primaryBackfillRelay = best;
|
||
return state.relay.primaryBackfillRelay;
|
||
}
|
||
|
||
function relayBackfillDone(cursor) {
|
||
return !cursor.recentUntil && cursor.historyDone;
|
||
}
|
||
|
||
function relayBackfillDelay(cursor) {
|
||
const nextRetryAt = Number(cursor && cursor.nextRetryAt || 0);
|
||
if (nextRetryAt > hotNow()) return Math.max(HOT_BACKFILL_IDLE_MS, (nextRetryAt - hotNow()) * 1000);
|
||
return HOT_BACKFILL_IDLE_MS;
|
||
}
|
||
|
||
function markRelayBackfillRetry(cursor) {
|
||
cursor.failures = Math.min(12, Number(cursor.failures || 0) + 1);
|
||
const delay = Math.min(HOT_BACKFILL_RETRY_MAX_MS, HOT_BACKFILL_RETRY_MS * Math.pow(2, cursor.failures - 1));
|
||
cursor.nextRetryAt = hotNow() + Math.ceil(delay / 1000);
|
||
}
|
||
|
||
function clearRelayBackfillRetry(cursor) {
|
||
cursor.failures = 0;
|
||
cursor.nextRetryAt = 0;
|
||
}
|
||
|
||
function scheduleRelayBackfill(relay, delay) {
|
||
clearTimeout(state.relay.backfillTimers[relay]);
|
||
state.relay.backfillTimers[relay] = setTimeout(() => {
|
||
syncRelayBackfill(relay).catch(() => {});
|
||
}, Number.isFinite(delay) ? delay : HOT_BACKFILL_IDLE_MS);
|
||
}
|
||
|
||
function normalizeRelayCursor(row, relay, since, now) {
|
||
if (!row) {
|
||
return {
|
||
relay,
|
||
since,
|
||
newest: now,
|
||
recentHigh: 0,
|
||
recentUntil: 0,
|
||
recentTarget: 0,
|
||
historyUntil: now,
|
||
historyDone: false,
|
||
updatedAt: 0,
|
||
failures: 0,
|
||
nextRetryAt: 0
|
||
};
|
||
}
|
||
const cursor = row || {};
|
||
const previousSince = Number(cursor.since || 0);
|
||
const expandedWindow = previousSince > since;
|
||
const newest = Math.max(Number(cursor.newest || 0), since - 1);
|
||
let historyUntil = Number(cursor.historyUntil || cursor.until || 0);
|
||
if (expandedWindow) historyUntil = Math.max(since, previousSince - 1);
|
||
if (!historyUntil || historyUntil < since) historyUntil = Math.max(newest, now);
|
||
const historyDone = expandedWindow ? false : cursor.historyDone === true || cursor.done === true && historyUntil <= since;
|
||
return {
|
||
relay,
|
||
since,
|
||
newest,
|
||
recentHigh: Math.max(Number(cursor.recentHigh || 0), 0),
|
||
recentUntil: Math.max(Number(cursor.recentUntil || 0), 0),
|
||
recentTarget: Math.max(Number(cursor.recentTarget || 0), 0),
|
||
historyUntil,
|
||
historyDone,
|
||
updatedAt: Number(cursor.updatedAt || 0),
|
||
failures: Number(cursor.failures || 0),
|
||
nextRetryAt: Number(cursor.nextRetryAt || 0)
|
||
};
|
||
}
|
||
|
||
async function syncRelayRecent(relay, cursor, since, now) {
|
||
if (!cursor.recentUntil && now > cursor.newest) {
|
||
cursor.recentHigh = now;
|
||
cursor.recentUntil = now;
|
||
cursor.recentTarget = Math.max(since, Number(cursor.newest || 0) + 1);
|
||
}
|
||
if (!cursor.recentUntil) return false;
|
||
let changed = false;
|
||
for (let page = 0; page < HOT_RECENT_PAGES_PER_RELAY; page++) {
|
||
if (cursor.recentUntil < cursor.recentTarget) break;
|
||
const events = await queryRelayHotPage(relay, {
|
||
kinds: [window.WEBHOME_CONFIG.nostr.kind],
|
||
"#t": [window.WEBHOME_CONFIG.nostr.tag],
|
||
"#d": [HOT_VECTOR_D],
|
||
since: cursor.recentTarget,
|
||
until: cursor.recentUntil,
|
||
limit: HOT_PAGE_LIMIT
|
||
}, 9000);
|
||
if (!events.length) {
|
||
if (!events.complete) {
|
||
markRelayBackfillRetry(cursor);
|
||
break;
|
||
}
|
||
clearRelayBackfillRetry(cursor);
|
||
finishRelayRecent(cursor);
|
||
break;
|
||
}
|
||
clearRelayBackfillRetry(cursor);
|
||
let oldest = cursor.recentUntil;
|
||
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
|
||
if (await hotIngestEvents(events)) changed = true;
|
||
cursor.recentUntil = oldest - 1;
|
||
if (!events.complete) break;
|
||
if (events.length < HOT_PAGE_LIMIT || cursor.recentUntil < cursor.recentTarget) {
|
||
finishRelayRecent(cursor);
|
||
break;
|
||
}
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
function finishRelayRecent(cursor) {
|
||
const historyCeiling = Number(cursor.recentTarget || 0) - 1;
|
||
cursor.newest = Math.max(Number(cursor.newest || 0), Number(cursor.recentHigh || 0));
|
||
if (historyCeiling <= Number(cursor.since || 0)) {
|
||
cursor.historyDone = true;
|
||
cursor.historyUntil = Number(cursor.since || 0) - 1;
|
||
} else if (!cursor.historyDone && (!cursor.historyUntil || cursor.historyUntil > historyCeiling)) {
|
||
cursor.historyUntil = historyCeiling;
|
||
}
|
||
cursor.recentHigh = 0;
|
||
cursor.recentUntil = 0;
|
||
cursor.recentTarget = 0;
|
||
}
|
||
|
||
async function syncRelayHistory(relay, cursor, since, now) {
|
||
if (cursor.historyDone) return false;
|
||
if (!cursor.historyUntil || cursor.historyUntil < since) cursor.historyUntil = Math.max(cursor.newest || 0, now);
|
||
let changed = false;
|
||
for (let page = 0; page < HOT_HISTORY_PAGES_PER_RELAY; page++) {
|
||
if (cursor.historyUntil <= since) {
|
||
cursor.historyDone = true;
|
||
break;
|
||
}
|
||
const events = await queryRelayHotPage(relay, {
|
||
kinds: [window.WEBHOME_CONFIG.nostr.kind],
|
||
"#t": [window.WEBHOME_CONFIG.nostr.tag],
|
||
"#d": [HOT_VECTOR_D],
|
||
since,
|
||
until: cursor.historyUntil,
|
||
limit: HOT_PAGE_LIMIT
|
||
}, 9000);
|
||
if (!events.length) {
|
||
if (!events.complete) {
|
||
markRelayBackfillRetry(cursor);
|
||
break;
|
||
}
|
||
clearRelayBackfillRetry(cursor);
|
||
cursor.historyDone = true;
|
||
break;
|
||
}
|
||
clearRelayBackfillRetry(cursor);
|
||
let oldest = cursor.historyUntil;
|
||
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
|
||
if (await hotIngestEvents(events)) changed = true;
|
||
cursor.historyUntil = oldest - 1;
|
||
if (!events.complete) break;
|
||
cursor.historyDone = events.length < HOT_PAGE_LIMIT || cursor.historyUntil <= since;
|
||
if (cursor.historyDone) break;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
function publishEvent(event) {
|
||
if (!event || !event.sig) {
|
||
setStatus("publish", "未签名,未发布");
|
||
return;
|
||
}
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
state.relay.total = relays.length;
|
||
let done = 0;
|
||
let ok = 0;
|
||
state.relay.lastOk = 0;
|
||
state.relay.lastDone = 0;
|
||
setStatus("publish", `发布中 0/${relays.length}`);
|
||
relays.forEach((relay) => {
|
||
try {
|
||
const ws = new WebSocket(relay);
|
||
const finish = (success, text) => {
|
||
done += 1;
|
||
if (success) ok += 1;
|
||
state.relay.lastOk = ok;
|
||
state.relay.lastDone = done;
|
||
setStatus("publish", `发布 ${ok}/${done}/${relays.length}${text ? " · " + text : ""}`);
|
||
renderMetrics();
|
||
};
|
||
const timer = setTimeout(() => {
|
||
finish(false, shortRelay(relay) + " 超时");
|
||
try { ws.close(); } catch (e) {}
|
||
}, 6000);
|
||
ws.onopen = () => ws.send(JSON.stringify(["EVENT", stripLocal(event)]));
|
||
ws.onmessage = (message) => {
|
||
const data = safeJson(message.data, []);
|
||
if (data[0] === "OK") {
|
||
clearTimeout(timer);
|
||
if (data[2]) state.relay.published += 1;
|
||
finish(!!data[2], `${shortRelay(relay)} ${data[2] ? "OK" : data[3] || "拒绝"}`);
|
||
try { ws.close(); } catch (e) {}
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
finish(false, shortRelay(relay) + " 失败");
|
||
};
|
||
} catch (e) {
|
||
done += 1;
|
||
state.relay.lastOk = ok;
|
||
state.relay.lastDone = done;
|
||
setStatus("publish", `发布 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} 失败`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function eventAddress(event) {
|
||
const d = getD(event);
|
||
if (!event || !event.kind || !event.pubkey || !d) return "";
|
||
return `${event.kind}:${event.pubkey}:${d}`;
|
||
}
|
||
|
||
function createDeleteEvent(events, identity) {
|
||
const ids = Array.from(new Set(events.map((event) => event && event.id).filter(Boolean)));
|
||
const addresses = Array.from(new Set(events.map(eventAddress).filter(Boolean)));
|
||
const tags = ids.map((id) => ["e", id]).concat(addresses.map((addr) => ["a", addr]));
|
||
tags.push(["t", window.WEBHOME_CONFIG.nostr.tag], ["app", "fongmi-webhome"]);
|
||
const draft = {
|
||
kind: 5,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
tags,
|
||
content: "delete fongmi webhome preference events"
|
||
};
|
||
return window.NostrTools.finalizeEvent(draft, identity.secret);
|
||
}
|
||
|
||
function publishToRelay(relay, event, timeout) {
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
const done = (ok, text) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve({ ok, text });
|
||
};
|
||
try {
|
||
const ws = new WebSocket(relay);
|
||
const timer = setTimeout(() => {
|
||
try { ws.close(); } catch (e) {}
|
||
done(false, "超时");
|
||
}, timeout || 7000);
|
||
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
|
||
ws.onmessage = (message) => {
|
||
const data = safeJson(message.data, []);
|
||
if (data[0] !== "OK") return;
|
||
clearTimeout(timer);
|
||
try { ws.close(); } catch (e) {}
|
||
done(!!data[2], data[2] ? "OK" : data[3] || "拒绝");
|
||
};
|
||
ws.onerror = () => {
|
||
clearTimeout(timer);
|
||
done(false, "失败");
|
||
};
|
||
} catch (e) {
|
||
done(false, e.message || "失败");
|
||
}
|
||
});
|
||
}
|
||
|
||
async function queryRelayEventsPaged(relay, pubkey, since) {
|
||
const events = [];
|
||
let until = Math.floor(Date.now() / 1000);
|
||
for (let page = 0; page < 20; page++) {
|
||
const batch = await queryRelayHotPage(relay, {
|
||
kinds: [window.WEBHOME_CONFIG.nostr.kind],
|
||
authors: [pubkey],
|
||
"#t": [window.WEBHOME_CONFIG.nostr.tag],
|
||
"#d": [HOT_VECTOR_D],
|
||
since,
|
||
until,
|
||
limit: HOT_PAGE_LIMIT
|
||
}, 9000);
|
||
if (!batch.length) break;
|
||
events.push(...batch);
|
||
until = Math.min(...batch.map((event) => Number(event.created_at || until))) - 1;
|
||
if (batch.length < HOT_PAGE_LIMIT) break;
|
||
}
|
||
return events;
|
||
}
|
||
|
||
async function clearLocalPreferenceData() {
|
||
await clearLocalEvents(false);
|
||
setStatus("publish", "已清理本机缓存");
|
||
toast("本机偏好缓存已清理");
|
||
}
|
||
|
||
async function clearMyNostrData(identity) {
|
||
identity = identity || await ensureIdentity();
|
||
if (!identity || !identity.pubkey || !identity.secret) return toast("请先同步或生成身份");
|
||
setStatus("publish", "查询我的事件");
|
||
const since = hotWindowStart();
|
||
const relays = window.WEBHOME_CONFIG.nostr.relays;
|
||
const found = [];
|
||
for (const relay of relays) found.push(...await queryRelayEventsPaged(relay, identity.pubkey, since));
|
||
const events = Array.from(new Map(found.filter((event) => event && event.id).map((event) => [event.id, event])).values());
|
||
if (!events.length) {
|
||
await clearLocalEvents(true);
|
||
setStatus("publish", "没有找到我的远端事件");
|
||
toast("没有找到需要删除的数据");
|
||
return;
|
||
}
|
||
const deleteEvent = createDeleteEvent(events, identity);
|
||
let ok = 0;
|
||
let done = 0;
|
||
setStatus("publish", `删除中 0/${relays.length}`);
|
||
for (const relay of relays) {
|
||
const result = await publishToRelay(relay, deleteEvent, 7000);
|
||
done += 1;
|
||
if (result.ok) ok += 1;
|
||
setStatus("publish", `删除 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} ${result.text || ""}`);
|
||
}
|
||
await clearLocalEvents(true);
|
||
toast(`删除请求已发送 ${ok}/${relays.length}`);
|
||
}
|
||
|
||
async function deleteAllPreferenceData() {
|
||
const identity = await ensureIdentity();
|
||
await markIdentityDeletedLocally(identity);
|
||
stopWatchTracking(false);
|
||
await clearPendingWatch();
|
||
await clearLocalPreferenceData();
|
||
await clearMyNostrData(identity);
|
||
}
|
||
|
||
function stripLocal(event) {
|
||
const copy = Object.assign({}, event);
|
||
delete copy.local;
|
||
return copy;
|
||
}
|
||
|
||
function getD(event) {
|
||
const tag = (event && event.tags || []).find((item) => item && item[0] === "d");
|
||
return tag && tag[1];
|
||
}
|
||
|
||
function eventContent(event) {
|
||
const content = event && event.content;
|
||
if (content && typeof content === "object") return content;
|
||
return safeJson(content, null);
|
||
}
|
||
|
||
function bindActions() {
|
||
$("syncBtn").addEventListener("click", openSync);
|
||
// 影视简介折叠
|
||
$("detailOverviewToggle").addEventListener("click", () => {
|
||
const wrap = $("detailOverviewWrap");
|
||
const btn = $("detailOverviewToggle");
|
||
const open = wrap.classList.toggle("open");
|
||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||
});
|
||
$("closeSyncBtn").addEventListener("click", () => closeSync(false));
|
||
$("saveNsecBtn").addEventListener("click", async () => {
|
||
try {
|
||
await setIdentity($("nsecInput").value);
|
||
toast("同步身份已导入");
|
||
} catch (e) {
|
||
toast(e.message || "导入失败");
|
||
}
|
||
});
|
||
$("newNsecBtn").addEventListener("click", async () => {
|
||
await waitForNostrTools();
|
||
if (!window.NostrTools) return toast("Nostr 工具未加载");
|
||
const nsec = window.NostrTools.nip19.nsecEncode(window.NostrTools.generateSecretKey());
|
||
await setIdentity(nsec);
|
||
$("nsecInput").value = nsec;
|
||
toast("已生成新身份");
|
||
});
|
||
$("connectionToggle").addEventListener("click", toggleConnectionPanel);
|
||
$("connectionToggle").addEventListener("keydown", (event) => {
|
||
const key = normalizeRemoteKey(event);
|
||
if (key !== "Enter" && event.key !== " ") return;
|
||
toggleConnectionPanel(event);
|
||
});
|
||
$("deleteDataBtn").addEventListener("click", () => {
|
||
closeConnectionPanel();
|
||
setStatus("publish", "后台删除数据中");
|
||
toast("已开始后台删除数据");
|
||
deleteAllPreferenceData().catch((e) => toast(e.message || "删除失败"));
|
||
});
|
||
$("savePanConfigBtn").addEventListener("click", () => {
|
||
savePanConfig().catch((e) => toast(e.message || "保存失败"));
|
||
});
|
||
const panConfig = document.querySelector(".pan-config");
|
||
if (panConfig) {
|
||
panConfig.addEventListener("input", markPanConfigDirty);
|
||
panConfig.addEventListener("change", markPanConfigDirty);
|
||
}
|
||
$("closeImageBtn").addEventListener("click", () => closeImage(false));
|
||
$("imageViewer").addEventListener("click", (event) => {
|
||
if (event.target === $("imageViewer")) closeImage(false);
|
||
});
|
||
bindDetailCoverSwipe();
|
||
bindDetailPullToClose();
|
||
|
||
$("panTabs").addEventListener("click", handlePanTabEvent);
|
||
$("panTabs").addEventListener("touchend", handlePanTabEvent);
|
||
$("panTabs").addEventListener("keydown", (event) => {
|
||
const key = normalizeRemoteKey(event);
|
||
if (key !== "Enter" && event.key !== " ") return;
|
||
handlePanTabEvent(event);
|
||
});
|
||
$("panTabs").addEventListener("keyup", (event) => {
|
||
const key = normalizeRemoteKey(event);
|
||
if (key !== "Enter" && event.key !== " ") return;
|
||
handlePanTabEvent(event);
|
||
});
|
||
$("searchInput").addEventListener("input", scheduleSearchSuggest);
|
||
if ($("searchFocusMask")) {
|
||
$("searchFocusMask").addEventListener("touchmove", (event) => event.preventDefault(), { passive: false });
|
||
$("searchFocusMask").addEventListener("wheel", (event) => event.preventDefault(), { passive: false });
|
||
}
|
||
$("searchInput").addEventListener("pointerdown", enableSearchEditing);
|
||
$("searchInput").addEventListener("mousedown", enableSearchEditing);
|
||
$("searchInput").addEventListener("touchstart", enableSearchEditing, { passive: true });
|
||
$("searchInput").addEventListener("focus", () => {
|
||
if ($("searchInput").readOnly) { hideSearchSuggest(); return; }
|
||
document.documentElement.classList.add("search-focused");
|
||
// 聚焦即压入统一的 #search 历史层;结果出现时复用同一层(searchTmdb 不再额外压栈)。
|
||
// 这样无论处于「聚焦态」还是「结果页」,一次返回都能整体关闭搜索、回到主页且保持全屏,
|
||
// 不会因为多压了一层而出现「返回先消费聚焦层、结果仍留在屏幕上」的错位。
|
||
if (location.hash !== "#search" && location.hash !== "#detail") {
|
||
history.pushState({ sheet: "search" }, "", "#search");
|
||
_searchHistoryPushed = true;
|
||
}
|
||
// 输入框为空时展示热搜词
|
||
const val = ($("searchInput").value || "").trim();
|
||
if (!val) showHotSuggest();
|
||
});
|
||
$("searchInput").addEventListener("blur", () => {
|
||
disableSearchEditing();
|
||
// 延迟移除,避免点联想项时 blur 先触发导致联想面板消失
|
||
setTimeout(() => {
|
||
if (document.activeElement && $("suggestPanel") && $("suggestPanel").contains(document.activeElement)) return;
|
||
if (document.activeElement === $("searchInput")) return;
|
||
document.documentElement.classList.remove("search-focused");
|
||
}, 120);
|
||
});
|
||
document.querySelectorAll("#connectionBody input, #connectionBody textarea").forEach((el) => {
|
||
el.addEventListener("pointerdown", () => enablePanelTextEditing(el));
|
||
el.addEventListener("mousedown", () => enablePanelTextEditing(el));
|
||
el.addEventListener("touchstart", () => enablePanelTextEditing(el), { passive: true });
|
||
el.addEventListener("blur", () => disablePanelTextEditing(el));
|
||
});
|
||
$("searchInput").addEventListener("keydown", (event) => {
|
||
if (document.activeElement !== $("searchInput")) return;
|
||
const key = normalizeRemoteKey(event);
|
||
if (key === "ArrowDown" && isSearchSuggestOpen()) {
|
||
const first = firstSearchSuggestItem();
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (first) focusSearchSuggestTarget(first);
|
||
return;
|
||
}
|
||
if (key === "ArrowDown" && $("searchInput").readOnly) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
focusInitialHomeNow();
|
||
return;
|
||
}
|
||
if (key === "Enter" && $("searchInput").readOnly) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
enableSearchEditing();
|
||
$("searchInput").focus();
|
||
return;
|
||
}
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!$("searchForm").contains(event.target)) {
|
||
const input = $("searchInput");
|
||
const hasValue = input && input.value.trim().length > 0;
|
||
// 点击搜索区域外:退出聚焦态
|
||
document.documentElement.classList.remove("search-focused");
|
||
if (isSearchSuggestOpen() || hasValue) {
|
||
clearSearchResults();
|
||
} else {
|
||
hideSearchSuggest();
|
||
}
|
||
}
|
||
// 点击状态面板外部空白处时自动关闭状态面板
|
||
const dock = $("connectionDock");
|
||
if (dock && dock.classList.contains("open")) {
|
||
const body = $("connectionBody");
|
||
const toggle = $("connectionToggle");
|
||
const clickedInside = (body && body.contains(event.target)) || (toggle && toggle.contains(event.target));
|
||
if (!clickedInside) closeConnectionPanel();
|
||
}
|
||
});
|
||
$("searchForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
submitSearchInput();
|
||
});
|
||
$("searchForm").querySelector("button[type='submit']").addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
submitSearchInput();
|
||
});
|
||
|
||
// 长按搜索图标 → 直接用搜索框关键词执行"搜索播放"(nativeSearch)
|
||
(function bindSearchBtnLongPress() {
|
||
const searchBtn = $("searchForm").querySelector("button[type='submit']");
|
||
if (!searchBtn) return;
|
||
let _lpTimer = 0;
|
||
let _lpFired = false;
|
||
function _lpStart(e) {
|
||
_lpFired = false;
|
||
clearTimeout(_lpTimer);
|
||
_lpTimer = setTimeout(function() {
|
||
_lpFired = true;
|
||
const kw = ($("searchInput") && $("searchInput").value.trim()) || (state.selected && state.selected.title) || "";
|
||
if (!kw) { toast("请先输入搜索词"); return; }
|
||
haptic("medium");
|
||
if (state.selected) {
|
||
rememberWatchIntent(state.selected, "search");
|
||
startWatchTracking(state.selected);
|
||
}
|
||
nativeSearch(kw);
|
||
}, 500);
|
||
}
|
||
function _lpCancel() {
|
||
clearTimeout(_lpTimer);
|
||
}
|
||
function _lpClickGuard(e) {
|
||
if (_lpFired) {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
_lpFired = false;
|
||
}
|
||
}
|
||
searchBtn.addEventListener("pointerdown", _lpStart);
|
||
searchBtn.addEventListener("touchstart", _lpStart, { passive: true });
|
||
searchBtn.addEventListener("pointerup", _lpCancel);
|
||
searchBtn.addEventListener("touchend", _lpCancel);
|
||
searchBtn.addEventListener("pointerleave", _lpCancel);
|
||
searchBtn.addEventListener("pointercancel", _lpCancel);
|
||
searchBtn.addEventListener("touchcancel", _lpCancel);
|
||
searchBtn.addEventListener("click", _lpClickGuard, true);
|
||
})();
|
||
|
||
$("closeDetailBtn").addEventListener("click", () => {
|
||
const sheet = $("detailSheet");
|
||
if (sheet && sheet.classList.contains("sheet-closing")) return;
|
||
closeDetail(false);
|
||
});
|
||
|
||
// ── 详情页滚动 → 轮播图渐进高斯模糊 ──
|
||
(function bindDetailScrollBlur() {
|
||
const sheet = $("detailSheet");
|
||
if (!sheet) return;
|
||
|
||
// blurStart/End 在首次滚动时根据 detail-spacer 高度动态计算
|
||
// 原理:detail-spacer = 42vh,是背景图的视觉占位高度
|
||
// - blurStart:spacer 的 55%,用户刚滑动,文字开始进入画面上半区
|
||
// - blurEnd: spacer 的 90%,文字已基本覆盖背景,完全模糊
|
||
// 返回(scrollTop 减小)时沿同一曲线还原清晰,完全对称跟手
|
||
let blurStart = -1;
|
||
let blurEnd = -1;
|
||
|
||
function measureBlurRange() {
|
||
const spacer = sheet.querySelector(".detail-spacer");
|
||
const spacerH = spacer ? spacer.offsetHeight : window.innerHeight * 0.42;
|
||
blurStart = Math.round(spacerH * 0.55); // 开始渐入模糊
|
||
blurEnd = Math.round(spacerH * 0.90); // 完全模糊
|
||
}
|
||
|
||
// MutationObserver:每次打开详情重置状态
|
||
const mo = new MutationObserver(() => {
|
||
if (sheet.classList.contains("active")) {
|
||
sheet.scrollTop = 0;
|
||
setHeroBlur(0, 0);
|
||
sheet.style.setProperty("--cover-blur-pct", "0");
|
||
delete sheet.dataset.blurLocked;
|
||
blurStart = blurEnd = -1; // 标记需要重新测量
|
||
} else if (!sheet.classList.contains("sheet-closing")) {
|
||
setHeroBlur(0, 0);
|
||
sheet.style.setProperty("--cover-blur-pct", "0");
|
||
delete sheet.dataset.blurLocked;
|
||
blurStart = blurEnd = -1;
|
||
}
|
||
});
|
||
mo.observe(sheet, { attributes: true, attributeFilter: ["class"] });
|
||
|
||
let rafId = 0;
|
||
let lastScrollTop = 0;
|
||
|
||
sheet.addEventListener("scroll", () => {
|
||
if (rafId) return;
|
||
// TV 端:完全跳过滚动时的 filter/blur 更新(GPU 合成层代价高)
|
||
if (isTvMode()) return;
|
||
rafId = requestAnimationFrame(() => {
|
||
rafId = 0;
|
||
|
||
// 首次滚动时测量(布局已稳定)
|
||
if (blurStart < 0) measureBlurRange();
|
||
|
||
const scrollTop = sheet.scrollTop || 0;
|
||
|
||
// panSearch 锁定:保持当前模糊值,直到用户滚回 blurStart 以上才解锁
|
||
if (sheet.dataset.blurLocked === "1") {
|
||
if (scrollTop < lastScrollTop && scrollTop < blurStart) {
|
||
delete sheet.dataset.blurLocked;
|
||
setHeroBlur(0, 0);
|
||
sheet.style.setProperty("--cover-blur-pct", "0");
|
||
} else {
|
||
const lockedPx = parseFloat(sheet.dataset.blurLockedPx || "0");
|
||
const lockedPct = parseFloat(sheet.dataset.blurLockedPct || "1");
|
||
setHeroBlur(lockedPx, lockedPct);
|
||
sheet.style.setProperty("--cover-blur-pct", String(lockedPct));
|
||
}
|
||
lastScrollTop = scrollTop;
|
||
return;
|
||
}
|
||
|
||
lastScrollTop = scrollTop;
|
||
|
||
// 线性插值:blurStart→blurEnd 之间 0→1,双向对称
|
||
const range = Math.max(1, blurEnd - blurStart);
|
||
const pct = Math.min(1, Math.max(0, (scrollTop - blurStart) / range));
|
||
// 用 easeInOut 曲线让过渡更自然(在端点附近更平滑)
|
||
const ease = pct < 0.5 ? 2 * pct * pct : 1 - Math.pow(-2 * pct + 2, 2) / 2;
|
||
const blurPx = parseFloat((ease * 20).toFixed(1)); // 最大 20px 模糊
|
||
|
||
setHeroBlur(blurPx, ease);
|
||
sheet.style.setProperty("--cover-blur-pct", ease.toFixed(3));
|
||
});
|
||
}, { passive: true });
|
||
})();
|
||
$("detailContinueBtn").addEventListener("click", () => {
|
||
openDetailContinueHistory();
|
||
});
|
||
$("detailSearchBtn").addEventListener("click", () => {
|
||
if (!state.selected) return;
|
||
haptic("light");
|
||
rememberWatchIntent(state.selected, "search");
|
||
startWatchTracking(state.selected);
|
||
nativeSearch(state.selected.title);
|
||
});
|
||
$("panSearchBtn").addEventListener("click", () => {
|
||
if (!state.selected) return;
|
||
const sheet = $("detailSheet");
|
||
if (sheet) {
|
||
// 立即压暗背景(不等滚动),锁定在最暗
|
||
sheet.dataset.blurLocked = "1";
|
||
sheet.dataset.blurLockedPx = "20";
|
||
sheet.dataset.blurLockedPct = "1";
|
||
setHeroBlur(20, 1);
|
||
sheet.style.setProperty("--cover-blur-pct", "1");
|
||
}
|
||
searchPanResources(state.selected);
|
||
});
|
||
$("backTopBtn").addEventListener("click", () => {
|
||
scrollHomeToTop();
|
||
});
|
||
}
|
||
|
||
// ── 详情页下拉关闭手势(已禁用)──
|
||
function bindDetailPullToClose() {
|
||
// 下拉关闭手势已禁用,避免误触
|
||
}
|
||
|
||
function bindDetailCoverSwipe() {
|
||
const cover = $("detailImage") && $("detailImage").parentElement;
|
||
if (!cover) return;
|
||
cover.addEventListener("touchstart", (event) => {
|
||
if (document.documentElement.classList.contains("tv-mode") || state.detailCover.images.length <= 1) return;
|
||
const touch = event.touches && event.touches[0];
|
||
if (!touch) return;
|
||
state.detailCover.swipe = { x: touch.clientX, y: touch.clientY, at: Date.now(), moved: false };
|
||
}, { passive: true });
|
||
cover.addEventListener("touchmove", (event) => {
|
||
const swipe = state.detailCover.swipe;
|
||
const touch = event.touches && event.touches[0];
|
||
if (!swipe || !touch) return;
|
||
const dx = touch.clientX - swipe.x;
|
||
const dy = touch.clientY - swipe.y;
|
||
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.25) {
|
||
swipe.moved = true;
|
||
event.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
cover.addEventListener("touchend", (event) => {
|
||
const swipe = state.detailCover.swipe;
|
||
state.detailCover.swipe = null;
|
||
if (!swipe || !swipe.moved || state.detailCover.images.length <= 1) return;
|
||
const touch = event.changedTouches && event.changedTouches[0];
|
||
if (!touch) return;
|
||
const dx = touch.clientX - swipe.x;
|
||
const dy = touch.clientY - swipe.y;
|
||
if (Math.abs(dx) < 42 || Math.abs(dx) < Math.abs(dy) * 1.25 || Date.now() - swipe.at > 900) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
shiftDetailCover(dx < 0 ? 1 : -1);
|
||
}, { passive: false });
|
||
cover.addEventListener("touchcancel", () => {
|
||
state.detailCover.swipe = null;
|
||
}, { passive: true });
|
||
}
|
||
|
||
function scrollHomeToTop() {
|
||
try {
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
} catch (e) {
|
||
window.scrollTo(0, 0);
|
||
document.documentElement.scrollTop = 0;
|
||
document.body.scrollTop = 0;
|
||
}
|
||
setTimeout(updateBackTopButton, 260);
|
||
}
|
||
|
||
function installRemoteKeys() {
|
||
document.addEventListener("keydown", (event) => {
|
||
const key = normalizeRemoteKey(event);
|
||
if (!key) return;
|
||
const el = document.activeElement;
|
||
const editing = isTextEditingElement(el);
|
||
if (key !== "Enter" && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key) && ensureRemoteActiveFocus()) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
if (key === "Enter") {
|
||
if (handleConnectionPanelEnterKey(event)) return;
|
||
if (el === $("searchInput") && el.readOnly) {
|
||
event.preventDefault();
|
||
enableSearchEditing();
|
||
el.focus();
|
||
return;
|
||
}
|
||
if (activateFocusedElement(el, event)) return;
|
||
return;
|
||
}
|
||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) {
|
||
if (handleSearchSuggestDirectionalKey(key, event)) return;
|
||
if (handleSearchFormDirectionalKey(key, event)) return;
|
||
if (handleConnectionPanelDirectionalKey(key, event)) return;
|
||
}
|
||
if ((key === "Escape" || key === "Backspace") && handleConnectionPanelBackKey(event)) return;
|
||
if (editing) {
|
||
if (el === $("searchInput") && el.readOnly && key === "ArrowDown") {
|
||
event.preventDefault();
|
||
el.blur();
|
||
focusInitialHomeNow();
|
||
return;
|
||
}
|
||
if (key === "Escape") {
|
||
event.preventDefault();
|
||
el.blur();
|
||
}
|
||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) {
|
||
const target = nearestFocusable(key, el);
|
||
if (!target) return;
|
||
event.preventDefault();
|
||
el.blur();
|
||
focusRemoteTarget(target);
|
||
}
|
||
return;
|
||
}
|
||
if (key === "Escape" || key === "Backspace") {
|
||
if (handleSearchSuggestBackKey(event)) return;
|
||
if (handleConnectionPanelBackKey(event)) return;
|
||
if (handlePanBackKey(event)) return;
|
||
// Backspace 时,若 searchInput 正在编辑中,放行让浏览器正常删字
|
||
const _searchEl = $("searchInput");
|
||
if (key === "Backspace" && _searchEl && document.activeElement === _searchEl && !_searchEl.readOnly) return;
|
||
if (handleSearchBackKey(event)) return;
|
||
event.preventDefault();
|
||
// 二级页面:只关闭当前层,不退出 App
|
||
if ($("imageViewer").classList.contains("active")) return history.back();
|
||
if ($("detailSheet").classList.contains("active")) return history.back();
|
||
if ($("syncSheet").classList.contains("active")) return history.back();
|
||
// 主页面:才允许调用 App 原生返回(可能退出)
|
||
if (sdk().back) return sdk().back();
|
||
return;
|
||
}
|
||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
|
||
if (handleScrollableOverlayKey(key, event)) return;
|
||
if (handlePanDirectionalKey(key, event)) return;
|
||
if (handleDetailDirectionalKey(key, event)) return;
|
||
// TV 快速路径:焦点在 .media-grid 内的 card 时,用 data-card-index 直接计算目标,跳过全局几何搜索
|
||
if (isTvMode() && tryGridCardFastNav(key, event)) return;
|
||
const target = nearestFocusable(key);
|
||
if (target) {
|
||
event.preventDefault();
|
||
if (!isPanFocusTarget(target)) state.pan.focusMode = "";
|
||
focusRemoteTarget(target);
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
const key = normalizeRemoteKey(event);
|
||
if (key !== "Escape" && key !== "Backspace") return;
|
||
if (handleSearchSuggestBackKey(event)) return;
|
||
if (handleConnectionPanelBackKey(event)) return;
|
||
if (handlePanBackKey(event)) return;
|
||
// Backspace 时,若 searchInput 正在编辑中,放行让浏览器正常删字
|
||
const searchEl = $("searchInput");
|
||
if (key === "Backspace" && searchEl && document.activeElement === searchEl && !searchEl.readOnly) return;
|
||
handleSearchBackKey(event);
|
||
}, true);
|
||
|
||
// ── 手机端主页面左右滑动切换 chip(类别)──
|
||
(function installHomeSwipe() {
|
||
var _t = null;
|
||
var HORIZ_THRESHOLD = 52;
|
||
var RATIO_THRESHOLD = 1.6;
|
||
var TIME_LIMIT = 480;
|
||
|
||
function isLayerOpen() {
|
||
return (
|
||
($("detailSheet") && $("detailSheet").classList.contains("active")) ||
|
||
($("imageViewer") && $("imageViewer").classList.contains("active")) ||
|
||
($("syncSheet") && $("syncSheet").classList.contains("active")) ||
|
||
document.documentElement.classList.contains("search-active")
|
||
);
|
||
}
|
||
|
||
function isInHorizScrollable(el) {
|
||
while (el && el !== document.body) {
|
||
var style = window.getComputedStyle(el);
|
||
var ox = style.overflowX;
|
||
if ((ox === "auto" || ox === "scroll") && el.scrollWidth > el.clientWidth + 4) return true;
|
||
el = el.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function getChipIds() {
|
||
return Array.from($("chips").querySelectorAll("[data-chip-id]")).map(function(b) { return b.dataset.chipId; });
|
||
}
|
||
|
||
function switchChipByDelta(delta) {
|
||
var ids = getChipIds();
|
||
if (!ids.length) return;
|
||
var cur = ids.indexOf(state.activeList);
|
||
var next = cur + delta;
|
||
if (next < 0 || next >= ids.length) return;
|
||
// 先 blur 消除触摸残留的 :focus 背景
|
||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||
selectChip(ids[next]);
|
||
requestAnimationFrame(function() {
|
||
var btn = $("chips").querySelector("[data-chip-id='" + ids[next] + "']");
|
||
if (btn) btn.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||
});
|
||
}
|
||
|
||
document.addEventListener("touchstart", function(e) {
|
||
if (isTvMode()) return;
|
||
if (isLayerOpen()) return;
|
||
var touch = e.touches[0];
|
||
if (!touch) return;
|
||
if (isInHorizScrollable(e.target)) return;
|
||
_t = { x: touch.clientX, y: touch.clientY, at: Date.now() };
|
||
}, { passive: true });
|
||
|
||
document.addEventListener("touchend", function(e) {
|
||
if (!_t || isTvMode()) { _t = null; return; }
|
||
if (isLayerOpen()) { _t = null; return; }
|
||
var touch = e.changedTouches[0];
|
||
if (!touch) { _t = null; return; }
|
||
var dx = touch.clientX - _t.x;
|
||
var dy = touch.clientY - _t.y;
|
||
var dt = Date.now() - _t.at;
|
||
_t = null;
|
||
if (dt > TIME_LIMIT) return;
|
||
if (Math.abs(dx) < HORIZ_THRESHOLD) return;
|
||
if (Math.abs(dx) < Math.abs(dy) * RATIO_THRESHOLD) return;
|
||
switchChipByDelta(dx < 0 ? 1 : -1);
|
||
}, { passive: true });
|
||
})();
|
||
}
|
||
|
||
function activateFocusedElement(el, event) {
|
||
if (!isVisibleFocusable(el)) return false;
|
||
if (isTextEditingElement(el)) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const panType = getPanTypeFromElement(el);
|
||
if (panType) {
|
||
selectPanType(panType);
|
||
return true;
|
||
}
|
||
if (typeof el.click === "function") {
|
||
el.click();
|
||
} else {
|
||
const click = document.createEvent("MouseEvents");
|
||
click.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
|
||
el.dispatchEvent(click);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isTextEditingElement(el) {
|
||
if (!el) return false;
|
||
if (el.tagName === "TEXTAREA" || el.isContentEditable) return true;
|
||
if (el.tagName !== "INPUT") return false;
|
||
const type = String(el.getAttribute("type") || "text").toLowerCase();
|
||
return !["button", "checkbox", "radio", "submit", "reset", "range", "color"].includes(type);
|
||
}
|
||
|
||
function isPanelTextField(el) {
|
||
return !!(el && $("connectionBody") && $("connectionBody").contains(el) && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" && !["button", "checkbox", "radio", "submit", "reset", "range", "color"].includes(String(el.getAttribute("type") || "text").toLowerCase())));
|
||
}
|
||
|
||
function enablePanelTextEditing(el) {
|
||
if (!isPanelTextField(el)) return false;
|
||
el.readOnly = false;
|
||
el.classList.add("panel-editing");
|
||
try {
|
||
el.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
el.focus();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function disablePanelTextEditing(el) {
|
||
if (!isPanelTextField(el)) return false;
|
||
el.readOnly = true;
|
||
el.classList.remove("panel-editing");
|
||
return true;
|
||
}
|
||
|
||
function disableConnectionTextEditing() {
|
||
const body = $("connectionBody");
|
||
if (!body) return;
|
||
body.querySelectorAll("input,textarea").forEach((el) => {
|
||
if (isPanelTextField(el)) disablePanelTextEditing(el);
|
||
});
|
||
}
|
||
|
||
function handleConnectionPanelEnterKey(event) {
|
||
if (!isConnectionPanelOpen()) return false;
|
||
const active = document.activeElement;
|
||
if (!active || !$("connectionDock") || !$("connectionDock").contains(active)) return false;
|
||
if (active === $("connectionToggle")) return false;
|
||
if (active.matches && active.matches("input[type='checkbox']")) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
active.click();
|
||
markPanConfigDirty();
|
||
return true;
|
||
}
|
||
if (isPanelTextField(active)) {
|
||
if (active.readOnly) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
enablePanelTextEditing(active);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function handleScrollableOverlayKey(key, event) {
|
||
if (!$("imageViewer") || !$("imageViewer").classList.contains("active")) return false;
|
||
const content = $("imageContent");
|
||
if (!content || key !== "ArrowUp" && key !== "ArrowDown") return false;
|
||
const max = content.scrollHeight - content.clientHeight;
|
||
if (max <= 4) return false;
|
||
const delta = Math.max(80, Math.round(content.clientHeight * 0.72)) * (key === "ArrowDown" ? 1 : -1);
|
||
if (key === "ArrowUp" && content.scrollTop <= 0) {
|
||
event.preventDefault();
|
||
focusRemoteTarget($("closeImageBtn"));
|
||
return true;
|
||
}
|
||
if (key === "ArrowDown" && content.scrollTop >= max - 2) return false;
|
||
event.preventDefault();
|
||
content.scrollTop = Math.max(0, Math.min(max, content.scrollTop + delta));
|
||
if (!content.contains(document.activeElement)) focusRemoteTarget(content);
|
||
return true;
|
||
}
|
||
|
||
function handlePanBackKey(event) {
|
||
const tabs = $("panTabs");
|
||
const list = $("panResultList");
|
||
const active = document.activeElement;
|
||
const inResults = !!(tabs && list && active && list.contains(active));
|
||
if (!tabs || !list || !inResults && state.pan.focusMode !== "results") return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
focusPanTabFromResults();
|
||
scheduleUiSnapshotSave();
|
||
return true;
|
||
}
|
||
|
||
function handleSearchBackKey(event) {
|
||
if (!state.searchItems.length || !$("searchSection") || $("searchSection").style.display === "none") return false;
|
||
const active = document.activeElement;
|
||
const inSearch = active && ($("searchSection").contains(active) || $("searchForm").contains(active));
|
||
if (!inSearch) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
clearSearchResults();
|
||
requestAnimationFrame(() => focusInitialHomeNow());
|
||
return true;
|
||
}
|
||
|
||
function handleSearchSuggestBackKey(event) {
|
||
if (!isSearchSuggestOpen()) return false;
|
||
const active = document.activeElement;
|
||
const input = $("searchInput");
|
||
const panel = $("suggestPanel");
|
||
const form = $("searchForm");
|
||
const inSuggest = active && (active === input || panel && panel.contains(active) || form && form.contains(active));
|
||
if (!inSuggest) return false;
|
||
// 如果焦点在输入框且正在编辑(非 readOnly),Backspace 应删字而不是关闭 suggest
|
||
if (active === input && !input.readOnly && normalizeRemoteKey(event) === "Backspace") return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
hideSearchSuggest();
|
||
disableSearchEditing();
|
||
if (input) requestAnimationFrame(() => focusRemoteTarget(input));
|
||
return true;
|
||
}
|
||
|
||
function handleSearchSuggestDirectionalKey(key, event) {
|
||
if (!isSearchSuggestOpen() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
|
||
const input = $("searchInput");
|
||
const panel = $("suggestPanel");
|
||
const form = $("searchForm");
|
||
const active = document.activeElement;
|
||
if (!active || active !== input && !(panel && panel.contains(active)) && !(form && form.contains(active))) return false;
|
||
const items = searchSuggestItems();
|
||
let target = null;
|
||
if (active === input || form && form.contains(active) && !(panel && panel.contains(active))) {
|
||
if (key === "ArrowDown") target = items[0] || input;
|
||
else target = input;
|
||
} else if (panel && panel.contains(active)) {
|
||
const index = items.indexOf(active);
|
||
if (key === "ArrowDown") target = items[Math.min(items.length - 1, index + 1)] || active;
|
||
else if (key === "ArrowUp") target = index <= 0 ? input : items[index - 1];
|
||
else target = active;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (target) focusSearchSuggestTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function handleSearchFormDirectionalKey(key, event) {
|
||
if (!$("searchForm")) return false;
|
||
const input = $("searchInput");
|
||
const submit = $("searchForm").querySelector("button[type='submit']");
|
||
const active = document.activeElement;
|
||
let target = null;
|
||
if (active === submit && key === "ArrowLeft") target = input;
|
||
else if (active === input && key === "ArrowRight") target = submit;
|
||
else if (active === input && key === "ArrowDown") target = currentHomeFocus();
|
||
else if (active === submit && key === "ArrowDown") target = currentHomeFocus();
|
||
if (!target) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (target === input && input.readOnly) hideSearchSuggest();
|
||
focusRemoteTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function isSearchSuggestOpen() {
|
||
const panel = $("suggestPanel");
|
||
return !!(panel && panel.classList.contains("open") && panel.querySelector(".suggest-item"));
|
||
}
|
||
|
||
function searchSuggestItems() {
|
||
const panel = $("suggestPanel");
|
||
return panel ? Array.from(panel.querySelectorAll(".suggest-item")).filter(isVisibleFocusable) : [];
|
||
}
|
||
|
||
function firstSearchSuggestItem() {
|
||
return searchSuggestItems()[0] || null;
|
||
}
|
||
|
||
function focusSearchSuggestTarget(target) {
|
||
if (!target) return;
|
||
try {
|
||
target.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
target.focus();
|
||
}
|
||
keepSearchSuggestItemVisible(target);
|
||
}
|
||
|
||
function keepSearchSuggestItemVisible(target) {
|
||
const panel = $("suggestPanel");
|
||
if (!panel || !target || !panel.contains(target)) return;
|
||
const itemRect = target.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
const topDelta = itemRect.top - panelRect.top;
|
||
const bottomDelta = itemRect.bottom - panelRect.bottom;
|
||
if (topDelta < 0) panel.scrollTop += topDelta;
|
||
else if (bottomDelta > 0) panel.scrollTop += bottomDelta;
|
||
}
|
||
|
||
function handleConnectionPanelBackKey(event) {
|
||
if (!isConnectionPanelOpen()) return false;
|
||
const active = document.activeElement;
|
||
if (isPanelTextField(active) && !active.readOnly) {
|
||
if (normalizeRemoteKey(event) === "Backspace") return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
disablePanelTextEditing(active);
|
||
focusRemoteTarget(active);
|
||
return true;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
|
||
closeConnectionPanel();
|
||
const toggle = $("connectionToggle");
|
||
if (toggle) requestAnimationFrame(() => focusRemoteTarget(toggle));
|
||
return true;
|
||
}
|
||
|
||
function handleConnectionPanelDirectionalKey(key, event) {
|
||
if (!isConnectionPanelOpen() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
|
||
const dock = $("connectionDock");
|
||
const body = $("connectionBody");
|
||
const toggle = $("connectionToggle");
|
||
if (!dock || !body) return false;
|
||
const active = document.activeElement;
|
||
const bodyItems = connectionPanelItems();
|
||
const inDock = active && dock.contains(active);
|
||
let target = null;
|
||
if (!inDock || active === toggle) {
|
||
target = key === "ArrowDown" ? bodyItems[0] || toggle : toggle || bodyItems[0];
|
||
} else if (body.contains(active)) {
|
||
target = nearestFocusableFromList(key, active, bodyItems);
|
||
if (!target && key === "ArrowUp") target = toggle || bodyItems[0];
|
||
if (!target) target = active;
|
||
} else {
|
||
target = bodyItems[0] || toggle;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (target) focusRemoteTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function restoreConnectionPanelFocus() {
|
||
if (!isConnectionPanelOpen()) return false;
|
||
const dock = $("connectionDock");
|
||
const active = document.activeElement;
|
||
if (active && dock && dock.contains(active) && isVisibleFocusable(active)) return false;
|
||
const target = $("connectionToggle") || connectionPanelItems()[0];
|
||
if (!target) return false;
|
||
focusRemoteTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function isConnectionPanelOpen() {
|
||
const dock = $("connectionDock");
|
||
const body = $("connectionBody");
|
||
return !!(dock && body && dock.classList.contains("open") && body.classList.contains("open"));
|
||
}
|
||
|
||
function connectionPanelItems() {
|
||
const body = $("connectionBody");
|
||
return body ? Array.from(body.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable) : [];
|
||
}
|
||
|
||
function nearestFocusableFromList(key, current, list) {
|
||
const items = (list || []).filter((item) => item && item !== current && isVisibleFocusable(item));
|
||
if (!current || !items.length) return null;
|
||
const from = center(current.getBoundingClientRect());
|
||
const vertical = key === "ArrowUp" || key === "ArrowDown";
|
||
const forward = key === "ArrowRight" || key === "ArrowDown";
|
||
let best = null;
|
||
let bestScore = Infinity;
|
||
for (const el of items) {
|
||
const to = center(el.getBoundingClientRect());
|
||
const main = vertical ? to.y - from.y : to.x - from.x;
|
||
const cross = vertical ? Math.abs(to.x - from.x) : Math.abs(to.y - from.y);
|
||
if (forward ? main <= 4 : main >= -4) continue;
|
||
const score = Math.abs(main) * 1.25 + cross * 1.9;
|
||
if (score < bestScore) {
|
||
best = el;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function focusPanTabFromResults() {
|
||
const tabs = $("panTabs");
|
||
if (!tabs) return false;
|
||
const target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
|
||
if (!target) return false;
|
||
state.pan.focusMode = "tabs";
|
||
focusPanTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function handlePanDirectionalKey(key, event) {
|
||
const tabs = $("panTabs");
|
||
const list = $("panResultList");
|
||
const active = document.activeElement;
|
||
if (!tabs || !list || !active) return false;
|
||
const inTabs = tabs.contains(active);
|
||
const inResults = list.contains(active);
|
||
if (!inTabs && !inResults) {
|
||
if ((active === $("detailContinueBtn") || active === $("panSearchBtn") || active === $("detailSearchBtn")) && key === "ArrowDown" && isPanSearchActive()) {
|
||
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
|
||
if (target) {
|
||
event.preventDefault();
|
||
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
|
||
focusPanTarget(target, { keepBlockPosition: true });
|
||
return true;
|
||
}
|
||
}
|
||
if (isPanSearchActive() && $("detailSheet") && $("detailSheet").contains(active)) {
|
||
const panRect = $("panSearchBlock").getBoundingClientRect();
|
||
const activeRect = active.getBoundingClientRect ? active.getBoundingClientRect() : null;
|
||
if (key === "ArrowDown" && activeRect && activeRect.bottom <= panRect.bottom + 8) {
|
||
event.preventDefault();
|
||
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
|
||
if (target) {
|
||
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
|
||
focusPanTarget(target, { keepBlockPosition: true });
|
||
} else {
|
||
centerPanSearchBlock();
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
state.pan.focusMode = "";
|
||
return false;
|
||
}
|
||
let target = null;
|
||
if (inTabs && key === "ArrowDown") target = panResultByColumn(active) || list.querySelector(".pan-result-item");
|
||
else if (inResults && key === "ArrowUp" && isFirstVisiblePanResult(active)) target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
|
||
else if (inTabs && (key === "ArrowLeft" || key === "ArrowRight")) target = siblingFocusable(tabs, active, key === "ArrowRight" ? 1 : -1);
|
||
else if (inResults && (key === "ArrowUp" || key === "ArrowDown")) target = siblingFocusable(list, active, key === "ArrowDown" ? 1 : -1);
|
||
if (!target) {
|
||
if (inTabs && key === "ArrowDown" || inResults && key === "ArrowDown") {
|
||
event.preventDefault();
|
||
centerPanSearchBlock();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
event.preventDefault();
|
||
if (list.contains(target)) {
|
||
state.pan.focusKey = target.dataset.panKey || state.pan.focusKey;
|
||
state.pan.focusMode = "results";
|
||
focusPanTarget(target, { keepBlockPosition: true });
|
||
} else {
|
||
state.pan.focusMode = "tabs";
|
||
const panType = getPanTypeFromElement(target);
|
||
if (panType && panType !== state.pan.activeType) selectPanType(panType);
|
||
focusPanTarget(target, { keepBlockPosition: true });
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isPanFocusTarget(target) {
|
||
return !!(target && (($("panTabs") && $("panTabs").contains(target)) || ($("panResultList") && $("panResultList").contains(target))));
|
||
}
|
||
|
||
function panResultByColumn(tab) {
|
||
const list = $("panResultList");
|
||
const items = list ? Array.from(list.querySelectorAll(".pan-result-item")).filter(isVisibleFocusable) : [];
|
||
if (!items.length) return null;
|
||
const from = center(tab.getBoundingClientRect());
|
||
let best = null;
|
||
let bestScore = Infinity;
|
||
for (const item of items.slice(0, 5)) {
|
||
const rect = item.getBoundingClientRect();
|
||
const to = center(rect);
|
||
if (to.y < from.y - 4) continue;
|
||
const score = Math.abs(to.x - from.x) * 1.15 + Math.max(0, to.y - from.y) * .35;
|
||
if (score < bestScore) {
|
||
best = item;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best || items[0];
|
||
}
|
||
|
||
function handleDetailDirectionalKey(key, event) {
|
||
const sheet = $("detailSheet");
|
||
const active = document.activeElement;
|
||
if (!sheet || !sheet.classList.contains("active") || !active || !sheet.contains(active)) return false;
|
||
if (isPanSearchActive() && isPanFocusTarget(active)) return false;
|
||
const currentBlock = detailFocusBlock(active);
|
||
if (!currentBlock) return false;
|
||
let target = null;
|
||
if (key === "ArrowLeft" || key === "ArrowRight") target = detailHorizontalTarget(currentBlock, active, key === "ArrowRight" ? 1 : -1);
|
||
else target = detailVerticalTarget(currentBlock, active, key === "ArrowDown" ? 1 : -1);
|
||
if (!target) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
state.pan.focusMode = "";
|
||
focusRemoteTarget(target);
|
||
return true;
|
||
}
|
||
|
||
function detailFocusBlock(el) {
|
||
if (el === $("closeDetailBtn")) return { id: "closeDetailBtn", root: $("closeDetailBtn") };
|
||
if (el === $("detailContinueBtn") || el === $("detailSearchBtn") || el === $("panSearchBtn")) return { id: "detailActions", root: el.closest(".actions") };
|
||
const ids = ["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"];
|
||
for (const id of ids) {
|
||
const root = $(id);
|
||
if (root && root.contains(el)) return { id, root };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function detailBlockOrder() {
|
||
const order = [];
|
||
if (isVisibleFocusable($("closeDetailBtn"))) order.push({ id: "closeDetailBtn", root: $("closeDetailBtn") });
|
||
const info = $("detailText") && $("detailText").closest(".detail-info");
|
||
if (info && detailBlockFocusables(info).length) order.push({ id: "detailInfo", root: info });
|
||
const actions = $("detailSearchBtn") && $("detailSearchBtn").closest(".actions");
|
||
if (actions && detailBlockFocusables(actions).length) order.push({ id: "detailActions", root: actions });
|
||
["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => {
|
||
const root = $(id);
|
||
if (root && root.style.display !== "none" && root.getAttribute("aria-hidden") !== "true" && detailBlockFocusables(root).length) order.push({ id, root });
|
||
});
|
||
return order;
|
||
}
|
||
|
||
function detailVerticalTarget(currentBlock, active, delta) {
|
||
const seasonTarget = currentBlock.id === "seasonBlock" ? detailSeasonVerticalTarget(active, delta) : null;
|
||
if (seasonTarget) return seasonTarget;
|
||
const order = detailBlockOrder();
|
||
const index = order.findIndex((block) => block.id === currentBlock.id);
|
||
if (index < 0) return null;
|
||
const blocks = delta > 0 ? order.slice(index + 1) : order.slice(0, index).reverse();
|
||
for (const block of blocks) {
|
||
const target = detailClosestInBlock(block.root, active, delta);
|
||
if (target) return target;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function detailHorizontalTarget(currentBlock, active, delta) {
|
||
const row = active && active.closest && active.closest("#seasonTabs, #episodeRail, #castRail, #personWorkRail, #recommendWorkRail, #panTabs, .actions");
|
||
const items = detailBlockFocusables(row || currentBlock.root);
|
||
const index = items.indexOf(active);
|
||
if (index < 0) return null;
|
||
return items[index + delta] || null;
|
||
}
|
||
|
||
function detailSeasonVerticalTarget(active, delta) {
|
||
const tabs = $("seasonTabs");
|
||
const rail = $("episodeRail");
|
||
if (tabs && tabs.contains(active)) {
|
||
if (delta < 0) return null;
|
||
return detailClosestInBlock(rail, active, delta);
|
||
}
|
||
if (rail && rail.contains(active)) {
|
||
if (delta > 0) return null;
|
||
return detailClosestInBlock(tabs, active, delta);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function detailClosestInBlock(root, active, delta) {
|
||
const items = detailBlockFocusables(root);
|
||
if (!items.length) return null;
|
||
if (!active || !active.getBoundingClientRect) return delta > 0 ? items[0] : items[items.length - 1];
|
||
const from = center(active.getBoundingClientRect());
|
||
let best = null;
|
||
let bestScore = Infinity;
|
||
for (const item of items) {
|
||
const rect = item.getBoundingClientRect();
|
||
const to = center(rect);
|
||
const vertical = Math.max(0, delta > 0 ? rect.top - from.y : from.y - rect.bottom);
|
||
const cross = Math.abs(to.x - from.x);
|
||
const score = vertical * 1.1 + cross * 1.5;
|
||
if (score < bestScore) {
|
||
best = item;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best || (delta > 0 ? items[0] : items[items.length - 1]);
|
||
}
|
||
|
||
function detailBlockFocusables(root) {
|
||
if (!root) return [];
|
||
if (root.matches && root.matches(".focusable,button,input,textarea")) return isVisibleFocusable(root) ? [root] : [];
|
||
return Array.from(root.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable);
|
||
}
|
||
|
||
function isFirstVisiblePanResult(el) {
|
||
const list = $("panResultList");
|
||
if (!list || !list.contains(el)) return false;
|
||
return Array.from(list.querySelectorAll(".pan-result-item")).filter(isVisibleFocusable)[0] === el;
|
||
}
|
||
|
||
function siblingFocusable(root, current, delta) {
|
||
if (!root || !current) return null;
|
||
if (root.id === "panResultList" && current.classList && current.classList.contains("pan-result-item")) {
|
||
let target = delta > 0 ? current.nextElementSibling : current.previousElementSibling;
|
||
while (target) {
|
||
if (target.classList && target.classList.contains("pan-result-item") && isVisibleFocusable(target)) return target;
|
||
target = delta > 0 ? target.nextElementSibling : target.previousElementSibling;
|
||
}
|
||
return null;
|
||
}
|
||
const items = Array.from(root.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable);
|
||
const index = items.indexOf(current);
|
||
if (index < 0) return null;
|
||
return items[index + delta] || null;
|
||
}
|
||
|
||
function normalizeRemoteKey(event) {
|
||
if (["Enter", "Escape", "Backspace", "BrowserBack", "GoBack", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) return event.key === "BrowserBack" || event.key === "GoBack" ? "Escape" : event.key;
|
||
const map = { 13: "Enter", 23: "Enter", 66: "Enter", 4: "Escape", 8: "Backspace", 27: "Escape", 10009: "Escape", 461: "Escape", 19: "ArrowUp", 20: "ArrowDown", 21: "ArrowLeft", 22: "ArrowRight" };
|
||
return map[event.keyCode || event.which] || "";
|
||
}
|
||
|
||
var _keepFocusRaf = 0;
|
||
function focusRemoteTarget(target) {
|
||
if (!target) return;
|
||
try {
|
||
target.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
target.focus();
|
||
}
|
||
maybeAppendGridForFocus(target);
|
||
// TV 上 keepFocusInView 用 rAF 延迟执行,避免每次 keydown 同步强制回流
|
||
if (isTvMode() || isNativeLeanbackClient()) {
|
||
if (_keepFocusRaf) return; // 已有排队的 rAF,跳过叠加
|
||
_keepFocusRaf = requestAnimationFrame(function() {
|
||
_keepFocusRaf = 0;
|
||
keepFocusInView(document.activeElement);
|
||
});
|
||
} else {
|
||
keepFocusInView(target);
|
||
}
|
||
}
|
||
|
||
function keepFocusInView(target) {
|
||
if (!target) return;
|
||
// 详情页关闭动画中 / 详情激活时不干扰主页滚动位置
|
||
if (document.body.classList.contains("detail-active") || state.detailClosing) return;
|
||
const rect = target.getBoundingClientRect();
|
||
const height = window.innerHeight || document.documentElement.clientHeight || 0;
|
||
const width = window.innerWidth || document.documentElement.clientWidth || 0;
|
||
if (!height || !width) return;
|
||
// TV 端用更大边距(确保焦点不贴边),手机端用小边距
|
||
const margin = isTvMode() ? 80 : 54;
|
||
const hMargin = isTvMode() ? 60 : 8;
|
||
if (rect.top >= margin && rect.bottom <= height - margin &&
|
||
rect.left >= hMargin && rect.right <= width - hMargin) return;
|
||
// chips / sticky 元素在页面顶部,但用户已滚动下去时不强制跳回顶部
|
||
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
|
||
if (scrollY > 0 && rect.top < height * 0.3 && rect.bottom < height * 0.5) return;
|
||
try {
|
||
// TV 即时滚动更响应,手机平滑更舒适
|
||
const behavior = isTvMode() ? "instant" : "smooth";
|
||
target.scrollIntoView({ block: "nearest", inline: "nearest", behavior });
|
||
} catch (e) {
|
||
target.scrollIntoView(false);
|
||
}
|
||
}
|
||
|
||
function isVisibleFocusable(el) {
|
||
if (!el || !el.matches || !el.matches(".focusable,button,input,textarea")) return false;
|
||
if (el.disabled || el.getAttribute("tabindex") === "-1" || el.closest('[aria-hidden="true"]')) return false;
|
||
// Android 9 TV:getComputedStyle 极慢,改用 offsetParent + offsetWidth 快速判断
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return false;
|
||
if (el.offsetParent === null) {
|
||
// fixed/sticky 元素 offsetParent 为 null 但可能可见,补检 getClientRects
|
||
try { return el.getClientRects().length > 0; } catch(e) { return false; }
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function rememberFocusReturn() {
|
||
const active = document.activeElement;
|
||
if (isVisibleFocusable(active)) state.focusReturnEl = active;
|
||
}
|
||
|
||
function restoreFocusReturn(fallback) {
|
||
const target = isVisibleFocusable(state.focusReturnEl) ? state.focusReturnEl : fallback || firstContentFocus();
|
||
state.focusReturnEl = null;
|
||
if (target) requestAnimationFrame(() => focusRemoteTarget(target));
|
||
}
|
||
|
||
// TV 性能优化:每帧只计算一次可聚焦列表,避免重复 querySelectorAll + getBoundingClientRect
|
||
var _focusListCache = null;
|
||
var _focusListFrame = -1;
|
||
var _focusListScopeId = "";
|
||
var _rafFrameCount = 0;
|
||
// 每帧递增帧计数,作为缓存key
|
||
(function tickFocusCache() {
|
||
_rafFrameCount++;
|
||
// 帧变化时清除缓存(而非主动清空列表,避免浪费)
|
||
_focusListFrame = -1; // 标记需要重新计算
|
||
requestAnimationFrame(tickFocusCache);
|
||
})();
|
||
|
||
function visibleFocusable() {
|
||
var root = focusScopeRoot();
|
||
var scopeId = root === document ? "doc" : (root.id || "root");
|
||
// 同一帧、同一scope直接复用缓存
|
||
var currentFrame = _rafFrameCount;
|
||
if (_focusListCache && _focusListFrame === currentFrame && _focusListScopeId === scopeId) {
|
||
return _focusListCache;
|
||
}
|
||
_focusListCache = Array.from(root.querySelectorAll(".focusable,button,input,textarea"))
|
||
.filter(isVisibleFocusable);
|
||
_focusListFrame = currentFrame;
|
||
_focusListScopeId = scopeId;
|
||
return _focusListCache;
|
||
}
|
||
// 布局变化时主动清除缓存(强制下次重算)
|
||
function invalidateFocusListCache() { _focusListFrame = -1; _focusListCache = null; }
|
||
|
||
function focusScopeRoot() {
|
||
if ($("imageViewer") && $("imageViewer").classList.contains("active")) return $("imageViewer");
|
||
if ($("syncSheet") && $("syncSheet").classList.contains("active")) return $("syncSheet");
|
||
if ($("detailSheet") && $("detailSheet").classList.contains("active")) return $("detailSheet");
|
||
return document;
|
||
}
|
||
|
||
// ── TV grid 快速导航:焦点在 .media-grid 内 card 时,通过 data-card-index 直接计算目标
|
||
// 避免每次方向键全局 querySelectorAll + getBoundingClientRect
|
||
function tryGridCardFastNav(key, event) {
|
||
const active = document.activeElement;
|
||
if (!active || !active.dataset || active.dataset.cardIndex === undefined) return false;
|
||
const grid = active.closest && active.closest(".media-grid");
|
||
if (!grid) return false;
|
||
const currentIndex = parseInt(active.dataset.cardIndex, 10);
|
||
if (isNaN(currentIndex)) return false;
|
||
const cols = gridColumns(grid);
|
||
let targetIndex = -1;
|
||
if (key === "ArrowRight") targetIndex = currentIndex + 1;
|
||
else if (key === "ArrowLeft") targetIndex = currentIndex - 1;
|
||
else if (key === "ArrowDown") targetIndex = currentIndex + cols;
|
||
else if (key === "ArrowUp") {
|
||
targetIndex = currentIndex - cols;
|
||
// 第一行向上 → 跳出到 chips
|
||
if (targetIndex < 0) {
|
||
const chip = document.querySelector("#chips .chip.active") || document.querySelector("#chips .chip");
|
||
if (chip && isVisibleFocusable(chip)) {
|
||
event.preventDefault();
|
||
focusRemoteTarget(chip);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
if (targetIndex < 0) return false;
|
||
// 右边界:不能越过同行末尾
|
||
if (key === "ArrowRight" && Math.floor(targetIndex / cols) !== Math.floor(currentIndex / cols)) return false;
|
||
if (key === "ArrowLeft" && Math.floor(targetIndex / cols) !== Math.floor(currentIndex / cols)) return false;
|
||
const targetCard = grid.querySelector(`[data-card-index="${targetIndex}"]`);
|
||
if (!targetCard || !isVisibleFocusable(targetCard)) {
|
||
// 目标不存在可能需要追加 → 让 appendGridBatch 先补充再重试
|
||
if (key === "ArrowDown" && targetIndex >= parseInt(grid.children.length, 10)) {
|
||
maybeAppendGridForFocus(active);
|
||
}
|
||
return false;
|
||
}
|
||
event.preventDefault();
|
||
maybeAppendGridForFocus(targetCard);
|
||
focusRemoteTarget(targetCard);
|
||
return true;
|
||
}
|
||
|
||
function nearestFocusable(key, fromEl) {
|
||
const active = document.activeElement;
|
||
const current = fromEl || (isVisibleFocusable(active) ? active : null);
|
||
const list = visibleFocusable();
|
||
if (!list.length) return null;
|
||
if (!current) return firstContentFocus() || list[0];
|
||
const from = center(current.getBoundingClientRect());
|
||
const vertical = key === "ArrowUp" || key === "ArrowDown";
|
||
const forward = key === "ArrowRight" || key === "ArrowDown";
|
||
let best = null;
|
||
let bestScore = Infinity;
|
||
for (const el of list) {
|
||
if (el === current) continue;
|
||
const to = center(el.getBoundingClientRect());
|
||
const main = vertical ? to.y - from.y : to.x - from.x;
|
||
const cross = vertical ? Math.abs(to.x - from.x) : Math.abs(to.y - from.y);
|
||
if (forward ? main <= 4 : main >= -4) continue;
|
||
const score = Math.abs(main) * 1.25 + cross * 1.9;
|
||
if (score < bestScore) {
|
||
best = el;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function firstContentFocus() {
|
||
const root = focusScopeRoot();
|
||
const selectors = ["#chips .chip.active", "#chips .chip", "#recommendRail .card", "#listStack .card", "#searchRail .card", ".focusable:not(#searchInput)", "#searchInput"];
|
||
for (const selector of selectors) {
|
||
const target = Array.from(root.querySelectorAll(selector)).find(isVisibleFocusable);
|
||
if (target) return target;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function center(rect) {
|
||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||
}
|
||
|
||
function uiSnapshotRoute() {
|
||
if ($("imageViewer") && $("imageViewer").classList.contains("active")) return "image";
|
||
if ($("syncSheet") && $("syncSheet").classList.contains("active")) return "sync";
|
||
if ($("detailSheet") && $("detailSheet").classList.contains("active")) return "detail";
|
||
return "home";
|
||
}
|
||
|
||
function compactPanResults(items) {
|
||
return (items || []).slice(0, 400).map((item) => ({
|
||
key: item.key,
|
||
diskType: item.diskType,
|
||
url: item.url,
|
||
normalizedUrl: item.normalizedUrl || "",
|
||
password: item.password || "",
|
||
title: item.title || "",
|
||
source: item.source || "",
|
||
datetime: item.datetime || "",
|
||
index: item.index || 0
|
||
}));
|
||
}
|
||
|
||
function buildUiSnapshot() {
|
||
const panList = $("panResultList");
|
||
const detail = $("detailSheet");
|
||
return {
|
||
version: 1,
|
||
savedAt: Date.now(),
|
||
route: uiSnapshotRoute(),
|
||
hash: location.hash || "",
|
||
activeList: state.activeList,
|
||
scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0),
|
||
connectionOpen: !!($("connectionDock") && $("connectionDock").classList.contains("open")),
|
||
selected: state.selected ? normalizeSnapshot(state.selected) : null,
|
||
detailScrollTop: detail ? Math.round(detail.scrollTop || 0) : 0,
|
||
imageUrl: $("viewerImage") && $("viewerImage").style.display !== "none" ? $("viewerImage").getAttribute("src") || "" : "",
|
||
detailReturn: state.detailReturn || null,
|
||
pan: {
|
||
visible: !!($("panSearchBlock") && $("panSearchBlock").classList.contains("active")),
|
||
keyword: state.pan.keyword || "",
|
||
activeType: state.pan.activeType || "",
|
||
results: compactPanResults(state.pan.results),
|
||
health: state.pan.health || {},
|
||
listScrollTop: panList ? Math.round(panList.scrollTop || 0) : 0,
|
||
focusKey: state.pan.focusKey || "",
|
||
focusMode: state.pan.focusMode || "",
|
||
playbackReturn: state.pan.playbackReturn || null
|
||
}
|
||
};
|
||
}
|
||
|
||
function scheduleUiSnapshotSave() {
|
||
clearTimeout(scheduleUiSnapshotSave.timer);
|
||
scheduleUiSnapshotSave.timer = setTimeout(saveUiSnapshotNow, 260);
|
||
}
|
||
|
||
async function saveUiSnapshotNow() {
|
||
try {
|
||
await sdk().cache.set(cacheKey("ui"), JSON.stringify(buildUiSnapshot()));
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function readUiSnapshot() {
|
||
try {
|
||
const snapshot = safeJson(await sdk().cache.get(cacheKey("ui")), null);
|
||
if (!snapshot || snapshot.version !== 1) return null;
|
||
if (Date.now() - Number(snapshot.savedAt || 0) > UI_SNAPSHOT_TTL_MS) return null;
|
||
return snapshot;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function shouldRestoreUiSnapshot() {
|
||
try {
|
||
return new URLSearchParams(location.search || "").get("_fm_restore") === "1";
|
||
} catch (e) {
|
||
return /(?:^|[?&])_fm_restore=1(?:&|$)/.test(location.search || "");
|
||
}
|
||
}
|
||
|
||
function restorePanSnapshot(pan) {
|
||
if (!pan || !pan.keyword && !(pan.results || []).length) return;
|
||
state.pan.loading = false;
|
||
state.pan.keyword = pan.keyword || "";
|
||
state.pan.activeType = pan.activeType || "";
|
||
state.pan.results = Array.isArray(pan.results) ? pan.results.map((item, index) => Object.assign({ index }, item)) : [];
|
||
state.pan.health = pan.health && typeof pan.health === "object" ? pan.health : {};
|
||
state.pan.focusKey = pan.focusKey || "";
|
||
state.pan.focusMode = pan.focusMode || "";
|
||
state.pan.playbackReturn = pan.playbackReturn || null;
|
||
state.pan.pending = {};
|
||
state.pan.queued.clear();
|
||
state.pan.inFlight.clear();
|
||
state.pan.renderKeys = "";
|
||
state.pan.viewToken = `restore-${Date.now()}`;
|
||
if ($("panSearchBlock")) {
|
||
$("panSearchBlock").classList.add("active");
|
||
$("panSearchBlock").style.display = "";
|
||
}
|
||
renderPanResults();
|
||
updatePostPanFocusState();
|
||
requestAnimationFrame(() => {
|
||
if ($("panResultList")) $("panResultList").scrollTop = Number(pan.listScrollTop || 0);
|
||
});
|
||
}
|
||
|
||
function restoreSearchSnapshot() {
|
||
if ($("searchInput")) $("searchInput").value = "";
|
||
state.searchItems = [];
|
||
hideSearchSuggest();
|
||
renderSearch();
|
||
}
|
||
|
||
async function restoreUiSnapshot(snapshot) {
|
||
if (!snapshot) return false;
|
||
state.detailReturn = snapshot.detailReturn || null;
|
||
restoreSearchSnapshot(snapshot.search);
|
||
if (snapshot.activeList) state.activeList = snapshot.activeList;
|
||
normalizeActiveListForViewport();
|
||
renderAll({ deferContent: snapshot.route === "home" });
|
||
if (snapshot.connectionOpen && $("connectionDock") && $("connectionBody")) {
|
||
$("connectionDock").classList.add("open");
|
||
$("connectionBody").classList.add("open");
|
||
fitConnectionPanel();
|
||
}
|
||
if ((snapshot.route === "detail" || snapshot.route === "image") && snapshot.selected) {
|
||
if (location.hash !== "#detail" && snapshot.route === "detail") history.replaceState({ sheet: "detail" }, "", "#detail");
|
||
openDetail(snapshot.selected, { restore: true, skipHistory: true });
|
||
restorePanSnapshot(snapshot.pan);
|
||
// 若快照包含盘搜且盘搜 block 已激活,补推 #pan 条目保持返回栈完整
|
||
if (snapshot.pan && snapshot.pan.visible && $("panSearchBlock") && $("panSearchBlock").classList.contains("active")) {
|
||
if (location.hash !== "#pan") history.pushState({ sheet: "pan" }, "", "#pan");
|
||
}
|
||
requestAnimationFrame(() => {
|
||
if ($("detailSheet")) $("detailSheet").scrollTop = Number(snapshot.detailScrollTop || 0);
|
||
});
|
||
if (snapshot.route === "image" && snapshot.imageUrl) {
|
||
history.replaceState({ sheet: "image" }, "", "#image");
|
||
openImage(snapshot.imageUrl, { restore: true, skipHistory: true });
|
||
}
|
||
} else if (snapshot.route === "sync") {
|
||
history.replaceState({ sheet: "sync" }, "", "#sync");
|
||
openSync({ restore: true, skipHistory: true });
|
||
} else {
|
||
// 主页路由:不强制恢复滚动,让浏览器自行管理
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function scheduleWebHomeResume(options) {
|
||
const opts = options || {};
|
||
clearTimeout(state.resume.timer);
|
||
state.resume.timer = setTimeout(() => handleWebHomeResume(opts), opts.delay == null ? 80 : opts.delay);
|
||
}
|
||
|
||
function handleWebHomeResume(options) {
|
||
const opts = options || {};
|
||
const now = Date.now();
|
||
if (!opts.force && now - Number(state.resume.lastAt || 0) < 650) return;
|
||
state.resume.lastAt = now;
|
||
const isPanPlaybackReturn = !!(state.pan.playbackReturn && $("detailSheet") && $("detailSheet").classList.contains("active") && $("panSearchBlock") && $("panSearchBlock").classList.contains("active"));
|
||
lockViewportWidth();
|
||
fitConnectionPanel();
|
||
renderConnection();
|
||
if (!isPanPlaybackReturn) {
|
||
if ($("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
|
||
if ($("panSearchBlock") && $("panSearchBlock").classList.contains("active")) renderPanResults();
|
||
renderAll({ deferContent: uiSnapshotRoute() === "home" });
|
||
normalizeRails();
|
||
}
|
||
if (!restorePanPlaybackReturn({ skipRender: isPanPlaybackReturn })) restoreDetailReturn();
|
||
updateBackTopButton();
|
||
scheduleHistorySettlement();
|
||
loadRecentList({ refresh: true, silent: true }).catch(() => {});
|
||
sampleWatchStatus();
|
||
if (!isPanPlaybackReturn) {
|
||
const home = $("home");
|
||
if (home) {
|
||
home.style.transform = "translateZ(0)";
|
||
requestAnimationFrame(() => home.style.transform = "");
|
||
}
|
||
}
|
||
scheduleUiSnapshotSave();
|
||
}
|
||
|
||
function refreshActiveDetailView() {
|
||
if (state.detail && state.selected) {
|
||
renderDetailExtras(state.selected, state.detail);
|
||
return;
|
||
}
|
||
if (state.detailCover.images && state.detailCover.images.length) {
|
||
updateDetailCoverControls();
|
||
syncDetailCoverFrame($("detailImage"));
|
||
return;
|
||
}
|
||
renderDetailBase(state.selected);
|
||
}
|
||
|
||
function safeJson(text, fallback) {
|
||
try { return JSON.parse(text || ""); } catch (e) { return fallback; }
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||
}
|
||
|
||
// ── 主页哨兵:确保 history stack 始终有一个"主页"条目
|
||
// 这样从任何二级页面 history.back() 都只是关闭 sheet,不会退到 App 外
|
||
function ensureHomeHistoryEntry() {
|
||
if (!location.hash) {
|
||
const _sy = Math.round(window.scrollY || document.documentElement.scrollTop || 0);
|
||
history.pushState({ sheet: "home" }, "", location.pathname + location.search);
|
||
if (_sy > 0) {
|
||
window.scrollTo(0, _sy);
|
||
document.documentElement.scrollTop = _sy;
|
||
document.body.scrollTop = _sy;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 搜索板块状态判断与整体关闭(对齐 Eclipse:以「可见状态」为准,一次返回关全部)──
|
||
// 搜索 UI 是否处于活动状态:聚焦态 / 联想框 / 结果列表,任一即算开启。
|
||
function _isSearchUiOpen() {
|
||
return document.documentElement.classList.contains("search-focused")
|
||
|| document.documentElement.classList.contains("search-active")
|
||
|| isSearchSuggestOpen()
|
||
|| !!(state.searchItems && state.searchItems.length);
|
||
}
|
||
// 一次性关闭所有搜索相关 UI(聚焦态 + 联想框 + 结果列表),复原主页布局。
|
||
// 不在此处操作 history —— 历史栈由 popstate 处理器统一管理(_afterCloseStay 补哨兵)。
|
||
function _closeAllSearchUi() {
|
||
document.documentElement.classList.remove("search-focused");
|
||
document.documentElement.classList.remove("search-active");
|
||
hideSearchSuggest();
|
||
disableSearchEditing();
|
||
const input = $("searchInput");
|
||
if (input) { input.value = ""; input.blur(); }
|
||
state.searchItems = [];
|
||
renderSearch(); // 依据空结果复原 recommendSection/listStack/chips 被折叠的高度
|
||
scheduleUiSnapshotSave();
|
||
}
|
||
// 关闭某一层后:若仍有更底层 sheet 打开则保持现状;否则回到主页补哨兵、保持全屏。
|
||
// 对齐 Eclipse 的 _afterCloseStay —— 「其它任何返回只关闭当前层、保持全屏」。
|
||
function _afterCloseStay() {
|
||
if (_anySheetActive()) return;
|
||
if (!location.hash) ensureHomeHistoryEntry();
|
||
}
|
||
|
||
window.addEventListener("popstate", (event) => {
|
||
// 正在关闭动画中,忽略重复触发
|
||
const isClosing = detailSheet && detailSheet.classList.contains("sheet-closing");
|
||
if (isClosing) return;
|
||
|
||
// 盘搜层:用户从 #pan back 到 #detail → 只关闭盘搜 block,留在详情页
|
||
// 用 _panHistoryPushed 标记确认 #pan 条目确实推入过(避免误判)
|
||
if (location.hash === "#detail" && detailSheet && detailSheet.classList.contains("active") && isPanSearchActive() && _panHistoryPushed) {
|
||
_panHistoryPushed = false;
|
||
resetPanSearchState(true);
|
||
requestAnimationFrame(() => focusRemoteTarget($("panSearchBtn") || $("detailSearchBtn") || $("closeDetailBtn")));
|
||
return;
|
||
}
|
||
// 二级页面关闭逻辑:依次检查,有 active 就关闭
|
||
if (location.hash !== "#image" && $("imageViewer").classList.contains("active")) {
|
||
closeImage(true);
|
||
return;
|
||
}
|
||
if (location.hash !== "#detail" && detailSheet && detailSheet.classList.contains("active")) {
|
||
// 系统返回键路径:history.back() 已发生,scrollY 可能已被重置为 0
|
||
// 先暂存(可能是 0),closeDetail 动画结束后再还原
|
||
_pendingHomeScrollY = _pendingHomeScrollY || Math.round(window.scrollY || document.documentElement.scrollTop || 0);
|
||
closeDetail(true);
|
||
return;
|
||
}
|
||
if (location.hash !== "#sync" && $("syncSheet").classList.contains("active")) {
|
||
closeSync(true);
|
||
return;
|
||
}
|
||
// 搜索板块(聚焦态 / 联想框 / 结果列表 共用同一 #search 历史层):
|
||
// 一次返回整体关闭,回到主页推荐,并保持全屏、留在 App(对齐 Eclipse)。
|
||
if (location.hash !== "#search" && _isSearchUiOpen()) {
|
||
_searchHistoryPushed = false;
|
||
_closeAllSearchUi();
|
||
_afterCloseStay();
|
||
requestAnimationFrame(() => focusInitialHomeNow());
|
||
return;
|
||
}
|
||
// 主页面:所有 sheet 都已关闭。
|
||
// 如果 hash 是空(说明用户 back 到了主页根路由),推入新哨兵,防止再 back 退出 App
|
||
// 注意:只有在没有任何 sheet 的情况下才允许这么做
|
||
if (!location.hash) {
|
||
const anySheetActive = _anySheetActive();
|
||
if (!anySheetActive) {
|
||
if (_fsOn) {
|
||
// 第一次在主页返回:退出全屏(显示 Toolbar),消费这次返回,留在 App
|
||
// pushState / history.back() 都会把 window.scrollY 重置为 0(manual 模式无自动恢复)
|
||
// 优先用 closeDetail 在 history.back() 前保存的值
|
||
_exitFullscreen();
|
||
const _sy = _pendingHomeScrollY || Math.round(window.scrollY || document.documentElement.scrollTop || 0);
|
||
_pendingHomeScrollY = 0;
|
||
history.pushState({ sheet: "home" }, "", location.pathname + location.search);
|
||
if (_sy > 0) {
|
||
window.scrollTo(0, _sy);
|
||
document.documentElement.scrollTop = _sy;
|
||
document.body.scrollTop = _sy;
|
||
}
|
||
}
|
||
// 已非全屏:不再补哨兵,放行让返回交由宿主(退出 App)
|
||
}
|
||
}
|
||
});
|
||
|
||
// 移动端默认沉浸式:主页退出全屏后,点击任意内容自动重新进入全屏。
|
||
// 仅在「移动端 + 当前非全屏 + 处于主页(无二级 sheet)」时触发;
|
||
// TV 端不处理,也不影响「主页按返回退出全屏、再按返回退出 App」的流程。
|
||
document.addEventListener("click", () => {
|
||
if (isTvMode() || isNativeLeanbackClient()) return; // 仅移动端
|
||
if (_fsOn) return; // 已是全屏
|
||
if (_anySheetActive()) return; // 只在主页
|
||
_enterFullscreen();
|
||
}, true);
|
||
|
||
window.addEventListener("fmviewport", () => {
|
||
document.body.style.minHeight = getComputedStyle(document.documentElement).getPropertyValue("--fm-web-height");
|
||
lockViewportWidth();
|
||
fitConnectionPanel();
|
||
const detailLayoutChanged = syncDetailLayout();
|
||
if (detailLayoutChanged && $("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
|
||
renderAll({ deferContent: uiSnapshotRoute() === "home" });
|
||
scheduleDetailTextClamp();
|
||
scheduleHistorySettlement();
|
||
});
|
||
|
||
let _resizeTimer = 0;
|
||
window.addEventListener("resize", () => {
|
||
// lockViewportWidth 立即执行避免布局抖动,其他操作防抖
|
||
lockViewportWidth();
|
||
clearTimeout(_resizeTimer);
|
||
_resizeTimer = setTimeout(() => {
|
||
fitConnectionPanel();
|
||
const detailLayoutChanged = syncDetailLayout();
|
||
if (detailLayoutChanged && $("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
|
||
renderAll({ deferContent: uiSnapshotRoute() === "home" });
|
||
scheduleDetailTextClamp();
|
||
}, 120);
|
||
});
|
||
window.addEventListener("scroll", () => {
|
||
state.scrollingUntil = Date.now() + 900;
|
||
updateBackTopButton();
|
||
LongPressMenu.destroy();
|
||
scheduleUiSnapshotSave();
|
||
if ("IntersectionObserver" in window) return;
|
||
const doc = document.documentElement;
|
||
if ((window.scrollY || doc.scrollTop || 0) + window.innerHeight < doc.scrollHeight - 900) return;
|
||
clearTimeout(state.scrollLoadTimer);
|
||
state.scrollLoadTimer = setTimeout(loadMoreVisible, 80);
|
||
}, { passive: true });
|
||
window.addEventListener("pagehide", () => {
|
||
saveUiSnapshotNow();
|
||
clearRelayBackfillTimers();
|
||
pauseDetailCoverCarousel();
|
||
stopWatchTracking(true);
|
||
});
|
||
window.addEventListener("pageshow", () => {
|
||
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
|
||
if (panWasPlaying) {
|
||
localStorage.removeItem("fm_pan_playing");
|
||
state.pan.isPlaying = false;
|
||
// 从播放器返回:保持详情页打开
|
||
return;
|
||
}
|
||
scheduleWebHomeResume();
|
||
});
|
||
window.addEventListener("fmsdk", () => {
|
||
initPanConfig({ preserveDirty: true, timeout: 0 })
|
||
.then(loadInfo)
|
||
.then(() => scheduleWebHomeResume({ force: true }))
|
||
.catch(() => {});
|
||
});
|
||
window.addEventListener("fmresume", () => {
|
||
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
|
||
if (panWasPlaying) {
|
||
localStorage.removeItem("fm_pan_playing");
|
||
state.pan.isPlaying = false;
|
||
// 从播放器返回:保持详情页打开
|
||
return;
|
||
}
|
||
loadInfo().finally(() => scheduleWebHomeResume());
|
||
});
|
||
window.addEventListener("fmpause", saveUiSnapshotNow);
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "hidden") {
|
||
saveUiSnapshotNow();
|
||
state.detailCover.swipe = null;
|
||
pauseDetailCoverCarousel();
|
||
}
|
||
if (document.visibilityState === "visible") {
|
||
restartDetailCoverCarousel();
|
||
updateDetailCoverControls();
|
||
syncDetailCoverFrame($("detailImage"));
|
||
|
||
// 从盘搜播放器返回检测:用 localStorage 而非内存(WebView 挂起后内存丢失)
|
||
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
|
||
if (panWasPlaying) {
|
||
localStorage.removeItem("fm_pan_playing");
|
||
state.pan.isPlaying = false;
|
||
// 从播放器返回:保持详情页打开
|
||
return;
|
||
}
|
||
|
||
scheduleWebHomeResume();
|
||
}
|
||
});
|
||
|
||
function lockViewportWidth() {
|
||
const width = Math.floor(window.innerWidth || document.documentElement.clientWidth || 0);
|
||
if (!width) return;
|
||
document.documentElement.style.width = width + "px";
|
||
document.body.style.width = width + "px";
|
||
document.body.style.maxWidth = width + "px";
|
||
}
|
||
|
||
// TV 端:页面初始化后强制加载所有可见图片(含 rail 内的 poster)
|
||
function tvForceLoadImages() {
|
||
if (!isTvMode() && !isNativeLeanbackClient()) return;
|
||
document.querySelectorAll("img[src]").forEach(function(img) {
|
||
if (!img.complete || img.naturalWidth === 0) {
|
||
var src = img.getAttribute("src");
|
||
if (src) {
|
||
img.removeAttribute("loading");
|
||
img.decoding = "async"; // sync 会阻塞主线程导致卡顿,用 async
|
||
img.src = src;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function boot() {
|
||
lockViewportWidth();
|
||
bindActions();
|
||
installRemoteKeys();
|
||
// 确保主页始终有一个 history 哨兵条目,防止返回键直接退出 App
|
||
if (history.scrollRestoration) history.scrollRestoration = "manual";
|
||
if (!location.hash) ensureHomeHistoryEntry();
|
||
|
||
// 涟漪效果已移除
|
||
|
||
// ── 手机端:滚动时 chip 栏自动居中选中项 ──
|
||
(function setupChipScroll() {
|
||
const chips = $("chips");
|
||
if (!chips) return;
|
||
chips.addEventListener("click", function(e) {
|
||
const chip = e.target.closest(".chip");
|
||
if (!chip) return;
|
||
chip.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||
});
|
||
})();
|
||
|
||
// ── 电视端:方向键焦点移动加速(长按加速,用 rAF 批处理防掉帧) ──
|
||
(function setupKeyAccel() {
|
||
let pressCount = 0;
|
||
let accelRafId = 0;
|
||
document.addEventListener("keydown", function(e) {
|
||
const key = normalizeRemoteKey(e);
|
||
if (!["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(key)) { pressCount = 0; return; }
|
||
pressCount++;
|
||
// 长按加速:>8 次跳额外 1 格,>20 次跳额外 3 格
|
||
const extra = pressCount > 20 ? 3 : pressCount > 8 ? 1 : 0;
|
||
if (extra <= 0) return;
|
||
// 用 rAF 把额外跳格延到下一帧,避免同一帧多次 scrollIntoView 掉帧
|
||
if (accelRafId) return; // 上一帧还没处理完,跳过避免叠加
|
||
accelRafId = requestAnimationFrame(function() {
|
||
accelRafId = 0;
|
||
for (let i = 0; i < extra; i++) {
|
||
const t = nearestFocusable(key);
|
||
if (t) {
|
||
try { t.focus({ preventScroll: true }); } catch(_) { t.focus(); }
|
||
maybeAppendGridForFocus(t);
|
||
}
|
||
}
|
||
// 只在最后一次跳格后才执行 scrollIntoView,减少重绘
|
||
const finalTarget = document.activeElement;
|
||
if (finalTarget && isVisibleFocusable(finalTarget)) keepFocusInView(finalTarget);
|
||
});
|
||
});
|
||
document.addEventListener("keyup", function() {
|
||
pressCount = 0;
|
||
if (accelRafId) { cancelAnimationFrame(accelRafId); accelRafId = 0; }
|
||
});
|
||
})();
|
||
await initPanConfig();
|
||
renderConnection();
|
||
hotLoadIndex().catch(() => {});
|
||
await loadInfo();
|
||
await loadCatalog();
|
||
const canRestoreUi = shouldRestoreUiSnapshot();
|
||
const restored = canRestoreUi ? await restoreUiSnapshot(await readUiSnapshot()) : false;
|
||
await ensureIdentity();
|
||
await loadEvents();
|
||
scheduleHistorySettlement();
|
||
setTimeout(subscribeNostr, 600);
|
||
renderMetrics();
|
||
if (restored) scheduleWebHomeResume({ force: true });
|
||
if (!canRestoreUi) scheduleUiSnapshotSave();
|
||
updateBackTopButton();
|
||
setTimeout(uiSnapshotRoute() === "home" ? focusInitialHomeNow : ensureRemoteInitialFocus, 80);
|
||
if (uiSnapshotRoute() === "home") setTimeout(focusInitialHomeNow, 260);
|
||
// TV 端:启动完成后分两批强制加载图片,应对 WebView 不触发 lazy load 的情况
|
||
if (isTvMode() || isNativeLeanbackClient()) {
|
||
setTimeout(tvForceLoadImages, 400);
|
||
setTimeout(tvForceLoadImages, 1800);
|
||
}
|
||
}
|
||
|
||
boot();
|
||
</script>
|
||
|
||
</body>
|
||
</html> |