@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.
@@ -1,5 +1,5 @@
1
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, music-player.js */
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
3
  "use strict";
4
4
  /* ===== footer-links.js ===== */
5
5
  ;(function(){
@@ -300,53 +300,45 @@
300
300
  (function () {
301
301
  "use strict";
302
302
 
303
- // 上传状态面板(v7)
304
- // 仅负责在上传弹窗顶部展示大文件分片直传的实时反馈,覆盖所有原本显示成
305
- // “-/s” 的阶段:取直传地址(presign) → 分片直传(第 x/N 片 + 实时速率 + 并发)
306
- // → 云端合并(complete)。
307
- //
308
- // 文件的添加/拖放完全使用上传弹窗原生能力:闲置视图自带“拖放区 / 选择文件 /
309
- // 选择文件夹”;上传过程中点“返回上传”即可继续添加(原上传不中断)。
310
- // 本脚本不再注入任何额外的虚线框或全屏拖放层。
311
-
303
+ // 上传状态面板:状态只以真实事件为准,避免“文件已传完仍显示直传中”或
304
+ // “没有速度却显示传输中”。速率由各活跃分片的 1s 滑动窗口聚合计算。
312
305
  var PANEL_ID = "ym-upload-panel";
313
306
 
314
- var parts = Object.create(null); // uploadId -> {total,done{},doneCount,active{}}
315
- var presign = Object.create(null); // uploadId -> Set(等待取地址的 index)
316
- var merge = { on: false, at: 0 };
307
+ var uploads = Object.create(null);
308
+ var phase = { name: "idle", at: 0 };
317
309
 
318
- // 权威完成:收到服务端 status=completed / 404 时强制收尾整个面板。
319
- // uploadId 为空(旧调用未带)时清空全部,保证多文件场景也能可靠结束。
320
- function finishUpload(id) {
321
- if (id && parts[id]) {
322
- delete parts[id];
323
- delete presign[id];
324
- } else {
325
- parts = Object.create(null);
326
- presign = Object.create(null);
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
+ };
327
322
  }
328
- merge.on = false;
329
- render();
323
+ return uploads[id];
330
324
  }
331
325
 
332
- // 防卡死兜底:merge 阶段最长展示 90s 后自动收起(正常几十秒内必结束),
333
- // 避免任何漏网事件导致状态条永不消失。
334
- function startMerge() {
335
- merge.on = true;
336
- merge.at = Date.now();
337
- if (merge.timer) clearTimeout(merge.timer);
338
- merge.timer = setTimeout(function () {
339
- merge.on = false;
340
- parts = Object.create(null);
341
- presign = Object.create(null);
342
- render();
343
- }, 90000);
344
- render();
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
+ });
345
336
  }
346
- function endMerge() {
347
- if (merge.timer) { clearTimeout(merge.timer); merge.timer = null; }
348
- // 给用户一个“合并完成”的短暂反馈后再收起
349
- setTimeout(function () { merge.on = false; render(); }, 1200);
337
+
338
+ function resetAll(reason) {
339
+ uploads = Object.create(null);
340
+ phase = reason ? { name: reason, at: nowMs() } : { name: "idle", at: 0 };
341
+ render();
350
342
  }
351
343
 
352
344
  var cssInjected = false;
@@ -361,17 +353,18 @@
361
353
  " box-shadow:0 2px 10px rgba(2,132,199,.18);}",
362
354
  "#" + PANEL_ID + ".ym-merge{color:#7c2d12;background:linear-gradient(135deg,#ffedd5,#fed7aa);border-color:#fdba74;}",
363
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;}",
364
357
  "#" + PANEL_ID + " .ym-spin{width:18px;height:18px;margin-top:2px;flex:0 0 auto;",
365
358
  " border:3px solid rgba(2,132,199,.25);border-top-color:#0284c7;border-radius:50%;animation:ym-sp .8s linear infinite;}",
366
359
  "#" + PANEL_ID + ".ym-merge .ym-spin{border-color:rgba(194,65,12,.25);border-top-color:#c2410c;}",
367
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;}",
368
362
  "#" + PANEL_ID + " .ym-sub{display:block;font-size:12px;font-weight:600;opacity:.92;margin-top:3px;}",
369
363
  "@keyframes ym-sp{to{transform:rotate(360deg)}}",
370
364
  ].join("\n");
371
365
  document.head.appendChild(st);
372
366
  }
373
367
 
374
- // 上传弹窗:可见的 role=dialog,且内容含上传视图的特征文案
375
368
  function findUploadModal() {
376
369
  var dlgs = document.querySelectorAll('[role="dialog"].hope-modal__content, [role="dialog"]');
377
370
  for (var i = 0; i < dlgs.length; i++) {
@@ -386,9 +379,7 @@
386
379
  t.indexOf("选择文件夹") !== -1 ||
387
380
  t.indexOf("上传中") !== -1 ||
388
381
  t.indexOf("后台上传中") !== -1
389
- ) {
390
- return d;
391
- }
382
+ ) return d;
392
383
  }
393
384
  return null;
394
385
  }
@@ -405,51 +396,112 @@
405
396
  }
406
397
 
407
398
  function summarize() {
408
- var ids = Object.keys(parts), done = 0, total = 0, bps = 0, act = [];
399
+ var ids = Object.keys(uploads);
400
+ var done = 0, total = 0, active = 0, bytesLoaded = 0, bytesTotal = 0, bps = 0;
409
401
  ids.forEach(function (id) {
410
- var p = parts[id];
411
- total += p.total || 0;
412
- done += p.doneCount || 0;
413
- Object.keys(p.active).forEach(function (k) { act.push(p.active[k]); bps += p.active[k].bps || 0; });
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
+ });
414
412
  });
