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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,571 +1,164 @@
1
1
  /* =============================================================================
2
- * Yuuichi 资源站 · 悬浮背景音乐播放器
2
+ * Yuuichi 资源站 · 背景音乐(与 blog.yuuichi.me / www.yuuichi.me 统一的 bgm-dock)
3
3
  * -----------------------------------------------------------------------------
4
- * 想换歌单,只需要修改下面 MUSIC_CONFIG 里的 id / 链接即可,然后重新部署:
5
- * 1) 网易云歌单:打开歌单复制地址里的 id 数字
6
- * 例 https://music.163.com/#/playlist?id=18475904830 -> id 填 "18475904830"
7
- * 网易云使用内置 APlayer 内核(支持音量调节 / 封面 / 歌词 / 列表循环)
8
- * 2) Spotify 歌单:复制歌单链接
9
- * 例 https://open.spotify.com/playlist/3bePpYfOLDwFtVVs6cqwyh -> id 填 "3bePpYfOLDwFtVVs6cqwyh"
10
- * Spotify 为官方嵌入播放器,其桌面端自身右下角带音量条(浏览器跨域限制,外部无法代为控制)
11
- * 每个平台可填多个歌单(顶部可切换);不用的平台把列表留空 [] 即可。
12
- * 网易云歌单由本站同源接口 /api/music/netease 实时拉取网易云官方数据(自动反映你在网易云的增删);
13
- * 每首歌的直链 / 封面 / 歌词由 metingApi 按 id 实时解析(公益服务,失效可换其它 Meting 兼容地址)。
14
- * 改完保存,在 openlist 目录执行:
15
- * node scripts/sync-branding.mjs
16
- * pnpm exec wrangler deploy
4
+ * 统一播放器:左下角圆形按钮(半吸附边缘 / 可拖动 / 可缩放),点击展开面板,
5
+ * 内置 Spotify 官方嵌入 + 网易云(Meting/APlayer,含封面/歌词/列表循环/倍速)。
6
+ *
7
+ * 想换歌单,修改下面 MUSIC_CONFIG 的 id,然后:
8
+ * node scripts/sync-branding.mjs && pnpm exec wrangler deploy
9
+ * 也可以在 OpenList 管理台设置项 music_config 在线覆盖(无需重新部署)。
10
+ *
11
+ * 资源站资源全部同源托管在 /brand/bgm/ 下;网易云歌单/直链/歌词走本站
12
+ * /api/music/meting(Worker 服务端代理网易云),浏览器不直接访问 workers.dev。
17
13
  * ============================================================================= */
