@yuuichi/openlist-assets 4.2.6-ym.2 → 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.
@@ -0,0 +1,1733 @@
1
+ /* Yuuichi brand bundle — generated, do not edit directly.
2
+ * Source order: footer-links.js, upload-direct.js, upload-add.js, notify-auto-close.js, bg-mask-toggle.js, global-search.js, music-player.js */
3
+ "use strict";
4
+ /* ===== footer-links.js ===== */
5
+ ;(function(){
6
+ (function () {
7
+ "use strict";
8
+
9
+ function inject() {
10
+ var footer = document.querySelector(".footer");
11
+ if (!footer || document.getElementById("ym-footer-links")) {
12
+ return !!document.getElementById("ym-footer-links");
13
+ }
14
+
15
+ var row = document.createElement("div");
16
+ row.id = "ym-footer-links";
17
+
18
+ var home = document.createElement("a");
19
+ home.href = "https://www.yuuichi.me";
20
+ home.target = "_blank";
21
+ home.rel = "noopener noreferrer";
22
+ home.textContent = "欢迎页";
23
+
24
+ var sep = document.createElement("span");
25
+ sep.className = "ym-footer-sep";
26
+ sep.textContent = "|";
27
+
28
+ var blog = document.createElement("a");
29
+ blog.href = "https://blog.yuuichi.me";
30
+ blog.target = "_blank";
31
+ blog.rel = "noopener noreferrer";
32
+ blog.textContent = "Blog";
33
+
34
+ row.appendChild(home);
35
+ row.appendChild(sep);
36
+ row.appendChild(blog);
37
+
38
+ var poweredRow = footer.firstElementChild;
39
+ if (poweredRow) footer.insertBefore(row, poweredRow.nextSibling);
40
+ else footer.appendChild(row);
41
+ return true;
42
+ }
43
+
44
+ // 用一次性 MutationObserver 等待 .footer 出现,替代原来的 800ms×20 轮询
45
+ // (轮询在页面整个加载早期反复触发布局查询,纯属浪费)。
46
+ function watch() {
47
+ if (inject()) return;
48
+ var obs = new MutationObserver(function () {
49
+ if (inject()) obs.disconnect();
50
+ });
51
+ obs.observe(document.body || document.documentElement, {
52
+ childList: true,
53
+ subtree: true,
54
+ });
55
+ // 兜底:30s 后停止观察,避免极端情况下常驻
56
+ setTimeout(function () { obs.disconnect(); }, 30000);
57
+ }
58
+
59
+ if (document.readyState === "loading")
60
+ document.addEventListener("DOMContentLoaded", watch, { once: true });
61
+ else watch();
62
+ })();
63
+ })();
64
+ /* ===== upload-direct.js ===== */
65
+ ;(function(){
66
+ (function () {
67
+ "use strict";
68
+
69
+ // 大文件分片:浏览器拿预签 URL 直传阿里云盘 OSS(Worker 不中转文件内容)。
70
+ // 全过程派发 "ym:multipart" 事件,供 upload-add.js 做实时反馈,尤其覆盖
71
+ // 「取直传地址(presign)」与「云端合并(complete)」这两个原本无进度、会显示 -/s 的阶段:
72
+ // presign:start/done | part:start/progress/done | relay | complete:start/done
73
+
74
+ var EVT = "ym:multipart";
75
+ function emit(type, detail) {
76
+ try {
77
+ window.dispatchEvent(
78
+ new CustomEvent(EVT, { detail: Object.assign({ type: type }, detail) }),
79
+ );
80
+ } catch (e) {}
81
+ }
82
+
83
+ function apiOrigin() {
84
+ var cfg = window.OPENLIST_CONFIG || {};
85
+ if (cfg.api && cfg.api !== "/") return cfg.api.replace(/\/+$/, "");
86
+ return "";
87
+ }
88
+ function authHeaders() {
89
+ var token = localStorage.getItem("token") || "";
90
+ return token ? { Authorization: token } : {};
91
+ }
92
+
93
+ function xhrPut(url, body, onProgress) {
94
+ return new Promise(function (resolve, reject) {
95
+ var x = new XMLHttpRequest();
96
+ x.open("PUT", url, true);
97
+ x.upload.addEventListener("progress", function (ev) {
98
+ if (onProgress && ev.lengthComputable) onProgress({ loaded: ev.loaded, total: ev.total });
99
+ });
100
+ x.addEventListener("load", function () {
101
+ if (x.status >= 200 && x.status < 300) resolve({ status: x.status });
102
+ else reject(new Error("direct PUT " + x.status));
103
+ });
104
+ x.addEventListener("error", function () { reject(new Error("direct PUT network error")); });
105
+ x.addEventListener("abort", function () { reject(new Error("direct PUT aborted")); });
106
+ x.send(body); // 不带 Content-Type(预签未包含该头)
107
+ });
108
+ }
109
+
110
+ async function getJson(url) {
111
+ var res = await fetch(url, { headers: authHeaders(), credentials: "include" });
112
+ if (!res.ok) throw new Error("HTTP " + res.status);
113
+ return res.json();
114
+ }
115
+
116
+ function isMultipartUrl(u) {
117
+ return typeof u === "string" && u.indexOf("/fs/multipart/") !== -1;
118
+ }
119
+ function kindOf(u) {
120
+ if (u.indexOf("/multipart/complete") !== -1) return "complete";
121
+ if (u.indexOf("/multipart/status") !== -1) return "status";
122
+ return "";
123
+ }
124
+
125
+ // ── 监听合并/完成阶段:拦截内置 axios 对 /fs/multipart/complete 与
126
+ // /fs/multipart/status 的调用 ──
127
+ //
128
+ // 为什么必须同时盯 status:前端在 complete 之后会轮询 /multipart/status,
129
+ // 并把 `state==="completed"` 或 404(会话已删)当作「最终完成」。
130
+ // 仅靠本地 part:done 计数会在「某片走 relay 兜底」或「complete 请求失败、
131
+ // 改由 status 轮询收尾」时永远停在「已完成 x/N 片」(进度欺诈)。
132
+ // 因此以服务端 status 的结论作为权威完成信号(upload:finish)。
133
+ function watchComplete() {
134
+ if (window.__ymCompleteWatched) return;
135
+ window.__ymCompleteWatched = true;
136
+ var ofetch = window.fetch;
137
+ if (ofetch) {
138
+ window.fetch = function (input, init) {
139
+ var url = typeof input === "string" ? input : input && input.url;
140
+ if (!isMultipartUrl(url)) return ofetch.apply(this, arguments);
141
+ var kind = kindOf(url);
142
+ var uploadId =
143
+ (init && init.headers && (init.headers["X-Upload-Id"] || init.headers["x-upload-id"])) ||
144
+ (url && url.split("upload_id=")[1]) ||
145
+ "";
146
+ if (kind === "complete") emit("complete:start", { uploadId: uploadId });
147
+ return ofetch.apply(this, arguments).then(
148
+ function (r) {
149
+ if (kind === "complete") emit("complete:done", { ok: r.ok, uploadId: uploadId });
150
+ if (kind === "status") observeStatus(r.clone(), uploadId);
151
+ return r;
152
+ },
153
+ function (e) {
154
+ if (kind === "complete") emit("complete:done", { ok: false, uploadId: uploadId });
155
+ throw e;
156
+ },
157
+ );
158
+ };
159
+ }
160
+ // 内置用 axios(XHR),再包一层 XHR open/send 兜底
161
+ var XO = XMLHttpRequest.prototype.open;
162
+ var XS = XMLHttpRequest.prototype.send;
163
+ var XH = XMLHttpRequest.prototype.setRequestHeader;
164
+ XMLHttpRequest.prototype.open = function (method, url) {
165
+ this.__ymUrl = url || "";
166
+ return XO.apply(this, arguments);
167
+ };
168
+ XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
169
+ try {
170
+ if (/^x-upload-id$/i.test(k)) this.__ymUploadId = v;
171
+ } catch (e) {}
172
+ return XH.apply(this, arguments);
173
+ };
174
+ XMLHttpRequest.prototype.send = function () {
175
+ var url = this.__ymUrl || "";
176
+ if (isMultipartUrl(url)) {
177
+ var kind = kindOf(url);
178
+ var uploadId = this.__ymUploadId || (url.split("upload_id=")[1] || "");
179
+ if (kind === "complete") {
180
+ emit("complete:start", { uploadId: uploadId });
181
+ this.addEventListener("loadend", function () {
182
+ emit("complete:done", { ok: this.status >= 200 && this.status < 300, uploadId: uploadId });
183
+ });
184
+ } else if (kind === "status") {
185
+ this.addEventListener("loadend", function () {
186
+ parseAndEmitStatus(this.status, this.responseText, uploadId);
187
+ });
188
+ }
189
+ }
190
+ return XS.apply(this, arguments);
191
+ };
192
+ }
193
+
194
+ function observeStatus(res, uploadId) {
195
+ res
196
+ .text()
197
+ .then(function (txt) {
198
+ parseAndEmitStatus(res.status, txt, uploadId);
199
+ })
200
+ .catch(function () {});
201
+ }
202
+
203
+ function parseAndEmitStatus(status, txt, uploadId) {
204
+ // 404:会话已被删除 = complete 已成功收尾
205
+ if (status === 404) {
206
+ emit("upload:finish", { uploadId: uploadId, state: "completed" });
207
+ return;
208
+ }
209
+ var j = null;
210
+ try {
211
+ j = txt ? JSON.parse(txt) : null;
212
+ } catch (e) {
213
+ return;
214
+ }
215
+ if (!j || j.code !== 200 || !j.data) return;
216
+ var state = j.data.state;
217
+ if (state === "completed") {
218
+ emit("upload:finish", { uploadId: uploadId, state: state, progress: 100 });
219
+ } else if (state === "merging") {
220
+ emit("complete:start", { uploadId: uploadId });
221
+ }
222
+ }
223
+
224
+ async function directPutChunk(h, payload, cfg) {
225
+ watchComplete();
226
+
227
+ var headers = (cfg && cfg.headers) || {};
228
+ var uploadId = headers["X-Upload-Id"];
229
+ var index = Number(headers["X-Chunk-Index"]);
230
+ var onProgress = cfg && cfg.onUploadProgress;
231
+ var partSize = (payload && (payload.size || payload.byteLength || payload.length)) || 0;
232
+
233
+ // 1) 取直传地址(这一段原本无反馈)
234
+ emit("presign:start", { uploadId: uploadId, index: index });
235
+ var directUrl = "", total = 0, gotUrl = false;
236
+ try {
237
+ var presign = await getJson(
238
+ apiOrigin() +
239
+ "/api/fs/multipart/presign?upload_id=" +
240
+ encodeURIComponent(uploadId) +
241
+ "&index=" +
242
+ index,
243
+ );
244
+ if (presign && presign.code === 200 && presign.data && presign.data.url) {
245
+ directUrl = presign.data.url;
246
+ total = presign.data.total_chunks || 0;
247
+ gotUrl = true;
248
+ }
249
+ } catch (e) {
250
+ directUrl = "";
251
+ }
252
+ emit("presign:done", { uploadId: uploadId, index: index, total: total, ok: gotUrl });
253
+
254
+ if (directUrl) {
255
+ var t0 = Date.now();
256
+ emit("part:start", { uploadId: uploadId, index: index, total: total, bytes: partSize });
257
+ try {
258
+ await xhrPut(directUrl, payload, function (ev) {
259
+ var dt = (Date.now() - t0) / 1000 || 0.001;
260
+ emit("part:progress", {
261
+ uploadId: uploadId,
262
+ index: index,
263
+ total: total,
264
+ loaded: ev.loaded,
265
+ bytes: ev.total || partSize,
266
+ bps: ev.loaded / dt,
267
+ });
268
+ if (onProgress) onProgress(ev);
269
+ });
270
+ // 登记进度:不 await,避免拖住下一片上传
271
+ getJson(
272
+ apiOrigin() +
273
+ "/api/fs/multipart/confirm?upload_id=" +
274
+ encodeURIComponent(uploadId) +
275
+ "&index=" +
276
+ index,
277
+ ).catch(function () {});
278
+ emit("part:done", {
279
+ uploadId: uploadId, index: index, total: total, bytes: partSize, ms: Date.now() - t0,
280
+ });
281
+ return { code: 200, message: "success", data: { state: "receiving", storage_progress: 0 } };
282
+ } catch (err) {
283
+ emit("part:error", {
284
+ uploadId: uploadId, index: index, total: total,
285
+ message: String((err && err.message) || err),
286
+ });
287
+ // 落到 Worker 中转兜底
288
+ }
289
+ }
290
+
291
+ emit("relay", { uploadId: uploadId, index: index, total: total });
292
+ return h.put("/fs/multipart/chunk", payload, cfg);
293
+ }
294
+
295
+ window.__ymDirectPut = directPutChunk;
296
+ })();
297
+ })();
298
+ /* ===== upload-add.js ===== */
299
+ ;(function(){
300
+ (function () {
301
+ "use strict";
302
+
303
+ // 上传状态面板:状态只以真实事件为准,避免“文件已传完仍显示直传中”或
304
+ // “没有速度却显示传输中”。速率由各活跃分片的 1s 滑动窗口聚合计算。
305
+ var PANEL_ID = "ym-upload-panel";
306
+
307
+ var uploads = Object.create(null);
308
+ var phase = { name: "idle", at: 0 };
309
+
310
+ function nowMs() { return Date.now(); }
311
+
312
+ function ensure(id) {
313
+ if (!uploads[id]) {
314
+ uploads[id] = {
315
+ total: 0,
316
+ doneCount: 0,
317
+ done: Object.create(null),
318
+ waiting: new Set(),
319
+ active: Object.create(null),
320
+ finished: false,
321
+ };
322
+ }
323
+ return uploads[id];
324
+ }
325
+
326
+ function activeUploadCount() { return Object.keys(uploads).length; }
327
+ function anyWaiting() {
328
+ return Object.keys(uploads).some(function (id) { return uploads[id].waiting.size > 0; });
329
+ }
330
+ function allPartsDone() {
331
+ var ids = Object.keys(uploads);
332
+ return ids.length > 0 && ids.every(function (id) {
333
+ var u = uploads[id];
334
+ return u.total > 0 && u.doneCount >= u.total;
335
+ });
336
+ }
337
+
338
+ function resetAll(reason) {
339
+ uploads = Object.create(null);
340
+ phase = reason ? { name: reason, at: nowMs() } : { name: "idle", at: 0 };
341
+ render();
342
+ }
343
+
344
+ var cssInjected = false;
345
+ function injectCss() {
346
+ if (cssInjected) return;
347
+ cssInjected = true;
348
+ var st = document.createElement("style");
349
+ st.textContent = [
350
+ "#" + PANEL_ID + "{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;padding:12px 14px;",
351
+ " border-radius:12px;font-size:14px;font-weight:700;line-height:1.4;color:#075985;",
352
+ " background:linear-gradient(135deg,#e0f2fe,#bae6fd);border:1px solid #7dd3fc;",
353
+ " box-shadow:0 2px 10px rgba(2,132,199,.18);}",
354
+ "#" + PANEL_ID + ".ym-merge{color:#7c2d12;background:linear-gradient(135deg,#ffedd5,#fed7aa);border-color:#fdba74;}",
355
+ "#" + PANEL_ID + ".ym-wait{color:#854d0e;background:linear-gradient(135deg,#fef9c3,#fde68a);border-color:#facc15;}",
356
+ "#" + PANEL_ID + ".ym-done{color:#14532d;background:linear-gradient(135deg,#dcfce7,#bbf7d0);border-color:#86efac;}",
357
+ "#" + PANEL_ID + " .ym-spin{width:18px;height:18px;margin-top:2px;flex:0 0 auto;",
358
+ " border:3px solid rgba(2,132,199,.25);border-top-color:#0284c7;border-radius:50%;animation:ym-sp .8s linear infinite;}",
359
+ "#" + PANEL_ID + ".ym-merge .ym-spin{border-color:rgba(194,65,12,.25);border-top-color:#c2410c;}",
360
+ "#" + PANEL_ID + ".ym-wait .ym-spin{border-color:rgba(161,98,7,.25);border-top-color:#a16207;}",
361
+ "#" + PANEL_ID + ".ym-done .ym-spin{display:none;}",
362
+ "#" + PANEL_ID + " .ym-sub{display:block;font-size:12px;font-weight:600;opacity:.92;margin-top:3px;}",
363
+ "@keyframes ym-sp{to{transform:rotate(360deg)}}",
364
+ ].join("\n");
365
+ document.head.appendChild(st);
366
+ }
367
+
368
+ function findUploadModal() {
369
+ var dlgs = document.querySelectorAll('[role="dialog"].hope-modal__content, [role="dialog"]');
370
+ for (var i = 0; i < dlgs.length; i++) {
371
+ var d = dlgs[i];
372
+ var r = d.getBoundingClientRect();
373
+ if (r.width === 0) continue;
374
+ var t = d.textContent || "";
375
+ if (
376
+ t.indexOf("返回上传") !== -1 ||
377
+ t.indexOf("清空完成") !== -1 ||
378
+ t.indexOf("选择文件") !== -1 ||
379
+ t.indexOf("选择文件夹") !== -1 ||
380
+ t.indexOf("上传中") !== -1 ||
381
+ t.indexOf("后台上传中") !== -1
382
+ ) return d;
383
+ }
384
+ return null;
385
+ }
386
+
387
+ function panelBody() {
388
+ var modal = findUploadModal();
389
+ return modal ? modal.querySelector(".hope-modal__body, [id$='--body']") || modal : null;
390
+ }
391
+
392
+ function fmtSpeed(bps) {
393
+ if (!bps || bps < 0) return "0 KB/s";
394
+ if (bps >= 1048576) return (bps / 1048576).toFixed(1) + " MB/s";
395
+ return Math.round(bps / 1024) + " KB/s";
396
+ }
397
+
398
+ function summarize() {
399
+ var ids = Object.keys(uploads);
400
+ var done = 0, total = 0, active = 0, bytesLoaded = 0, bytesTotal = 0, bps = 0;
401
+ ids.forEach(function (id) {
402
+ var u = uploads[id];
403
+ total += u.total || 0;
404
+ done += u.doneCount || 0;
405
+ Object.keys(u.active).forEach(function (k) {
406
+ var a = u.active[k];
407
+ active++;
408
+ bytesLoaded += a.loaded || 0;
409
+ bytesTotal += a.bytes || a.total || 0;
410
+ bps += currentPartSpeed(a);
411
+ });
412
+ });
413
+ return { done: done, total: total, active: active, bps: bps, bytesLoaded: bytesLoaded, bytesTotal: bytesTotal };
414
+ }
415
+
416
+ function currentPartSpeed(a) {
417
+ if (!a.samples || a.samples.length < 2) return 0;
418
+ var first = a.samples[0], last = a.samples[a.samples.length - 1];
419
+ var dt = (last.t - first.t) / 1000;
420
+ if (dt <= 0) return 0;
421
+ return Math.max(0, (last.loaded - first.loaded) / dt);
422
+ }
423
+
424
+ function pushSample(a, loaded) {
425
+ var t = nowMs();
426
+ a.loaded = loaded;
427
+ if (!a.samples) a.samples = [];
428
+ a.samples.push({ t: t, loaded: loaded });
429
+ while (a.samples.length > 2 && t - a.samples[0].t > 1500) a.samples.shift();
430
+ }
431
+
432
+ function onEvent(ev) {
433
+ var d = ev.detail || {};
434
+ var id = d.uploadId || "_";
435
+
436
+ if (d.type === "upload:finish") {
437
+ if (id && uploads[id]) delete uploads[id];
438
+ else uploads = Object.create(null);
439
+ if (!activeUploadCount()) {
440
+ phase = { name: "done", at: nowMs() };
441
+ render();
442
+ setTimeout(function () { if (phase.name === "done") resetAll("idle"); }, 1200);
443
+ }
444
+ return;
445
+ }
446
+ if (d.type === "complete:start" || d.state === "merging") {
447
+ // 前端在发出 complete 请求后、本地最后一个 part:done 事件到达前可能先触发本事件;
448
+ // 仍有活跃分片时必须显示“传输中”,不能提前显示“合并中”。
449
+ var st = summarize();
450
+ phase = { name: st.active > 0 || anyWaiting() ? "parts" : "merging", at: nowMs() };
451
+ render();
452
+ return;
453
+ }
454
+ if (d.type === "complete:done") {
455
+ if (d.ok) {
456
+ phase = { name: "done", at: nowMs() };
457
+ uploads = Object.create(null);
458
+ render();
459
+ setTimeout(function () { resetAll("idle"); }, 1200);
460
+ } else {
461
+ phase = { name: "parts", at: nowMs() };
462
+ render();
463
+ }
464
+ return;
465
+ }
466
+
467
+ if (d.type === "presign:start" || d.type === "part:start" || d.type === "part:progress" ||
468
+ d.type === "part:done" || d.type === "part:error" || d.type === "relay") {
469
+ if (phase.name === "idle" || phase.name === "done") phase = { name: "parts", at: nowMs() };
470
+ }
471
+
472
+ var u = ensure(id);
473
+ if (d.total) u.total = d.total;
474
+
475
+ if (d.type === "presign:start") {
476
+ u.waiting.add(d.index);
477
+ } else if (d.type === "presign:done") {
478
+ u.waiting.delete(d.index);
479
+ } else if (d.type === "part:start") {
480
+ u.waiting.delete(d.index);
481
+ u.active[d.index] = {
482
+ index: d.index,
483
+ total: d.total,
484
+ bytes: d.bytes || 0,
485
+ loaded: 0,
486
+ samples: [{ t: nowMs(), loaded: 0 }],
487
+ };
488
+ } else if (d.type === "part:progress") {
489
+ if (!u.active[d.index]) {
490
+ u.active[d.index] = { index: d.index, total: d.total, bytes: d.bytes || 0, loaded: 0, samples: [] };
491
+ }
492
+ if (d.bytes) u.active[d.index].bytes = d.bytes;
493
+ pushSample(u.active[d.index], d.loaded || 0);
494
+ } else if (d.type === "part:done") {
495
+ if (!u.done[d.index]) { u.done[d.index] = 1; u.doneCount++; }
496
+ delete u.active[d.index];
497
+ u.waiting.delete(d.index);
498
+ if (u.total && u.doneCount >= u.total) {
499
+ u.finished = true;
500
+ phase = { name: "finalizing", at: nowMs() };
501
+ }
502
+ } else if (d.type === "part:error" || d.type === "relay") {
503
+ delete u.active[d.index];
504
+ u.waiting.delete(d.index);
505
+ }
506
+ render();
507
+ }
508
+
509
+ function render() {
510
+ var body = panelBody();
511
+ var panel = document.getElementById(PANEL_ID);
512
+ var hasUploads = activeUploadCount() > 0;
513
+ var s = hasUploads ? summarize() : null;
514
+
515
+ if (phase.name === "merging" || phase.name === "finalizing") {
516
+ if (!body) return;
517
+ if (!panel) { panel = document.createElement("div"); panel.id = PANEL_ID; body.insertBefore(panel, body.firstChild); }
518
+ var secs = Math.max(1, Math.round((nowMs() - phase.at) / 1000));
519
+ var merging = phase.name === "merging" || (s && s.active === 0 && allPartsDone());
520
+ panel.className = "ym-merge";
521
+ panel.innerHTML =
522
+ '<span class="ym-spin"></span><span>' +
523
+ (merging ? "正在云端合并分片…(已 " + secs + " 秒)" : "分片已全部上传,正在提交合并请求…") +
524
+ '<span class="ym-sub">此阶段由网盘服务器处理,不再上传文件内容,因此下方速度显示为 0 或 “-/s” 属正常;大文件可能需要几秒到几十秒,请勿关闭窗口。</span></span>';
525
+ return;
526
+ }
527
+
528
+ if (phase.name === "done") {
529
+ if (!body) return;
530
+ if (!panel) { panel = document.createElement("div"); panel.id = PANEL_ID; body.insertBefore(panel, body.firstChild); }
531
+ panel.className = "ym-done";
532
+ panel.innerHTML = "<span>✓</span><span>上传完成,文件正在列表中刷新…</span>";
533
+ return;
534
+ }
535
+
536
+ if (!hasUploads) { if (panel) panel.remove(); return; }
537
+ if (!body) return;
538
+ if (!panel) { panel = document.createElement("div"); panel.id = PANEL_ID; body.insertBefore(panel, body.firstChild); }
539
+
540
+ var pct = s.total ? Math.min(99, Math.round((s.done / s.total) * 100)) : 0;
541
+ var line1 = "大文件切片直传中 · 已完成 " + s.done + (s.total ? " / " + s.total + " 片(" + pct + "%)" : " 片");
542
+ var line2;
543
+ if (s.active > 0 && s.bps > 1024) {
544
+ line2 = "正在传输 " + s.active + (s.active > 1 ? " 个分片" : " 个分片") + " · 实时速率 " + fmtSpeed(s.bps);
545
+ panel.className = "";
546
+ } else if (s.active > 0) {
547
+ line2 = "正在传输 " + s.active + (s.active > 1 ? " 个分片" : " 个分片") + " · 正在建立连接/等待首包,暂不计速";
548
+ panel.className = "ym-wait";
549
+ } else if (anyWaiting()) {
550
+ line2 = "正在向网盘申请分片直传地址…(这一步不传文件,暂时没有速度)";
551
+ panel.className = "ym-wait";
552
+ } else {
553
+ line2 = "正在准备下一片…(暂不传文件,速度为 0)";
554
+ panel.className = "ym-wait";
555
+ }
556
+ panel.innerHTML =
557
+ '<span class="ym-spin"></span><span>' + line1 + '<span class="ym-sub">' + line2 + "</span></span>";
558
+ }
559
+
560
+ var scheduled = false;
561
+ function schedule() {
562
+ if (scheduled) return;
563
+ scheduled = true;
564
+ requestAnimationFrame(function () {
565
+ scheduled = false;
566
+ var panel = document.getElementById(PANEL_ID);
567
+ if (panel || document.querySelector('[role="dialog"]')) render();
568
+ });
569
+ }
570
+
571
+ setInterval(function () {
572
+ if (phase.name === "merging" || phase.name === "finalizing") render();
573
+ else if (activeUploadCount()) {
574
+ var s = summarize();
575
+ if (s.active > 0) render();
576
+ }
577
+ }, 1000);
578
+
579
+ function boot() {
580
+ injectCss();
581
+ render();
582
+ window.addEventListener("ym:multipart", onEvent);
583
+ new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
584
+ }
585
+
586
+ if (document.readyState === "loading")
587
+ document.addEventListener("DOMContentLoaded", boot, { once: true });
588
+ else boot();
589
+ })();
590
+ })();
591
+ /* ===== notify-auto-close.js ===== */
592
+ ;(function(){
593
+ (function () {
594
+ "use strict";
595
+
596
+ // ─────────────────────────────────────────────────────────────
597
+ // 通知策略(基于真实 DOM 行为,适配 Hope UI 通知实现)
598
+ //
599
+ // Hope UI 通知有两种形态:
600
+ // 1) 普通通知:容器为 [role=alert],成功/失败/信息,成功(success) 3s 自动关;
601
+ // 失败/警告/信息保持常驻,由用户手动点 X。
602
+ // 2) 首页公告:announcement 走「自定义 render」,挂载为 .hope-notification__list
603
+ // 下的普通 div(无 role=alert),内含 markdown。Solid 内部在挂载时
604
+ // 用闭包 setTimeout(duration=5000) 自动关闭,且无法通过修改事件 detail 取消。
605
+ // => 这里在它出现的瞬间反复 mouseenter 暂停计时,并在 5s 计时窗口内
606
+ // 克隆节点以打断原闭包定时器,得到一个“常驻、可关闭、带勾选框”的公告。
607
+ //
608
+ // 公告提供「今日内不再提示」勾选框;勾选后写入 localStorage,当天不再出现。
609
+ // ─────────────────────────────────────────────────────────────
610
+
611
+ var AUTO_CLOSE_MS = 3000;
612
+ var SUCCESS_PATH = "m14 21.591"; // Hope 成功对勾 path 特征
613
+ var GUARD_MS = 9000; // 公告自动关闭窗口(默认 5s),此期间持续保护
614
+
615
+ var DAY_KEY = (function () {
616
+ var d = new Date();
617
+ return (
618
+ "ym-announcement-hide-" +
619
+ d.getFullYear() +
620
+ "-" +
621
+ String(d.getMonth() + 1).padStart(2, "0") +
622
+ "-" +
623
+ String(d.getDate()).padStart(2, "0")
624
+ );
625
+ })();
626
+
627
+ function suppressed() {
628
+ try { return localStorage.getItem(DAY_KEY) === "1"; } catch (e) { return false; }
629
+ }
630
+ function setSuppressed(v) {
631
+ try {
632
+ if (v) localStorage.setItem(DAY_KEY, "1");
633
+ else localStorage.removeItem(DAY_KEY);
634
+ } catch (e) {}
635
+ }
636
+
637
+ function listEl() {
638
+ return document.querySelector(".hope-notification__list");
639
+ }
640
+
641
+ // 通知的直接子项:普通通知是 [role=alert],自定义公告是普通 div
642
+ function items() {
643
+ var l = listEl();
644
+ return l ? Array.prototype.slice.call(l.children) : [];
645
+ }
646
+
647
+ function isSuccessAlert(node) {
648
+ if (!node || node.getAttribute("role") !== "alert") return false;
649
+ var paths = node.querySelectorAll("path");
650
+ for (var i = 0; i < paths.length; i++) {
651
+ if ((paths[i].getAttribute("d") || "").indexOf(SUCCESS_PATH) !== -1) return true;
652
+ }
653
+ return false;
654
+ }
655
+
656
+ function hasMarkdown(node) {
657
+ return !!node.querySelector(".markdown-body, .markdown, table, h1, h2, h3");
658
+ }
659
+
660
+ // 判定“自定义公告”:列表里无 role 的直接子项,且含 markdown/较长正文
661
+ function isAnnouncementNode(node) {
662
+ if (!node || node.getAttribute && node.getAttribute("role") === "alert") return false;
663
+ if (node.__ymAnnMark) return true;
664
+ var t = (node.textContent || "").replace(/\s+/g, "");
665
+ if (hasMarkdown(node) && t.length >= 12) return true;
666
+ return false;
667
+ }
668
+
669
+ function removeNode(node) {
670
+ if (node && node.parentNode) node.parentNode.removeChild(node);
671
+ }
672
+
673
+ // 通过派发 mouseenter 让 Hope 清除内部 setTimeout(其 onMouseEnter 会 clearTimeout)
674
+ function pauseTimer(node) {
675
+ try {
676
+ var ev = new MouseEvent("mouseenter", { bubbles: false, cancelable: true });
677
+ node.dispatchEvent(ev);
678
+ } catch (e) {}
679
+ }
680
+ // 离开会重启计时 —— 我们不主动派发 mouseleave,保持“指针悬停”态即可常驻。
681
+
682
+ function injectCheckbox(node) {
683
+ if (node.__ymAnnMark) {
684
+ // 已注入但位置不对(markdown 晚于注入渲染):移回正文容器内部
685
+ var bar = node.querySelector("label.ym-ann-label");
686
+ if (bar) relocate(bar, node);
687
+ return;
688
+ }
689
+ node.__ymAnnMark = true;
690
+ // 公告外层卡片是 flex(横向),直接 append 会被排到右侧。
691
+ // 必须放进正文容器 .markdown-body:块级,自然落在文字正下方。
692
+ var bar = document.createElement("label");
693
+ bar.className = "ym-ann-label";
694
+ bar.style.cssText =
695
+ "display:flex;align-items:center;gap:6px;margin-top:10px;width:100%;" +
696
+ "padding-top:8px;border-top:1px solid rgba(128,128,128,.3);" +
697
+ "font-size:13px;cursor:pointer;user-select:none;opacity:.92;font-weight:500;";
698
+ var cb = document.createElement("input");
699
+ cb.type = "checkbox";
700
+ cb.style.cssText = "width:15px;height:15px;margin:0;flex:0 0 auto;cursor:pointer;";
701
+ var txt = document.createElement("span");
702
+ txt.textContent = "今日内不再提示(仅记录偏好,关闭请点右上角 ×)";
703
+ bar.appendChild(cb);
704
+ bar.appendChild(txt);
705
+ // 勾选只记忆偏好,不关闭当前公告;关闭一律由用户点 ×
706
+ cb.addEventListener("change", function () {
707
+ setSuppressed(cb.checked);
708
+ });
709
+ relocate(bar, node);
710
+ }
711
+
712
+ // 把勾选条放进 .markdown-body;正文尚未渲染时先放卡片,后续 scan 会再迁移
713
+ function relocate(bar, node) {
714
+ var host =
715
+ node.querySelector(".markdown-body") ||
716
+ node.querySelector(".markdown") ||
717
+ node;
718
+ if (bar.parentNode !== host) host.appendChild(bar);
719
+ }
720
+
721
+ // 处理普通 [role=alert]:仅成功通知 3s 自动关
722
+ function processAlert(node) {
723
+ if (!node || node.__ymAlertHandled) return;
724
+ node.__ymAlertHandled = true;
725
+ if (isSuccessAlert(node)) {
726
+ setTimeout(function () {
727
+ // 优先点 Hope 自带 X;兜底直接移除
728
+ var btn = node.querySelector("button, [role='button']");
729
+ if (btn) btn.click();
730
+ setTimeout(function () { removeNode(node); }, 120);
731
+ }, AUTO_CLOSE_MS);
732
+ }
733
+ }
734
+
735
+ function handleAnnouncement(node) {
736
+ if (node.__ymAnnDone) return;
737
+ node.__ymAnnDone = true;
738
+
739
+ if (suppressed()) {
740
+ removeNode(node);
741
+ return;
742
+ }
743
+
744
+ // 1) 立即暂停其内部 5s 计时
745
+ pauseTimer(node);
746
+ // 2) 注入勾选框(markdown 已随公告内容同步渲染)
747
+ injectCheckbox(node);
748
+ // 3) 保护期内:若节点仍在,持续暂停(防止 Solid 因任何原因重启计时),
749
+ // 并确保勾选框存在
750
+ var guardUntil = Date.now() + GUARD_MS;
751
+ var iv = setInterval(function () {
752
+ if (!document.body.contains(node)) {
753
+ clearInterval(iv);
754
+ return;
755
+ }
756
+ // 勾选只影响“下次访问”,本次公告仍常驻、仍由 × 关闭,所以这里始终暂停计时
757
+ pauseTimer(node);
758
+ injectCheckbox(node); // 幂等;若 markdown 晚渲染会把勾选条迁移到正文下方
759
+ if (Date.now() > guardUntil) clearInterval(iv);
760
+ }, 400);
761
+ }
762
+
763
+ function scan() {
764
+ var list = items();
765
+ for (var i = 0; i < list.length; i++) {
766
+ var n = list[i];
767
+ if (n.getAttribute && n.getAttribute("role") === "alert") {
768
+ processAlert(n);
769
+ } else if (isAnnouncementNode(n)) {
770
+ handleAnnouncement(n);
771
+ }
772
+ }
773
+ }
774
+
775
+ var scheduled = false;
776
+ function schedule() {
777
+ if (scheduled) return;
778
+ scheduled = true;
779
+ requestAnimationFrame(function () {
780
+ scheduled = false;
781
+ scan();
782
+ });
783
+ }
784
+
785
+ // 早期捕获:公告事件触发时记录一个极早时间点,下一次微任务即扫描,
786
+ // 尽量在 5s 计时起步时就 mouseenter 暂停
787
+ window.addEventListener(
788
+ "hope-ui:show-notification",
789
+ function () {
790
+ for (var k = 0; k < 6; k++) setTimeout(scan, k * 120);
791
+ },
792
+ true,
793
+ );
794
+
795
+ // X 按钮兜底:高延迟下 Solid 的 onClick 偶发不生效。在通知列表上做捕获
796
+ // 委托,点到关闭按钮后,下一帧若通知仍存在则强制移除(不拦截原事件)。
797
+ function bindCloseFallback() {
798
+ document.addEventListener(
799
+ "click",
800
+ function (ev) {
801
+ var t = ev.target;
802
+ if (!t || !t.closest) return;
803
+ var list = listEl();
804
+ if (!list || !list.contains(t)) return;
805
+ var isClose =
806
+ t.closest("button, [role='button'], .hope-close-button") !== null &&
807
+ !t.closest("label, input[type=checkbox]");
808
+ if (!isClose) return;
809
+ var card = t.closest("[role=alert]") || (t.closest && t.closest(".hope-notification__list > *"));
810
+ if (card) {
811
+ setTimeout(function () {
812
+ if (document.body.contains(card) && card.parentNode) {
813
+ card.parentNode.removeChild(card);
814
+ }
815
+ }, 150);
816
+ }
817
+ },
818
+ true,
819
+ );
820
+ }
821
+
822
+ function boot() {
823
+ bindCloseFallback();
824
+ scan();
825
+ var ticks = 0;
826
+ var iv = setInterval(function () {
827
+ scan();
828
+ if (++ticks > 60) clearInterval(iv); // 前 ~15s 高频补扫
829
+ }, 250);
830
+ new MutationObserver(schedule).observe(document.body, {
831
+ childList: true,
832
+ subtree: true,
833
+ });
834
+ }
835
+
836
+ if (document.readyState === "loading")
837
+ document.addEventListener("DOMContentLoaded", boot, { once: true });
838
+ else boot();
839
+ })();
840
+ })();
841
+ /* ===== bg-mask-toggle.js ===== */
842
+ ;(function(){
843
+ (function () {
844
+ "use strict";
845
+
846
+ var KEY = "ym-bg-mask-off";
847
+ var CARD_ID = "ym-bg-mask-card";
848
+ var meCache = null;
849
+ var observer = null;
850
+
851
+ function maskOff() {
852
+ try { return localStorage.getItem(KEY) === "1"; } catch (e) { return false; }
853
+ }
854
+
855
+ function applyMask(off) {
856
+ if (document.documentElement) document.documentElement.classList.toggle("ym-no-mask", !!off);
857
+ if (document.body) document.body.classList.toggle("ym-no-mask", !!off);
858
+ }
859
+
860
+ function setMask(off) {
861
+ try { localStorage.setItem(KEY, off ? "1" : "0"); } catch (e) {}
862
+ applyMask(off);
863
+ syncControl();
864
+ }
865
+
866
+ function isSettingsPath() {
867
+ var hash = location.hash || "";
868
+ return hash.indexOf("#/@manage/settings") === 0;
869
+ }
870
+
871
+ function isAdmin() {
872
+ if (meCache !== null) return Promise.resolve(meCache);
873
+ return fetch("/api/me", { cache: "no-store" })
874
+ .then(function (r) { return r.json(); })
875
+ .then(function (j) {
876
+ meCache = !!(j && j.data && j.data.role === 2);
877
+ return meCache;
878
+ })
879
+ .catch(function () {
880
+ meCache = false;
881
+ return false;
882
+ });
883
+ }
884
+
885
+ function injectStyles() {
886
+ if (document.getElementById("ym-bg-mask-style")) return;
887
+ var style = document.createElement("style");
888
+ style.id = "ym-bg-mask-style";
889
+ style.textContent = [
890
+ "#" + CARD_ID + " {",
891
+ " width:100%; display:flex; align-items:center; justify-content:space-between; gap:12px;",
892
+ " padding:14px 16px; margin-top:12px;",
893
+ " border-radius:var(--hope-radii-lg, 0.5rem);",
894
+ " border:1px solid var(--hope-colors-neutral3, rgba(0,0,0,.12));",
895
+ " background:var(--hope-colors-loContrast, #fff);",
896
+ " box-shadow:var(--hope-shadows-sm, none); box-sizing:border-box;",
897
+ "}",
898
+ "#" + CARD_ID + " .ym-mask-text { min-width:0; }",
899
+ "#" + CARD_ID + " .ym-mask-title {",
900
+ " font-size:14px; font-weight:600; line-height:1.4;",
901
+ " color:var(--hope-colors-neutral12, #1a202c);",
902
+ "}",
903
+ "#" + CARD_ID + " .ym-mask-desc {",
904
+ " margin-top:2px; font-size:12px; line-height:1.45;",
905
+ " color:var(--hope-colors-neutral10, #4a5568);",
906
+ "}",
907
+ "#" + CARD_ID + " .ym-mask-switch {",
908
+ " position:relative; flex:0 0 auto; width:40px; height:22px; border:0; padding:0;",
909
+ " border-radius:999px; cursor:pointer; transition:background .2s;",
910
+ " background:var(--hope-colors-neutral6, #cbd5e0);",
911
+ "}",
912
+ "#" + CARD_ID + " .ym-mask-switch::after {",
913
+ " content:''; position:absolute; top:3px; left:3px; width:16px; height:16px;",
914
+ " border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(0,0,0,.25);",
915
+ "}",
916
+ "#" + CARD_ID + " .ym-mask-switch.is-on { background:var(--hope-colors-primary9, #0ea5e9); }",
917
+ "#" + CARD_ID + " .ym-mask-switch.is-on::after { transform:translateX(18px); }"
918
+ ].join("\n");
919
+ document.head.appendChild(style);
920
+ }
921
+
922
+ function syncControl() {
923
+ var card = document.getElementById(CARD_ID);
924
+ if (!card) return;
925
+ var sw = card.querySelector(".ym-mask-switch");
926
+ var off = maskOff();
927
+ if (sw) {
928
+ sw.classList.toggle("is-on", !off);
929
+ sw.setAttribute("aria-checked", off ? "false" : "true");
930
+ sw.title = off ? "当前遮罩已关闭,点击开启" : "当前遮罩已开启,点击关闭";
931
+ }
932
+ }
933
+
934
+ function findAnchor() {
935
+ // Common 设置组件的操作区包含“刷新 / 保存 / 恢复默认”按钮;把卡片插到该操作区之前
936
+ var saveBtn = null;
937
+ var nodes = document.querySelectorAll("button");
938
+ for (var i = 0; i < nodes.length; i++) {
939
+ var text = (nodes[i].textContent || "").replace(/\s+/g, "");
940
+ if (text === "保存" || text === "Save" || text === "儲存") {
941
+ saveBtn = nodes[i];
942
+ break;
943
+ }
944
+ }
945
+ if (saveBtn) {
946
+ var p = saveBtn.parentElement;
947
+ while (p && p !== document.body) {
948
+ if (p.querySelector("button") && p.querySelectorAll("button").length >= 2) return p;
949
+ p = p.parentElement;
950
+ }
951
+ return saveBtn.parentElement;
952
+ }
953
+ return document.querySelector("main") || document.querySelector(".body") || null;
954
+ }
955
+
956
+ function mountCard() {
957
+ if (!isSettingsPath()) {
958
+ removeCard();
959
+ return;
960
+ }
961
+ isAdmin().then(function (admin) {
962
+ if (!admin || !isSettingsPath()) {
963
+ removeCard();
964
+ return;
965
+ }
966
+ if (document.getElementById(CARD_ID)) {
967
+ syncControl();
968
+ return;
969
+ }
970
+ var anchor = findAnchor();
971
+ if (!anchor || !anchor.parentNode) return;
972
+
973
+ injectStyles();
974
+ var card = document.createElement("div");
975
+ card.id = CARD_ID;
976
+ card.innerHTML =
977
+ '<div class="ym-mask-text">' +
978
+ ' <div class="ym-mask-title">背景遮罩</div>' +
979
+ ' <div class="ym-mask-desc">关闭后背景图不再叠加半透明阴影;此设置仅保存在当前浏览器。</div>' +
980
+ "</div>" +
981
+ '<button type="button" class="ym-mask-switch" role="switch" aria-label="切换背景遮罩"></button>';
982
+ anchor.parentNode.insertBefore(card, anchor);
983
+ card.querySelector(".ym-mask-switch").addEventListener("click", function () {
984
+ setMask(!maskOff());
985
+ });
986
+ syncControl();
987
+ });
988
+ }
989
+
990
+ function removeCard() {
991
+ var card = document.getElementById(CARD_ID);
992
+ if (card && card.parentNode) card.parentNode.removeChild(card);
993
+ }
994
+
995
+ function ensureObserver() {
996
+ if (observer || !document.body) return;
997
+ var scheduled = false;
998
+ observer = new MutationObserver(function () {
999
+ if (scheduled) return;
1000
+ scheduled = true;
1001
+ requestAnimationFrame(function () {
1002
+ scheduled = false;
1003
+ mountCard();
1004
+ });
1005
+ });
1006
+ observer.observe(document.body, { childList: true, subtree: true });
1007
+ window.addEventListener("hashchange", mountCard);
1008
+ }
1009
+
1010
+ applyMask(maskOff());
1011
+
1012
+ function boot() {
1013
+ applyMask(maskOff());
1014
+ ensureObserver();
1015
+ mountCard();
1016
+ }
1017
+
1018
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot, { once: true });
1019
+ else boot();
1020
+ })();
1021
+ })();
1022
+ /* ===== global-search.js ===== */
1023
+ ;(function(){
1024
+ (function () {
1025
+ "use strict";
1026
+
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
+ }
1071
+
1072
+ // 后端返回的 parent 是 URL 编码的虚拟路径(中文显示成 %E4%B8…)。
1073
+ // 链接必须保留编码(路由按编码路径解析),仅展示文本逐段解码。
1074
+ function decodePath(p) {
1075
+ if (!p) return "/";
1076
+ try {
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;
1169
+ } catch (e) {
1170
+ return "";
1171
+ }
1172
+ }
1173
+
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);
1230
+ }
1231
+
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
+
1247
+ try {
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
+ }
1259
+ } catch (e) {
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();
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;
1372
+ }
1373
+
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;
1408
+ var ofetch = window.fetch;
1409
+ window.fetch = function (input, init) {
1410
+ try {
1411
+ var url = typeof input === "string" ? input : input && input.url;
1412
+ if (url && url.indexOf("/fs/search") !== -1 && init && typeof init.body === "string") {
1413
+ var nb = rewriteSearchBody(init.body);
1414
+ if (nb !== init.body) init = Object.assign({}, init, { body: nb });
1415
+ }
1416
+ } catch (e) {}
1417
+ return ofetch.apply(this, arguments);
1418
+ };
1419
+ }
1420
+
1421
+ function patchXhr() {
1422
+ if (window.__ymGlobalXhrPatched) return;
1423
+ window.__ymGlobalXhrPatched = true;
1424
+ var OOpen = XMLHttpRequest.prototype.open;
1425
+ var OSend = XMLHttpRequest.prototype.send;
1426
+ XMLHttpRequest.prototype.open = function (method, url) {
1427
+ this.__ymIsSearch = typeof url === "string" && url.indexOf("/fs/search") !== -1;
1428
+ return OOpen.apply(this, arguments);
1429
+ };
1430
+ XMLHttpRequest.prototype.send = function (body) {
1431
+ try {
1432
+ if (this.__ymIsSearch && typeof body === "string") {
1433
+ var nb = rewriteSearchBody(body);
1434
+ if (nb !== body) body = nb;
1435
+ }
1436
+ } catch (e) {}
1437
+ return OSend.call(this, body);
1438
+ };
1439
+ }
1440
+
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;
1461
+ }
1462
+
1463
+ function hookModal() {
1464
+ // 已缓存的弹窗已脱离文档:SPA 导航后清理,确保重新 hook 新弹窗
1465
+ if (state.modal && isDetached(state.modal)) {
1466
+ resetModalState();
1467
+ }
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;
1477
+
1478
+ var input = modal.querySelector("#search-input");
1479
+ if (!input) return;
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
+ }
1506
+ }
1507
+
1508
+ function boot() {
1509
+ injectCss();
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);
1559
+ }
1560
+
1561
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
1562
+ else boot();
1563
+ })();
1564
+ })();
1565
+ /* ===== music-player.js ===== */
1566
+ ;(function(){
1567
+ /* =============================================================================
1568
+ * Yuuichi 资源站 · 背景音乐(与 blog.yuuichi.me / www.yuuichi.me 统一的 bgm-dock)
1569
+ * -----------------------------------------------------------------------------
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。
1579
+ * ============================================================================= */
1580
+ (function () {
1581
+ "use strict";
1582
+
1583
+ var ASSET_BASE = "/brand/bgm";
1584
+ var METING_API = "/api/music/meting?server=:server&type=:type&id=:id&r=:r";
1585
+
1586
+ var MUSIC_CONFIG = {
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
1594
+ };
1595
+
1596
+ // 管理台 music_config 在线覆盖
1597
+ try {
1598
+ var __ov = window.__YUUICHI_MUSIC_CONFIG_OVERLAY__;
1599
+ if (__ov && typeof __ov === "object") {
1600
+ ["netease", "spotify"].forEach(function (k) {
1601
+ if (Array.isArray(__ov[k])) MUSIC_CONFIG[k] = __ov[k];
1602
+ });
1603
+ ["defaultTab", "theme", "order"].forEach(function (k) {
1604
+ if (typeof __ov[k] === "string" && __ov[k]) MUSIC_CONFIG[k] = __ov[k];
1605
+ });
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;
1610
+ }
1611
+ } catch (e) {}
1612
+
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
+ });
1661
+ });
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
+ });
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
+ }
1678
+
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) {}
1717
+ };
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 });
1726
+ });
1727
+ }
1728
+ }
1729
+
1730
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
1731
+ else init();
1732
+ })();
1733
+ })();