415
- return { done: done, total: total, bps: bps, act: act };
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();
416
430
  }
417
431
 
418
432
  function onEvent(ev) {
419
- var d = ev.detail || {}, id = d.uploadId || "_";
420
- if (d.type === "upload:finish") { finishUpload(d.uploadId); return; }
421
- if (d.type === "complete:start") { startMerge(); return; }
422
- if (d.type === "complete:done") { if (d.ok) endMerge(); else render(); return; }
423
-
424
- if (!parts[id]) parts[id] = { total: 0, done: {}, doneCount: 0, active: {} };
425
- var p = parts[id];
426
- if (d.total) p.total = d.total;
427
- if (!presign[id]) presign[id] = new Set();
428
-
429
- if (d.type === "presign:start") { presign[id].add(d.index); }
430
- else if (d.type === "presign:done") { presign[id].delete(d.index); }
431
- else if (d.type === "part:start") {
432
- presign[id].delete(d.index);
433
- p.active[d.index] = { index: d.index, total: d.total, loaded: 0, bytes: d.bytes, bps: 0 };
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
+ };
434
488
  } else if (d.type === "part:progress") {
435
- if (!p.active[d.index]) p.active[d.index] = { index: d.index, total: d.total, loaded: 0 };
436
- p.active[d.index].loaded = d.loaded;
437
- p.active[d.index].bps = d.bps;
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);
438
494
  } else if (d.type === "part:done") {
439
- if (!p.done[d.index]) { p.done[d.index] = 1; p.doneCount++; }
440
- delete p.active[d.index];
441
- presign[id].delete(d.index);
442
- if (p.total && p.doneCount >= p.total) {
443
- (function (key) {
444
- setTimeout(function () { delete parts[key]; delete presign[key]; render(); }, 1200);
445
- // 看门狗:本地分片已全部直传,若 60s 内既没等到 complete 也没收到
446
- // 服务端 status=completed/404(极端漏网),强制收尾,杜绝状态条卡死。
447
- setTimeout(function () { if (parts[key]) finishUpload(key); }, 60000);
448
- })(id);
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() };
449
501
  }
450
502
  } else if (d.type === "part:error" || d.type === "relay") {
451
- delete p.active[d.index];
452
- presign[id].delete(d.index);
503
+ delete u.active[d.index];
504
+ u.waiting.delete(d.index);
453
505
  }
454
506
  render();
455
507
  }
@@ -457,39 +509,48 @@
457
509
  function render() {
458
510
  var body = panelBody();
459
511
  var panel = document.getElementById(PANEL_ID);
460
- var hasParts = Object.keys(parts).length > 0;
461
- var waitingPresign = Object.keys(presign).some(function (id) { return presign[id].size > 0; });
512
+ var hasUploads = activeUploadCount() > 0;
513
+ var s = hasUploads ? summarize() : null;
462
514
 
463
- if (merge.on) {
515
+ if (phase.name === "merging" || phase.name === "finalizing") {
464
516
  if (!body) return;
465
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());
466
520
  panel.className = "ym-merge";
467
- var secs = Math.round((Date.now() - merge.at) / 1000);
468
521
  panel.innerHTML =
469
- '<span class="ym-spin"></span><span>正在云端合并分片…(已 ' + secs + ' 秒)' +
470
- '<span class="ym-sub">所有分片已直传完成,这一步由网盘服务器合并,期间显示“-/s”属正常,大文件约需几秒到几十秒,请勿关闭窗口。</span></span>';
522
+ '<span class="ym-spin"></span><span>' +
523
+ (merging ? "正在云端合并分片…(已 " + secs + " 秒)" : "分片已全部上传,正在提交合并请求…") +
524
+ '<span class="ym-sub">此阶段由网盘服务器处理,不再上传文件内容,因此下方速度显示为 0 或 “-/s” 属正常;大文件可能需要几秒到几十秒,请勿关闭窗口。</span></span>';
471
525
  return;
472
526
  }
473
527
 
474
- if (!hasParts && !waitingPresign) { if (panel) panel.remove(); return; }
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; }
475
537
  if (!body) return;
476
538
  if (!panel) { panel = document.createElement("div"); panel.id = PANEL_ID; body.insertBefore(panel, body.firstChild); }
477
539
 
478
- var s = summarize();
479
540
  var pct = s.total ? Math.min(99, Math.round((s.done / s.total) * 100)) : 0;
480
541
  var line1 = "大文件切片直传中 · 已完成 " + s.done + (s.total ? " / " + s.total + " 片(" + pct + "%)" : " 片");
481
542
  var line2;
