@yuuichi/openlist-assets 4.2.6-ym.3 → 4.2.6-ym.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1024,118 +1024,538 @@
1024
1024
  (function () {
1025
1025
  "use strict";
1026
1026
 
1027
- // 官方前端搜索框固定把当前路径作为 parent,后端 /fs/search 本身已支持递归,
1028
- // 因此这里在弹窗内增加“全局 / 当前目录”开关,并在请求发出前改写 parent。
1029
- var KEY = "ym-global-search";
1030
- var globalByDefault = true;
1027
+ /* =============================================================================
1028
+ * 全局实时搜索
1029
+ * - 去掉“全局”勾选框:搜索永远从根目录全局递归(在任何文件夹都能搜到全盘)。
1030
+ * - 输入即搜:300ms 防抖,旧请求自动取消,结果逐个增量渲染。
1031
+ * - 每条结果始终显示完整所在路径(parent),点击进入对应目录/文件。
1032
+ * 实现方式:劫持官方搜索弹窗,隐藏其原生结果区与类型下拉,注入自定义结果层;
1033
+ * 网络仍走同源 /api/fs/search(parent 固定 "/"),复用登录态。
1034
+ * ============================================================================= */
1035
+
1036
+ var DEBOUNCE_MS = 300;
1037
+ var state = {
1038
+ modal: null,
1039
+ input: null,
1040
+ listEl: null,
1041
+ statusEl: null,
1042
+ scopeRow: null,
1043
+ nativeList: null,
1044
+ nativePagination: null,
1045
+ nativeEmpty: null,
1046
+ nativeSpinner: null,
1047
+ seq: 0,
1048
+ timer: null,
1049
+ abort: null,
1050
+ lastKeyword: "",
1051
+ lastNodes: [],
1052
+ scope: 0
1053
+ };
1054
+
1055
+ /* ---------- 工具 ---------- */
1056
+ function $(sel, root) { return (root || document).querySelector(sel); }
1057
+ function $all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
1058
+
1059
+ function isDark() {
1060
+ return document.documentElement.getAttribute("data-theme") === "dark" ||
1061
+ document.body.classList.contains("hope-ui-dark") ||
1062
+ document.documentElement.classList.contains("dark");
1063
+ }
1064
+
1065
+ function joinPath(parent, name) {
1066
+ var p = (parent || "/").replace(/\/+$/, "");
1067
+ if (!p) p = "/";
1068
+ if (p === "/") return "/" + name;
1069
+ return p + "/" + name;
1070
+ }
1031
1071
 
1032
- function isGlobal() {
1072
+ // 后端返回的 parent 是 URL 编码的虚拟路径(中文显示成 %E4%B8…)。
1073
+ // 链接必须保留编码(路由按编码路径解析),仅展示文本逐段解码。
1074
+ function decodePath(p) {
1075
+ if (!p) return "/";
1033
1076
  try {
1034
- return localStorage.getItem(KEY) !== "0";
1077
+ return p.split("/").map(function (seg) {
1078
+ if (!seg) return seg;
1079
+ try { return decodeURIComponent(seg); } catch (e) { return seg; }
1080
+ }).join("/");
1081
+ } catch (e) { return p; }
1082
+ }
1083
+
1084
+ function fmtSize(n) {
1085
+ if (!n || n <= 0) return "";
1086
+ var units = ["B", "KB", "MB", "GB", "TB"];
1087
+ var i = 0;
1088
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
1089
+ return (i === 0 ? n : n.toFixed(1)) + " " + units[i];
1090
+ }
1091
+
1092
+ var FILE_ICON = {
1093
+ video: "M8 5v14l11-7z",
1094
+ audio: "M12 3v10.55A4 4 0 1 0 14 17V7h4V3z",
1095
+ image: "M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5z",
1096
+ doc: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM6 9V4h7v5h5v11H6z",
1097
+ archive: "M21 8v13H3V8M1 3h22v5H1zM10 12h4"
1098
+ };
1099
+ var DIR_SVG = '<svg viewBox="0 0 24 24" width="18" height="18" fill="#f0b429"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z"/></svg>';
1100
+ var FILE_SVG = '<svg viewBox="0 0 24 24" width="18" height="18" fill="#7dd3fc"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM6 9V4h7v5h5v11H6z"/></svg>';
1101
+
1102
+ function highlight(text, kw) {
1103
+ var safe = String(text == null ? "" : text);
1104
+ if (!kw) return safe;
1105
+ var idx = safe.toLowerCase().indexOf(kw.toLowerCase());
1106
+ if (idx < 0) return safe;
1107
+ return safe.slice(0, idx) +
1108
+ '<mark class="ym-hl">' + safe.slice(idx, idx + kw.length) + "</mark>" +
1109
+ safe.slice(idx + kw.length);
1110
+ }
1111
+
1112
+ function iconFor(node) {
1113
+ if (node.is_dir) return DIR_SVG;
1114
+ var ext = String(node.name || "").split(".").pop().toLowerCase();
1115
+ if (/\b(mp4|mkv|avi|mov|flv|webm|ts|m4v|rmvb)$/.test(ext)) return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#f472b6"><path d="M8 5v14l11-7z"/></svg>';
1116
+ if (/\b(mp3|flac|wav|aac|ogg|m4a|ape)$/.test(ext)) return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#c084fc"><path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3z"/></svg>';
1117
+ if (/\b(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#34d399"><path d="M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5z"/></svg>';
1118
+ if (/\b(zip|rar|7z|tar|gz)$/.test(ext)) return '<svg viewBox="0 0 24 24" width="18" height="18" fill="#fbbf24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4"/></svg>';
1119
+ return FILE_SVG;
1120
+ }
1121
+
1122
+ /* ---------- 样式 ---------- */
1123
+ function injectCss() {
1124
+ if (document.getElementById("ym-global-search-css")) return;
1125
+ var st = document.createElement("style");
1126
+ st.id = "ym-global-search-css";
1127
+ st.textContent = [
1128
+ "#ym-search-results{max-height:min(62vh,560px);overflow-y:auto;padding:4px 2px;}",
1129
+ ".ym-sr{display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border-radius:10px;cursor:pointer;text-decoration:none;color:inherit;}",
1130
+ ".ym-sr:hover,.ym-sr:focus{background:rgba(14,165,233,.12);outline:none;}",
1131
+ ".ym-sr__ic{flex:0 0 auto;margin-top:2px;display:flex;}",
1132
+ ".ym-sr__main{flex:1;min-width:0;}",
1133
+ ".ym-sr__name{font-size:14px;font-weight:600;line-height:1.35;word-break:break-all;}",
1134
+ ".ym-sr__path{font-size:12px;opacity:.7;margin-top:2px;word-break:break-all;display:flex;align-items:center;gap:4px;}",
1135
+ ".ym-sr__size{font-size:11px;opacity:.6;margin-left:6px;}",
1136
+ ".ym-hl{background:#fde68a;color:#92400e;border-radius:3px;padding:0 1px;font-weight:700;}",
1137
+ ".ym-sstatus{padding:18px 10px;text-align:center;font-size:13px;opacity:.65;}",
1138
+ "[role=dialog][data-ym-search] [data-ym-native-block]{display:none !important;}",
1139
+ // 永远全局:隐藏官方“搜索范围”下拉(全部/文件夹/文件所在的整行控件)。
1140
+ "[role=dialog][data-ym-search] [data-ym-scope-select]{display:none !important;}",
1141
+ "[role=dialog][data-ym-search] [data-ym-empty-hint]{display:none !important;}",
1142
+ ".ym-sspinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:ym-sspin .7s linear infinite;vertical-align:-3px;margin-right:8px;}",
1143
+ "@keyframes ym-sspin{to{transform:rotate(360deg)}}",
1144
+ ].join("\n");
1145
+ document.head.appendChild(st);
1146
+ }
1147
+
1148
+ /* ---------- 网络请求(同源,复用 cookie/token) ---------- */
1149
+ function getToken() {
1150
+ try {
1151
+ var raw = localStorage.getItem("token");
1152
+ if (!raw) return "";
1153
+ raw = raw.trim();
1154
+ // 官方在 localStorage.token 里存的是【裸 token】,axios 拦截器再补
1155
+ // "Bearer " 前缀(见 store chunk:Authorization = localStorage.token)。
1156
+ // 若历史数据里已带 Bearer 前缀,剥掉它,由 doSearch 统一补,避免出现
1157
+ // "Bearer Bearer xxx" 导致 401。
1158
+ var m = raw.match(/^Bearer\s+(.+)$/i);
1159
+ if (m) raw = m[1].trim();
1160
+ // 兼容 { token: "xxx" | "Bearer xxx" } 形态
1161
+ try {
1162
+ var t = JSON.parse(raw);
1163
+ if (t && typeof t.token === "string") {
1164
+ var mm = t.token.match(/^Bearer\s+(.+)$/i);
1165
+ return mm ? mm[1].trim() : t.token;
1166
+ }
1167
+ } catch (e) {}
1168
+ return raw;
1035
1169
  } catch (e) {
1036
- return globalByDefault;
1170
+ return "";
1037
1171
  }
1038
1172
  }
1039
1173
 
1040
- function setGlobal(v) {
1041
- try { localStorage.setItem(KEY, v ? "1" : "0"); } catch (e) {}
1174
+ async function doSearch(keyword, signal) {
1175
+ var headers = { "Content-Type": "application/json" };
1176
+ var token = getToken();
1177
+ if (token) headers.Authorization = "Bearer " + token;
1178
+ var res = await fetch("/api/fs/search", {
1179
+ method: "POST",
1180
+ headers: headers,
1181
+ signal: signal,
1182
+ body: JSON.stringify({
1183
+ parent: "/", // 永远全局
1184
+ keywords: keyword,
1185
+ scope: 0, // 永远全局(官方范围下拉已隐藏);0 = 全部
1186
+ page: 1,
1187
+ per_page: 100,
1188
+ password: ""
1189
+ })
1190
+ });
1191
+ var j = await res.json();
1192
+ if (j.code !== 200) throw new Error(j.message || "search failed");
1193
+ return j.data || { content: [], total: 0 };
1194
+ }
1195
+
1196
+ /* ---------- 渲染 ---------- */
1197
+ function setStatus(html) {
1198
+ state.statusEl.innerHTML = html;
1199
+ state.statusEl.style.display = html ? "block" : "none";
1200
+ }
1201
+
1202
+ function renderRows(nodes, kw, append) {
1203
+ if (!append) {
1204
+ state.listEl.innerHTML = "";
1205
+ state.lastNodes = [];
1206
+ }
1207
+ var frag = document.createDocumentFragment();
1208
+ nodes.forEach(function (node) {
1209
+ var fullPath = joinPath(node.parent, node.name);
1210
+ var a = document.createElement("a");
1211
+ a.className = "ym-sr";
1212
+ a.href = fullPath + "?from=search";
1213
+ a.setAttribute("role", "button");
1214
+ if (node.is_dir) a.setAttribute("data-dir", "1");
1215
+ a.innerHTML =
1216
+ '<span class="ym-sr__ic">' + iconFor(node) + "</span>" +
1217
+ '<span class="ym-sr__main">' +
1218
+ '<span class="ym-sr__name">' + highlight(node.name, kw) +
1219
+ (!node.is_dir && node.size ? '<span class="ym-sr__size">' + fmtSize(node.size) + "</span>" : "") +
1220
+ "</span>" +
1221
+ '<span class="ym-sr__path">' +
1222
+ '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" style="opacity:.7"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z"/></svg>' +
1223
+ "<span>" + highlight(decodePath(node.parent || "/"), kw) + "</span>" +
1224
+ "</span>" +
1225
+ "</span>";
1226
+ frag.appendChild(a);
1227
+ });
1228
+ state.listEl.appendChild(frag);
1229
+ state.lastNodes = state.lastNodes.concat(nodes);
1042
1230
  }
1043
1231
 
1044
- function rewriteBody(body) {
1232
+ async function runSearch(keyword) {
1233
+ var mySeq = ++state.seq;
1234
+ if (state.abort) { try { state.abort.abort(); } catch (e) {} }
1235
+ var ctrl = ("AbortController" in window) ? new AbortController() : null;
1236
+ state.abort = ctrl;
1237
+
1238
+ state.listEl.innerHTML = "";
1239
+ state.lastNodes = [];
1240
+ setStatus('<span class="ym-sspinner"></span>搜索中…');
1241
+
1242
+ if (!keyword) {
1243
+ setStatus("");
1244
+ return;
1245
+ }
1246
+
1045
1247
  try {
1046
- if (typeof body !== "string" || body.indexOf("/fs/search") === -1) return body;
1047
- var data = JSON.parse(body);
1048
- if (isGlobal()) data.parent = "/";
1049
- return JSON.stringify(data);
1248
+ var data = await doSearch(keyword, ctrl ? ctrl.signal : undefined);
1249
+ if (mySeq !== state.seq) return; // 已被更新的输入取代
1250
+ var nodes = data.content || [];
1251
+ renderRows(nodes, keyword, false);
1252
+ // 我们已渲染结果(或明确空态):隐藏官方弹窗里的“暂无结果”原生文案
1253
+ try { hideNativeEmptyHints(); } catch (e) {}
1254
+ if (!nodes.length) {
1255
+ setStatus("没有找到与 “" + keyword + "” 相关的内容");
1256
+ } else {
1257
+ setStatus("");
1258
+ }
1050
1259
  } catch (e) {
1051
- return body;
1260
+ if (mySeq !== state.seq) return;
1261
+ if (e && e.name === "AbortError") return;
1262
+ setStatus("搜索失败:" + (e.message || "网络错误"));
1263
+ }
1264
+ }
1265
+
1266
+ function scheduleSearch() {
1267
+ var kw = (state.input.value || "").trim();
1268
+ state.lastKeyword = kw;
1269
+ if (state.timer) clearTimeout(state.timer);
1270
+ if (!kw) {
1271
+ state.seq++;
1272
+ if (state.abort) { try { state.abort.abort(); } catch (e) {} }
1273
+ state.listEl.innerHTML = "";
1274
+ setStatus("");
1275
+ return;
1276
+ }
1277
+ state.timer = setTimeout(function () { runSearch(kw); }, DEBOUNCE_MS);
1278
+ }
1279
+
1280
+ /* ---------- 劫持官方弹窗 ---------- */
1281
+ function ensureOverlay() {
1282
+ if (!state.modal) return;
1283
+ // 幂等:重复 setAttribute 会触发属性观察器,必须避免无谓写操作
1284
+ if (state.modal.getAttribute("data-ym-search") !== "1") {
1285
+ state.modal.setAttribute("data-ym-search", "1");
1286
+ }
1287
+
1288
+ if (state.listEl) return;
1289
+ var box = document.createElement("div");
1290
+ box.id = "ym-search-results";
1291
+ var status = document.createElement("div");
1292
+ status.className = "ym-sstatus";
1293
+ status.style.display = "none";
1294
+
1295
+ // 插入到 modal body:该容器的直接子节点 = 输入卡片 + 原生结果块 + 我们两层。
1296
+ var body = state.modal.querySelector(".hope-modal__body, [class*=modal__body]");
1297
+ var mount = body || state.modal;
1298
+ mount.appendChild(status);
1299
+ mount.appendChild(box);
1300
+
1301
+ function hideScopeSelect() {
1302
+ // 官方“搜索范围”下拉(全部/文件夹/文件)在输入卡片左侧,宽度 $32。
1303
+ // 找到 #search-input 所在行内的 Hope Select 触发器并隐藏整行容器。
1304
+ var input = state.modal.querySelector("#search-input");
1305
+ if (!input) return;
1306
+ var sel = state.modal.querySelector('[role="combobox"]');
1307
+ if (!sel) return;
1308
+ var holder = sel.closest(".hope-select, [class*=select]") || sel;
1309
+ if (holder.getAttribute("data-ym-scope-select") !== "1") {
1310
+ holder.setAttribute("data-ym-scope-select", "1");
1311
+ }
1312
+ // 从触发器向上,标记“不包含搜索框”的最外层包裹节点,即下拉独占的整行容器。
1313
+ var wrap = holder;
1314
+ for (var i = 0; i < 6 && wrap.parentElement; i++) {
1315
+ var p = wrap.parentElement;
1316
+ if (p.querySelector("#search-input")) break; // 再往上就会连带隐藏输入框
1317
+ wrap = p;
1318
+ }
1319
+ if (wrap.getAttribute("data-ym-scope-select") !== "1") {
1320
+ wrap.setAttribute("data-ym-scope-select", "1");
1321
+ }
1322
+ }
1323
+
1324
+ function hideNativeEmptyHints() {
1325
+ // 原生空态文案“暂无结果”是 <p class="hope-text">。在不同构建版本里它可能
1326
+ // 位于输入卡片内,也可能与输入卡片平级(modal__body 直接子节点),因此在整个
1327
+ // 弹窗范围内查找,但绝不标记包含 #search-input 的节点(输入框/按钮)。
1328
+ if (!state.modal) return;
1329
+ var ps = state.modal.querySelectorAll("p.hope-text, .hope-text");
1330
+ Array.prototype.forEach.call(ps, function (p) {
1331
+ if (p.querySelector && p.querySelector("#search-input")) return;
1332
+ if (p.closest && p.closest("#ym-search-results,.ym-sstatus")) return;
1333
+ var txt = (p.textContent || "").trim();
1334
+ if (/暂无|没有|无结果|no\s*result|nothing|empty/i.test(txt)
1335
+ && p.getAttribute("data-ym-empty-hint") !== "1") {
1336
+ p.setAttribute("data-ym-empty-hint", "1");
1337
+ }
1338
+ });
1339
+ }
1340
+
1341
+ function syncNative() {
1342
+ Array.prototype.forEach.call(mount.children, function (ch) {
1343
+ if (ch === box || ch === status) return;
1344
+ if (ch.querySelector && ch.querySelector("#search-input")) {
1345
+ // 输入卡片保留,但其内的原生“暂无结果”空态要隐藏(我们自己渲染结果)。
1346
+ hideNativeEmptyHints();
1347
+ return;
1348
+ }
1349
+ if (ch.getAttribute("data-ym-native-block") !== "1") {
1350
+ ch.setAttribute("data-ym-native-block", "1");
1351
+ }
1352
+ });
1353
+ hideScopeSelect();
1354
+ hideNativeEmptyHints();
1052
1355
  }
1356
+ syncNative();
1357
+ var snQueued = false, snRunning = false;
1358
+ function snFlush() {
1359
+ snQueued = false;
1360
+ if (snRunning) return;
1361
+ snRunning = true;
1362
+ try { syncNative(); } finally { snRunning = false; }
1363
+ }
1364
+ new MutationObserver(function () {
1365
+ if (snQueued) return;
1366
+ snQueued = true;
1367
+ (window.requestAnimationFrame || function (f) { setTimeout(f, 16); })(snFlush);
1368
+ }).observe(mount, { childList: true, subtree: true });
1369
+
1370
+ state.listEl = box;
1371
+ state.statusEl = status;
1053
1372
  }
1054
1373
 
1055
- function installFetchPatch() {
1056
- if (window.__ymSearchFetchPatched) return;
1057
- window.__ymSearchFetchPatched = true;
1374
+ function bindInput() {
1375
+ if (!state.input || state.input.__ymBound) return;
1376
+ state.input.__ymBound = true;
1377
+ state.input.setAttribute("placeholder", "全局搜索文件 / 文件夹…");
1378
+ state.input.addEventListener("input", function () {
1379
+ scheduleSearch();
1380
+ });
1381
+ // 回车立即搜(不等防抖)
1382
+ state.input.addEventListener("keydown", function (e) {
1383
+ if (e.key === "Enter") {
1384
+ e.preventDefault();
1385
+ e.stopPropagation();
1386
+ if (state.timer) clearTimeout(state.timer);
1387
+ runSearch((state.input.value || "").trim());
1388
+ }
1389
+ });
1390
+ }
1391
+
1392
+ /* 拦截官方 /fs/search:官方前端用 axios(XHR),不是 fetch。
1393
+ 重写请求体里的 parent 为 "/",保证即便走官方自己的搜索通道也是全局。 */
1394
+ function rewriteSearchBody(bodyText) {
1395
+ try {
1396
+ var data = JSON.parse(bodyText);
1397
+ if (data && typeof data === "object" && "keywords" in data) {
1398
+ data.parent = "/"; // 永远全局
1399
+ return JSON.stringify(data);
1400
+ }
1401
+ } catch (e) {}
1402
+ return bodyText;
1403
+ }
1404
+
1405
+ function patchFetch() {
1406
+ if (window.__ymGlobalFetchPatched) return;
1407
+ window.__ymGlobalFetchPatched = true;
1058
1408
  var ofetch = window.fetch;
1059
1409
  window.fetch = function (input, init) {
1060
1410
  try {
1061
1411
  var url = typeof input === "string" ? input : input && input.url;
1062
1412
  if (url && url.indexOf("/fs/search") !== -1 && init && typeof init.body === "string") {
1063
- init = Object.assign({}, init, { body: rewriteBody(init.body) });
1413
+ var nb = rewriteSearchBody(init.body);
1414
+ if (nb !== init.body) init = Object.assign({}, init, { body: nb });
1064
1415
  }
1065
1416
  } catch (e) {}
1066
1417
  return ofetch.apply(this, arguments);
1067
1418
  };
1068
1419
  }
1069
1420
 
1070
- function installXhrPatch() {
1071
- if (window.__ymSearchXhrPatched) return;
1072
- window.__ymSearchXhrPatched = true;
1073
- var open = XMLHttpRequest.prototype.open;
1074
- var send = XMLHttpRequest.prototype.send;
1421
+ function patchXhr() {
1422
+ if (window.__ymGlobalXhrPatched) return;
1423
+ window.__ymGlobalXhrPatched = true;
1424
+ var OOpen = XMLHttpRequest.prototype.open;
1425
+ var OSend = XMLHttpRequest.prototype.send;
1075
1426
  XMLHttpRequest.prototype.open = function (method, url) {
1076
- this.__ymSearchUrl = url || "";
1077
- return open.apply(this, arguments);
1427
+ this.__ymIsSearch = typeof url === "string" && url.indexOf("/fs/search") !== -1;
1428
+ return OOpen.apply(this, arguments);
1078
1429
  };
1079
1430
  XMLHttpRequest.prototype.send = function (body) {
1080
1431
  try {
1081
- if (this.__ymSearchUrl.indexOf("/fs/search") !== -1 && typeof body === "string") {
1082
- arguments[0] = rewriteBody(body);
1432
+ if (this.__ymIsSearch && typeof body === "string") {
1433
+ var nb = rewriteSearchBody(body);
1434
+ if (nb !== body) body = nb;
1083
1435
  }
1084
1436
  } catch (e) {}
1085
- return send.apply(this, arguments);
1437
+ return OSend.call(this, body);
1086
1438
  };
1087
1439
  }
1088
1440
 
1089
- function injectCss() {
1090
- if (document.getElementById("ym-search-toggle-css")) return;
1091
- var st = document.createElement("style");
1092
- st.id = "ym-search-toggle-css";
1093
- st.textContent = [
1094
- ".ym-search-scope{display:inline-flex;align-items:center;gap:5px;margin-left:6px;padding:0 8px;",
1095
- "height:32px;border-radius:8px;border:1px solid var(--hope-colors-neutral7,#cbd5e1);",
1096
- "font-size:12px;white-space:nowrap;cursor:pointer;color:var(--hope-colors-neutral11,#475569);",
1097
- "background:var(--hope-colors-neutral2,#f8fafc);user-select:none;}",
1098
- ".ym-search-scope input{margin:0;cursor:pointer;}",
1099
- ].join("");
1100
- document.head.appendChild(st);
1441
+ // 弹窗关闭 / SPA 路由切换(点搜索结果进文件夹)后,Solid 会把旧弹窗移出文档。
1442
+ // 此时必须重置缓存的 modal/input,否则下次打开的是【新弹窗节点】,而我们仍持有
1443
+ // 游离的旧节点,自定义结果层不会挂回去,看起来“变回了原来的搜索界面”。
1444
+ function resetModalState() {
1445
+ state.modal = null;
1446
+ state.input = null;
1447
+ state.listEl = null;
1448
+ state.statusEl = null;
1449
+ state.lastKeyword = "";
1450
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
1451
+ if (state.abort) { try { state.abort.abort(); } catch (e) {} state.abort = null; }
1452
+ state.seq++;
1453
+ }
1454
+
1455
+ function isDetached(el) {
1456
+ if (!el) return true;
1457
+ if (!el.isConnected) return true;
1458
+ // 旧弹窗节点:仍在 DOM 中但已不可见(Solid 复用/卸载),也视为失效
1459
+ try { if (el.getBoundingClientRect().width === 0) return true; } catch (e) {}
1460
+ return false;
1101
1461
  }
1102
1462
 
1103
- function findModal() {
1104
- var nodes = document.querySelectorAll('[role="dialog"]');
1105
- for (var i = 0; i < nodes.length; i++) {
1106
- var n = nodes[i];
1107
- if (n.querySelector("#search-input") && n.getBoundingClientRect().width > 0) return n;
1463
+ function hookModal() {
1464
+ // 已缓存的弹窗已脱离文档:SPA 导航后清理,确保重新 hook 新弹窗
1465
+ if (state.modal && isDetached(state.modal)) {
1466
+ resetModalState();
1108
1467
  }
1109
- return null;
1110
- }
1468
+ var modal = null;
1469
+ var dialogs = $all('[role="dialog"]');
1470
+ for (var i = 0; i < dialogs.length; i++) {
1471
+ if (dialogs[i].querySelector("#search-input") && dialogs[i].getBoundingClientRect().width > 0 && dialogs[i].isConnected) {
1472
+ modal = dialogs[i];
1473
+ break;
1474
+ }
1475
+ }
1476
+ if (!modal) return;
1111
1477
 
1112
- function ensureToggle() {
1113
- var modal = findModal();
1114
- if (!modal || modal.querySelector(".ym-search-scope")) return;
1115
1478
  var input = modal.querySelector("#search-input");
1116
1479
  if (!input) return;
1117
- var holder = input.closest("div");
1118
- if (!holder || !holder.parentElement) return;
1119
- var row = holder.parentElement;
1120
- var label = document.createElement("label");
1121
- label.className = "ym-search-scope";
1122
- label.title = "全局:从根目录递归搜索全部存储;当前目录:仅搜索所在文件夹及其子目录";
1123
- var cb = document.createElement("input");
1124
- cb.type = "checkbox";
1125
- cb.checked = isGlobal();
1126
- cb.addEventListener("change", function () { setGlobal(cb.checked); });
1127
- label.appendChild(cb);
1128
- label.appendChild(document.createTextNode("全局"));
1129
- row.appendChild(label);
1480
+
1481
+ var isNewModal = state.modal !== modal;
1482
+ state.modal = modal;
1483
+ state.input = input;
1484
+ // 弹窗 DOM 在 Solid 中复用;overlay 只建一次并缓存在节点上,避免重复插入。
1485
+ if (modal.__ymListEl) {
1486
+ state.listEl = modal.__ymListEl;
1487
+ state.statusEl = modal.__ymStatusEl;
1488
+ } else {
1489
+ state.listEl = null;
1490
+ state.statusEl = null;
1491
+ }
1492
+
1493
+ ensureOverlay();
1494
+ modal.__ymListEl = state.listEl;
1495
+ modal.__ymStatusEl = state.statusEl;
1496
+ bindInput();
1497
+
1498
+ if (isNewModal) {
1499
+ // 弹窗打开时聚焦并按当前内容立即搜
1500
+ setTimeout(function () {
1501
+ try { input.focus(); } catch (e) {}
1502
+ var kw = (input.value || "").trim();
1503
+ if (kw) runSearch(kw);
1504
+ }, 50);
1505
+ }
1130
1506
  }
1131
1507
 
1132
1508
  function boot() {
1133
1509
  injectCss();
1134
- installFetchPatch();
1135
- installXhrPatch();
1136
- new MutationObserver(function () {
1137
- if (document.querySelector("#search-input")) ensureToggle();
1138
- }).observe(document.body, { childList: true, subtree: true });
1510
+ patchFetch();
1511
+ patchXhr();
1512
+ // 只监听节点增删(不监听 attributes,避免我们自己 setAttribute 引发观察器回调风暴);
1513
+ // 用 rAF 合并高频回调,并防重入。
1514
+ var queued = false, running = false;
1515
+ function flush() {
1516
+ queued = false;
1517
+ if (running) return;
1518
+ if (!document.querySelector("#search-input")) return;
1519
+ running = true;
1520
+ try { hookModal(); } finally { running = false; }
1521
+ }
1522
+ var mo = new MutationObserver(function () {
1523
+ if (queued) return;
1524
+ queued = true;
1525
+ (window.requestAnimationFrame || function (f) { setTimeout(f, 16); })(flush);
1526
+ });
1527
+ mo.observe(document.body, { childList: true, subtree: true });
1528
+
1529
+ // SPA 路由切换(点搜索结果进文件夹):旧弹窗被卸载,复位缓存,使下次打开
1530
+ // 搜索时重新 hook 新弹窗,而不是停留在旧的原生搜索界面直到整页刷新。
1531
+ // 用【只读轮询 pathname】而非包装 history.pushState/replaceState —— 后者可能
1532
+ // 干扰 Solid 路由器(曾导致导航后转圈/卡死)。轮询零副作用、最稳。
1533
+ var lastPath = location.pathname + location.search;
1534
+ function onRouteChange() {
1535
+ lastPath = location.pathname + location.search;
1536
+ setTimeout(function () {
1537
+ if (isDetached(state.modal)) resetModalState();
1538
+ }, 0);
1539
+ }
1540
+ setInterval(function () {
1541
+ var cur = location.pathname + location.search;
1542
+ if (cur !== lastPath) {
1543
+ lastPath = cur;
1544
+ setTimeout(function () {
1545
+ if (isDetached(state.modal)) resetModalState();
1546
+ }, 0);
1547
+ }
1548
+ }, 300);
1549
+ window.addEventListener("popstate", onRouteChange);
1550
+ window.addEventListener("hashchange", onRouteChange);
1551
+
1552
+ // 弹窗关闭时复位
1553
+ document.addEventListener("click", function (e) {
1554
+ var t = e.target;
1555
+ if (t && t.closest && t.closest(".hope-modal__overlay, [class*=modal__overlay]")) {
1556
+ state.seq++;
1557
+ }
1558
+ }, true);
1139
1559
  }
1140
1560
 
1141
1561
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
@@ -1145,573 +1565,166 @@
1145
1565
  /* ===== music-player.js ===== */
1146
1566
  ;(function(){
1147
1567
  /* =============================================================================
1148
- * Yuuichi 资源站 · 悬浮背景音乐播放器
1568
+ * Yuuichi 资源站 · 背景音乐(与 blog.yuuichi.me / www.yuuichi.me 统一的 bgm-dock)
1149
1569
  * -----------------------------------------------------------------------------
1150
- * 想换歌单,只需要修改下面 MUSIC_CONFIG 里的 id / 链接即可,然后重新部署:
1151
- * 1) 网易云歌单:打开歌单复制地址里的 id 数字
1152
- * 例 https://music.163.com/#/playlist?id=18475904830 -> id 填 "18475904830"
1153
- * 网易云使用内置 APlayer 内核(支持音量调节 / 封面 / 歌词 / 列表循环)
1154
- * 2) Spotify 歌单:复制歌单链接
1155
- * 例 https://open.spotify.com/playlist/3bePpYfOLDwFtVVs6cqwyh -> id 填 "3bePpYfOLDwFtVVs6cqwyh"
1156
- * Spotify 为官方嵌入播放器,其桌面端自身右下角带音量条(浏览器跨域限制,外部无法代为控制)
1157
- * 每个平台可填多个歌单(顶部可切换);不用的平台把列表留空 [] 即可。
1158
- * 网易云歌单由本站同源接口 /api/music/netease 实时拉取网易云官方数据(自动反映你在网易云的增删);
1159
- * 每首歌的直链 / 封面 / 歌词由 metingApi 按 id 实时解析(公益服务,失效可换其它 Meting 兼容地址)。
1160
- * 改完保存,在 openlist 目录执行:
1161
- * node scripts/sync-branding.mjs
1162
- * pnpm exec wrangler deploy
1570
+ * 统一播放器:左下角圆形按钮(半吸附边缘 / 可拖动 / 可缩放),点击展开面板,
1571
+ * 内置 Spotify 官方嵌入 + 网易云(Meting/APlayer,含封面/歌词/列表循环/倍速)。
1572
+ *
1573
+ * 想换歌单,修改下面 MUSIC_CONFIG 的 id,然后:
1574
+ * node scripts/sync-branding.mjs && pnpm exec wrangler deploy
1575
+ * 也可以在 OpenList 管理台设置项 music_config 在线覆盖(无需重新部署)。
1576
+ *
1577
+ * 资源站资源全部同源托管在 /brand/bgm/ 下;网易云歌单/直链/歌词走本站
1578
+ * /api/music/meting(Worker 服务端代理网易云),浏览器不直接访问 workers.dev。
1163
1579
  * ============================================================================= */
1164
1580
  (function () {
1165
1581
  "use strict";
1166
1582
 
1583
+ var ASSET_BASE = "/brand/bgm";
1584
+ var METING_API = "/api/music/meting?server=:server&type=:type&id=:id&r=:r";
1585
+
1167
1586
  var MUSIC_CONFIG = {
1168
- netease: [
1169
- { id: "18475904830", name: "网易云歌单" }
1170
- ],
1171
- spotify: [
1172
- { id: "3bePpYfOLDwFtVVs6cqwyh", name: "Spotify 歌单" }
1173
- ],
1174
- defaultTab: "netease", // "netease" 或 "spotify"
1175
- autoplay: false, // 网易云是否自动播放(多数浏览器会拦截,建议 false)
1176
- fabHint: "音乐"
1587
+ netease: [{ id: "18475904830", name: "网易云" }],
1588
+ spotify: [{ id: "3bePpYfOLDwFtVVs6cqwyh", name: "Spotify" }],
1589
+ defaultTab: "spotify", // "spotify" 或 "netease"
1590
+ autoplay: false,
1591
+ theme: "#0ea5e9",
1592
+ volume: 0.6,
1593
+ order: "list" // list / random
1177
1594
  };
1178
1595
 
1179
- // 管理台(OpenList 设置项 music_config)可在线覆盖歌单/开关,无需重新部署。
1180
- // Worker 会在本脚本前注入 window.__YUUICHI_MUSIC_CONFIG_OVERLAY__。
1596
+ // 管理台 music_config 在线覆盖
1181
1597
  try {
1182
1598
  var __ov = window.__YUUICHI_MUSIC_CONFIG_OVERLAY__;
1183
1599
  if (__ov && typeof __ov === "object") {
1184
1600
  ["netease", "spotify"].forEach(function (k) {
1185
1601
  if (Array.isArray(__ov[k])) MUSIC_CONFIG[k] = __ov[k];
1186
1602
  });
1187
- ["defaultTab", "fabHint"].forEach(function (k) {
1603
+ ["defaultTab", "theme", "order"].forEach(function (k) {
1188
1604
  if (typeof __ov[k] === "string" && __ov[k]) MUSIC_CONFIG[k] = __ov[k];
1189
1605
  });
1190
- if (typeof __ov.autoplay === "boolean") MUSIC_CONFIG.autoplay = __ov.autoplay;
1606
+ ["autoplay"].forEach(function (k) {
1607
+ if (typeof __ov[k] === "boolean") MUSIC_CONFIG[k] = __ov[k];
1608
+ });
1609
+ if (typeof __ov.volume === "number") MUSIC_CONFIG.volume = __ov.volume;
1191
1610
  }
1192
1611
  } catch (e) {}
1193
1612
 
1194
- function init() {
1195
- if (window.__yuuichiMusicInited) return;
1196
- window.__yuuichiMusicInited = true;
1197
-
1198
- var platforms = [];
1199
- if (MUSIC_CONFIG.netease.length) platforms.push({ key: "netease", label: "网易云", items: MUSIC_CONFIG.netease });
1200
- if (MUSIC_CONFIG.spotify.length) platforms.push({ key: "spotify", label: "Spotify", items: MUSIC_CONFIG.spotify });
1201
- if (!platforms.length) return;
1202
-
1203
- var host = document.createElement("div");
1204
- host.id = "yuuichi-music-root";
1205
- host.style.cssText = "all:initial;position:fixed;z-index:2147483000;";
1206
- var shadow = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
1207
- document.body.appendChild(host);
1208
-
1209
- /* ---------------- 样式 ---------------- */
1210
- // APlayer 厂商 CSS(12KB)改为首次真正打开歌单时才注入,避免首屏多一个请求
1211
- var vendorLinkLoaded = false;
1212
- function ensureVendorCss() {
1213
- if (vendorLinkLoaded) return;
1214
- vendorLinkLoaded = true;
1215
- var vendorLink = document.createElement("link");
1216
- vendorLink.rel = "stylesheet";
1217
- vendorLink.href = "/brand/vendor/APlayer.min.css";
1218
- shadow.appendChild(vendorLink);
1219
- }
1220
-
1221
- var style = document.createElement("style");
1222
- style.textContent = [
1223
- ":host, * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }",
1224
- ":host { --ym-txt:#0f172a; --ym-sub:#475569; --ym-card:rgba(255,255,255,.92); --ym-line:rgba(14,165,233,.28); --ym-soft:rgba(14,165,233,.12); }",
1225
- ":host([data-dark]) { --ym-txt:#e2e8f0; --ym-sub:#94a3b8; --ym-card:rgba(15,23,42,.92); --ym-line:rgba(56,189,248,.32); --ym-soft:rgba(56,189,248,.16); }",
1226
- ".wrap { position:fixed; left:0; top:0; font-family:system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif; }",
1227
- ".fab { position:fixed; left:18px; bottom:18px; width:54px; height:54px; border-radius:50%;",
1228
- " border:none; cursor:pointer; color:#fff; display:flex; align-items:center; justify-content:center;",
1229
- " background:linear-gradient(135deg,#38bdf8,#0284c7); box-shadow:0 8px 24px rgba(2,132,199,.45);",
1230
- " transform:translateX(-58%); transition:transform .22s cubic-bezier(.2,.8,.2,1), box-shadow .18s ease; padding:0; }",
1231
- ".wrap:hover .fab, .fab:focus-visible, .fab.peek { transform:translateX(0); }",
1232
- ".fab:hover { box-shadow:0 12px 30px rgba(2,132,199,.55); }",
1233
- ".fab:active { transform:scale(.94); }",
1234
- ".fab svg { width:26px; height:26px; }",
1235
- ".panel { position:fixed; left:18px; bottom:84px; width:360px; height:560px; max-width:calc(100vw - 24px);",
1236
- " background:var(--ym-card); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);",
1237
- " border:1px solid var(--ym-line); border-radius:18px; overflow:hidden; display:none;",
1238
- " flex-direction:column; box-shadow:0 20px 60px rgba(2,8,23,.35); color:var(--ym-txt); }",
1239
- ".panel.open { display:flex; }",
1240
- ".bar { display:flex; align-items:center; gap:6px; padding:8px 10px; cursor:move; user-select:none;",
1241
- " background:linear-gradient(135deg,rgba(56,189,248,.18),rgba(2,132,199,.10)); border-bottom:1px solid var(--ym-line); flex:0 0 auto; }",
1242
- ".tab { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt);",
1243
- " border-radius:999px; padding:4px 12px; font-size:12px; cursor:pointer; }",
1244
- ".tab.active { background:linear-gradient(135deg,#38bdf8,#0284c7); color:#fff; border-color:transparent; }",
1245
- ".sel { flex:1; min-width:0; border:1px solid var(--ym-line); background:transparent;",
1246
- " color:var(--ym-txt); border-radius:8px; font-size:12px; padding:4px 6px; outline:none; }",
1247
- ".sel option { color:#0f172a; }",
1248
- ".close { border:none; background:transparent; color:var(--ym-txt); font-size:18px; line-height:1; cursor:pointer; padding:2px 7px; border-radius:8px; }",
1249
- ".close:hover { background:var(--ym-soft); }",
1250
- ".refresh { display:none; border:none; background:transparent; color:var(--ym-txt); cursor:pointer; padding:2px 6px; border-radius:8px; align-items:center; }",
1251
- ".refresh.show { display:inline-flex; }",
1252
- ".refresh:hover { background:var(--ym-soft); }",
1253
- ".refresh svg { width:16px; height:16px; }",
1254
- ".refresh.spin svg { animation:ym-rspin .8s linear infinite; }",
1255
- "@keyframes ym-rspin { to { transform:rotate(360deg); } }",
1256
- ".ctrl { display:none; align-items:center; gap:6px; padding:5px 10px; flex:0 0 auto;",
1257
- " border-bottom:1px solid var(--ym-line); }",
1258
- ".ctrl.show { display:flex; }",
1259
- ".cbtn { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt); cursor:pointer;",
1260
- " border-radius:999px; font-size:12px; line-height:1; padding:4px 10px; display:inline-flex; align-items:center; gap:5px; }",
1261
- ".cbtn:hover { background:var(--ym-soft); }",
1262
- ".cbtn svg { width:14px; height:14px; }",
1263
- ".stages { flex:1; min-height:0; position:relative; }",
1264
- ".stage { position:absolute; inset:0; display:none; }",
1265
- ".stage.active { display:block; }",
1266
- ".stage iframe { width:100%; height:100%; border:0; display:block; background:#fff; }",
1267
- ".ap-wrap { position:absolute; inset:0; overflow:auto; }",
1268
- ".status { position:absolute; inset:0; display:flex; flex-direction:column; gap:10px; align-items:center; justify-content:center; text-align:center; color:var(--ym-sub); font-size:13px; padding:24px; }",
1269
- ".status button { border:none; cursor:pointer; color:#fff; background:linear-gradient(135deg,#38bdf8,#0284c7); border-radius:999px; padding:6px 18px; font-size:13px; }",
1270
- ".resizer { position:absolute; right:0; bottom:0; width:22px; height:22px; cursor:nwse-resize; z-index:5; }",
1271
- ".resizer::after { content:''; position:absolute; right:5px; bottom:5px; width:10px; height:10px;",
1272
- " border-right:2px solid var(--ym-line); border-bottom:2px solid var(--ym-line); }",
1273
- /* APlayer 皮肤适配(透明、贴合面板、青蓝主题) */
1274
- ".aplayer { width:100%; margin:0; background:transparent !important; box-shadow:none !important; border-radius:0; color:var(--ym-txt); }",
1275
- ".aplayer .aplayer-info { border-top:1px solid var(--ym-line); }",
1276
- ".aplayer .aplayer-title, .aplayer .aplayer-list ol li .aplayer-list-index, .aplayer .aplayer-time { color:var(--ym-sub); }",
1277
- ".aplayer .aplayer-list ol li { border-top:1px solid var(--ym-line); }",
1278
- ".aplayer .aplayer-list ol li.aplayer-list-light { background:var(--ym-soft); }",
1279
- ".aplayer .aplayer-list ol li:hover { background:var(--ym-soft); }",
1280
- ".aplayer .aplayer-list ol li .aplayer-list-author { color:var(--ym-sub); }",
1281
- ".aplayer .aplayer-lrc { text-shadow:0 1px 4px rgba(0,0,0,.35); }",
1282
- ".aplayer .aplayer-lrc p.aplayer-lrc-current { color:#38bdf8; }",
1283
- ".aplayer .aplayer-miniswitcher { background:var(--ym-soft); }",
1284
- ":host([data-dark]) .aplayer svg, :host([data-dark]) .aplayer .aplayer-icon { filter:invert(1) hue-rotate(180deg); }",
1285
- "@media (max-width:600px) {",
1286
- " .fab { left:14px; bottom:14px; width:50px; height:50px; }",
1287
- " .panel { left:12px !important; bottom:76px !important; width:calc(100vw - 24px) !important; height:min(76vh,600px) !important; }",
1288
- " .resizer { display:none; }",
1289
- "}"
1290
- ].join("\n");
1291
- shadow.appendChild(style);
1292
-
1293
- /* ---------------- DOM 骨架 ---------------- */
1294
- var wrap = document.createElement("div");
1295
- wrap.className = "wrap";
1296
- shadow.appendChild(wrap);
1297
-
1298
- var fab = document.createElement("button");
1299
- fab.className = "fab";
1300
- fab.title = MUSIC_CONFIG.fabHint || "音乐";
1301
- fab.setAttribute("aria-label", "音乐播放器");
1302
- fab.innerHTML =
1303
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
1304
- '<path d="M9 18V5l10-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="16" cy="16" r="3"></circle></svg>';
1305
- wrap.appendChild(fab);
1306
-
1307
- var panel = document.createElement("div");
1308
- panel.className = "panel";
1309
- panel.innerHTML =
1310
- '<div class="bar"></div>' +
1311
- '<div class="ctrl">' +
1312
- '<button class="cbtn js-speed" type="button" title="播放倍速"><span class="speed-label">1.0x</span></button>' +
1313
- '<button class="cbtn js-order" type="button" title="播放顺序"></button>' +
1314
- '</div>' +
1315
- '<div class="stages"></div>' +
1316
- '<div class="resizer"></div>';
1317
- wrap.appendChild(panel);
1318
-
1319
- var bar = panel.querySelector(".bar");
1320
- var stages = panel.querySelector(".stages");
1321
- var ctrlBar = panel.querySelector(".ctrl");
1322
- var speedBtn = panel.querySelector(".js-speed");
1323
- var speedLabel = panel.querySelector(".speed-label");
1324
- var orderBtn = panel.querySelector(".js-order");
1325
-
1326
- var tabBtns = {};
1327
- platforms.forEach(function (p) {
1328
- var b = document.createElement("button");
1329
- b.className = "tab";
1330
- b.textContent = p.label;
1331
- b.dataset.key = p.key;
1332
- b.addEventListener("click", function () { selectTab(p.key); });
1333
- bar.appendChild(b);
1334
- tabBtns[p.key] = b;
1335
- });
1336
-
1337
- var sel = document.createElement("select");
1338
- sel.className = "sel";
1339
- bar.appendChild(sel);
1340
-
1341
- var refreshBtn = document.createElement("button");
1342
- refreshBtn.className = "refresh";
1343
- refreshBtn.title = "重新拉取最新歌单";
1344
- refreshBtn.setAttribute("aria-label", "刷新歌单");
1345
- refreshBtn.innerHTML =
1346
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
1347
- '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline></svg>';
1348
- refreshBtn.addEventListener("click", function () {
1349
- if (state.tab === "netease") {
1350
- refreshBtn.classList.add("spin");
1351
- setupNetease(true).finally(function () { refreshBtn.classList.remove("spin"); });
1352
- }
1613
+ /* 收起态关键 CSS(与博客 #bgm-dock-critical 保持一致),内联防止展开前闪烁 */
1614
+ var CRITICAL_CSS =
1615
+ ".bgm-dock{--bgm-accent:" + (MUSIC_CONFIG.theme || "#0ea5e9") + ";" +
1616
+ "position:fixed;left:16px;bottom:16px;z-index:99999;width:52px;max-width:calc(100vw - 24px);" +
1617
+ "font-size:13px;line-height:1.4;color:#222;}" +
1618
+ ".bgm-dock.is-open{width:320px;max-width:calc(100vw - 24px);}" +
1619
+ ".bgm-dock__fab{position:relative;width:52px;height:52px;border:0;border-radius:50%;" +
1620
+ "background:var(--bgm-accent);color:#fff;font-size:20px;cursor:pointer;" +
1621
+ "box-shadow:0 8px 22px rgba(0,0,0,.28);display:inline-flex;align-items:center;justify-content:center;touch-action:none;}" +
1622
+ ".bgm-dock__fab.is-dragging{cursor:grabbing;opacity:.92;}" +
1623
+ ".bgm-dock:not(.is-open) .bgm-dock__fab{--bgm-peek:18px;transition:transform .28s cubic-bezier(.22,.61,.36,1);will-change:transform;}" +
1624
+ // 收起态半吸附到屏幕边缘:默认只露出一小截(半圆即可 hover),鼠标移上 /
1625
+ // 键盘聚焦 / 拖拽 / 已全露 时整个滑回屏幕内,保证桌面鼠标始终点得到。
1626
+ // 触摸设备点一下全露、再点展开(见 bgm-dock.js 的 is-revealed 逻辑)。
1627
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-left{transform:translateX(calc(var(--bgm-peek) - 100%));}" +
1628
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-right{transform:translateX(calc(100% - var(--bgm-peek)));}" +
1629
+ ".bgm-dock:not(.is-open) .bgm-dock__fab:hover," +
1630
+ ".bgm-dock:not(.is-open) .bgm-dock__fab:focus-visible," +
1631
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-revealed," +
1632
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-dragging{transform:none;}" +
1633
+ // 触摸(无 hover)首次点按仅全露:未全露时不要因 hover 规则干扰(触摸无 hover,
1634
+ // 该媒体查询下默认半藏即可)。
1635
+ "@media (hover:none),(pointer:coarse){" +
1636
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-left{transform:translateX(calc(var(--bgm-peek) - 100%));}" +
1637
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-right{transform:translateX(calc(100% - var(--bgm-peek)));}}" +
1638
+ ".bgm-dock__fab svg{width:22px;height:22px;fill:currentColor;display:block;}" +
1639
+ ".bgm-dock__fab-bar{position:absolute;right:9px;top:9px;width:9px;height:9px;border-radius:50%;" +
1640
+ "background:#2ecc71;border:2px solid #fff;opacity:0;}" +
1641
+ ".bgm-dock.is-playing .bgm-dock__fab-bar{opacity:1;}" +
1642
+ ".bgm-dock__panel{display:none;}" +
1643
+ ".bgm-dock.is-open .bgm-dock__panel{display:flex;}" +
1644
+ ".bgm-dock.is-open .bgm-dock__fab{display:none;}" +
1645
+ ".bgm-dock__overlay{position:fixed;inset:0;z-index:999999;cursor:grabbing;user-select:none;}" +
1646
+ "@media (max-width:768px){.bgm-dock{left:14px;bottom:14px;width:52px;}" +
1647
+ ".bgm-dock.is-open{width:auto;left:0;right:0;bottom:0;max-width:none;}}";
1648
+
1649
+ function buildSources() {
1650
+ var sources = [];
1651
+ var i = 0;
1652
+ (MUSIC_CONFIG.spotify || []).forEach(function (s) {
1653
+ if (!s || !s.id) return;
1654
+ sources.push({
1655
+ name: s.name || "Spotify",
1656
+ type: "spotify",
1657
+ resourceType: "playlist",
1658
+ id: String(s.id),
1659
+ key: "spotify-" + i++
1660
+ });
1353
1661
  });
1354
- bar.appendChild(refreshBtn);
1355
-
1356
- var closeBtn = document.createElement("button");
1357
- closeBtn.className = "close";
1358
- closeBtn.innerHTML = "&times;";
1359
- closeBtn.addEventListener("click", close);
1360
- bar.appendChild(closeBtn);
1361
-
1362
- /* 每个平台一个常驻 stage(切换/收起只隐藏,不销毁,播放不中断、不重载) */
1363
- var stageEl = {};
1364
- platforms.forEach(function (p) {
1365
- var d = document.createElement("div");
1366
- d.className = "stage";
1367
- d.dataset.key = p.key;
1368
- stages.appendChild(d);
1369
- stageEl[p.key] = d;
1662
+ (MUSIC_CONFIG.netease || []).forEach(function (s) {
1663
+ if (!s || !s.id) return;
1664
+ sources.push({
1665
+ name: s.name || "网易云",
1666
+ type: "netease",
1667
+ resourceType: "playlist",
1668
+ id: String(s.id),
1669
+ key: "netease-" + i++
1670
+ });
1370
1671
  });
1672
+ // 默认源标记
1673
+ var wantDefault = MUSIC_CONFIG.defaultTab === "netease" ? "netease" : "spotify";
1674
+ var def = sources.filter(function (s) { return s.type === wantDefault; })[0] || sources[0];
1675
+ if (def) def.default = true;
1676
+ return sources;
1677
+ }
1371
1678
 
1372
- var state = {
1373
- tab: MUSIC_CONFIG.defaultTab,
1374
- idx: {},
1375
- ready: {}, // 该平台当前 stage 是否已初始化
1376
- ap: null, // 网易云 APlayer 实例
1377
- tracks: null,
1378
- loading: false
1679
+ function init() {
1680
+ if (window.__yuuichiBgmInited) return;
1681
+ if (document.getElementById("bgm-dock")) return;
1682
+ window.__yuuichiBgmInited = true;
1683
+
1684
+ var sources = buildSources();
1685
+ if (!sources.length) return;
1686
+
1687
+ var styleEl = document.createElement("style");
1688
+ styleEl.id = "bgm-dock-critical";
1689
+ styleEl.textContent = CRITICAL_CSS;
1690
+ document.head.appendChild(styleEl);
1691
+
1692
+ var dock = document.createElement("div");
1693
+ dock.id = "bgm-dock";
1694
+ dock.className = "bgm-dock";
1695
+ dock.setAttribute(
1696
+ "data-bgm",
1697
+ JSON.stringify({
1698
+ sources: sources,
1699
+ autoplay: MUSIC_CONFIG.autoplay === true,
1700
+ order: MUSIC_CONFIG.order === "random" ? "random" : "list",
1701
+ volume: typeof MUSIC_CONFIG.volume === "number" ? MUSIC_CONFIG.volume : 0.6,
1702
+ theme: MUSIC_CONFIG.theme || "#0ea5e9",
1703
+ assetBase: ASSET_BASE,
1704
+ metingApi: METING_API
1705
+ })
1706
+ );
1707
+ document.body.appendChild(dock);
1708
+
1709
+ // 收起态只依赖内联 CRITICAL_CSS,主脚本 bgm-dock.js(~28KB)推迟到浏览器
1710
+ // 空闲 / load 后再拉,避免与首屏 JS、目录列表、背景图争抢带宽与主线程。
1711
+ var s = document.createElement("script");
1712
+ s.src = ASSET_BASE + "/js/bgm-dock.js";
1713
+ s.defer = true;
1714
+ s.onerror = function () {
1715
+ // 加载失败时至少保证按钮还能看到,避免完全无反应
1716
+ try { dock.style.display = "none"; } catch (e) {}
1379
1717
  };
1380
- platforms.forEach(function (p) { state.idx[p.key] = 0; state.ready[p.key] = false; });
1381
- if (!tabBtns[state.tab]) state.tab = platforms[0].key;
1382
-
1383
- var SPEEDS = [1, 1.25, 1.5, 2, 0.75];
1384
- var speedIdx = 0;
1385
- var ORDER_MODES = [
1386
- { loop: "all", order: "list", label: "列表循环", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>' },
1387
- { loop: "one", order: "list", label: "单曲循环", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 2l4 4-4 4"></path><path d="M3 11v-1a4 4 0 0 1 4-4h14"></path><path d="M7 22l-4-4 4-4"></path><path d="M21 13v1a4 4 0 0 1-4 4H3"></path><text x="12" y="15" text-anchor="middle" font-size="9" fill="currentColor" stroke="none">1</text></svg>' },
1388
- { loop: "none", order: "random", label: "随机播放", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line><polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line><line x1="4" y1="4" x2="9" y2="9"></line></svg>' },
1389
- { loop: "all", order: "list", label: "全部循环", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 2l4 4-4 4"></path><path d="M3 11v-1a4 4 0 0 1 4-4h14"></path><path d="M7 22l-4-4 4-4"></path><path d="M21 13v1a4 4 0 0 1-4 4H3"></path></svg>' }
1390
- ];
1391
- var orderIdx = 0;
1392
- try {
1393
- var sv = parseFloat(localStorage.getItem("ym-speed"));
1394
- var si = SPEEDS.indexOf(sv);
1395
- if (si >= 0) speedIdx = si;
1396
- var oi = parseInt(localStorage.getItem("ym-order"), 10);
1397
- if (!isNaN(oi) && ORDER_MODES[oi]) orderIdx = oi;
1398
- } catch (e) {}
1399
-
1400
- function applySpeed() {
1401
- var rate = SPEEDS[speedIdx];
1402
- speedLabel.textContent = (rate % 1 === 0 ? rate.toFixed(1) : String(rate)) + "x";
1403
- try {
1404
- if (state.ap && state.ap.audio) state.ap.audio.playbackRate = rate;
1405
- localStorage.setItem("ym-speed", String(rate));
1406
- } catch (e) {}
1407
- }
1408
-
1409
- function applyOrder() {
1410
- var mode = ORDER_MODES[orderIdx];
1411
- orderBtn.innerHTML = mode.icon + '<span class="order-label">' + mode.label + '</span>';
1412
- orderBtn.title = "播放顺序:" + mode.label;
1413
- try {
1414
- if (state.ap && state.ap.options) {
1415
- state.ap.options.loop = mode.loop;
1416
- state.ap.options.order = mode.order;
1417
- }
1418
- localStorage.setItem("ym-order", String(orderIdx));
1419
- } catch (e) {}
1420
- }
1421
-
1422
- speedBtn.addEventListener("click", function () {
1423
- speedIdx = (speedIdx + 1) % SPEEDS.length;
1424
- applySpeed();
1425
- });
1426
- orderBtn.addEventListener("click", function () {
1427
- orderIdx = (orderIdx + 1) % ORDER_MODES.length;
1428
- applyOrder();
1429
- });
1430
- applySpeed();
1431
- applyOrder();
1432
-
1433
- function currentPlatform() {
1434
- for (var i = 0; i < platforms.length; i++) if (platforms[i].key === state.tab) return platforms[i];
1435
- return platforms[0];
1436
- }
1437
-
1438
- function loadScriptOnce(attr, src) {
1439
- return new Promise(function (resolve, reject) {
1440
- var existing = document.querySelector("script[" + attr + "]");
1441
- if (existing) {
1442
- if (existing.dataset.loaded === "1") return resolve();
1443
- existing.addEventListener("load", function () { resolve(); });
1444
- existing.addEventListener("error", reject);
1445
- return;
1446
- }
1447
- var s = document.createElement("script");
1448
- s.async = true;
1449
- s.setAttribute(attr, "1");
1450
- s.onload = function () { s.dataset.loaded = "1"; resolve(); };
1451
- s.onerror = reject;
1452
- s.src = src;
1453
- document.head.appendChild(s);
1454
- });
1455
- }
1456
-
1457
- function loadScript(src) {
1458
- return loadScriptOnce("data-ym-aplayer", src);
1459
- }
1460
-
1461
- /* 实时同源接口:Worker 服务端优先走自建 Meting、失败回退网易云官方,无 CORS */
1462
- function fetchWithTimeout(url, ms) {
1463
- var ctrl = ("AbortController" in window) ? new AbortController() : null;
1464
- var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, ms) : null;
1465
- return fetch(url, { cache: "no-store", signal: ctrl ? ctrl.signal : undefined })
1466
- .finally(function () { if (timer) clearTimeout(timer); });
1467
- }
1468
-
1469
- async function fetchPlaylist(item, force) {
1470
- var url = "/api/music/netease?id=" + encodeURIComponent(item.id) + (force ? "&refresh=1" : "");
1471
- var res = await fetchWithTimeout(url, 15000);
1472
- var j = await res.json();
1473
- if (!j || j.code !== 200 || !Array.isArray(j.data && j.data.songs) || !j.data.songs.length) {
1474
- throw new Error("empty");
1475
- }
1476
- return j.data.songs;
1477
- }
1478
-
1479
- function statusEl(el, msg, withRetry) {
1480
- el.innerHTML = "";
1481
- var d = document.createElement("div");
1482
- d.className = "status";
1483
- var span = document.createElement("span");
1484
- span.textContent = msg;
1485
- d.appendChild(span);
1486
- if (withRetry) {
1487
- var btn = document.createElement("button");
1488
- btn.textContent = "重试";
1489
- btn.addEventListener("click", function () { setupNetease(true); });
1490
- d.appendChild(btn);
1491
- }
1492
- el.appendChild(d);
1493
- }
1494
-
1495
- async function setupNetease(force) {
1496
- var el = stageEl.netease;
1497
- if (state.ready.netease && !force) return;
1498
- if (state.loading) return;
1499
- state.loading = true;
1500
- statusEl(el, "正在加载网易云歌单…", false);
1501
- try {
1502
- var item = MUSIC_CONFIG.netease[state.idx.netease || 0];
1503
- var list = await fetchPlaylist(item, !!force);
1504
- state.tracks = list;
1505
-
1506
- if (state.ap) { try { state.ap.destroy(); } catch (e) {} state.ap = null; }
1507
- el.innerHTML = '<div class="ap-wrap"></div>';
1508
- ensureVendorCss();
1509
- await loadScript("/brand/vendor/APlayer.min.js");
1510
-
1511
- var mode = ORDER_MODES[orderIdx];
1512
- var ap = new window.APlayer({
1513
- container: el.querySelector(".ap-wrap"),
1514
- fixed: false,
1515
- mini: false,
1516
- autoplay: !!MUSIC_CONFIG.autoplay,
1517
- theme: "#0ea5e9",
1518
- loop: mode.loop,
1519
- order: mode.order,
1520
- listMaxHeight: "999px",
1521
- lrcType: list[0].lrc ? 3 : 0,
1522
- audio: list.map(function (t) {
1523
- return { name: t.name, artist: t.artist, url: t.url, cover: t.pic, lrc: t.lrc, theme: "#0ea5e9" };
1524
- })
1525
- });
1526
- state.ap = ap;
1527
- var audio = ap.audio;
1528
- if (audio) {
1529
- audio.playbackRate = SPEEDS[speedIdx];
1530
- audio.addEventListener("play", function () { audio.playbackRate = SPEEDS[speedIdx]; });
1531
- }
1532
- state.ready.netease = true;
1533
- } catch (e) {
1534
- statusEl(el, "网易云歌单加载失败(解析服务可能暂时不可用)", true);
1535
- } finally {
1536
- state.loading = false;
1537
- }
1538
- }
1539
-
1540
- function spotifySrc(item) {
1541
- var dark = document.body.classList.contains("hope-ui-dark");
1542
- return "https://open.spotify.com/embed/playlist/" + encodeURIComponent(item.id) +
1543
- "?utm_source=generator&theme=" + (dark ? "0" : "1");
1544
- }
1545
-
1546
- function setupSpotify(force) {
1547
- var el = stageEl.spotify;
1548
- if (state.ready.spotify && !force) return;
1549
- var item = MUSIC_CONFIG.spotify[state.idx.spotify || 0];
1550
- el.innerHTML = "";
1551
- var ifr = document.createElement("iframe");
1552
- ifr.loading = "lazy";
1553
- ifr.allow = "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture";
1554
- ifr.referrerPolicy = "no-referrer";
1555
- ifr.src = spotifySrc(item);
1556
- el.appendChild(ifr);
1557
- state.ready.spotify = true;
1558
- }
1559
-
1560
- function fillSelect(p) {
1561
- sel.innerHTML = "";
1562
- if (p.items.length <= 1) { sel.style.display = "none"; return; }
1563
- sel.style.display = "";
1564
- p.items.forEach(function (it, i) {
1565
- var o = document.createElement("option");
1566
- o.value = String(i);
1567
- o.textContent = it.name || ("歌单 " + (i + 1));
1568
- sel.appendChild(o);
1569
- });
1570
- sel.value = String(state.idx[p.key] || 0);
1571
- }
1572
-
1573
- function activateStages(key) {
1574
- Object.keys(stageEl).forEach(function (k) {
1575
- stageEl[k].classList.toggle("active", k === key);
1718
+ var load = function () { document.body.appendChild(s); };
1719
+ if (document.readyState === "complete") {
1720
+ var ric = window.requestIdleCallback || function (cb) { return setTimeout(cb, 200); };
1721
+ ric(load, { timeout: 2500 });
1722
+ } else {
1723
+ window.addEventListener("load", function () {
1724
+ var ric = window.requestIdleCallback || function (cb) { return setTimeout(cb, 300); };
1725
+ ric(load, { timeout: 3000 });
1576
1726
  });
1577
- ctrlBar.classList.toggle("show", key === "netease");
1578
- refreshBtn.classList.toggle("show", key === "netease");
1579
- }
1580
-
1581
- function selectTab(key) {
1582
- state.tab = key;
1583
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
1584
- var p = currentPlatform();
1585
- fillSelect(p);
1586
- activateStages(key);
1587
- if (key === "netease") setupNetease(false);
1588
- else setupSpotify(false);
1589
- try { localStorage.setItem("ym-tab", key); } catch (e) {}
1590
- }
1591
-
1592
- sel.addEventListener("change", function () {
1593
- var key = state.tab;
1594
- state.idx[key] = parseInt(sel.value, 10) || 0;
1595
- if (key === "netease") setupNetease(true);
1596
- else setupSpotify(true);
1597
- });
1598
-
1599
- function open() {
1600
- panel.classList.add("open");
1601
- var key = state.tab;
1602
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
1603
- fillSelect(currentPlatform());
1604
- activateStages(key);
1605
- if (key === "netease") setupNetease(true);
1606
- else setupSpotify(false);
1607
- try { localStorage.setItem("ym-open", "1"); } catch (e) {}
1608
- }
1609
- function close() {
1610
- panel.classList.remove("open");
1611
- fab.classList.remove("peek");
1612
- state.ready.netease = false;
1613
- try { localStorage.setItem("ym-open", "0"); } catch (e) {}
1614
- }
1615
- fab.addEventListener("click", function (ev) {
1616
- // 第一次点击在半露出吸附态时只负责全露出,不立刻打开面板;再次点击才打开。
1617
- if (!panel.classList.contains("open") && !fab.classList.contains("peek")) {
1618
- fab.classList.add("peek");
1619
- ev.stopPropagation();
1620
- return;
1621
- }
1622
- fab.classList.remove("peek");
1623
- panel.classList.contains("open") ? close() : open();
1624
- });
1625
- // 触屏没有 hover:点 FAB 之外恢复半露出;面板打开时不收起,避免遮挡交互。
1626
- document.addEventListener("pointerdown", function (ev) {
1627
- if (panel.classList.contains("open")) return;
1628
- if (wrap.contains(ev.target)) return;
1629
- fab.classList.remove("peek");
1630
- }, true);
1631
-
1632
- /* 记忆位置/尺寸/上次标签 */
1633
- try {
1634
- var savedRect = JSON.parse(localStorage.getItem("ym-rect") || "null");
1635
- if (savedRect && savedRect.w && savedRect.h && !window.matchMedia("(max-width:600px)").matches) {
1636
- panel.style.left = savedRect.x + "px";
1637
- panel.style.top = savedRect.y + "px";
1638
- panel.style.bottom = "auto";
1639
- panel.style.width = savedRect.w + "px";
1640
- panel.style.height = savedRect.h + "px";
1641
- }
1642
- var savedTab = localStorage.getItem("ym-tab");
1643
- if (savedTab && tabBtns[savedTab]) state.tab = savedTab;
1644
- } catch (e) {}
1645
-
1646
- function isMobile() { return window.matchMedia("(max-width:600px)").matches; }
1647
- function saveRect() {
1648
- if (isMobile()) return;
1649
- var r = panel.getBoundingClientRect();
1650
- try { localStorage.setItem("ym-rect", JSON.stringify({ x: r.left, y: r.top, w: r.width, h: r.height })); } catch (e) {}
1651
- }
1652
-
1653
- /* 拖动 */
1654
- var drag = null;
1655
- function startDrag(ev) {
1656
- if (ev.target.closest(".tab,.close,.sel")) return;
1657
- var pt = ev.touches ? ev.touches[0] : ev;
1658
- var r = panel.getBoundingClientRect();
1659
- drag = { x: pt.clientX, y: pt.clientY, left: r.left, top: r.top };
1660
- panel.style.left = r.left + "px";
1661
- panel.style.top = r.top + "px";
1662
- panel.style.bottom = "auto";
1663
- ev.preventDefault();
1664
- }
1665
- function moveDrag(ev) {
1666
- if (!drag) return;
1667
- var pt = ev.touches ? ev.touches[0] : ev;
1668
- var nx = drag.left + (pt.clientX - drag.x);
1669
- var ny = drag.top + (pt.clientY - drag.y);
1670
- var r = panel.getBoundingClientRect();
1671
- nx = Math.max(0, Math.min(window.innerWidth - r.width, nx));
1672
- ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
1673
- panel.style.left = nx + "px";
1674
- panel.style.top = ny + "px";
1675
- }
1676
- function endDrag() { if (drag) { drag = null; saveRect(); } }
1677
- bar.addEventListener("mousedown", startDrag);
1678
- window.addEventListener("mousemove", moveDrag);
1679
- window.addEventListener("mouseup", endDrag);
1680
- bar.addEventListener("touchstart", startDrag, { passive: false });
1681
- window.addEventListener("touchmove", moveDrag, { passive: false });
1682
- window.addEventListener("touchend", endDrag);
1683
-
1684
- /* 缩放(桌面) */
1685
- var resizer = panel.querySelector(".resizer");
1686
- var rsz = null;
1687
- resizer.addEventListener("mousedown", function (ev) {
1688
- var r = panel.getBoundingClientRect();
1689
- rsz = { x: ev.clientX, y: ev.clientY, w: r.width, h: r.height, left: r.left, top: r.top };
1690
- panel.style.left = r.left + "px";
1691
- panel.style.top = r.top + "px";
1692
- panel.style.bottom = "auto";
1693
- ev.preventDefault();
1694
- ev.stopPropagation();
1695
- });
1696
- window.addEventListener("mousemove", function (ev) {
1697
- if (!rsz) return;
1698
- var w = Math.max(300, rsz.w + (ev.clientX - rsz.x));
1699
- var h = Math.max(360, rsz.h + (ev.clientY - rsz.y));
1700
- w = Math.min(w, window.innerWidth - rsz.left - 8);
1701
- panel.style.width = w + "px";
1702
- panel.style.height = h + "px";
1703
- });
1704
- window.addEventListener("mouseup", function () { if (rsz) { rsz = null; saveRect(); } });
1705
-
1706
- /* 明暗主题:同步宿主属性;Spotify 已初始化时重建一次(仅换主题,曲目由其自身续播) */
1707
- function syncDark() {
1708
- var dark = document.body.classList.contains("hope-ui-dark");
1709
- if (dark) host.setAttribute("data-dark", "");
1710
- else host.removeAttribute("data-dark");
1711
1727
  }
1712
- syncDark();
1713
- var mo = new MutationObserver(syncDark);
1714
- mo.observe(document.body, { attributes: true, attributeFilter: ["class"] });
1715
1728
  }
1716
1729
 
1717
1730
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);