18
14
  (function () {
19
15
  "use strict";
20
16
 
17
+ var ASSET_BASE = "/brand/bgm";
18
+ var METING_API = "/api/music/meting?server=:server&type=:type&id=:id&r=:r";
19
+
21
20
  var MUSIC_CONFIG = {
22
- netease: [
23
- { id: "18475904830", name: "网易云歌单" }
24
- ],
25
- spotify: [
26
- { id: "3bePpYfOLDwFtVVs6cqwyh", name: "Spotify 歌单" }
27
- ],
28
- defaultTab: "netease", // "netease" 或 "spotify"
29
- autoplay: false, // 网易云是否自动播放(多数浏览器会拦截,建议 false)
30
- fabHint: "音乐"
21
+ netease: [{ id: "18475904830", name: "网易云" }],
22
+ spotify: [{ id: "3bePpYfOLDwFtVVs6cqwyh", name: "Spotify" }],
23
+ defaultTab: "spotify", // "spotify" 或 "netease"
24
+ autoplay: false,
25
+ theme: "#0ea5e9",
26
+ volume: 0.6,
27
+ order: "list" // list / random
31
28
  };
32
29
 
33
- // 管理台(OpenList 设置项 music_config)可在线覆盖歌单/开关,无需重新部署。
34
- // Worker 会在本脚本前注入 window.__YUUICHI_MUSIC_CONFIG_OVERLAY__。
30
+ // 管理台 music_config 在线覆盖
35
31
  try {
36
32
  var __ov = window.__YUUICHI_MUSIC_CONFIG_OVERLAY__;
37
33
  if (__ov && typeof __ov === "object") {
38
34
  ["netease", "spotify"].forEach(function (k) {
39
35
  if (Array.isArray(__ov[k])) MUSIC_CONFIG[k] = __ov[k];
40
36
  });
41
- ["defaultTab", "fabHint"].forEach(function (k) {
37
+ ["defaultTab", "theme", "order"].forEach(function (k) {
42
38
  if (typeof __ov[k] === "string" && __ov[k]) MUSIC_CONFIG[k] = __ov[k];
43
39
  });
44
- if (typeof __ov.autoplay === "boolean") MUSIC_CONFIG.autoplay = __ov.autoplay;
40
+ ["autoplay"].forEach(function (k) {
41
+ if (typeof __ov[k] === "boolean") MUSIC_CONFIG[k] = __ov[k];
42
+ });
43
+ if (typeof __ov.volume === "number") MUSIC_CONFIG.volume = __ov.volume;
45
44
  }
46
45
  } catch (e) {}
47
46
 
48
- function init() {
49
- if (window.__yuuichiMusicInited) return;
50
- window.__yuuichiMusicInited = true;
51
-
52
- var platforms = [];
53
- if (MUSIC_CONFIG.netease.length) platforms.push({ key: "netease", label: "网易云", items: MUSIC_CONFIG.netease });
54
- if (MUSIC_CONFIG.spotify.length) platforms.push({ key: "spotify", label: "Spotify", items: MUSIC_CONFIG.spotify });
55
- if (!platforms.length) return;
56
-
57
- var host = document.createElement("div");
58
- host.id = "yuuichi-music-root";
59
- host.style.cssText = "all:initial;position:fixed;z-index:2147483000;";
60
- var shadow = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
61
- document.body.appendChild(host);
62
-
63
- /* ---------------- 样式 ---------------- */
64
- // APlayer 厂商 CSS(12KB)改为首次真正打开歌单时才注入,避免首屏多一个请求
65
- var vendorLinkLoaded = false;
66
- function ensureVendorCss() {
67
- if (vendorLinkLoaded) return;
68
- vendorLinkLoaded = true;
69
- var vendorLink = document.createElement("link");
70
- vendorLink.rel = "stylesheet";
71
- vendorLink.href = "/brand/vendor/APlayer.min.css";
72
- shadow.appendChild(vendorLink);
73
- }
74
-
75
- var style = document.createElement("style");
76
- style.textContent = [
77
- ":host, * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }",
78
- ":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); }",
79
- ":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); }",
80
- ".wrap { position:fixed; left:0; top:0; font-family:system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif; }",
81
- ".fab { position:fixed; left:18px; bottom:18px; width:54px; height:54px; border-radius:50%;",
82
- " border:none; cursor:pointer; color:#fff; display:flex; align-items:center; justify-content:center;",
83
- " background:linear-gradient(135deg,#38bdf8,#0284c7); box-shadow:0 8px 24px rgba(2,132,199,.45);",
84
- " transform:translateX(-58%); transition:transform .22s cubic-bezier(.2,.8,.2,1), box-shadow .18s ease; padding:0; }",
85
- ".wrap:hover .fab, .fab:focus-visible, .fab.peek { transform:translateX(0); }",
86
- ".fab:hover { box-shadow:0 12px 30px rgba(2,132,199,.55); }",
87
- ".fab:active { transform:scale(.94); }",
88
- ".fab svg { width:26px; height:26px; }",
89
- ".panel { position:fixed; left:18px; bottom:84px; width:360px; height:560px; max-width:calc(100vw - 24px);",
90
- " background:var(--ym-card); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);",
91
- " border:1px solid var(--ym-line); border-radius:18px; overflow:hidden; display:none;",
92
- " flex-direction:column; box-shadow:0 20px 60px rgba(2,8,23,.35); color:var(--ym-txt); }",
93
- ".panel.open { display:flex; }",
94
- ".bar { display:flex; align-items:center; gap:6px; padding:8px 10px; cursor:move; user-select:none;",
95
- " 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; }",
96
- ".tab { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt);",
97
- " border-radius:999px; padding:4px 12px; font-size:12px; cursor:pointer; }",
98
- ".tab.active { background:linear-gradient(135deg,#38bdf8,#0284c7); color:#fff; border-color:transparent; }",
99
- ".sel { flex:1; min-width:0; border:1px solid var(--ym-line); background:transparent;",
100
- " color:var(--ym-txt); border-radius:8px; font-size:12px; padding:4px 6px; outline:none; }",
101
- ".sel option { color:#0f172a; }",
102
- ".close { border:none; background:transparent; color:var(--ym-txt); font-size:18px; line-height:1; cursor:pointer; padding:2px 7px; border-radius:8px; }",
103
- ".close:hover { background:var(--ym-soft); }",
104
- ".refresh { display:none; border:none; background:transparent; color:var(--ym-txt); cursor:pointer; padding:2px 6px; border-radius:8px; align-items:center; }",
105
- ".refresh.show { display:inline-flex; }",
106
- ".refresh:hover { background:var(--ym-soft); }",
107
- ".refresh svg { width:16px; height:16px; }",
108
- ".refresh.spin svg { animation:ym-rspin .8s linear infinite; }",
109
- "@keyframes ym-rspin { to { transform:rotate(360deg); } }",
110
- ".ctrl { display:none; align-items:center; gap:6px; padding:5px 10px; flex:0 0 auto;",
111
- " border-bottom:1px solid var(--ym-line); }",
112
- ".ctrl.show { display:flex; }",
113
- ".cbtn { border:1px solid var(--ym-line); background:transparent; color:var(--ym-txt); cursor:pointer;",
114
- " border-radius:999px; font-size:12px; line-height:1; padding:4px 10px; display:inline-flex; align-items:center; gap:5px; }",
115
- ".cbtn:hover { background:var(--ym-soft); }",
116
- ".cbtn svg { width:14px; height:14px; }",
117
- ".stages { flex:1; min-height:0; position:relative; }",
118
- ".stage { position:absolute; inset:0; display:none; }",
119
- ".stage.active { display:block; }",
120
- ".stage iframe { width:100%; height:100%; border:0; display:block; background:#fff; }",
121
- ".ap-wrap { position:absolute; inset:0; overflow:auto; }",
122
- ".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; }",
123
- ".status button { border:none; cursor:pointer; color:#fff; background:linear-gradient(135deg,#38bdf8,#0284c7); border-radius:999px; padding:6px 18px; font-size:13px; }",
124
- ".resizer { position:absolute; right:0; bottom:0; width:22px; height:22px; cursor:nwse-resize; z-index:5; }",
125
- ".resizer::after { content:''; position:absolute; right:5px; bottom:5px; width:10px; height:10px;",
126
- " border-right:2px solid var(--ym-line); border-bottom:2px solid var(--ym-line); }",
127
- /* APlayer 皮肤适配(透明、贴合面板、青蓝主题) */
128
- ".aplayer { width:100%; margin:0; background:transparent !important; box-shadow:none !important; border-radius:0; color:var(--ym-txt); }",
129
- ".aplayer .aplayer-info { border-top:1px solid var(--ym-line); }",
130
- ".aplayer .aplayer-title, .aplayer .aplayer-list ol li .aplayer-list-index, .aplayer .aplayer-time { color:var(--ym-sub); }",
131
- ".aplayer .aplayer-list ol li { border-top:1px solid var(--ym-line); }",
132
- ".aplayer .aplayer-list ol li.aplayer-list-light { background:var(--ym-soft); }",
133
- ".aplayer .aplayer-list ol li:hover { background:var(--ym-soft); }",
134
- ".aplayer .aplayer-list ol li .aplayer-list-author { color:var(--ym-sub); }",
135
- ".aplayer .aplayer-lrc { text-shadow:0 1px 4px rgba(0,0,0,.35); }",
136
- ".aplayer .aplayer-lrc p.aplayer-lrc-current { color:#38bdf8; }",
137
- ".aplayer .aplayer-miniswitcher { background:var(--ym-soft); }",
138
- ":host([data-dark]) .aplayer svg, :host([data-dark]) .aplayer .aplayer-icon { filter:invert(1) hue-rotate(180deg); }",
139
- "@media (max-width:600px) {",
140
- " .fab { left:14px; bottom:14px; width:50px; height:50px; }",
141
- " .panel { left:12px !important; bottom:76px !important; width:calc(100vw - 24px) !important; height:min(76vh,600px) !important; }",
142
- " .resizer { display:none; }",
143
- "}"
144
- ].join("\n");
145
- shadow.appendChild(style);
146
-
147
- /* ---------------- DOM 骨架 ---------------- */
148
- var wrap = document.createElement("div");
149
- wrap.className = "wrap";
150
- shadow.appendChild(wrap);
151
-
152
- var fab = document.createElement("button");
153
- fab.className = "fab";
154
- fab.title = MUSIC_CONFIG.fabHint || "音乐";
155
- fab.setAttribute("aria-label", "音乐播放器");
156
- fab.innerHTML =
157
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
158
- '<path d="M9 18V5l10-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="16" cy="16" r="3"></circle></svg>';
159
- wrap.appendChild(fab);
160
-
161
- var panel = document.createElement("div");
162
- panel.className = "panel";
163
- panel.innerHTML =
164
- '<div class="bar"></div>' +
165
- '<div class="ctrl">' +
166
- '<button class="cbtn js-speed" type="button" title="播放倍速"><span class="speed-label">1.0x</span></button>' +
167
- '<button class="cbtn js-order" type="button" title="播放顺序"></button>' +
168
- '</div>' +
169
- '<div class="stages"></div>' +
170
- '<div class="resizer"></div>';
171
- wrap.appendChild(panel);
172
-
173
- var bar = panel.querySelector(".bar");
174
- var stages = panel.querySelector(".stages");
175
- var ctrlBar = panel.querySelector(".ctrl");
176
- var speedBtn = panel.querySelector(".js-speed");
177
- var speedLabel = panel.querySelector(".speed-label");
178
- var orderBtn = panel.querySelector(".js-order");
179
-
180
- var tabBtns = {};
181
- platforms.forEach(function (p) {
182
- var b = document.createElement("button");
183
- b.className = "tab";
184
- b.textContent = p.label;
185
- b.dataset.key = p.key;
186
- b.addEventListener("click", function () { selectTab(p.key); });
187
- bar.appendChild(b);
188
- tabBtns[p.key] = b;
189
- });
190
-
191
- var sel = document.createElement("select");
192
- sel.className = "sel";
193
- bar.appendChild(sel);
194
-
195
- var refreshBtn = document.createElement("button");
196
- refreshBtn.className = "refresh";
197
- refreshBtn.title = "重新拉取最新歌单";
198
- refreshBtn.setAttribute("aria-label", "刷新歌单");
199
- refreshBtn.innerHTML =
200
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
201
- '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline></svg>';
202
- refreshBtn.addEventListener("click", function () {
203
- if (state.tab === "netease") {
204
- refreshBtn.classList.add("spin");
205
- setupNetease(true).finally(function () { refreshBtn.classList.remove("spin"); });
206
- }
47
+ /* 收起态关键 CSS(与博客 #bgm-dock-critical 保持一致),内联防止展开前闪烁 */
48
+ var CRITICAL_CSS =
49
+ ".bgm-dock{--bgm-accent:" + (MUSIC_CONFIG.theme || "#0ea5e9") + ";" +
50
+ "position:fixed;left:16px;bottom:16px;z-index:99999;width:52px;max-width:calc(100vw - 24px);" +
51
+ "font-size:13px;line-height:1.4;color:#222;}" +
52
+ ".bgm-dock.is-open{width:320px;max-width:calc(100vw - 24px);}" +
53
+ ".bgm-dock__fab{position:relative;width:52px;height:52px;border:0;border-radius:50%;" +
54
+ "background:var(--bgm-accent);color:#fff;font-size:20px;cursor:pointer;" +
55
+ "box-shadow:0 8px 22px rgba(0,0,0,.28);display:inline-flex;align-items:center;justify-content:center;touch-action:none;}" +
56
+ ".bgm-dock__fab.is-dragging{cursor:grabbing;opacity:.92;}" +
57
+ ".bgm-dock:not(.is-open) .bgm-dock__fab{--bgm-peek:18px;transition:transform .28s cubic-bezier(.22,.61,.36,1);will-change:transform;}" +
58
+ // 收起态半吸附到屏幕边缘:默认只露出一小截(半圆即可 hover),鼠标移上 /
59
+ // 键盘聚焦 / 拖拽 / 已全露 时整个滑回屏幕内,保证桌面鼠标始终点得到。
60
+ // 触摸设备点一下全露、再点展开(见 bgm-dock.js 的 is-revealed 逻辑)。
61
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-left{transform:translateX(calc(var(--bgm-peek) - 100%));}" +
62
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-right{transform:translateX(calc(100% - var(--bgm-peek)));}" +
63
+ ".bgm-dock:not(.is-open) .bgm-dock__fab:hover," +
64
+ ".bgm-dock:not(.is-open) .bgm-dock__fab:focus-visible," +
65
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-revealed," +
66
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-dragging{transform:none;}" +
67
+ // 触摸(无 hover)首次点按仅全露:未全露时不要因 hover 规则干扰(触摸无 hover,
68
+ // 该媒体查询下默认半藏即可)。
69
+ "@media (hover:none),(pointer:coarse){" +
70
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-left{transform:translateX(calc(var(--bgm-peek) - 100%));}" +
71
+ ".bgm-dock:not(.is-open) .bgm-dock__fab.is-peek-right{transform:translateX(calc(100% - var(--bgm-peek)));}}" +
72
+ ".bgm-dock__fab svg{width:22px;height:22px;fill:currentColor;display:block;}" +
73
+ ".bgm-dock__fab-bar{position:absolute;right:9px;top:9px;width:9px;height:9px;border-radius:50%;" +
74
+ "background:#2ecc71;border:2px solid #fff;opacity:0;}" +
75
+ ".bgm-dock.is-playing .bgm-dock__fab-bar{opacity:1;}" +
76
+ ".bgm-dock__panel{display:none;}" +
77
+ ".bgm-dock.is-open .bgm-dock__panel{display:flex;}" +
78
+ ".bgm-dock.is-open .bgm-dock__fab{display:none;}" +
79
+ ".bgm-dock__overlay{position:fixed;inset:0;z-index:999999;cursor:grabbing;user-select:none;}" +
80
+ "@media (max-width:768px){.bgm-dock{left:14px;bottom:14px;width:52px;}" +
81
+ ".bgm-dock.is-open{width:auto;left:0;right:0;bottom:0;max-width:none;}}";
82
+
83
+ function buildSources() {
84
+ var sources = [];
85
+ var i = 0;
86
+ (MUSIC_CONFIG.spotify || []).forEach(function (s) {
87
+ if (!s || !s.id) return;
88
+ sources.push({
89
+ name: s.name || "Spotify",
90
+ type: "spotify",
91
+ resourceType: "playlist",
92
+ id: String(s.id),
93
+ key: "spotify-" + i++
94
+ });
207
95
  });
208
- bar.appendChild(refreshBtn);
209
-
210
- var closeBtn = document.createElement("button");
211
- closeBtn.className = "close";
212
- closeBtn.innerHTML = "&times;";
213
- closeBtn.addEventListener("click", close);
214
- bar.appendChild(closeBtn);
215
-
216
- /* 每个平台一个常驻 stage(切换/收起只隐藏,不销毁,播放不中断、不重载) */
217
- var stageEl = {};
218
- platforms.forEach(function (p) {
219
- var d = document.createElement("div");
220
- d.className = "stage";
221
- d.dataset.key = p.key;
222
- stages.appendChild(d);
223
- stageEl[p.key] = d;
96
+ (MUSIC_CONFIG.netease || []).forEach(function (s) {
97
+ if (!s || !s.id) return;
98
+ sources.push({
99
+ name: s.name || "网易云",
100
+ type: "netease",
101
+ resourceType: "playlist",
102
+ id: String(s.id),
103
+ key: "netease-" + i++
104
+ });
224
105
  });
106
+ // 默认源标记
107
+ var wantDefault = MUSIC_CONFIG.defaultTab === "netease" ? "netease" : "spotify";
108
+ var def = sources.filter(function (s) { return s.type === wantDefault; })[0] || sources[0];
109
+ if (def) def.default = true;
110
+ return sources;
111
+ }
225
112
 
226
- var state = {
227
- tab: MUSIC_CONFIG.defaultTab,
228
- idx: {},
229
- ready: {}, // 该平台当前 stage 是否已初始化
230
- ap: null, // 网易云 APlayer 实例
231
- tracks: null,
232
- loading: false
113
+ function init() {
114
+ if (window.__yuuichiBgmInited) return;
115
+ if (document.getElementById("bgm-dock")) return;
116
+ window.__yuuichiBgmInited = true;
117
+
118
+ var sources = buildSources();
119
+ if (!sources.length) return;
120
+
121
+ var styleEl = document.createElement("style");
122
+ styleEl.id = "bgm-dock-critical";
123
+ styleEl.textContent = CRITICAL_CSS;
124
+ document.head.appendChild(styleEl);
125
+
126
+ var dock = document.createElement("div");
127
+ dock.id = "bgm-dock";
128
+ dock.className = "bgm-dock";
129
+ dock.setAttribute(
130
+ "data-bgm",
131
+ JSON.stringify({
132
+ sources: sources,
133
+ autoplay: MUSIC_CONFIG.autoplay === true,
134
+ order: MUSIC_CONFIG.order === "random" ? "random" : "list",
135
+ volume: typeof MUSIC_CONFIG.volume === "number" ? MUSIC_CONFIG.volume : 0.6,
136
+ theme: MUSIC_CONFIG.theme || "#0ea5e9",
137
+ assetBase: ASSET_BASE,
138
+ metingApi: METING_API
139
+ })
140
+ );
141
+ document.body.appendChild(dock);
142
+
143
+ // 收起态只依赖内联 CRITICAL_CSS,主脚本 bgm-dock.js(~28KB)推迟到浏览器
144
+ // 空闲 / load 后再拉,避免与首屏 JS、目录列表、背景图争抢带宽与主线程。
145
+ var s = document.createElement("script");
146
+ s.src = ASSET_BASE + "/js/bgm-dock.js";
147
+ s.defer = true;
148
+ s.onerror = function () {
149
+ // 加载失败时至少保证按钮还能看到,避免完全无反应
150
+ try { dock.style.display = "none"; } catch (e) {}
233
151
  };
234
- platforms.forEach(function (p) { state.idx[p.key] = 0; state.ready[p.key] = false; });
235
- if (!tabBtns[state.tab]) state.tab = platforms[0].key;
236
-
237
- var SPEEDS = [1, 1.25, 1.5, 2, 0.75];
238
- var speedIdx = 0;
239
- var ORDER_MODES = [
240
- { 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>' },
241
- { 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>' },
242
- { 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>' },
243
- { 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>' }
244
- ];
245
- var orderIdx = 0;
246
- try {
247
- var sv = parseFloat(localStorage.getItem("ym-speed"));
248
- var si = SPEEDS.indexOf(sv);
249
- if (si >= 0) speedIdx = si;
250
- var oi = parseInt(localStorage.getItem("ym-order"), 10);
251
- if (!isNaN(oi) && ORDER_MODES[oi]) orderIdx = oi;
252
- } catch (e) {}
253
-
254
- function applySpeed() {
255
- var rate = SPEEDS[speedIdx];
256
- speedLabel.textContent = (rate % 1 === 0 ? rate.toFixed(1) : String(rate)) + "x";
257
- try {
258
- if (state.ap && state.ap.audio) state.ap.audio.playbackRate = rate;
259
- localStorage.setItem("ym-speed", String(rate));
260
- } catch (e) {}
261
- }
262
-
263
- function applyOrder() {
264
- var mode = ORDER_MODES[orderIdx];
265
- orderBtn.innerHTML = mode.icon + '<span class="order-label">' + mode.label + '</span>';
266
- orderBtn.title = "播放顺序:" + mode.label;
267
- try {
268
- if (state.ap && state.ap.options) {
269
- state.ap.options.loop = mode.loop;
270
- state.ap.options.order = mode.order;
271
- }
272
- localStorage.setItem("ym-order", String(orderIdx));
273
- } catch (e) {}
274
- }
275
-
276
- speedBtn.addEventListener("click", function () {
277
- speedIdx = (speedIdx + 1) % SPEEDS.length;
278
- applySpeed();
279
- });
280
- orderBtn.addEventListener("click", function () {
281
- orderIdx = (orderIdx + 1) % ORDER_MODES.length;
282
- applyOrder();
283
- });
284
- applySpeed();
285
- applyOrder();
286
-
287
- function currentPlatform() {
288
- for (var i = 0; i < platforms.length; i++) if (platforms[i].key === state.tab) return platforms[i];
289
- return platforms[0];
290
- }
291
-
292
- function loadScriptOnce(attr, src) {
293
- return new Promise(function (resolve, reject) {
294
- var existing = document.querySelector("script[" + attr + "]");
295
- if (existing) {
296
- if (existing.dataset.loaded === "1") return resolve();
297
- existing.addEventListener("load", function () { resolve(); });
298
- existing.addEventListener("error", reject);
299
- return;
300
- }
301
- var s = document.createElement("script");
302
- s.async = true;
303
- s.setAttribute(attr, "1");
304
- s.onload = function () { s.dataset.loaded = "1"; resolve(); };
305
- s.onerror = reject;
306
- s.src = src;
307
- document.head.appendChild(s);
308
- });
309
- }
310
-
311
- function loadScript(src) {
312
- return loadScriptOnce("data-ym-aplayer", src);
313
- }
314
-
315
- /* 实时同源接口:Worker 服务端优先走自建 Meting、失败回退网易云官方,无 CORS */
316
- function fetchWithTimeout(url, ms) {
317
- var ctrl = ("AbortController" in window) ? new AbortController() : null;
318
- var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, ms) : null;
319
- return fetch(url, { cache: "no-store", signal: ctrl ? ctrl.signal : undefined })
320
- .finally(function () { if (timer) clearTimeout(timer); });
321
- }
322
-
323
- async function fetchPlaylist(item, force) {
324
- var url = "/api/music/netease?id=" + encodeURIComponent(item.id) + (force ? "&refresh=1" : "");
325
- var res = await fetchWithTimeout(url, 15000);
326
- var j = await res.json();
327
- if (!j || j.code !== 200 || !Array.isArray(j.data && j.data.songs) || !j.data.songs.length) {
328
- throw new Error("empty");
329
- }
330
- return j.data.songs;
331
- }
332
-
333
- function statusEl(el, msg, withRetry) {
334
- el.innerHTML = "";
335
- var d = document.createElement("div");
336
- d.className = "status";
337
- var span = document.createElement("span");
338
- span.textContent = msg;
339
- d.appendChild(span);
340
- if (withRetry) {
341
- var btn = document.createElement("button");
342
- btn.textContent = "重试";
343
- btn.addEventListener("click", function () { setupNetease(true); });
344
- d.appendChild(btn);
345
- }
346
- el.appendChild(d);
347
- }
348
-
349
- async function setupNetease(force) {
350
- var el = stageEl.netease;
351
- if (state.ready.netease && !force) return;
352
- if (state.loading) return;
353
- state.loading = true;
354
- statusEl(el, "正在加载网易云歌单…", false);
355
- try {
356
- var item = MUSIC_CONFIG.netease[state.idx.netease || 0];
357
- var list = await fetchPlaylist(item, !!force);
358
- state.tracks = list;
359
-
360
- if (state.ap) { try { state.ap.destroy(); } catch (e) {} state.ap = null; }
361
- el.innerHTML = '<div class="ap-wrap"></div>';
362
- ensureVendorCss();
363
- await loadScript("/brand/vendor/APlayer.min.js");
364
-
365
- var mode = ORDER_MODES[orderIdx];
366
- var ap = new window.APlayer({
367
- container: el.querySelector(".ap-wrap"),
368
- fixed: false,
369
- mini: false,
370
- autoplay: !!MUSIC_CONFIG.autoplay,
371
- theme: "#0ea5e9",
372
- loop: mode.loop,
373
- order: mode.order,
374
- listMaxHeight: "999px",
375
- lrcType: list[0].lrc ? 3 : 0,
376
- audio: list.map(function (t) {
377
- return { name: t.name, artist: t.artist, url: t.url, cover: t.pic, lrc: t.lrc, theme: "#0ea5e9" };
378
- })
379
- });
380
- state.ap = ap;
381
- var audio = ap.audio;
382
- if (audio) {
383
- audio.playbackRate = SPEEDS[speedIdx];
384
- audio.addEventListener("play", function () { audio.playbackRate = SPEEDS[speedIdx]; });
385
- }
386
- state.ready.netease = true;
387
- } catch (e) {
388
- statusEl(el, "网易云歌单加载失败(解析服务可能暂时不可用)", true);
389
- } finally {
390
- state.loading = false;
391
- }
392
- }
393
-
394
- function spotifySrc(item) {
395
- var dark = document.body.classList.contains("hope-ui-dark");
396
- return "https://open.spotify.com/embed/playlist/" + encodeURIComponent(item.id) +
397
- "?utm_source=generator&theme=" + (dark ? "0" : "1");
398
- }
399
-
400
- function setupSpotify(force) {
401
- var el = stageEl.spotify;
402
- if (state.ready.spotify && !force) return;
403
- var item = MUSIC_CONFIG.spotify[state.idx.spotify || 0];
404
- el.innerHTML = "";
405
- var ifr = document.createElement("iframe");
406
- ifr.loading = "lazy";
407
- ifr.allow = "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture";
408
- ifr.referrerPolicy = "no-referrer";
409
- ifr.src = spotifySrc(item);
410
- el.appendChild(ifr);
411
- state.ready.spotify = true;
412
- }
413
-
414
- function fillSelect(p) {
415
- sel.innerHTML = "";
416
- if (p.items.length <= 1) { sel.style.display = "none"; return; }
417
- sel.style.display = "";
418
- p.items.forEach(function (it, i) {
419
- var o = document.createElement("option");
420
- o.value = String(i);
421
- o.textContent = it.name || ("歌单 " + (i + 1));
422
- sel.appendChild(o);
152
+ var load = function () { document.body.appendChild(s); };
153
+ if (document.readyState === "complete") {
154
+ var ric = window.requestIdleCallback || function (cb) { return setTimeout(cb, 200); };
155
+ ric(load, { timeout: 2500 });
156
+ } else {
157
+ window.addEventListener("load", function () {
158
+ var ric = window.requestIdleCallback || function (cb) { return setTimeout(cb, 300); };
159
+ ric(load, { timeout: 3000 });
423
160
  });
424
- sel.value = String(state.idx[p.key] || 0);
425
- }
426
-
427
- function activateStages(key) {
428
- Object.keys(stageEl).forEach(function (k) {
429
- stageEl[k].classList.toggle("active", k === key);
430
- });
431
- ctrlBar.classList.toggle("show", key === "netease");
432
- refreshBtn.classList.toggle("show", key === "netease");
433
- }
434
-
435
- function selectTab(key) {
436
- state.tab = key;
437
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
438
- var p = currentPlatform();
439
- fillSelect(p);
440
- activateStages(key);
441
- if (key === "netease") setupNetease(false);
442
- else setupSpotify(false);
443
- try { localStorage.setItem("ym-tab", key); } catch (e) {}
444
- }
445
-
446
- sel.addEventListener("change", function () {
447
- var key = state.tab;
448
- state.idx[key] = parseInt(sel.value, 10) || 0;
449
- if (key === "netease") setupNetease(true);
450
- else setupSpotify(true);
451
- });
452
-
453
- function open() {
454
- panel.classList.add("open");
455
- var key = state.tab;
456
- Object.keys(tabBtns).forEach(function (k) { tabBtns[k].classList.toggle("active", k === key); });
457
- fillSelect(currentPlatform());
458
- activateStages(key);
459
- if (key === "netease") setupNetease(true);
460
- else setupSpotify(false);
461
- try { localStorage.setItem("ym-open", "1"); } catch (e) {}
462
- }
463
- function close() {
464
- panel.classList.remove("open");
465
- fab.classList.remove("peek");
466
- state.ready.netease = false;
467
- try { localStorage.setItem("ym-open", "0"); } catch (e) {}
468
- }
469
- fab.addEventListener("click", function (ev) {
470
- // 第一次点击在半露出吸附态时只负责全露出,不立刻打开面板;再次点击才打开。
471
- if (!panel.classList.contains("open") && !fab.classList.contains("peek")) {
472
- fab.classList.add("peek");
473
- ev.stopPropagation();
474
- return;
475
- }
476
- fab.classList.remove("peek");
477
- panel.classList.contains("open") ? close() : open();
478
- });
479
- // 触屏没有 hover:点 FAB 之外恢复半露出;面板打开时不收起,避免遮挡交互。
480
- document.addEventListener("pointerdown", function (ev) {
481
- if (panel.classList.contains("open")) return;
482
- if (wrap.contains(ev.target)) return;
483
- fab.classList.remove("peek");
484
- }, true);
485
-
486
- /* 记忆位置/尺寸/上次标签 */
487
- try {
488
- var savedRect = JSON.parse(localStorage.getItem("ym-rect") || "null");
489
- if (savedRect && savedRect.w && savedRect.h && !window.matchMedia("(max-width:600px)").matches) {
490
- panel.style.left = savedRect.x + "px";
491
- panel.style.top = savedRect.y + "px";
492
- panel.style.bottom = "auto";
493
- panel.style.width = savedRect.w + "px";
494
- panel.style.height = savedRect.h + "px";
495
- }
496
- var savedTab = localStorage.getItem("ym-tab");
497
- if (savedTab && tabBtns[savedTab]) state.tab = savedTab;
498
- } catch (e) {}
499
-
500
- function isMobile() { return window.matchMedia("(max-width:600px)").matches; }
501
- function saveRect() {
502
- if (isMobile()) return;
503
- var r = panel.getBoundingClientRect();
504
- try { localStorage.setItem("ym-rect", JSON.stringify({ x: r.left, y: r.top, w: r.width, h: r.height })); } catch (e) {}
505
- }
506
-
507
- /* 拖动 */
508
- var drag = null;
509
- function startDrag(ev) {
510
- if (ev.target.closest(".tab,.close,.sel")) return;
511
- var pt = ev.touches ? ev.touches[0] : ev;
512
- var r = panel.getBoundingClientRect();
513
- drag = { x: pt.clientX, y: pt.clientY, left: r.left, top: r.top };
514
- panel.style.left = r.left + "px";
515
- panel.style.top = r.top + "px";
516
- panel.style.bottom = "auto";
517
- ev.preventDefault();
518
- }
519
- function moveDrag(ev) {
520
- if (!drag) return;
521
- var pt = ev.touches ? ev.touches[0] : ev;
522
- var nx = drag.left + (pt.clientX - drag.x);
523
- var ny = drag.top + (pt.clientY - drag.y);
524
- var r = panel.getBoundingClientRect();
525
- nx = Math.max(0, Math.min(window.innerWidth - r.width, nx));
526
- ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
527
- panel.style.left = nx + "px";
528
- panel.style.top = ny + "px";
529
- }
530
- function endDrag() { if (drag) { drag = null; saveRect(); } }
531
- bar.addEventListener("mousedown", startDrag);
532
- window.addEventListener("mousemove", moveDrag);
533
- window.addEventListener("mouseup", endDrag);
534
- bar.addEventListener("touchstart", startDrag, { passive: false });
535
- window.addEventListener("touchmove", moveDrag, { passive: false });
536
- window.addEventListener("touchend", endDrag);
537
-
538
- /* 缩放(桌面) */
539
- var resizer = panel.querySelector(".resizer");
540
- var rsz = null;
541
- resizer.addEventListener("mousedown", function (ev) {
542
- var r = panel.getBoundingClientRect();
543
- rsz = { x: ev.clientX, y: ev.clientY, w: r.width, h: r.height, left: r.left, top: r.top };
544
- panel.style.left = r.left + "px";
545
- panel.style.top = r.top + "px";
546
- panel.style.bottom = "auto";
547
- ev.preventDefault();
548
- ev.stopPropagation();
549
- });
550
- window.addEventListener("mousemove", function (ev) {
551
- if (!rsz) return;
552
- var w = Math.max(300, rsz.w + (ev.clientX - rsz.x));
553
- var h = Math.max(360, rsz.h + (ev.clientY - rsz.y));
554
- w = Math.min(w, window.innerWidth - rsz.left - 8);
555
- panel.style.width = w + "px";
556
- panel.style.height = h + "px";
557
- });
558
- window.addEventListener("mouseup", function () { if (rsz) { rsz = null; saveRect(); } });
559
-
560
- /* 明暗主题:同步宿主属性;Spotify 已初始化时重建一次(仅换主题,曲目由其自身续播) */
561
- function syncDark() {
562
- var dark = document.body.classList.contains("hope-ui-dark");
563
- if (dark) host.setAttribute("data-dark", "");
564
- else host.removeAttribute("data-dark");
565
161
  }
566
- syncDark();
567
- var mo = new MutationObserver(syncDark);
568
- mo.observe(document.body, { attributes: true, attributeFilter: ["class"] });
569
162
  }
570
163
 
571
164
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);