482
- if (s.act.length > 0) {
483
- var a = s.act[0];
484
- line2 =
485
- "正在传输第 " + (a.index + 1) + (a.total ? " / " + a.total : "") + " 片 · 实时速率 " +
486
- fmtSpeed(s.bps) + (s.act.length > 1 ? " · " + s.act.length + " 路并发" : "");
543
+ if (s.active > 0 && s.bps > 1024) {
544
+ line2 = "正在传输 " + s.active + (s.active > 1 ? " 个分片" : " 个分片") + " · 实时速率 " + fmtSpeed(s.bps);
487
545
  panel.className = "";
488
- } else if (waitingPresign) {
489
- line2 = "正在向网盘申请分片直传地址…(这一步会短暂没有速度,稍后即开始)";
546
+ } else if (s.active > 0) {
547
+ line2 = "正在传输 " + s.active + (s.active > 1 ? " 个分片" : " 个分片") + " · 正在建立连接/等待首包,暂不计速";
548
+ panel.className = "ym-wait";
549
+ } else if (anyWaiting()) {
550
+ line2 = "正在向网盘申请分片直传地址…(这一步不传文件,暂时没有速度)";
490
551
  panel.className = "ym-wait";
491
552
  } else {
492
- line2 = "正在准备下一片…";
553
+ line2 = "正在准备下一片…(暂不传文件,速度为 0)";
493
554
  panel.className = "ym-wait";
494
555
  }
495
556
  panel.innerHTML =
@@ -502,15 +563,18 @@
502
563
  scheduled = true;
503
564
  requestAnimationFrame(function () {
504
565
  scheduled = false;
505
- // 廉价门控:仅在「已存在面板需要更新位置」或「页面上可能有弹窗」时
506
- // 才执行较重的 findUploadModal()(含 getBoundingClientRect 与文案扫描),
507
- // 避免列表导航等无关 DOM 变更也反复触发布局查询。
508
566
  var panel = document.getElementById(PANEL_ID);
509
567
  if (panel || document.querySelector('[role="dialog"]')) render();
510
568
  });
511
569
  }
512
570
 
513
- setInterval(function () { if (merge.on) render(); }, 1000);
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);
514
578
 
515
579
  function boot() {
516
580
  injectCss();
@@ -955,559 +1019,712 @@
955
1019
  else boot();
956
1020
  })();
957
1021
  })();
958
- /* ===== music-player.js ===== */
1022
+ /* ===== global-search.js ===== */
959
1023
  ;(function(){
960
- /* =============================================================================
961
- * Yuuichi 资源站 · 悬浮背景音乐播放器
962
- * -----------------------------------------------------------------------------
963
- * 想换歌单,只需要修改下面 MUSIC_CONFIG 里的 id / 链接即可,然后重新部署:
964
- * 1) 网易云歌单:打开歌单复制地址里的 id 数字
965
- * 例 https://music.163.com/#/playlist?id=18475904830 -> id 填 "18475904830"
966
- * 网易云使用内置 APlayer 内核(支持音量调节 / 封面 / 歌词 / 列表循环)
967
- * 2) Spotify 歌单:复制歌单链接
968
- * 例 https://open.spotify.com/playlist/3bePpYfOLDwFtVVs6cqwyh -> id 填 "3bePpYfOLDwFtVVs6cqwyh"
969
- * Spotify 为官方嵌入播放器,其桌面端自身右下角带音量条(浏览器跨域限制,外部无法代为控制)
970
- * 每个平台可填多个歌单(顶部可切换);不用的平台把列表留空 [] 即可。
971
- * 网易云歌单由本站同源接口 /api/music/netease 实时拉取网易云官方数据(自动反映你在网易云的增删);
972
- * 每首歌的直链 / 封面 / 歌词由 metingApi 按 id 实时解析(公益服务,失效可换其它 Meting 兼容地址)。
973
- * 改完保存,在 openlist 目录执行:
974
- * node scripts/sync-branding.mjs
975
- * pnpm exec wrangler deploy
976
- * ============================================================================= */
977
1024
  (function () {
978
1025
  "use strict";
979
1026
 
980
- var MUSIC_CONFIG = {
981
- netease: [
982
- { id: "18475904830", name: "网易云歌单" }
983
- ],
984
- spotify: [
985
- { id: "3bePpYfOLDwFtVVs6cqwyh", name: "Spotify 歌单" }
986
- ],
987
- defaultTab: "netease", // "netease" 或 "spotify"
988
- autoplay: false, // 网易云是否自动播放(多数浏览器会拦截,建议 false)
989
- fabHint: "音乐"
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
990
1053
  };
991
1054
 
992
- // 管理台(OpenList 设置项 music_config)可在线覆盖歌单/开关,无需重新部署。
993
- // Worker 会在本脚本前注入 window.__YUUICHI_MUSIC_CONFIG_OVERLAY__。
994
- try {
995
- var __ov = window.__YUUICHI_MUSIC_CONFIG_OVERLAY__;
996
- if (__ov && typeof __ov === "object") {
997
- ["netease", "spotify"].forEach(function (k) {
998
- if (Array.isArray(__ov[k])) MUSIC_CONFIG[k] = __ov[k];
999
- });
1000
- ["defaultTab", "fabHint"].forEach(function (k) {
1001
- if (typeof __ov[k] === "string" && __ov[k]) MUSIC_CONFIG[k] = __ov[k];
1002
- });
1003
- if (typeof __ov.autoplay === "boolean") MUSIC_CONFIG.autoplay = __ov.autoplay;
1004
- }
1005
- } catch (e) {}
1006
-
1007
- function init() {
1008
- if (window.__yuuichiMusicInited) return;
1009
- window.__yuuichiMusicInited = true;
1010
-
1011
- var platforms = [];
1012
- if (MUSIC_CONFIG.netease.length) platforms.push({ key: "netease", label: "网易云", items: MUSIC_CONFIG.netease });
1013
- if (MUSIC_CONFIG.spotify.length) platforms.push({ key: "spotify", label: "Spotify", items: MUSIC_CONFIG.spotify });
1014
- if (!platforms.length) return;
1015
-
1016
- var host = document.createElement("div");
1017
- host.id = "yuuichi-music-root";
1018
- host.style.cssText = "all:initial;position:fixed;z-index:2147483000;";
1019
- var shadow = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
1020
- document.body.appendChild(host);
1021
-
1022
- /* ---------------- 样式 ---------------- */
1023
- // APlayer 厂商 CSS(12KB)改为首次真正打开歌单时才注入,避免首屏多一个请求
1024
- var vendorLinkLoaded = false;
1025
- function ensureVendorCss() {
1026
- if (vendorLinkLoaded) return;
1027
- vendorLinkLoaded = true;
1028
- var vendorLink = document.createElement("link");
1029
- vendorLink.rel = "stylesheet";
1030
- vendorLink.href = "/brand/vendor/APlayer.min.css";
1031
- shadow.appendChild(vendorLink);
1032
- }
1055
+ /* ---------- 工具 ---------- */
1056
+ function $(sel, root) { return (root || document).querySelector(sel); }
1057
+ function $all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
1033
1058
 
1034
- var style = document.createElement("style");
1035
- style.textContent = [
1036
- ":host, * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }",
1037
- ":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); }",
1038
- ":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); }",
1039
- ".wrap { position:fixed; left:0; top:0; font-family:system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif; }",
1040
- ".fab { position:fixed; left:18px; bottom:18px; width:54px; height:54px; border-radius:50%;",
1041
- " border:none; cursor:pointer; color:#fff; display:flex; align-items:center; justify-content:center;",
1042
- " background:linear-gradient(135deg,#38bdf8,#0284c7); box-shadow:0 8px 24px rgba(2,132,199,.45);",
1043
- " transition:transform .18s ease, box-shadow .18s ease; padding:0; }",
1044
- ".fab:hover { transform:translateY(-2px) scale(1.05); box-shadow:0 12px 30px rgba(2,132,199,.55); }",
1045
- ".fab:active { transform:scale(.94); }",
1046
- ".fab svg { width:26px; height:26px; }",
1047
- ".panel { position:fixed; left:18px; bottom:84px; width:360px; height:560px; max-width:calc(100vw - 24px);",
1048
- " background:var(--ym-card); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);",
1049
- " border:1px solid var(--ym-line); border-radius:18px; overflow:hidden; display:none;",
1050
- " flex-direction:column; box-shadow:0 20px 60px rgba(2,8,23,.35); color:var(--ym-txt); }",
1051
- ".panel.open { display:flex; }",
1052
- ".bar { display:flex; align-items:center; gap:6px; padding:8px 10px; cursor:move; user-select:none;",
1053
- " 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; }",
1054
- ".tab { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt);",
1055
- " border-radius:999px; padding:4px 12px; font-size:12px; cursor:pointer; }",
1056
- ".tab.active { background:linear-gradient(135deg,#38bdf8,#0284c7); color:#fff; border-color:transparent; }",
1057
- ".sel { flex:1; min-width:0; border:1px solid var(--ym-line); background:transparent;",
1058
- " color:var(--ym-txt); border-radius:8px; font-size:12px; padding:4px 6px; outline:none; }",
1059
- ".sel option { color:#0f172a; }",
1060
- ".close { border:none; background:transparent; color:var(--ym-txt); font-size:18px; line-height:1; cursor:pointer; padding:2px 7px; border-radius:8px; }",
1061
- ".close:hover { background:var(--ym-soft); }",
1062
- ".refresh { display:none; border:none; background:transparent; color:var(--ym-txt); cursor:pointer; padding:2px 6px; border-radius:8px; align-items:center; }",
1063
- ".refresh.show { display:inline-flex; }",
1064
- ".refresh:hover { background:var(--ym-soft); }",
1065
- ".refresh svg { width:16px; height:16px; }",
1066
- ".refresh.spin svg { animation:ym-rspin .8s linear infinite; }",
1067
- "@keyframes ym-rspin { to { transform:rotate(360deg); } }",
1068
- ".ctrl { display:none; align-items:center; gap:6px; padding:5px 10px; flex:0 0 auto;",
1069
- " border-bottom:1px solid var(--ym-line); }",
1070
- ".ctrl.show { display:flex; }",
1071
- ".cbtn { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt); cursor:pointer;",
1072
- " border-radius:999px; font-size:12px; line-height:1; padding:4px 10px; display:inline-flex; align-items:center; gap:5px; }",
1073
- ".cbtn:hover { background:var(--ym-soft); }",
1074
- ".cbtn svg { width:14px; height:14px; }",
1075
- ".stages { flex:1; min-height:0; position:relative; }",
1076
- ".stage { position:absolute; inset:0; display:none; }",
1077
- ".stage.active { display:block; }",
1078
- ".stage iframe { width:100%; height:100%; border:0; display:block; background:#fff; }",
1079
- ".ap-wrap { position:absolute; inset:0; overflow:auto; }",
1080
- ".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; }",
1081
- ".status button { border:none; cursor:pointer; color:#fff; background:linear-gradient(135deg,#38bdf8,#0284c7); border-radius:999px; padding:6px 18px; font-size:13px; }",
1082
- ".resizer { position:absolute; right:0; bottom:0; width:22px; height:22px; cursor:nwse-resize; z-index:5; }",
1083
- ".resizer::after { content:''; position:absolute; right:5px; bottom:5px; width:10px; height:10px;",
1084
- " border-right:2px solid var(--ym-line); border-bottom:2px solid var(--ym-line); }",
1085
- /* APlayer 皮肤适配(透明、贴合面板、青蓝主题) */
1086
- ".aplayer { width:100%; margin:0; background:transparent !important; box-shadow:none !important; border-radius:0; color:var(--ym-txt); }",
1087
- ".aplayer .aplayer-info { border-top:1px solid var(--ym-line); }",
1088
- ".aplayer .aplayer-title, .aplayer .aplayer-list ol li .aplayer-list-index, .aplayer .aplayer-time { color:var(--ym-sub); }",
1089
- ".aplayer .aplayer-list ol li { border-top:1px solid var(--ym-line); }",
1090
- ".aplayer .aplayer-list ol li.aplayer-list-light { background:var(--ym-soft); }",
1091
- ".aplayer .aplayer-list ol li:hover { background:var(--ym-soft); }",
1092
- ".aplayer .aplayer-list ol li .aplayer-list-author { color:var(--ym-sub); }",
1093
- ".aplayer .aplayer-lrc { text-shadow:0 1px 4px rgba(0,0,0,.35); }",
1094
- ".aplayer .aplayer-lrc p.aplayer-lrc-current { color:#38bdf8; }",
1095
- ".aplayer .aplayer-miniswitcher { background:var(--ym-soft); }",
1096
- ":host([data-dark]) .aplayer svg, :host([data-dark]) .aplayer .aplayer-icon { filter:invert(1) hue-rotate(180deg); }",
1097
- "@media (max-width:600px) {",
1098
- " .fab { left:14px; bottom:14px; width:50px; height:50px; }",
1099
- " .panel { left:12px !important; bottom:76px !important; width:calc(100vw - 24px) !important; height:min(76vh,600px) !important; }",
1100
- " .resizer { display:none; }",
1101
- "}"
1102
- ].join("\n");
1103
- shadow.appendChild(style);
1104
-
1105
- /* ---------------- DOM 骨架 ---------------- */
1106
- var wrap = document.createElement("div");
1107
- wrap.className = "wrap";
1108
- shadow.appendChild(wrap);
1109
-
1110
- var fab = document.createElement("button");
1111
- fab.className = "fab";
1112
- fab.title = MUSIC_CONFIG.fabHint || "音乐";
1113
- fab.setAttribute("aria-label", "音乐播放器");
1114
- fab.innerHTML =
1115
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
1116
- '<path d="M9 18V5l10-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="16" cy="16" r="3"></circle></svg>';
1117
- wrap.appendChild(fab);
1118
-
1119
- var panel = document.createElement("div");
1120
- panel.className = "panel";
1121
- panel.innerHTML =
1122
- '<div class="bar"></div>' +
1123
- '<div class="ctrl">' +
1124
- '<button class="cbtn js-speed" type="button" title="播放倍速"><span class="speed-label">1.0x</span></button>' +
1125
- '<button class="cbtn js-order" type="button" title="播放顺序"></button>' +
1126
- '</div>' +
1127
- '<div class="stages"></div>' +
1128
- '<div class="resizer"></div>';
1129
- wrap.appendChild(panel);
1130
-
1131
- var bar = panel.querySelector(".bar");
1132
- var stages = panel.querySelector(".stages");
1133
- var ctrlBar = panel.querySelector(".ctrl");
1134
- var speedBtn = panel.querySelector(".js-speed");
1135
- var speedLabel = panel.querySelector(".speed-label");
1136
- var orderBtn = panel.querySelector(".js-order");
1137
-
1138
- var tabBtns = {};
1139
- platforms.forEach(function (p) {
1140
- var b = document.createElement("button");
1141
- b.className = "tab";
1142
- b.textContent = p.label;
1143
- b.dataset.key = p.key;
1144
- b.addEventListener("click", function () { selectTab(p.key); });
1145
- bar.appendChild(b);
1146
- tabBtns[p.key] = b;
1147
- });
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
+ }
1148
1064
 
1149
- var sel = document.createElement("select");
1150
- sel.className = "sel";
1151
- bar.appendChild(sel);
1152
-
1153
- var refreshBtn = document.createElement("button");
1154
- refreshBtn.className = "refresh";
1155
- refreshBtn.title = "重新拉取最新歌单";
1156
- refreshBtn.setAttribute("aria-label", "刷新歌单");
1157
- refreshBtn.innerHTML =
1158
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
1159
- '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline></svg>';
1160
- refreshBtn.addEventListener("click", function () {
1161
- if (state.tab === "netease") {
1162
- refreshBtn.classList.add("spin");
1163
- setupNetease(true).finally(function () { refreshBtn.classList.remove("spin"); });
1164
- }
1165
- });
1166
- bar.appendChild(refreshBtn);
1167
-
1168
- var closeBtn = document.createElement("button");
1169
- closeBtn.className = "close";
1170
- closeBtn.innerHTML = "&times;";
1171
- closeBtn.addEventListener("click", close);
1172
- bar.appendChild(closeBtn);
1173
-
1174
- /* 每个平台一个常驻 stage(切换/收起只隐藏,不销毁,播放不中断、不重载) */
1175
- var stageEl = {};
1176
- platforms.forEach(function (p) {
1177
- var d = document.createElement("div");
1178
- d.className = "stage";
1179
- d.dataset.key = p.key;
1180
- stages.appendChild(d);
1181
- stageEl[p.key] = d;
1182
- });
1065
+ function joinPath(parent, name) {
1066
+ var p = (parent || "/").replace(/\/+$/, "");
1067
+ if (!p) p = "/";
1068
+ if (p === "/") return "/" + name;
1069
+ return p + "/" + name;
1070
+ }
1183
1071
 
1184
- var state = {
1185
- tab: MUSIC_CONFIG.defaultTab,
1186
- idx: {},
1187
- ready: {}, // 该平台当前 stage 是否已初始化
1188
- ap: null, // 网易云 APlayer 实例
1189
- tracks: null,
1190
- loading: false
1191
- };
1192
- platforms.forEach(function (p) { state.idx[p.key] = 0; state.ready[p.key] = false; });
1193
- if (!tabBtns[state.tab]) state.tab = platforms[0].key;
1194
-
1195
- var SPEEDS = [1, 1.25, 1.5, 2, 0.75];
1196
- var speedIdx = 0;
1197
- var ORDER_MODES = [
1198
- { 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>' },
1199
- { 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>' },
1200
- { 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>' },
1201
- { 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>' }
1202
- ];
1203
- var orderIdx = 0;
1072
+ // 后端返回的 parent 是 URL 编码的虚拟路径(中文显示成 %E4%B8…)。
1073
+ // 链接必须保留编码(路由按编码路径解析),仅展示文本逐段解码。
1074
+ function decodePath(p) {
1075
+ if (!p) return "/";
1204
1076
  try {
1205
- var sv = parseFloat(localStorage.getItem("ym-speed"));
1206
- var si = SPEEDS.indexOf(sv);
1207
- if (si >= 0) speedIdx = si;
1208
- var oi = parseInt(localStorage.getItem("ym-order"), 10);
1209
- if (!isNaN(oi) && ORDER_MODES[oi]) orderIdx = oi;
1210
- } catch (e) {}
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
+ }
1211
1083
 
1212
- function applySpeed() {
1213
- var rate = SPEEDS[speedIdx];
1214
- speedLabel.textContent = (rate % 1 === 0 ? rate.toFixed(1) : String(rate)) + "x";
1215
- try {
1216
- if (state.ap && state.ap.audio) state.ap.audio.playbackRate = rate;
1217
- localStorage.setItem("ym-speed", String(rate));
1218
- } catch (e) {}
1219
- }
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
+ }
1220
1111
 
1221
- function applyOrder() {
1222
- var mode = ORDER_MODES[orderIdx];
1223
- orderBtn.innerHTML = mode.icon + '<span class="order-label">' + mode.label + '</span>';
1224
- orderBtn.title = "播放顺序:" + mode.label;
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" } 形态
1225
1161
  try {
1226
- if (state.ap && state.ap.options) {
1227
- state.ap.options.loop = mode.loop;
1228
- state.ap.options.order = mode.order;
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;
1229
1166
  }
1230
- localStorage.setItem("ym-order", String(orderIdx));
1231
1167
  } catch (e) {}
1168
+ return raw;
1169
+ } catch (e) {
1170
+ return "";
1232
1171
  }
1172
+ }
1233
1173
 
1234
- speedBtn.addEventListener("click", function () {
1235
- speedIdx = (speedIdx + 1) % SPEEDS.length;
1236
- applySpeed();
1237
- });
1238
- orderBtn.addEventListener("click", function () {
1239
- orderIdx = (orderIdx + 1) % ORDER_MODES.length;
1240
- applyOrder();
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
+ })
1241
1190
  });
1242
- applySpeed();
1243
- applyOrder();
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
+ }
1244
1195
 
1245
- function currentPlatform() {
1246
- for (var i = 0; i < platforms.length; i++) if (platforms[i].key === state.tab) return platforms[i];
1247
- return platforms[0];
1248
- }
1196
+ /* ---------- 渲染 ---------- */
1197
+ function setStatus(html) {
1198
+ state.statusEl.innerHTML = html;
1199
+ state.statusEl.style.display = html ? "block" : "none";
1200
+ }
1249
1201
 
1250
- function loadScriptOnce(attr, src) {
1251
- return new Promise(function (resolve, reject) {
1252
- var existing = document.querySelector("script[" + attr + "]");
1253
- if (existing) {
1254
- if (existing.dataset.loaded === "1") return resolve();
1255
- existing.addEventListener("load", function () { resolve(); });
1256
- existing.addEventListener("error", reject);
1257
- return;
1258
- }
1259
- var s = document.createElement("script");
1260
- s.async = true;
1261
- s.setAttribute(attr, "1");
1262
- s.onload = function () { s.dataset.loaded = "1"; resolve(); };
1263
- s.onerror = reject;
1264
- s.src = src;
1265
- document.head.appendChild(s);
1266
- });
1202
+ function renderRows(nodes, kw, append) {
1203
+ if (!append) {
1204
+ state.listEl.innerHTML = "";
1205
+ state.lastNodes = [];
1267
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
+ }
1268
1231
 
1269
- function loadScript(src) {
1270
- return loadScriptOnce("data-ym-aplayer", src);
1271
- }
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;
1272
1237
 
1273
- /* 实时同源接口:Worker 服务端优先走自建 Meting、失败回退网易云官方,无 CORS */
1274
- function fetchWithTimeout(url, ms) {
1275
- var ctrl = ("AbortController" in window) ? new AbortController() : null;
1276
- var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, ms) : null;
1277
- return fetch(url, { cache: "no-store", signal: ctrl ? ctrl.signal : undefined })
1278
- .finally(function () { if (timer) clearTimeout(timer); });
1279
- }
1238
+ state.listEl.innerHTML = "";
1239
+ state.lastNodes = [];
1240
+ setStatus('<span class="ym-sspinner"></span>搜索中…');
1280
1241
 
1281
- async function fetchPlaylist(item, force) {
1282
- var url = "/api/music/netease?id=" + encodeURIComponent(item.id) + (force ? "&refresh=1" : "");
1283
- var res = await fetchWithTimeout(url, 15000);
1284
- var j = await res.json();
1285
- if (!j || j.code !== 200 || !Array.isArray(j.data && j.data.songs) || !j.data.songs.length) {
1286
- throw new Error("empty");
1287
- }
1288
- return j.data.songs;
1242
+ if (!keyword) {
1243
+ setStatus("");
1244
+ return;
1289
1245
  }
1290
1246
 
1291
- function statusEl(el, msg, withRetry) {
1292
- el.innerHTML = "";
1293
- var d = document.createElement("div");
1294
- d.className = "status";
1295
- var span = document.createElement("span");
1296
- span.textContent = msg;
1297
- d.appendChild(span);
1298
- if (withRetry) {
1299
- var btn = document.createElement("button");
1300
- btn.textContent = "重试";
1301
- btn.addEventListener("click", function () { setupNetease(true); });
1302
- d.appendChild(btn);
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("");
1303
1258
  }
1304
- el.appendChild(d);
1259
+ } catch (e) {
1260
+ if (mySeq !== state.seq) return;
1261
+ if (e && e.name === "AbortError") return;
1262
+ setStatus("搜索失败:" + (e.message || "网络错误"));
1305
1263
  }
1264
+ }
1306
1265
 
1307
- async function setupNetease(force) {
1308
- var el = stageEl.netease;
1309
- if (state.ready.netease && !force) return;
1310
- if (state.loading) return;
1311
- state.loading = true;
1312
- statusEl(el, "正在加载网易云歌单…", false);
1313
- try {
1314
- var item = MUSIC_CONFIG.netease[state.idx.netease || 0];
1315
- var list = await fetchPlaylist(item, !!force);
1316
- state.tracks = list;
1317
-
1318
- if (state.ap) { try { state.ap.destroy(); } catch (e) {} state.ap = null; }
1319
- el.innerHTML = '<div class="ap-wrap"></div>';
1320
- ensureVendorCss();
1321
- await loadScript("/brand/vendor/APlayer.min.js");
1322
-
1323
- var mode = ORDER_MODES[orderIdx];
1324
- var ap = new window.APlayer({
1325
- container: el.querySelector(".ap-wrap"),
1326
- fixed: false,
1327
- mini: false,
1328
- autoplay: !!MUSIC_CONFIG.autoplay,
1329
- theme: "#0ea5e9",
1330
- loop: mode.loop,
1331
- order: mode.order,
1332
- listMaxHeight: "999px",
1333
- lrcType: list[0].lrc ? 3 : 0,
1334
- audio: list.map(function (t) {
1335
- return { name: t.name, artist: t.artist, url: t.url, cover: t.pic, lrc: t.lrc, theme: "#0ea5e9" };
1336
- })
1337
- });
1338
- state.ap = ap;
1339
- var audio = ap.audio;
1340
- if (audio) {
1341
- audio.playbackRate = SPEEDS[speedIdx];
1342
- audio.addEventListener("play", function () { audio.playbackRate = SPEEDS[speedIdx]; });
1343
- }
1344
- state.ready.netease = true;
1345
- } catch (e) {
1346
- statusEl(el, "网易云歌单加载失败(解析服务可能暂时不可用)", true);
1347
- } finally {
1348
- state.loading = false;
1349
- }
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;
1350
1276
  }
1277
+ state.timer = setTimeout(function () { runSearch(kw); }, DEBOUNCE_MS);
1278
+ }
1351
1279
 
1352
- function spotifySrc(item) {
1353
- var dark = document.body.classList.contains("hope-ui-dark");
1354
- return "https://open.spotify.com/embed/playlist/" + encodeURIComponent(item.id) +
1355
- "?utm_source=generator&theme=" + (dark ? "0" : "1");
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");
1356
1286
  }
1357
1287
 
1358
- function setupSpotify(force) {
1359
- var el = stageEl.spotify;
1360
- if (state.ready.spotify && !force) return;
1361
- var item = MUSIC_CONFIG.spotify[state.idx.spotify || 0];
1362
- el.innerHTML = "";
1363
- var ifr = document.createElement("iframe");
1364
- ifr.loading = "lazy";
1365
- ifr.allow = "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture";
1366
- ifr.referrerPolicy = "no-referrer";
1367
- ifr.src = spotifySrc(item);
1368
- el.appendChild(ifr);
1369
- state.ready.spotify = true;
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
+ }
1370
1322
  }
1371
1323
 
1372
- function fillSelect(p) {
1373
- sel.innerHTML = "";
1374
- if (p.items.length <= 1) { sel.style.display = "none"; return; }
1375
- sel.style.display = "";
1376
- p.items.forEach(function (it, i) {
1377
- var o = document.createElement("option");
1378
- o.value = String(i);
1379
- o.textContent = it.name || ("歌单 " + (i + 1));
1380
- sel.appendChild(o);
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
+ }
1381
1338
  });
1382
- sel.value = String(state.idx[p.key] || 0);
1383
1339
  }
1384
1340
 
1385
- function activateStages(key) {
1386
- Object.keys(stageEl).forEach(function (k) {
1387
- stageEl[k].classList.toggle("active", k === key);
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
+ }
1388
1352
  });
1389
- ctrlBar.classList.toggle("show", key === "netease");
1390
- refreshBtn.classList.toggle("show", key === "netease");
1353
+ hideScopeSelect();
1354
+ hideNativeEmptyHints();
1391
1355
  }
1392
-
1393
- function selectTab(key) {
1394
- state.tab = key;
1395
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
1396
- var p = currentPlatform();
1397
- fillSelect(p);
1398
- activateStages(key);
1399
- if (key === "netease") setupNetease(false);
1400
- else setupSpotify(false);
1401
- try { localStorage.setItem("ym-tab", key); } catch (e) {}
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; }
1402
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
+ }
1403
1373
 
1404
- sel.addEventListener("change", function () {
1405
- var key = state.tab;
1406
- state.idx[key] = parseInt(sel.value, 10) || 0;
1407
- if (key === "netease") setupNetease(true);
1408
- else setupSpotify(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();
1409
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
+ }
1410
1391
 
1411
- function open() {
1412
- panel.classList.add("open");
1413
- var key = state.tab;
1414
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
1415
- fillSelect(currentPlatform());
1416
- activateStages(key);
1417
- if (key === "netease") setupNetease(true);
1418
- else setupSpotify(false);
1419
- try { localStorage.setItem("ym-open", "1"); } catch (e) {}
1420
- }
1421
- function close() {
1422
- panel.classList.remove("open");
1423
- state.ready.netease = false;
1424
- try { localStorage.setItem("ym-open", "0"); } catch (e) {}
1425
- }
1426
- fab.addEventListener("click", function () { panel.classList.contains("open") ? close() : open(); });
1427
-
1428
- /* 记忆位置/尺寸/上次标签 */
1392
+ /* 拦截官方 /fs/search:官方前端用 axios(XHR),不是 fetch。
1393
+ 重写请求体里的 parent 为 "/",保证即便走官方自己的搜索通道也是全局。 */
1394
+ function rewriteSearchBody(bodyText) {
1429
1395
  try {
1430
- var savedRect = JSON.parse(localStorage.getItem("ym-rect") || "null");
1431
- if (savedRect && savedRect.w && savedRect.h && !window.matchMedia("(max-width:600px)").matches) {
1432
- panel.style.left = savedRect.x + "px";
1433
- panel.style.top = savedRect.y + "px";
1434
- panel.style.bottom = "auto";
1435
- panel.style.width = savedRect.w + "px";
1436
- panel.style.height = savedRect.h + "px";
1396
+ var data = JSON.parse(bodyText);
1397
+ if (data && typeof data === "object" && "keywords" in data) {
1398
+ data.parent = "/"; // 永远全局
1399
+ return JSON.stringify(data);
1437
1400
  }
1438
- var savedTab = localStorage.getItem("ym-tab");
1439
- if (savedTab && tabBtns[savedTab]) state.tab = savedTab;
1440
1401
  } catch (e) {}
1402
+ return bodyText;
1403
+ }
1441
1404
 
1442
- function isMobile() { return window.matchMedia("(max-width:600px)").matches; }
1443
- function saveRect() {
1444
- if (isMobile()) return;
1445
- var r = panel.getBoundingClientRect();
1446
- try { localStorage.setItem("ym-rect", JSON.stringify({ x: r.left, y: r.top, w: r.width, h: r.height })); } catch (e) {}
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
+ }
1447
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();
1448
1497
 
1449
- /* 拖动 */
1450
- var drag = null;
1451
- function startDrag(ev) {
1452
- if (ev.target.closest(".tab,.close,.sel")) return;
1453
- var pt = ev.touches ? ev.touches[0] : ev;
1454
- var r = panel.getBoundingClientRect();
1455
- drag = { x: pt.clientX, y: pt.clientY, left: r.left, top: r.top };
1456
- panel.style.left = r.left + "px";
1457
- panel.style.top = r.top + "px";
1458
- panel.style.bottom = "auto";
1459
- ev.preventDefault();
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);
1460
1505
  }
1461
- function moveDrag(ev) {
1462
- if (!drag) return;
1463
- var pt = ev.touches ? ev.touches[0] : ev;
1464
- var nx = drag.left + (pt.clientX - drag.x);
1465
- var ny = drag.top + (pt.clientY - drag.y);
1466
- var r = panel.getBoundingClientRect();
1467
- nx = Math.max(0, Math.min(window.innerWidth - r.width, nx));
1468
- ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
1469
- panel.style.left = nx + "px";
1470
- panel.style.top = ny + "px";
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;
1471
1610
  }
1472
- function endDrag() { if (drag) { drag = null; saveRect(); } }
1473
- bar.addEventListener("mousedown", startDrag);
1474
- window.addEventListener("mousemove", moveDrag);
1475
- window.addEventListener("mouseup", endDrag);
1476
- bar.addEventListener("touchstart", startDrag, { passive: false });
1477
- window.addEventListener("touchmove", moveDrag, { passive: false });
1478
- window.addEventListener("touchend", endDrag);
1479
-
1480
- /* 缩放(桌面) */
1481
- var resizer = panel.querySelector(".resizer");
1482
- var rsz = null;
1483
- resizer.addEventListener("mousedown", function (ev) {
1484
- var r = panel.getBoundingClientRect();
1485
- rsz = { x: ev.clientX, y: ev.clientY, w: r.width, h: r.height, left: r.left, top: r.top };
1486
- panel.style.left = r.left + "px";
1487
- panel.style.top = r.top + "px";
1488
- panel.style.bottom = "auto";
1489
- ev.preventDefault();
1490
- ev.stopPropagation();
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
+ });
1491
1661
  });
1492
- window.addEventListener("mousemove", function (ev) {
1493
- if (!rsz) return;
1494
- var w = Math.max(300, rsz.w + (ev.clientX - rsz.x));
1495
- var h = Math.max(360, rsz.h + (ev.clientY - rsz.y));
1496
- w = Math.min(w, window.innerWidth - rsz.left - 8);
1497
- panel.style.width = w + "px";
1498
- panel.style.height = h + "px";
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
+ });
1499
1671
  });
1500
- window.addEventListener("mouseup", function () { if (rsz) { rsz = null; saveRect(); } });
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
+ }
1501
1678
 
1502
- /* 明暗主题:同步宿主属性;Spotify 已初始化时重建一次(仅换主题,曲目由其自身续播) */
1503
- function syncDark() {
1504
- var dark = document.body.classList.contains("hope-ui-dark");
1505
- if (dark) host.setAttribute("data-dark", "");
1506
- else host.removeAttribute("data-dark");
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
+ });
1507
1727
  }
1508
- syncDark();
1509
- var mo = new MutationObserver(syncDark);
1510
- mo.observe(document.body, { attributes: true, attributeFilter: ["class"] });
1511
1728
  }
1512
1729
 
1513
1730
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);