zsbqb-static 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +4 -0
  2. package/catalog/bubble-4f53cda18c2b.json +1 -0
  3. package/catalog/crescent-4f53cda18c2b.json +1 -0
  4. package/catalog/dianfeng-4f53cda18c2b.json +1 -0
  5. package/catalog/manifest.json +1 -0
  6. package/catalog/pinball-4f53cda18c2b.json +1 -0
  7. package/catalog/pinyin-4f53cda18c2b.json +1 -0
  8. package/catalog/xingdong-4f53cda18c2b.json +1 -0
  9. package/css/app.css +2157 -0
  10. package/img/apple-touch-icon.png +0 -0
  11. package/img/brand-logo.png +0 -0
  12. package/img/favicon-192.png +0 -0
  13. package/img/favicon-32.png +0 -0
  14. package/img/favicon.ico +0 -0
  15. package/img/modes/bubble.png +0 -0
  16. package/img/modes/crescent.png +0 -0
  17. package/img/modes/dianfeng-master.png +0 -0
  18. package/img/modes/dianfeng.png +0 -0
  19. package/img/modes/master.png +0 -0
  20. package/img/modes/pinball.png +0 -0
  21. package/img/modes/xingdong.png +0 -0
  22. package/img/skills/fire.png +0 -0
  23. package/img/skills/limit.png +0 -0
  24. package/js/app.js +2124 -0
  25. package/js/auth-shell-sync.js +45 -0
  26. package/js/burst-preview-host.js +493 -0
  27. package/js/catalog-search.js +311 -0
  28. package/js/query-app.js +331 -0
  29. package/note-preview/assets/atlas/bubble.webp +0 -0
  30. package/note-preview/assets/atlas/crescent.webp +0 -0
  31. package/note-preview/assets/atlas/idol.webp +0 -0
  32. package/note-preview/assets/atlas/manifest.json +1 -0
  33. package/note-preview/assets/atlas/pinball.webp +0 -0
  34. package/note-preview/assets/atlas/shared.webp +0 -0
  35. package/note-preview/assets/sfx/burst-end.m4a +0 -0
  36. package/note-preview/assets/sfx/burst-start.m4a +0 -0
  37. package/note-preview/assets/sfx/key-tone-bubble-slide.m4a +0 -0
  38. package/note-preview/assets/sfx/key-tone-bubble.m4a +0 -0
  39. package/note-preview/assets/sfx/key-tone-classic.m4a +0 -0
  40. package/note-preview/assets/sfx/key-tone-crescent-slide.m4a +0 -0
  41. package/note-preview/assets/sfx/key-tone-crescent.m4a +0 -0
  42. package/note-preview/assets/sfx/key-tone-long.m4a +0 -0
  43. package/note-preview/assets/sfx/key-tone-pinball-slide.m4a +0 -0
  44. package/note-preview/assets/sfx/key-tone-pinball.m4a +0 -0
  45. package/note-preview/assets/sfx/key-tone-rhythm.m4a +0 -0
  46. package/note-preview/assets/sfx/key-tone-slide.m4a +0 -0
  47. package/note-preview/assets/sfx/key-tone-xingdong-slide.m4a +0 -0
  48. package/note-preview/assets/sfx/key-tone-xingdong.m4a +0 -0
  49. package/note-preview/assets/sfx/key-tone.m4a +0 -0
  50. package/note-preview/note-preview.min.js +1 -0
  51. package/package.json +18 -0
  52. package/public/season.json +1 -0
package/js/app.js ADDED
@@ -0,0 +1,2124 @@
1
+ function getCookie(name) {
2
+ const value = `; ${document.cookie}`;
3
+ const parts = value.split(`; ${name}=`);
4
+ if (parts.length === 2) return decodeURIComponent(parts.pop().split(";").shift());
5
+ return "";
6
+ }
7
+
8
+ const PREVIEW_AUDIO_KEY = "zsbqb.preview.audio";
9
+ const PREVIEW_AUDIO_EVENT = "zsbqb:preview-audio";
10
+ const PREVIEW_MUTE_ICON_ON =
11
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>';
12
+ const PREVIEW_MUTE_ICON_OFF =
13
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71 0 1.52-.51 2.93-1.37 4.06l.99.99zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>';
14
+
15
+ function clampPreviewVolume(value) {
16
+ const n = Number(value);
17
+ if (!Number.isFinite(n)) return 1;
18
+ return Math.min(1, Math.max(0, n));
19
+ }
20
+
21
+ function normalizePreviewAudioPrefs(raw) {
22
+ const volume = clampPreviewVolume(raw && raw.volume);
23
+ const muted = Boolean(raw && raw.muted) || volume <= 0;
24
+ return {
25
+ volume: volume > 0 ? volume : 1,
26
+ muted,
27
+ alwaysMute: Boolean(raw && raw.alwaysMute),
28
+ hitsounds: Boolean(raw && raw.hitsounds),
29
+ burstColor: raw && raw.burstColor === "red" ? "red" : "pink",
30
+ };
31
+ }
32
+
33
+ function readPreviewAudioPrefs() {
34
+ try {
35
+ const raw = window.localStorage.getItem(PREVIEW_AUDIO_KEY);
36
+ if (!raw) return { volume: 1, muted: false, alwaysMute: false, hitsounds: false, burstColor: "pink" };
37
+ return normalizePreviewAudioPrefs(JSON.parse(raw));
38
+ } catch (_err) {
39
+ return { volume: 1, muted: false, alwaysMute: false, hitsounds: false, burstColor: "pink" };
40
+ }
41
+ }
42
+
43
+ function writePreviewAudioPrefs(next, options) {
44
+ const prefs = normalizePreviewAudioPrefs({
45
+ ...readPreviewAudioPrefs(),
46
+ ...(next || {}),
47
+ });
48
+ const prev = readPreviewAudioPrefs();
49
+ const same =
50
+ prev.muted === prefs.muted &&
51
+ prev.alwaysMute === prefs.alwaysMute &&
52
+ prev.hitsounds === prefs.hitsounds &&
53
+ prev.burstColor === prefs.burstColor &&
54
+ Math.abs(prev.volume - prefs.volume) < 0.0005;
55
+ try {
56
+ window.localStorage.setItem(
57
+ PREVIEW_AUDIO_KEY,
58
+ JSON.stringify({
59
+ volume: prefs.volume,
60
+ muted: prefs.muted,
61
+ alwaysMute: prefs.alwaysMute,
62
+ hitsounds: prefs.hitsounds,
63
+ burstColor: prefs.burstColor,
64
+ }),
65
+ );
66
+ } catch (_err) {
67
+ // Private mode can reject localStorage writes.
68
+ }
69
+ if (!same) {
70
+ const detail = {
71
+ volume: prefs.volume,
72
+ muted: prefs.muted,
73
+ alwaysMute: prefs.alwaysMute,
74
+ hitsounds: prefs.hitsounds,
75
+ burstColor: prefs.burstColor,
76
+ };
77
+ if (options && options.apply === false) detail.apply = false;
78
+ window.dispatchEvent(new CustomEvent(PREVIEW_AUDIO_EVENT, { detail }));
79
+ }
80
+ return prefs;
81
+ }
82
+
83
+ window.ZsbqbPreviewAudio = {
84
+ key: PREVIEW_AUDIO_KEY,
85
+ event: PREVIEW_AUDIO_EVENT,
86
+ read: readPreviewAudioPrefs,
87
+ write: writePreviewAudioPrefs,
88
+ };
89
+
90
+ function closePreviewMuteHelp() {
91
+ document.querySelectorAll("[data-preview-mute-help-panel]").forEach((panel) => {
92
+ panel.hidden = true;
93
+ });
94
+ document.querySelectorAll("[data-preview-mute-help]").forEach((button) => {
95
+ button.setAttribute("aria-expanded", "false");
96
+ });
97
+ }
98
+
99
+ function syncPreviewMuteUi(prefs) {
100
+ const state = prefs || readPreviewAudioPrefs();
101
+ const on = Boolean(state.alwaysMute);
102
+ document.querySelectorAll("[data-preview-mute-toggle]").forEach((toggle) => {
103
+ if (toggle.getAttribute("aria-hidden") === "true") return;
104
+ toggle.setAttribute("aria-checked", on ? "true" : "false");
105
+ toggle.classList.toggle("is-on", on);
106
+ const icon = toggle.querySelector("[data-preview-mute-icon]");
107
+ if (icon) icon.innerHTML = on ? PREVIEW_MUTE_ICON_OFF : PREVIEW_MUTE_ICON_ON;
108
+ });
109
+ document.querySelectorAll("[data-preview-mute] .user-menu__mute").forEach((row) => {
110
+ row.classList.toggle("is-on", on);
111
+ });
112
+ document.querySelectorAll("[data-preview-mute-badge]").forEach((badge) => {
113
+ badge.hidden = !on;
114
+ });
115
+ document.querySelectorAll("[data-user-menu-trigger]").forEach((trigger) => {
116
+ trigger.classList.toggle("is-preview-muted", on);
117
+ });
118
+ const hits = Boolean(state.hitsounds);
119
+ document.querySelectorAll("[data-preview-hitsounds-toggle]").forEach((toggle) => {
120
+ toggle.setAttribute("aria-checked", hits ? "true" : "false");
121
+ toggle.classList.toggle("is-on", hits);
122
+ });
123
+ const color = state.burstColor === "red" ? "red" : "pink";
124
+ document.querySelectorAll("[data-preview-burst-color-toggle]").forEach((toggle) => {
125
+ toggle.setAttribute("aria-checked", color === "pink" ? "true" : "false");
126
+ toggle.classList.toggle("is-pink", color === "pink");
127
+ toggle.classList.toggle("is-red", color === "red");
128
+ toggle.setAttribute("aria-label", color === "pink" ? "爆气色 粉色" : "爆气色 红色");
129
+ });
130
+ }
131
+
132
+ function bindPreviewAudioMenu() {
133
+ syncPreviewMuteUi();
134
+
135
+ document.querySelectorAll("[data-preview-mute-toggle]").forEach((toggle) => {
136
+ toggle.addEventListener("click", (event) => {
137
+ event.preventDefault();
138
+ const prefs = readPreviewAudioPrefs();
139
+ writePreviewAudioPrefs({ alwaysMute: !prefs.alwaysMute });
140
+ });
141
+ });
142
+
143
+ document.querySelectorAll("[data-preview-hitsounds-toggle]").forEach((toggle) => {
144
+ toggle.addEventListener("click", (event) => {
145
+ event.preventDefault();
146
+ const prefs = readPreviewAudioPrefs();
147
+ writePreviewAudioPrefs({ hitsounds: !prefs.hitsounds });
148
+ });
149
+ });
150
+
151
+ document.querySelectorAll("[data-preview-burst-color-toggle]").forEach((toggle) => {
152
+ toggle.addEventListener("click", (event) => {
153
+ event.preventDefault();
154
+ const prefs = readPreviewAudioPrefs();
155
+ writePreviewAudioPrefs({ burstColor: prefs.burstColor === "red" ? "pink" : "red" });
156
+ });
157
+ });
158
+
159
+ document.querySelectorAll("[data-preview-mute]").forEach((root) => {
160
+ const help = root.querySelector("[data-preview-mute-help]");
161
+ const panel = root.querySelector("[data-preview-mute-help-panel]");
162
+ if (!help || !panel) return;
163
+ help.addEventListener("click", (event) => {
164
+ event.preventDefault();
165
+ event.stopPropagation();
166
+ const willOpen = panel.hidden;
167
+ closePreviewMuteHelp();
168
+ panel.hidden = !willOpen;
169
+ help.setAttribute("aria-expanded", willOpen ? "true" : "false");
170
+ });
171
+ });
172
+
173
+ window.addEventListener(PREVIEW_AUDIO_EVENT, (event) => {
174
+ syncPreviewMuteUi((event && event.detail) || readPreviewAudioPrefs());
175
+ });
176
+ window.addEventListener("storage", (event) => {
177
+ if (event.key !== PREVIEW_AUDIO_KEY) return;
178
+ const prefs = readPreviewAudioPrefs();
179
+ syncPreviewMuteUi(prefs);
180
+ window.dispatchEvent(new CustomEvent(PREVIEW_AUDIO_EVENT, { detail: prefs }));
181
+ });
182
+ }
183
+
184
+ function syncCsrfTokenFromCookie(form) {
185
+ const input = form.querySelector('input[name="csrfmiddlewaretoken"]');
186
+ const token = getCookie("csrftoken");
187
+ if (input && token) {
188
+ input.value = token;
189
+ }
190
+ }
191
+
192
+ function bindCsrfFormSync() {
193
+ document.querySelectorAll("form").forEach((form) => {
194
+ if (!form.querySelector('input[name="csrfmiddlewaretoken"]')) return;
195
+ form.addEventListener(
196
+ "submit",
197
+ () => {
198
+ syncCsrfTokenFromCookie(form);
199
+ },
200
+ true,
201
+ );
202
+ });
203
+ }
204
+
205
+ function runTencentCaptcha(appId) {
206
+ return new Promise((resolve, reject) => {
207
+ if (!window.TencentCaptcha) {
208
+ reject(new Error("sdk"));
209
+ return;
210
+ }
211
+ const callback = (res) => {
212
+ if (res.ret === 0) {
213
+ resolve({ ticket: res.ticket || "", randstr: res.randstr || "" });
214
+ return;
215
+ }
216
+ if (res.ret === 2) {
217
+ reject(new Error("cancel"));
218
+ return;
219
+ }
220
+ reject(new Error("fail"));
221
+ };
222
+ try {
223
+ const instance = new TencentCaptcha(appId, callback, {});
224
+ instance.show();
225
+ } catch (err) {
226
+ reject(err);
227
+ }
228
+ });
229
+ }
230
+
231
+ function getTencentCaptchaRoot(form) {
232
+ return form ? form.querySelector("[data-tencent-captcha]") : null;
233
+ }
234
+
235
+ function readTencentCaptcha(form) {
236
+ const root = getTencentCaptchaRoot(form);
237
+ if (!root) return null;
238
+ const ticketInput = root.querySelector('input[name="captcha_ticket"]');
239
+ const randstrInput = root.querySelector('input[name="captcha_randstr"]');
240
+ return {
241
+ ticket: ((ticketInput && ticketInput.value) || "").trim(),
242
+ randstr: ((randstrInput && randstrInput.value) || "").trim(),
243
+ ticketInput,
244
+ randstrInput,
245
+ root,
246
+ };
247
+ }
248
+
249
+ function setTencentCaptchaVerified(form, ticket, randstr) {
250
+ const data = readTencentCaptcha(form);
251
+ if (!data) return;
252
+ if (data.ticketInput) data.ticketInput.value = ticket;
253
+ if (data.randstrInput) data.randstrInput.value = randstr;
254
+ const btn = data.root.querySelector("[data-tencent-captcha-btn]");
255
+ const status = data.root.querySelector("[data-tencent-captcha-status]");
256
+ if (btn) btn.hidden = true;
257
+ if (status) status.hidden = false;
258
+ }
259
+
260
+ function resetTencentCaptcha(form) {
261
+ const data = readTencentCaptcha(form);
262
+ if (!data) return;
263
+ if (data.ticketInput) data.ticketInput.value = "";
264
+ if (data.randstrInput) data.randstrInput.value = "";
265
+ const btn = data.root.querySelector("[data-tencent-captcha-btn]");
266
+ const status = data.root.querySelector("[data-tencent-captcha-status]");
267
+ if (btn) {
268
+ btn.hidden = false;
269
+ btn.disabled = false;
270
+ }
271
+ if (status) status.hidden = true;
272
+ }
273
+
274
+ async function ensureTencentCaptcha(form) {
275
+ const root = getTencentCaptchaRoot(form);
276
+ if (!root) return true;
277
+ const appId = (root.dataset.captchaAppId || "").trim();
278
+ const current = readTencentCaptcha(form);
279
+ if (current && current.ticket && current.randstr) return true;
280
+ if (!appId) {
281
+ alert("人机验证未配置");
282
+ return false;
283
+ }
284
+ try {
285
+ const result = await runTencentCaptcha(appId);
286
+ setTencentCaptchaVerified(form, result.ticket, result.randstr);
287
+ return true;
288
+ } catch (err) {
289
+ if (err && err.message !== "cancel") {
290
+ alert("人机验证失败,请重试");
291
+ }
292
+ return false;
293
+ }
294
+ }
295
+
296
+ function bindTencentCaptcha() {
297
+ document.querySelectorAll("[data-tencent-captcha-btn]").forEach((button) => {
298
+ button.addEventListener("click", async () => {
299
+ const form = button.closest("form");
300
+ if (!form) return;
301
+ button.disabled = true;
302
+ const ok = await ensureTencentCaptcha(form);
303
+ button.disabled = false;
304
+ if (!ok) resetTencentCaptcha(form);
305
+ });
306
+ });
307
+
308
+ document.querySelectorAll("[data-login-form]").forEach((form) => {
309
+ form.addEventListener("submit", async (event) => {
310
+ if (!getTencentCaptchaRoot(form)) return;
311
+ const current = readTencentCaptcha(form);
312
+ if (current && current.ticket && current.randstr) return;
313
+ event.preventDefault();
314
+ const ok = await ensureTencentCaptcha(form);
315
+ if (ok) form.submit();
316
+ });
317
+ });
318
+ }
319
+
320
+ function bindSmsButtons() {
321
+ document.querySelectorAll("[data-sms-btn]").forEach((button) => {
322
+ button.addEventListener("click", async () => {
323
+ const form = button.closest("[data-sms-form]") || button.closest("form");
324
+ if (!form) return;
325
+ const phoneInput = form.querySelector('input[name="phone"]');
326
+ const debugEl = form.querySelector("[data-sms-debug]");
327
+ const phone = ((phoneInput && phoneInput.value) || "").trim();
328
+ if (!/^1[3-9]\d{9}$/.test(phone)) {
329
+ alert("请输入有效的 11 位手机号");
330
+ return;
331
+ }
332
+ const needsCaptcha = Boolean(getTencentCaptchaRoot(form));
333
+ if (needsCaptcha) {
334
+ const ok = await ensureTencentCaptcha(form);
335
+ if (!ok) return;
336
+ }
337
+ const captcha = readTencentCaptcha(form);
338
+ const original = button.textContent;
339
+ button.disabled = true;
340
+ button.textContent = "发送中...";
341
+ const body = {
342
+ phone,
343
+ purpose: button.dataset.purpose || "register",
344
+ };
345
+ if (captcha && captcha.ticket && captcha.randstr) {
346
+ body.captcha_ticket = captcha.ticket;
347
+ body.captcha_randstr = captcha.randstr;
348
+ }
349
+ try {
350
+ const response = await fetch("/accounts/sms/send/", {
351
+ method: "POST",
352
+ headers: {
353
+ "Content-Type": "application/json",
354
+ "X-CSRFToken": getCookie("csrftoken"),
355
+ },
356
+ body: JSON.stringify(body),
357
+ });
358
+ const data = await response.json();
359
+ if (!data.ok) {
360
+ button.disabled = false;
361
+ button.textContent = original;
362
+ resetTencentCaptcha(form);
363
+ alert(data.message || "发送失败");
364
+ return;
365
+ }
366
+ if (debugEl && data.debug_code) {
367
+ debugEl.hidden = false;
368
+ debugEl.textContent = `开发模式验证码:${data.debug_code}`;
369
+ }
370
+ let remain = data.retry_after || 60;
371
+ const timer = setInterval(() => {
372
+ remain -= 1;
373
+ button.textContent = `${remain}s`;
374
+ if (remain <= 0) {
375
+ clearInterval(timer);
376
+ button.disabled = false;
377
+ button.textContent = original;
378
+ resetTencentCaptcha(form);
379
+ }
380
+ }, 1000);
381
+ button.textContent = `${remain}s`;
382
+ resetTencentCaptcha(form);
383
+ } catch (err) {
384
+ button.disabled = false;
385
+ button.textContent = original;
386
+ resetTencentCaptcha(form);
387
+ alert("发送失败,请稍后重试");
388
+ }
389
+ });
390
+ });
391
+ }
392
+
393
+ function bindProfileUsername() {
394
+ document.querySelectorAll("[data-profile-username]").forEach((field) => {
395
+ const viewRow = field.querySelector("[data-profile-username-view]");
396
+ const editPanel = field.querySelector("[data-profile-username-edit-panel]");
397
+ const editBtn = field.querySelector("[data-profile-username-edit]");
398
+ const input = field.querySelector('input[name="username"]');
399
+ if (!viewRow || !editPanel || !editBtn || !input) return;
400
+
401
+ function showEdit() {
402
+ viewRow.hidden = true;
403
+ editPanel.hidden = false;
404
+ input.focus();
405
+ input.select();
406
+ }
407
+
408
+ editBtn.addEventListener("click", showEdit);
409
+
410
+ if (!editPanel.hidden) {
411
+ input.focus();
412
+ input.select();
413
+ }
414
+ });
415
+ }
416
+
417
+ function bindLoginTabs() {
418
+ document.querySelectorAll("[data-login-form]").forEach((form) => {
419
+ const tabs = form.closest(".auth-card")?.querySelector("[data-login-tabs]");
420
+ const modeInput = form.querySelector('input[name="login_mode"]');
421
+ const panels = form.querySelectorAll("[data-login-panel]");
422
+ const eyebrow = form.closest(".auth-card")?.querySelector("[data-login-eyebrow]");
423
+ if (!tabs || !modeInput) return;
424
+
425
+ function setMode(mode) {
426
+ modeInput.value = mode;
427
+ tabs.querySelectorAll("[data-login-mode]").forEach((tab) => {
428
+ const on = tab.dataset.loginMode === mode;
429
+ tab.classList.toggle("is-on", on);
430
+ tab.setAttribute("aria-selected", on ? "true" : "false");
431
+ });
432
+ panels.forEach((panel) => {
433
+ const isCurrent = panel.dataset.loginPanel === mode;
434
+ panel.hidden = !isCurrent;
435
+ if (!isCurrent) {
436
+ const input = panel.querySelector("input");
437
+ if (input) input.value = "";
438
+ }
439
+ });
440
+ if (eyebrow) {
441
+ eyebrow.textContent = mode === "phone" ? "手机号 + 密码" : "用户名 + 密码";
442
+ }
443
+ }
444
+
445
+ tabs.querySelectorAll("[data-login-mode]").forEach((tab) => {
446
+ tab.addEventListener("click", () => setMode(tab.dataset.loginMode || "username"));
447
+ });
448
+ });
449
+ }
450
+
451
+ document.addEventListener("DOMContentLoaded", bindCsrfFormSync);
452
+ document.addEventListener("DOMContentLoaded", bindLoginTabs);
453
+ document.addEventListener("DOMContentLoaded", bindProfileUsername);
454
+ document.addEventListener("DOMContentLoaded", bindSmsButtons);
455
+ document.addEventListener("DOMContentLoaded", bindTencentCaptcha);
456
+ document.addEventListener("DOMContentLoaded", bindCategoryModal);
457
+ document.addEventListener("DOMContentLoaded", bindSearchClear);
458
+ document.addEventListener("DOMContentLoaded", bindBpmFilter);
459
+ document.addEventListener("DOMContentLoaded", bindFamilyPicker);
460
+ document.addEventListener("DOMContentLoaded", bindStarPicker);
461
+ document.addEventListener("DOMContentLoaded", bindUserMenu);
462
+ document.addEventListener("DOMContentLoaded", bindPreviewAudioMenu);
463
+
464
+ function bindUserMenu() {
465
+ document.querySelectorAll("[data-user-menu]").forEach((root) => {
466
+ const trigger = root.querySelector("[data-user-menu-trigger]");
467
+ const menu = root.querySelector("[data-user-menu-panel]");
468
+ if (!trigger || !menu) return;
469
+
470
+ const closeMenu = () => {
471
+ menu.hidden = true;
472
+ trigger.setAttribute("aria-expanded", "false");
473
+ closePreviewMuteHelp();
474
+ };
475
+
476
+ const openMenu = () => {
477
+ menu.hidden = false;
478
+ trigger.setAttribute("aria-expanded", "true");
479
+ };
480
+
481
+ trigger.addEventListener("click", (event) => {
482
+ event.stopPropagation();
483
+ if (menu.hidden) openMenu();
484
+ else closeMenu();
485
+ });
486
+
487
+ document.addEventListener("click", (event) => {
488
+ if (!root.contains(event.target)) closeMenu();
489
+ });
490
+
491
+ document.addEventListener("keydown", (event) => {
492
+ if (event.key === "Escape") closeMenu();
493
+ });
494
+ });
495
+ }
496
+
497
+ function bindStarPicker() {
498
+ document.querySelectorAll("[data-star-picker]").forEach((root) => {
499
+ const native = root.querySelector(".star-picker__native");
500
+ const trigger = root.querySelector("[data-star-trigger]");
501
+ const menu = root.querySelector("[data-star-menu]");
502
+ const valueEl = root.querySelector("[data-star-value]");
503
+ const form = root.closest("form");
504
+ if (!native || !trigger || !menu || !valueEl || !form) return;
505
+
506
+ const blockWheel = (event) => {
507
+ event.preventDefault();
508
+ event.stopPropagation();
509
+ };
510
+ const blockDirectionKeys = (event) => {
511
+ if (
512
+ [
513
+ "ArrowUp",
514
+ "ArrowDown",
515
+ "ArrowLeft",
516
+ "ArrowRight",
517
+ "PageUp",
518
+ "PageDown",
519
+ "Home",
520
+ "End",
521
+ ].includes(event.key)
522
+ ) {
523
+ event.preventDefault();
524
+ event.stopPropagation();
525
+ }
526
+ };
527
+
528
+ native.addEventListener("wheel", blockWheel, { passive: false });
529
+ native.addEventListener("keydown", blockDirectionKeys);
530
+ native.addEventListener("focus", () => native.blur());
531
+ trigger.addEventListener("wheel", blockWheel, { passive: false });
532
+ trigger.addEventListener("keydown", (event) => {
533
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
534
+ event.preventDefault();
535
+ if (menu.hidden) openMenu();
536
+ }
537
+ });
538
+
539
+ native.addEventListener("change", () => {
540
+ const option = menu.querySelector(
541
+ `[data-star-option][data-star-value="${native.value}"]`
542
+ );
543
+ if (option) {
544
+ menu.querySelectorAll("[data-star-option]").forEach((item) => {
545
+ const on = item === option;
546
+ item.classList.toggle("is-on", on);
547
+ item.setAttribute("aria-selected", on ? "true" : "false");
548
+ });
549
+ renderValue(option);
550
+ }
551
+ });
552
+
553
+ const closeMenu = () => {
554
+ menu.hidden = true;
555
+ trigger.setAttribute("aria-expanded", "false");
556
+ };
557
+
558
+ const openMenu = () => {
559
+ menu.hidden = false;
560
+ trigger.setAttribute("aria-expanded", "true");
561
+ };
562
+
563
+ const renderValue = (option) => {
564
+ valueEl.textContent = option.textContent.trim() || "全部";
565
+ };
566
+
567
+ const pickOption = (option) => {
568
+ const value = option.getAttribute("data-star-value") || "";
569
+ native.value = value;
570
+ menu.querySelectorAll("[data-star-option]").forEach((item) => {
571
+ const on = item === option;
572
+ item.classList.toggle("is-on", on);
573
+ item.setAttribute("aria-selected", on ? "true" : "false");
574
+ });
575
+ renderValue(option);
576
+ closeMenu();
577
+ if (document.querySelector(".song-list")) {
578
+ window.__applyQueryFilters?.();
579
+ return;
580
+ }
581
+ form.requestSubmit();
582
+ };
583
+
584
+ trigger.addEventListener("click", () => {
585
+ if (menu.hidden) openMenu();
586
+ else closeMenu();
587
+ });
588
+
589
+ menu.addEventListener("click", (event) => {
590
+ const option = event.target.closest("[data-star-option]");
591
+ if (option && menu.contains(option)) pickOption(option);
592
+ });
593
+
594
+ document.addEventListener("click", (event) => {
595
+ if (!root.contains(event.target)) closeMenu();
596
+ });
597
+
598
+ document.addEventListener("pointerdown", (event) => {
599
+ if (!root.contains(event.target)) closeMenu();
600
+ });
601
+
602
+ window.addEventListener("scroll", closeMenu, { passive: true });
603
+
604
+ document.addEventListener("keydown", (event) => {
605
+ if (event.key === "Escape") closeMenu();
606
+ });
607
+ });
608
+ }
609
+
610
+ function bindFamilyPicker() {
611
+ document.querySelectorAll("[data-family-picker]").forEach((root) => {
612
+ const native = root.querySelector(".family-picker__native");
613
+ const trigger = root.querySelector("[data-family-trigger]");
614
+ const menu = root.querySelector("[data-family-menu]");
615
+ const valueEl = root.querySelector("[data-family-value]");
616
+ const form = root.closest("form");
617
+ if (!native || !trigger || !menu || !valueEl || !form) return;
618
+
619
+ const blockWheel = (event) => {
620
+ event.preventDefault();
621
+ event.stopPropagation();
622
+ };
623
+ const blockDirectionKeys = (event) => {
624
+ if (
625
+ [
626
+ "ArrowUp",
627
+ "ArrowDown",
628
+ "ArrowLeft",
629
+ "ArrowRight",
630
+ "PageUp",
631
+ "PageDown",
632
+ "Home",
633
+ "End",
634
+ ].includes(event.key)
635
+ ) {
636
+ event.preventDefault();
637
+ event.stopPropagation();
638
+ }
639
+ };
640
+
641
+ native.addEventListener("wheel", blockWheel, { passive: false });
642
+ native.addEventListener("keydown", blockDirectionKeys);
643
+ native.addEventListener("focus", () => native.blur());
644
+ trigger.addEventListener("wheel", blockWheel, { passive: false });
645
+ trigger.addEventListener("keydown", (event) => {
646
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
647
+ event.preventDefault();
648
+ if (menu.hidden) openMenu();
649
+ }
650
+ });
651
+
652
+ native.addEventListener("change", () => {
653
+ const option = menu.querySelector(
654
+ `[data-family-option][data-family-value="${native.value}"]`
655
+ );
656
+ if (option) {
657
+ menu.querySelectorAll("[data-family-option]").forEach((item) => {
658
+ const on = item === option;
659
+ item.classList.toggle("is-on", on);
660
+ item.setAttribute("aria-selected", on ? "true" : "false");
661
+ });
662
+ renderValue(option);
663
+ }
664
+ });
665
+
666
+ const closeMenu = () => {
667
+ menu.hidden = true;
668
+ trigger.setAttribute("aria-expanded", "false");
669
+ };
670
+
671
+ const openMenu = () => {
672
+ menu.hidden = false;
673
+ trigger.setAttribute("aria-expanded", "true");
674
+ };
675
+
676
+ const renderValue = (option) => {
677
+ const label = option.querySelector("span")?.textContent || "全部";
678
+ const icon = option.querySelector(".family-picker__icon");
679
+ valueEl.textContent = "";
680
+ if (icon) {
681
+ valueEl.appendChild(icon.cloneNode(true));
682
+ }
683
+ const text = document.createElement("span");
684
+ text.textContent = label;
685
+ valueEl.appendChild(text);
686
+ };
687
+
688
+ const pickOption = (option) => {
689
+ const value = option.getAttribute("data-family-value") || "";
690
+ if (!value) return;
691
+ native.value = value;
692
+ menu.querySelectorAll("[data-family-option]").forEach((item) => {
693
+ const on = item === option;
694
+ item.classList.toggle("is-on", on);
695
+ item.setAttribute("aria-selected", on ? "true" : "false");
696
+ });
697
+ renderValue(option);
698
+ closeMenu();
699
+ if (typeof window.__queryLoadFamily === "function" && window.__queryLoadFamily(value, "")) {
700
+ return;
701
+ }
702
+ // 歌单按模式整页拉取;切模式必须带 family 跳转,不能走空搜索拦截/前端过滤。
703
+ // 星级选项因模式而异,一并清掉以免旧星级在新模式上被前端滤成空列表。
704
+ navigateQueryForm(form, { family: value, star: "" });
705
+ };
706
+
707
+ trigger.addEventListener("click", () => {
708
+ if (menu.hidden) openMenu();
709
+ else closeMenu();
710
+ });
711
+
712
+ menu.querySelectorAll("[data-family-option]").forEach((option) => {
713
+ option.addEventListener("click", () => pickOption(option));
714
+ });
715
+
716
+ document.addEventListener("click", (event) => {
717
+ if (!root.contains(event.target)) closeMenu();
718
+ });
719
+
720
+ document.addEventListener("pointerdown", (event) => {
721
+ if (!root.contains(event.target)) closeMenu();
722
+ });
723
+
724
+ window.addEventListener("scroll", closeMenu, { passive: true });
725
+
726
+ document.addEventListener("keydown", (event) => {
727
+ if (event.key === "Escape") closeMenu();
728
+ });
729
+ });
730
+ }
731
+
732
+ document.addEventListener("DOMContentLoaded", bindBurstCorrect);
733
+
734
+ function correctBurstCodeNum(burstNum, halfCombo, userCb) {
735
+ return burstNum - (halfCombo - userCb);
736
+ }
737
+
738
+ function burstComboDelta(halfCombo, userCb) {
739
+ return userCb - halfCombo;
740
+ }
741
+
742
+ function burstComboTargets(item) {
743
+ if (item.dataset.burstKind === "double") {
744
+ const secondRow = item
745
+ .querySelector("[data-burst-code-second]")
746
+ ?.closest(".burst-code-row");
747
+ return secondRow ? secondRow.querySelectorAll("[data-burst-combo]") : [];
748
+ }
749
+ return item.querySelectorAll("[data-burst-combo]");
750
+ }
751
+
752
+ function applyBurstComboCorrection(item, delta) {
753
+ burstComboTargets(item).forEach((el) => {
754
+ const base = Number(el.dataset.burstCombo);
755
+ if (!Number.isFinite(base)) return;
756
+ el.textContent = String(base + delta);
757
+ });
758
+ }
759
+
760
+ function restoreBurstComboValues(item) {
761
+ burstComboTargets(item).forEach((el) => {
762
+ const base = el.dataset.burstCombo;
763
+ el.textContent = base !== "" ? base : "—";
764
+ });
765
+ }
766
+
767
+ function formatBurstDescLine(tag, desc, combo, classes) {
768
+ const comboVal = combo !== "" && combo !== undefined ? combo : "—";
769
+ return (
770
+ `<span class="burst-desc-tag">${tag}</span>${desc || "—"} (` +
771
+ `<span class="${classes}" data-burst-combo="${combo || ""}">${comboVal}</span>)`
772
+ );
773
+ }
774
+
775
+ function applyCorrectionBase(item) {
776
+ if (!item.dataset.corrBurstCodeNum) return;
777
+
778
+ const firstDescs = item.querySelector("[data-burst-code-main]")
779
+ ?.closest(".burst-code-row")
780
+ ?.querySelector(".burst-descs");
781
+ if (!firstDescs) return;
782
+
783
+ const updateLine = (tag, desc, combo) => {
784
+ firstDescs.querySelectorAll(".burst-desc-line").forEach((line) => {
785
+ if (line.querySelector(".burst-desc-tag")?.textContent?.trim() !== tag) return;
786
+ const comboEl = line.querySelector("[data-burst-combo]");
787
+ const classes = comboEl?.className || "";
788
+ line.innerHTML = formatBurstDescLine(tag, desc, combo, classes);
789
+ });
790
+ };
791
+
792
+ updateLine("[开始]", item.dataset.corrStartDesc, item.dataset.corrStartCombo);
793
+ updateLine("[结束]", item.dataset.corrEndDesc, item.dataset.corrEndCombo);
794
+
795
+ const gainEl = item.querySelector(".slot-gain");
796
+ if (gainEl) gainEl.textContent = item.dataset.corrGain || "—";
797
+
798
+ const scoreEl = item.querySelector("[data-burst-score]");
799
+ if (scoreEl) scoreEl.textContent = item.dataset.corrScore || "—";
800
+ }
801
+
802
+ function getSingleCalcBurstNum(item) {
803
+ if (item.dataset.corrBurstCodeNum) {
804
+ return {
805
+ prefix: item.dataset.corrBurstCodePrefix || "",
806
+ num: Number(item.dataset.corrBurstCodeNum),
807
+ };
808
+ }
809
+ return {
810
+ prefix: item.dataset.burstCodePrefix || "",
811
+ num: Number(item.dataset.burstCodeNum),
812
+ };
813
+ }
814
+
815
+ function setCorrectedBurstCode(el, text) {
816
+ if (!el) return;
817
+ el.textContent = text;
818
+ const rank = el.closest(".burst-code-col")?.querySelector(".burst-code-rank");
819
+ if (rank) rank.textContent = "(修正后)";
820
+ }
821
+
822
+ function snapshotBurstItem(item) {
823
+ item.querySelectorAll("[data-burst-snapshot]").forEach((el) => {
824
+ if (el.dataset.originalHtml == null) el.dataset.originalHtml = el.innerHTML;
825
+ });
826
+ }
827
+
828
+ function restoreBurstItem(item) {
829
+ item.querySelectorAll("[data-burst-snapshot]").forEach((el) => {
830
+ if (el.dataset.originalHtml != null) el.innerHTML = el.dataset.originalHtml;
831
+ });
832
+ }
833
+
834
+ function bindBurstCorrect() {
835
+ document.querySelectorAll(".burst-item[data-burst-kind]").forEach((item) => {
836
+ const input = item.querySelector("[data-burst-cb-input]");
837
+ const calcBtn = item.querySelector("[data-burst-calc]");
838
+ const undoBtn = item.querySelector("[data-burst-undo]");
839
+ const mainCode = item.querySelector("[data-burst-code-main]");
840
+ if (!input || !calcBtn || !undoBtn || !mainCode) return;
841
+
842
+ snapshotBurstItem(item);
843
+
844
+ const syncCalcButton = () => {
845
+ const raw = input.value.trim();
846
+ calcBtn.disabled = raw === "" || !Number.isFinite(Number(raw));
847
+ };
848
+
849
+ const syncUndoButton = () => {
850
+ undoBtn.disabled = !item.classList.contains("is-corrected");
851
+ };
852
+
853
+ const undoCorrection = () => {
854
+ restoreBurstItem(item);
855
+ item.classList.remove("is-corrected");
856
+ syncUndoButton();
857
+ };
858
+
859
+ input.addEventListener("input", syncCalcButton);
860
+ syncCalcButton();
861
+ syncUndoButton();
862
+
863
+ calcBtn.addEventListener("click", () => {
864
+ if (calcBtn.disabled) return;
865
+
866
+ const halfCombo = Number(item.dataset.halfCombo);
867
+ const userCb = Number(input.value);
868
+ if (!Number.isFinite(halfCombo) || !Number.isFinite(userCb)) return;
869
+
870
+ const kind = item.dataset.burstKind;
871
+ if (kind !== "double" && item.dataset.corrBurstCodeNum) {
872
+ applyCorrectionBase(item);
873
+ }
874
+
875
+ const delta = burstComboDelta(halfCombo, userCb);
876
+ if (kind === "double") {
877
+ const prefix = item.dataset.burstCode2Prefix || item.dataset.burstCodePrefix || "";
878
+ const bNum = Number(item.dataset.burstCode2Num || item.dataset.burstCodeNum);
879
+ if (!Number.isFinite(bNum)) return;
880
+ const corrected = correctBurstCodeNum(bNum, halfCombo, userCb);
881
+ const code2 = `${prefix}${corrected}`;
882
+ setCorrectedBurstCode(item.querySelector("[data-burst-code-second]"), code2);
883
+ } else {
884
+ const { prefix, num: bNum } = getSingleCalcBurstNum(item);
885
+ if (!Number.isFinite(bNum)) return;
886
+ const corrected = correctBurstCodeNum(bNum, halfCombo, userCb);
887
+ const code = prefix ? `${prefix}${corrected}` : String(corrected);
888
+ setCorrectedBurstCode(mainCode, code);
889
+ }
890
+ applyBurstComboCorrection(item, delta);
891
+ item.classList.add("is-corrected");
892
+ syncUndoButton();
893
+ });
894
+
895
+ undoBtn.addEventListener("click", () => {
896
+ if (undoBtn.disabled) return;
897
+ undoCorrection();
898
+ });
899
+ });
900
+ }
901
+
902
+ document.addEventListener("DOMContentLoaded", bindBurstAccordion);
903
+ document.addEventListener("DOMContentLoaded", bindBurstDetailClose);
904
+
905
+ function bindBurstDetailClose() {
906
+ document.querySelectorAll("[data-burst-close]").forEach((button) => {
907
+ button.addEventListener("click", (event) => {
908
+ event.preventDefault();
909
+ const returnUrl =
910
+ button.getAttribute("data-return-url") ||
911
+ button.getAttribute("href") ||
912
+ "/query/";
913
+
914
+ if (window.history.length > 1 && document.referrer) {
915
+ try {
916
+ const ref = new URL(document.referrer);
917
+ const target = new URL(returnUrl, window.location.origin);
918
+ const refPath = ref.pathname.replace(/\/$/, "") || "/";
919
+ const targetPath = target.pathname.replace(/\/$/, "") || "/";
920
+ if (
921
+ (refPath === "/query" || refPath === "/query/") &&
922
+ refPath === targetPath &&
923
+ ref.search === target.search
924
+ ) {
925
+ history.back();
926
+ return;
927
+ }
928
+ } catch (_) {
929
+ /* fall through */
930
+ }
931
+ }
932
+
933
+ window.location.assign(returnUrl);
934
+ });
935
+ });
936
+ }
937
+
938
+ function bindBurstAccordion() {
939
+ document.querySelectorAll("[data-burst-toggle]").forEach((button) => {
940
+ const item = button.closest(".burst-item");
941
+ if (!item) return;
942
+ button.addEventListener("click", () => {
943
+ const open = item.classList.toggle("is-open");
944
+ button.textContent = open ? "收起" : "展开";
945
+ button.setAttribute("aria-expanded", open ? "true" : "false");
946
+ item.dispatchEvent(new CustomEvent("burst:accordion-change", { bubbles: true }));
947
+ });
948
+ });
949
+ }
950
+
951
+ function debounce(fn, ms) {
952
+ let timer;
953
+ return (...args) => {
954
+ clearTimeout(timer);
955
+ timer = setTimeout(() => fn(...args), ms);
956
+ };
957
+ }
958
+
959
+ function bindBpmFilter() {
960
+ document.querySelectorAll("[data-bpm-filter]").forEach((root) => {
961
+ const bucketInput = root.querySelector("[data-bpm-bucket]");
962
+ const exactField = root.querySelector("[data-bpm-exact-field]");
963
+ const visibleInput = root.querySelector("[data-bpm-input]");
964
+ const caretBtn = root.querySelector("[data-bpm-caret]");
965
+ const menu = root.querySelector("[data-bpm-menu]");
966
+ const form = root.closest("form");
967
+ const searchInput = form?.querySelector("[data-search-input]");
968
+ if (!bucketInput || !exactField || !visibleInput || !caretBtn || !menu || !form) return;
969
+
970
+ const syncFormFields = () => {
971
+ const mode = root.dataset.bpmMode || "none";
972
+ if (mode === "range") {
973
+ bucketInput.disabled = false;
974
+ exactField.disabled = true;
975
+ exactField.value = "";
976
+ } else if (mode === "exact") {
977
+ bucketInput.disabled = true;
978
+ bucketInput.value = "";
979
+ exactField.disabled = false;
980
+ exactField.value = visibleInput.value.trim();
981
+ } else {
982
+ bucketInput.disabled = true;
983
+ bucketInput.value = "";
984
+ exactField.disabled = true;
985
+ exactField.value = "";
986
+ }
987
+ };
988
+
989
+ const hasBpmFilter = () => {
990
+ const mode = root.dataset.bpmMode || "none";
991
+ return mode === "range" || (mode === "exact" && visibleInput.value.trim());
992
+ };
993
+
994
+ const submitForm = () => {
995
+ syncFormFields();
996
+ if (document.querySelector(".song-list")) {
997
+ window.__applyQueryFilters?.();
998
+ return;
999
+ }
1000
+ form.requestSubmit();
1001
+ };
1002
+
1003
+ const snapshotSubmitted = () => {
1004
+ const mode = root.dataset.bpmMode || "none";
1005
+ if (mode === "exact") return visibleInput.value.trim();
1006
+ if (mode === "range") return `range:${bucketInput.value || ""}`;
1007
+ return "";
1008
+ };
1009
+
1010
+ let lastSubmitted = snapshotSubmitted();
1011
+ let skipBlurCommit = false;
1012
+
1013
+ const commitExactBpm = () => {
1014
+ if (visibleInput.readOnly) return;
1015
+ const text = visibleInput.value.trim();
1016
+ const mode = text ? "exact" : "none";
1017
+ const nextKey = mode === "exact" ? text : "";
1018
+ if (nextKey === lastSubmitted && mode === (root.dataset.bpmMode || "none")) return;
1019
+ root.dataset.bpmMode = mode;
1020
+ if (text) markRangeOption("");
1021
+ lastSubmitted = nextKey;
1022
+ submitForm();
1023
+ };
1024
+
1025
+ const markRangeOption = (value) => {
1026
+ menu.querySelectorAll("[data-bpm-range]").forEach((option) => {
1027
+ const on = (option.getAttribute("data-bpm-range") || "") === value;
1028
+ option.classList.toggle("is-on", on);
1029
+ option.setAttribute("aria-selected", on ? "true" : "false");
1030
+ });
1031
+ };
1032
+
1033
+ const closeMenu = () => {
1034
+ menu.hidden = true;
1035
+ caretBtn.setAttribute("aria-expanded", "false");
1036
+ };
1037
+
1038
+ const openMenu = () => {
1039
+ menu.hidden = false;
1040
+ caretBtn.setAttribute("aria-expanded", "true");
1041
+ };
1042
+
1043
+ const beginExactInput = () => {
1044
+ if (!visibleInput.readOnly) return;
1045
+ root.dataset.bpmMode = "none";
1046
+ visibleInput.readOnly = false;
1047
+ visibleInput.value = "";
1048
+ bucketInput.value = "";
1049
+ exactField.value = "";
1050
+ markRangeOption("");
1051
+ };
1052
+
1053
+ const pickRange = (option) => {
1054
+ const value = option.getAttribute("data-bpm-range") || "";
1055
+ const display = option.getAttribute("data-bpm-range-display") || value;
1056
+ if (!value) {
1057
+ root.dataset.bpmMode = "none";
1058
+ visibleInput.readOnly = false;
1059
+ visibleInput.value = "";
1060
+ bucketInput.value = "";
1061
+ exactField.value = "";
1062
+ } else {
1063
+ root.dataset.bpmMode = "range";
1064
+ bucketInput.value = value;
1065
+ visibleInput.value = display;
1066
+ visibleInput.readOnly = true;
1067
+ exactField.value = "";
1068
+ }
1069
+ markRangeOption(value);
1070
+ closeMenu();
1071
+ lastSubmitted = value ? `range:${value}` : "";
1072
+ submitForm();
1073
+ };
1074
+
1075
+ const debouncedSearchSubmit = debounce(() => {
1076
+ // 搜索词只在点「搜索」/回车时提交;这里仅当已选 BPM 时把关键词带上。
1077
+ if (!searchInput?.value.trim() || !hasBpmFilter()) return;
1078
+ if (document.querySelector(".song-list")) {
1079
+ window.__applyQueryFilters?.();
1080
+ return;
1081
+ }
1082
+ submitForm();
1083
+ }, 400);
1084
+
1085
+ caretBtn.addEventListener("click", (event) => {
1086
+ event.preventDefault();
1087
+ event.stopPropagation();
1088
+ if (menu.hidden) openMenu();
1089
+ else closeMenu();
1090
+ });
1091
+
1092
+ menu.querySelectorAll("[data-bpm-range]").forEach((option) => {
1093
+ option.addEventListener("click", () => pickRange(option));
1094
+ });
1095
+
1096
+ visibleInput.addEventListener("focus", beginExactInput);
1097
+ visibleInput.addEventListener("mousedown", (event) => {
1098
+ if (visibleInput.readOnly) {
1099
+ event.preventDefault();
1100
+ beginExactInput();
1101
+ visibleInput.focus();
1102
+ }
1103
+ });
1104
+
1105
+ visibleInput.addEventListener("blur", (event) => {
1106
+ if (skipBlurCommit) {
1107
+ skipBlurCommit = false;
1108
+ return;
1109
+ }
1110
+ const next = event.relatedTarget;
1111
+ if (next && root.contains(next)) return;
1112
+ commitExactBpm();
1113
+ });
1114
+
1115
+ visibleInput.addEventListener("keydown", (event) => {
1116
+ if (event.key === "Enter") {
1117
+ event.preventDefault();
1118
+ skipBlurCommit = true;
1119
+ commitExactBpm();
1120
+ }
1121
+ });
1122
+
1123
+ if (searchInput) {
1124
+ searchInput.addEventListener("input", debouncedSearchSubmit);
1125
+ }
1126
+
1127
+ document.addEventListener("click", (event) => {
1128
+ if (!root.contains(event.target)) closeMenu();
1129
+ });
1130
+
1131
+ document.addEventListener("keydown", (event) => {
1132
+ if (event.key === "Escape") closeMenu();
1133
+ });
1134
+ });
1135
+ }
1136
+
1137
+ document.addEventListener("DOMContentLoaded", bindQueryLetterSearch);
1138
+ document.addEventListener("DOMContentLoaded", bindIdempotentSearchForm);
1139
+ document.addEventListener("DOMContentLoaded", bindAlphaRail);
1140
+ document.addEventListener("DOMContentLoaded", bindTitleMarquees);
1141
+ window.bindTitleMarquees = bindTitleMarquees;
1142
+ document.addEventListener("DOMContentLoaded", bindClientQueryFilters);
1143
+
1144
+ function clearLetterWhenSearching(form) {
1145
+ const searchInput = form.querySelector("[data-search-input]");
1146
+ if (!searchInput?.value.trim()) return;
1147
+ form.querySelectorAll("[data-letter-field]").forEach((input) => input.remove());
1148
+ }
1149
+
1150
+ function queryFormParams(form) {
1151
+ const params = new URLSearchParams();
1152
+ for (const [key, value] of new FormData(form).entries()) {
1153
+ const trimmed = String(value).trim();
1154
+ if (key === "q" && !trimmed) continue;
1155
+ params.set(key, String(value));
1156
+ }
1157
+ return params;
1158
+ }
1159
+
1160
+ function navigateQueryForm(form, overrides) {
1161
+ const params = queryFormParams(form);
1162
+ Object.entries(overrides || {}).forEach(([key, value]) => {
1163
+ const next = value == null ? "" : String(value);
1164
+ if (!next) params.delete(key);
1165
+ else params.set(key, next);
1166
+ });
1167
+ const qs = params.toString();
1168
+ const path = window.location.pathname || "/query/";
1169
+ const target = qs ? `${path}?${qs}` : path;
1170
+ const current = `${window.location.pathname}${window.location.search}`;
1171
+ if (target === current) return;
1172
+ window.location.assign(target);
1173
+ }
1174
+
1175
+ function bindIdempotentSearchForm() {
1176
+ document.querySelectorAll(".query-filters").forEach((form) => {
1177
+ let lastKey = "";
1178
+ let inflight = false;
1179
+ form.addEventListener("submit", (event) => {
1180
+ const searchInput = form.querySelector("[data-search-input]");
1181
+ const newQuery = searchInput ? searchInput.value.trim() : "";
1182
+ const currentQuery = (new URLSearchParams(window.location.search).get("q") || "").trim();
1183
+ const formFamily = form.querySelector("[name='family']")?.value || "";
1184
+ const currentFamily = new URLSearchParams(window.location.search).get("family") || "";
1185
+ const familyChanged = Boolean(formFamily) && formFamily !== currentFamily;
1186
+
1187
+ // 空点搜索:输入框为空或纯空白。切模式时必须放行,否则选择器只改了 UI、歌单仍停在旧模式。
1188
+ if (!newQuery) {
1189
+ if (searchInput) {
1190
+ searchInput.value = "";
1191
+ }
1192
+ // 如果当前原本就处于无关键词的歌单缓存目录浏览态:
1193
+ // 不触发真正搜索与页面刷新,直接拦截并保持在当前歌单缓存目录
1194
+ if (!currentQuery && !familyChanged) {
1195
+ event.preventDefault();
1196
+ event.stopPropagation();
1197
+ if (searchInput) searchInput.focus();
1198
+ return;
1199
+ }
1200
+
1201
+ // 如果之前有搜索词(处于搜索结果态),本次清空搜索框点搜索是为了回到当前歌单缓存目录:
1202
+ // 临时禁用 searchInput 使得 URL 不带多余的空 q= 参数
1203
+ if (searchInput) {
1204
+ searchInput.disabled = true;
1205
+ setTimeout(() => {
1206
+ searchInput.disabled = false;
1207
+ }, 500);
1208
+ }
1209
+ }
1210
+
1211
+ // 若当前页面存在歌曲列表且未切换模式(family 相同),绝不打香港源站,由客户端直接过滤
1212
+ if (document.querySelector(".song-list") && !familyChanged) {
1213
+ event.preventDefault();
1214
+ event.stopPropagation();
1215
+ window.__applyQueryFilters?.();
1216
+ return;
1217
+ }
1218
+ if (familyChanged && typeof window.__queryLoadFamily === "function") {
1219
+ event.preventDefault();
1220
+ event.stopPropagation();
1221
+ window.__queryLoadFamily(formFamily, "");
1222
+ return;
1223
+ }
1224
+
1225
+ const key = new URLSearchParams(new FormData(form)).toString();
1226
+ if (inflight && key === lastKey) {
1227
+ event.preventDefault();
1228
+ return;
1229
+ }
1230
+ inflight = true;
1231
+ lastKey = key;
1232
+ });
1233
+ });
1234
+ }
1235
+
1236
+ function bindClientQueryFilters() {
1237
+ const songList = document.querySelector(".song-list");
1238
+ if (!songList || songList.hasAttribute("data-query-app")) return;
1239
+
1240
+ const searchInput = document.querySelector("[data-search-input]");
1241
+ const starNative = document.querySelector(".star-picker__native");
1242
+ const bpmBucketInput = document.querySelector("[data-bpm-bucket]");
1243
+ const bpmExactField = document.querySelector("[data-bpm-exact-field]");
1244
+ const bpmVisibleInput = document.querySelector("[data-bpm-input]");
1245
+ const emptyHint = document.querySelector("[data-client-empty]");
1246
+ const countEl = document.querySelector("[data-song-count]");
1247
+ const songRows = Array.from(document.querySelectorAll(".song-row"));
1248
+
1249
+ const urlParams = new URLSearchParams(window.location.search);
1250
+ let filterState = {
1251
+ q: (urlParams.get("q") || "").trim().toLowerCase(),
1252
+ star: (urlParams.get("star") || "").trim(),
1253
+ letter: (urlParams.get("letter") || "").trim(),
1254
+ bpmBucket: (urlParams.get("bpm") || "").trim(),
1255
+ bpmExact: (urlParams.get("bpm_exact") || "").trim(),
1256
+ };
1257
+
1258
+ // 同步搜索输入框
1259
+ if (searchInput && searchInput.value !== filterState.q) {
1260
+ if (filterState.q) searchInput.value = filterState.q;
1261
+ const clearBtn = searchInput.parentElement?.querySelector("[data-search-clear]");
1262
+ if (clearBtn) clearBtn.classList.toggle("is-on", filterState.q.length > 0);
1263
+ }
1264
+
1265
+ function compactText(value) {
1266
+ return (value || "").toLowerCase().replace(/\s+/g, "");
1267
+ }
1268
+
1269
+ function fieldMatches(raw, needle, compactNeedle) {
1270
+ const text = (raw || "").toLowerCase();
1271
+ if (text.includes(needle) || text.includes(compactNeedle)) return true;
1272
+ return compactText(text).includes(compactNeedle);
1273
+ }
1274
+
1275
+ function syncLetterField(letter) {
1276
+ const form = document.querySelector(".query-filters");
1277
+ if (!form) return;
1278
+ let input = form.querySelector("[data-letter-field]");
1279
+ const value = (letter || "").trim();
1280
+ if (!value) {
1281
+ if (input) input.remove();
1282
+ return;
1283
+ }
1284
+ if (!input) {
1285
+ input = document.createElement("input");
1286
+ input.type = "hidden";
1287
+ input.name = "letter";
1288
+ input.setAttribute("data-letter-field", "");
1289
+ form.appendChild(input);
1290
+ }
1291
+ input.value = value;
1292
+ }
1293
+
1294
+ function applyFilters() {
1295
+ let visibleCount = 0;
1296
+ const qRaw = filterState.q;
1297
+ const qClean = qRaw.replace(/\s+/g, "");
1298
+
1299
+ songRows.forEach((row) => {
1300
+ const title = (row.dataset.title || "").toLowerCase();
1301
+ const artist = (row.dataset.artist || "").toLowerCase();
1302
+ const pinyin = (row.dataset.pinyin || "").toLowerCase();
1303
+ const initials = (row.dataset.initials || "").toLowerCase();
1304
+ const artistPinyin = (row.dataset.artistPinyin || "").toLowerCase();
1305
+ const artistInitials = (row.dataset.artistInitials || "").toLowerCase();
1306
+ const leadNonZhEn = row.dataset.leadNonZhEn === "1";
1307
+ const star = row.dataset.star || "";
1308
+ const bpmRaw = (row.dataset.bpm || "").trim();
1309
+ const bpm = Number(bpmRaw);
1310
+ const hasBpm = bpmRaw !== "" && Number.isFinite(bpm);
1311
+ const letter = (row.dataset.firstLetter || "").toLowerCase();
1312
+
1313
+ let matchQ = true;
1314
+ if (qRaw) {
1315
+ matchQ = [title, artist, pinyin, initials, artistPinyin, artistInitials].some((field) =>
1316
+ fieldMatches(field, qRaw, qClean)
1317
+ );
1318
+ }
1319
+
1320
+ let matchStar = true;
1321
+ if (filterState.star) {
1322
+ matchStar = star === filterState.star;
1323
+ }
1324
+
1325
+ let matchLetter = true;
1326
+ if (filterState.letter) {
1327
+ if (filterState.letter === "#") {
1328
+ matchLetter = leadNonZhEn;
1329
+ } else {
1330
+ matchLetter = letter === filterState.letter.toLowerCase();
1331
+ }
1332
+ }
1333
+
1334
+ let matchBpm = true;
1335
+ if (filterState.bpmExact || filterState.bpmBucket) {
1336
+ if (!hasBpm) {
1337
+ matchBpm = false;
1338
+ } else if (filterState.bpmExact) {
1339
+ matchBpm = Math.round(bpm) === parseInt(filterState.bpmExact, 10);
1340
+ } else if (filterState.bpmBucket === "100-") {
1341
+ matchBpm = bpm < 100;
1342
+ } else if (filterState.bpmBucket === "200+") {
1343
+ matchBpm = bpm >= 200;
1344
+ } else {
1345
+ const low = parseInt(filterState.bpmBucket, 10);
1346
+ matchBpm = bpm >= low && bpm < low + 10;
1347
+ }
1348
+ }
1349
+
1350
+ const isMatch = matchQ && matchStar && matchLetter && matchBpm;
1351
+ row.hidden = !isMatch;
1352
+ if (isMatch) visibleCount++;
1353
+ });
1354
+
1355
+ if (emptyHint) emptyHint.hidden = visibleCount > 0;
1356
+ if (countEl) countEl.textContent = String(visibleCount);
1357
+
1358
+ const qHint = document.querySelector("[data-query-q-hint]");
1359
+ const hintTail = document.querySelector("[data-query-hint-tail]");
1360
+ const qDisplay = (searchInput?.value || "").trim();
1361
+ if (qHint) {
1362
+ qHint.textContent = qDisplay ? `[${qDisplay}]` : "";
1363
+ qHint.hidden = !qDisplay;
1364
+ }
1365
+ if (hintTail) {
1366
+ if (qDisplay) {
1367
+ hintTail.textContent = "。点击歌曲选择查看类别。";
1368
+ } else if (filterState.star || filterState.letter || filterState.bpmBucket || filterState.bpmExact) {
1369
+ hintTail.textContent = "。可继续输入关键词缩小范围。";
1370
+ } else {
1371
+ hintTail.textContent = "。点左侧字母或选择星级/BPM 浏览本模式全部歌曲。";
1372
+ }
1373
+ }
1374
+
1375
+ document.querySelectorAll("[data-alpha-item]").forEach((el) => {
1376
+ const active =
1377
+ filterState.letter &&
1378
+ el.textContent.trim().toLowerCase() === filterState.letter.toLowerCase();
1379
+ el.classList.toggle("is-on", Boolean(active));
1380
+ });
1381
+
1382
+ syncLetterField(filterState.letter);
1383
+
1384
+ const params = new URLSearchParams(window.location.search);
1385
+ if (filterState.q) params.set("q", filterState.q);
1386
+ else params.delete("q");
1387
+ if (filterState.star) params.set("star", filterState.star);
1388
+ else params.delete("star");
1389
+ if (filterState.letter) params.set("letter", filterState.letter);
1390
+ else params.delete("letter");
1391
+ if (filterState.bpmExact) params.set("bpm_exact", filterState.bpmExact);
1392
+ else params.delete("bpm_exact");
1393
+ if (filterState.bpmBucket) params.set("bpm", filterState.bpmBucket);
1394
+ else params.delete("bpm");
1395
+
1396
+ const qs = params.toString();
1397
+ const target = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
1398
+ window.history.replaceState(null, "", target);
1399
+
1400
+ if (songList.hasAttribute("data-filter-pending")) {
1401
+ songList.removeAttribute("data-filter-pending");
1402
+ songList.hidden = false;
1403
+ }
1404
+ }
1405
+
1406
+ // 暴露给星级、BPM、字母轨与清空按钮调用
1407
+ window.__applyQueryFilters = function () {
1408
+ // 从输入组件同步最新值
1409
+ filterState.q = (searchInput?.value || "").trim().toLowerCase();
1410
+ filterState.star = (starNative?.value || "").trim();
1411
+ filterState.bpmBucket = (bpmBucketInput?.value || "").trim();
1412
+ filterState.bpmExact = (bpmExactField?.value || "").trim();
1413
+ if (filterState.q) filterState.letter = "";
1414
+ applyFilters();
1415
+ };
1416
+
1417
+ window.__setQueryLetterFilter = function (label) {
1418
+ const next = (label || "").trim();
1419
+ if (filterState.letter.toLowerCase() === next.toLowerCase()) {
1420
+ filterState.letter = "";
1421
+ } else {
1422
+ filterState.letter = next;
1423
+ }
1424
+ // 选字母时清空搜索关键词
1425
+ if (searchInput) {
1426
+ searchInput.value = "";
1427
+ filterState.q = "";
1428
+ const clearBtn = searchInput.parentElement?.querySelector("[data-search-clear]");
1429
+ if (clearBtn) clearBtn.classList.remove("is-on");
1430
+ }
1431
+ applyFilters();
1432
+ };
1433
+
1434
+ if (searchInput) {
1435
+ searchInput.addEventListener("keydown", (e) => {
1436
+ if (e.key === "Enter") {
1437
+ e.preventDefault();
1438
+ window.__applyQueryFilters();
1439
+ }
1440
+ });
1441
+ }
1442
+
1443
+ // 首屏立即按 URL 参数执行一次过滤
1444
+ applyFilters();
1445
+ }
1446
+
1447
+ function bindQueryLetterSearch() {
1448
+ document.querySelectorAll(".query-filters").forEach((form) => {
1449
+ const searchInput = form.querySelector("[data-search-input]");
1450
+ if (!searchInput) return;
1451
+ form.addEventListener("submit", () => clearLetterWhenSearching(form));
1452
+ searchInput.addEventListener("input", () => clearLetterWhenSearching(form));
1453
+ });
1454
+ }
1455
+
1456
+ function bindTitleMarquees() {
1457
+ const DRAG_THRESHOLD = 5;
1458
+
1459
+ document.querySelectorAll("[data-title-marquee]").forEach((rail) => {
1460
+ const text = rail.querySelector("[data-title-marquee-text]");
1461
+ if (!text || rail.dataset.marqueeBound === "1") return;
1462
+ rail.dataset.marqueeBound = "1";
1463
+
1464
+ let offset = 0;
1465
+ let maxScroll = 0;
1466
+ let dragging = false;
1467
+ let moved = false;
1468
+ let pointerId = null;
1469
+ let startX = 0;
1470
+ let startOffset = 0;
1471
+
1472
+ function measure() {
1473
+ maxScroll = Math.max(0, text.scrollWidth - rail.clientWidth);
1474
+ rail.classList.toggle("is-overflow", maxScroll > 0);
1475
+ if (maxScroll <= 0) {
1476
+ offset = 0;
1477
+ text.style.transform = "";
1478
+ return false;
1479
+ }
1480
+ if (offset < -maxScroll) offset = -maxScroll;
1481
+ if (offset > 0) offset = 0;
1482
+ return true;
1483
+ }
1484
+
1485
+ function apply() {
1486
+ text.style.transform = offset ? `translateX(${offset}px)` : "";
1487
+ }
1488
+
1489
+ function suppressClick(target) {
1490
+ if (!target) return;
1491
+ const once = (event) => {
1492
+ event.preventDefault();
1493
+ event.stopPropagation();
1494
+ event.stopImmediatePropagation();
1495
+ target.removeEventListener("click", once, true);
1496
+ };
1497
+ target.addEventListener("click", once, true);
1498
+ }
1499
+
1500
+ rail.addEventListener("pointerdown", (event) => {
1501
+ if (event.pointerType === "mouse" && event.button !== 0) return;
1502
+ if (!measure()) return;
1503
+ dragging = true;
1504
+ moved = false;
1505
+ pointerId = event.pointerId;
1506
+ startX = event.clientX;
1507
+ startOffset = offset;
1508
+ rail.classList.add("is-dragging");
1509
+ try {
1510
+ rail.setPointerCapture(event.pointerId);
1511
+ } catch (err) {
1512
+ /* ignore */
1513
+ }
1514
+ event.preventDefault();
1515
+ event.stopPropagation();
1516
+ });
1517
+
1518
+ rail.addEventListener("pointermove", (event) => {
1519
+ if (!dragging || event.pointerId !== pointerId) return;
1520
+ const dx = event.clientX - startX;
1521
+ if (Math.abs(dx) > DRAG_THRESHOLD) moved = true;
1522
+ if (!measure()) return;
1523
+ offset = Math.min(0, Math.max(-maxScroll, startOffset + dx));
1524
+ apply();
1525
+ event.preventDefault();
1526
+ });
1527
+
1528
+ function endDrag(event) {
1529
+ if (!dragging || (event && event.pointerId !== pointerId)) return;
1530
+ dragging = false;
1531
+ pointerId = null;
1532
+ rail.classList.remove("is-dragging");
1533
+ if (moved) {
1534
+ suppressClick(rail);
1535
+ suppressClick(rail.closest("button"));
1536
+ }
1537
+ moved = false;
1538
+ }
1539
+
1540
+ rail.addEventListener("pointerup", endDrag);
1541
+ rail.addEventListener("pointercancel", endDrag);
1542
+
1543
+ if (typeof ResizeObserver !== "undefined") {
1544
+ const ro = new ResizeObserver(() => {
1545
+ measure();
1546
+ apply();
1547
+ });
1548
+ ro.observe(rail);
1549
+ }
1550
+
1551
+ measure();
1552
+ apply();
1553
+ });
1554
+ }
1555
+
1556
+ function bindAlphaRail() {
1557
+ const rail = document.querySelector("[data-alpha-rail]");
1558
+ if (!rail) return;
1559
+ const items = Array.from(rail.querySelectorAll("[data-alpha-item]"));
1560
+ if (!items.length) return;
1561
+
1562
+ let dragging = false;
1563
+ let current = null;
1564
+ let navigating = false;
1565
+
1566
+ function pickItem(clientY) {
1567
+ let best = null;
1568
+ let bestDist = Infinity;
1569
+ for (const item of items) {
1570
+ const rect = item.getBoundingClientRect();
1571
+ if (clientY >= rect.top && clientY <= rect.bottom) return item;
1572
+ const mid = (rect.top + rect.bottom) / 2;
1573
+ const dist = Math.abs(clientY - mid);
1574
+ if (dist < bestDist) {
1575
+ bestDist = dist;
1576
+ best = item;
1577
+ }
1578
+ }
1579
+ return best;
1580
+ }
1581
+
1582
+ function setScrub(item) {
1583
+ if (current === item) return;
1584
+ current = item;
1585
+ items.forEach((el) => el.classList.toggle("is-scrub", el === item));
1586
+ }
1587
+
1588
+ function clearScrub() {
1589
+ current = null;
1590
+ items.forEach((el) => el.classList.remove("is-scrub"));
1591
+ }
1592
+
1593
+ function goTo(item) {
1594
+ if (!item || navigating) return;
1595
+ if (document.querySelector(".song-list")) {
1596
+ const label = item.textContent.trim();
1597
+ window.__setQueryLetterFilter?.(label);
1598
+ return;
1599
+ }
1600
+ const next = item.href;
1601
+ if (!next || next === window.location.href) return;
1602
+ navigating = true;
1603
+ window.location.assign(next);
1604
+ }
1605
+
1606
+ function onPointerDown(event) {
1607
+ if (event.pointerType === "mouse" && event.button !== 0) return;
1608
+ dragging = true;
1609
+ setScrub(pickItem(event.clientY));
1610
+ try {
1611
+ rail.setPointerCapture(event.pointerId);
1612
+ } catch (err) {
1613
+ /* ignore */
1614
+ }
1615
+ event.preventDefault();
1616
+ }
1617
+
1618
+ function onPointerMove(event) {
1619
+ if (!dragging) return;
1620
+ setScrub(pickItem(event.clientY));
1621
+ event.preventDefault();
1622
+ }
1623
+
1624
+ function onPointerUp(event) {
1625
+ if (!dragging) return;
1626
+ dragging = false;
1627
+ const target = pickItem(event.clientY) || current;
1628
+ clearScrub();
1629
+ goTo(target);
1630
+ }
1631
+
1632
+ function onPointerCancel() {
1633
+ dragging = false;
1634
+ clearScrub();
1635
+ }
1636
+
1637
+ rail.addEventListener("pointerdown", onPointerDown);
1638
+ rail.addEventListener("pointermove", onPointerMove);
1639
+ rail.addEventListener("pointerup", onPointerUp);
1640
+ rail.addEventListener("pointercancel", onPointerCancel);
1641
+
1642
+ items.forEach((item) => {
1643
+ item.addEventListener("click", (event) => {
1644
+ event.preventDefault();
1645
+ });
1646
+ });
1647
+ }
1648
+
1649
+ function bindSearchClear() {
1650
+ document.querySelectorAll("[data-search-input]").forEach((input) => {
1651
+ const clearBtn = input.parentElement?.querySelector("[data-search-clear]");
1652
+ if (!clearBtn) return;
1653
+ const sync = () => {
1654
+ clearBtn.classList.toggle("is-on", input.value.length > 0);
1655
+ };
1656
+ input.addEventListener("input", sync);
1657
+ clearBtn.addEventListener("click", () => {
1658
+ input.value = "";
1659
+ sync();
1660
+ input.focus();
1661
+ if (document.querySelector(".song-list")) {
1662
+ window.__applyQueryFilters?.();
1663
+ return;
1664
+ }
1665
+ const form = input.closest("form");
1666
+ if (form) form.requestSubmit();
1667
+ });
1668
+ sync();
1669
+ });
1670
+ }
1671
+
1672
+ function atlasPackForFamily(family) {
1673
+ if (family === "pinball") return "pinball";
1674
+ if (family === "bubble") return "bubble";
1675
+ if (family === "crescent") return "crescent";
1676
+ return "idol";
1677
+ }
1678
+
1679
+ function prefetchHref(url, as) {
1680
+ if (!url) return;
1681
+ const found = Array.prototype.some.call(document.querySelectorAll('link[rel="prefetch"]'), function (link) {
1682
+ return link.getAttribute("href") === url;
1683
+ });
1684
+ if (found) return;
1685
+ const link = document.createElement("link");
1686
+ link.rel = "prefetch";
1687
+ if (as) link.as = as;
1688
+ link.href = url;
1689
+ document.head.appendChild(link);
1690
+ }
1691
+
1692
+ function stampAsset(url) {
1693
+ const ver = (window.__NOTE_PREVIEW_ASSET_VER__ || "").trim();
1694
+ if (!ver || !url || /[?&]v=/.test(url)) return url;
1695
+ return url + (url.indexOf("?") >= 0 ? "&" : "?") + "v=" + encodeURIComponent(ver);
1696
+ }
1697
+
1698
+ const PREVIEW_ATLAS_PACKS = ["idol", "pinball", "bubble", "crescent"];
1699
+ const PREVIEW_SFX_FILES = [
1700
+ "burst-start.m4a",
1701
+ "burst-end.m4a",
1702
+ "key-tone.m4a",
1703
+ "key-tone-long.m4a",
1704
+ "key-tone-slide.m4a",
1705
+ "key-tone-rhythm.m4a",
1706
+ "key-tone-classic.m4a",
1707
+ "key-tone-xingdong.m4a",
1708
+ "key-tone-xingdong-slide.m4a",
1709
+ "key-tone-pinball.m4a",
1710
+ "key-tone-pinball-slide.m4a",
1711
+ "key-tone-bubble.m4a",
1712
+ "key-tone-bubble-slide.m4a",
1713
+ "key-tone-crescent.m4a",
1714
+ "key-tone-crescent-slide.m4a",
1715
+ ];
1716
+ const warmedPreviewStatic = window.__NOTE_PREVIEW_WARMED__ || (window.__NOTE_PREVIEW_WARMED__ = {});
1717
+
1718
+ function readPreviewStaticUrls() {
1719
+ const node = document.getElementById("note-preview-static-urls");
1720
+ if (node && node.textContent) {
1721
+ try {
1722
+ const parsed = JSON.parse(node.textContent);
1723
+ if (Array.isArray(parsed) && parsed.length) return parsed;
1724
+ } catch (_error) {
1725
+ // fall through to constructed list
1726
+ }
1727
+ }
1728
+ if (Array.isArray(window.__NOTE_PREVIEW_STATIC_URLS__) && window.__NOTE_PREVIEW_STATIC_URLS__.length) {
1729
+ return window.__NOTE_PREVIEW_STATIC_URLS__;
1730
+ }
1731
+ const urls = [];
1732
+ const js = (window.__NOTE_PREVIEW_JS_URL__ || "").trim();
1733
+ if (js) urls.push(js);
1734
+ const host = (window.__NOTE_PREVIEW_HOST_JS_URL__ || "").trim();
1735
+ if (host) urls.push(host);
1736
+ let base = (window.__NOTE_PREVIEW_ASSET_BASE__ || "").trim();
1737
+ if (!base) return urls;
1738
+ if (base.charAt(base.length - 1) !== "/") base += "/";
1739
+ urls.push(stampAsset(base + "atlas/manifest.json"));
1740
+ urls.push(stampAsset(base + "atlas/shared.webp"));
1741
+ PREVIEW_ATLAS_PACKS.forEach(function (pack) {
1742
+ urls.push(stampAsset(base + "atlas/" + pack + ".webp"));
1743
+ });
1744
+ PREVIEW_SFX_FILES.forEach(function (name) {
1745
+ urls.push(stampAsset(base + "sfx/" + name));
1746
+ });
1747
+ return urls;
1748
+ }
1749
+
1750
+ function warmPreviewStaticUrl(url) {
1751
+ if (!url || warmedPreviewStatic[url]) return;
1752
+ warmedPreviewStatic[url] = 1;
1753
+ const sameOrigin = url.charAt(0) === "/" || url.indexOf(window.location.origin) === 0;
1754
+ const asHint = /\.js(\?|$)/i.test(url)
1755
+ ? "script"
1756
+ : /\.webp(\?|$)/i.test(url)
1757
+ ? "image"
1758
+ : /\.m4a(\?|$)/i.test(url)
1759
+ ? "audio"
1760
+ : "fetch";
1761
+ prefetchHref(url, asHint);
1762
+ const opts = {
1763
+ mode: "cors",
1764
+ credentials: sameOrigin ? "same-origin" : "omit",
1765
+ cache: "force-cache",
1766
+ priority: "low",
1767
+ };
1768
+ fetch(url, opts).catch(function () {
1769
+ fetch(url, {
1770
+ mode: "cors",
1771
+ credentials: sameOrigin ? "same-origin" : "omit",
1772
+ cache: "reload",
1773
+ }).catch(function () {});
1774
+ });
1775
+ }
1776
+
1777
+ function deployNotePreviewStatic(preferredFamily) {
1778
+ const pack = atlasPackForFamily(preferredFamily || "");
1779
+ const needle = pack ? "atlas/" + pack + ".webp" : "";
1780
+ const urls = readPreviewStaticUrls().slice();
1781
+ if (needle) {
1782
+ urls.sort(function (left, right) {
1783
+ const a = left.indexOf(needle) >= 0 ? 0 : 1;
1784
+ const b = right.indexOf(needle) >= 0 ? 0 : 1;
1785
+ return a - b;
1786
+ });
1787
+ }
1788
+ urls.forEach(warmPreviewStaticUrl);
1789
+ }
1790
+
1791
+ function prefetchPreviewAssets(family) {
1792
+ deployNotePreviewStatic(family);
1793
+ }
1794
+
1795
+ function readCatalogPrefetchUrls() {
1796
+ const node = document.getElementById("catalog-prefetch-urls");
1797
+ if (!node || !node.textContent) return [];
1798
+ try {
1799
+ const parsed = JSON.parse(node.textContent);
1800
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
1801
+ } catch (_error) {
1802
+ return [];
1803
+ }
1804
+ }
1805
+
1806
+ function deployCatalogStatic() {
1807
+ const urls = readCatalogPrefetchUrls();
1808
+ urls.forEach(function (url, index) {
1809
+ if (!url || warmedPreviewStatic[url]) return;
1810
+ // 搜索函数、查询页框架、拼音表、当前模式歌单用高优先级,其余模式随后填缓存。
1811
+ const high = index < 4;
1812
+ warmedPreviewStatic[url] = 1;
1813
+ const sameOrigin = url.charAt(0) === "/" || url.indexOf(window.location.origin) === 0;
1814
+ const asHint = /\.js(\?|$)/i.test(url) ? "script" : "fetch";
1815
+ prefetchHref(url, asHint);
1816
+ fetch(url, {
1817
+ mode: "cors",
1818
+ credentials: sameOrigin ? "same-origin" : "omit",
1819
+ cache: "force-cache",
1820
+ priority: high ? "high" : "low",
1821
+ }).catch(function () {
1822
+ fetch(url, {
1823
+ mode: "cors",
1824
+ credentials: sameOrigin ? "same-origin" : "omit",
1825
+ cache: "reload",
1826
+ }).catch(function () {});
1827
+ });
1828
+ });
1829
+ }
1830
+
1831
+ window.deployNotePreviewStatic = deployNotePreviewStatic;
1832
+ window.prefetchNotePreviewAssets = prefetchPreviewAssets;
1833
+ window.deployCatalogStatic = deployCatalogStatic;
1834
+
1835
+ function bindCategoryModal() {
1836
+ const modal = document.querySelector("[data-category-modal]");
1837
+ if (!modal) return;
1838
+
1839
+ const CATEGORY_KEY = "zsbqb.query.category";
1840
+ const PREFETCH_TTL_MS = 30 * 60 * 1000;
1841
+ const PREFETCH_FROZEN_KEY = "zsbqb.prefetch.frozenUntil";
1842
+ const titleEl = modal.querySelector("[data-category-title]");
1843
+ const artistEl = modal.querySelector("[data-category-artist]");
1844
+ const familyLabelEl = modal.querySelector("[data-category-family-label]");
1845
+ const familyIconEl = modal.querySelector("[data-category-family-icon]");
1846
+ const metaEl = modal.querySelector("[data-category-meta]");
1847
+ const singleLink = modal.querySelector("[data-category-single]");
1848
+ const doubleLink = modal.querySelector("[data-category-double]");
1849
+ const lockEl = modal.querySelector("[data-category-lock]");
1850
+ let prefetchAbort = null;
1851
+
1852
+ function readPredictedMode() {
1853
+ const server = (modal.getAttribute("data-predicted-mode") || "").trim();
1854
+ let stored = null;
1855
+ try {
1856
+ const raw = window.localStorage.getItem(CATEGORY_KEY);
1857
+ if (raw === "single" || raw === "double") {
1858
+ stored = { mode: raw, at: Date.now() };
1859
+ window.localStorage.setItem(CATEGORY_KEY, JSON.stringify(stored));
1860
+ } else if (raw) {
1861
+ stored = JSON.parse(raw);
1862
+ }
1863
+ } catch (_error) {
1864
+ stored = null;
1865
+ }
1866
+ if (stored && (stored.mode === "single" || stored.mode === "double")) {
1867
+ const at = Number(stored.at) || 0;
1868
+ if (at && Date.now() - at < PREFETCH_TTL_MS) {
1869
+ return stored.mode;
1870
+ }
1871
+ }
1872
+ if (server === "single" || server === "double") return server;
1873
+ return "";
1874
+ }
1875
+
1876
+ function saveCategory(mode) {
1877
+ const value = mode === "double" ? "double" : "single";
1878
+ try {
1879
+ window.localStorage.setItem(CATEGORY_KEY, JSON.stringify({ mode: value, at: Date.now() }));
1880
+ } catch (_error) {
1881
+ // Private mode can reject localStorage writes.
1882
+ }
1883
+ }
1884
+
1885
+ function prefetchHandoffKey(family, star, title, mode) {
1886
+ return `zsbqb.prefetch:${family}:${star}:${title}:${mode}`;
1887
+ }
1888
+
1889
+ function isPrefetchFrozen() {
1890
+ try {
1891
+ return Date.now() < Number(window.sessionStorage.getItem(PREFETCH_FROZEN_KEY) || 0);
1892
+ } catch (_error) {
1893
+ return false;
1894
+ }
1895
+ }
1896
+
1897
+ function markPrefetchFrozen(retryAfterSec) {
1898
+ const ms = Math.max(1, Number(retryAfterSec) || 3600) * 1000;
1899
+ try {
1900
+ window.sessionStorage.setItem(PREFETCH_FROZEN_KEY, String(Date.now() + ms));
1901
+ } catch (_error) {
1902
+ // Ignore storage failures.
1903
+ }
1904
+ }
1905
+
1906
+ function prefetchSongDetail(opts) {
1907
+ const family = opts.family;
1908
+ const star = opts.star;
1909
+ const title = opts.title;
1910
+ const mode = opts.mode;
1911
+ const ticket = opts.ticket;
1912
+ if (!family || !star || !title || !mode || !ticket) return;
1913
+ if (isPrefetchFrozen()) return;
1914
+ if (prefetchAbort) prefetchAbort.abort();
1915
+ prefetchAbort = new AbortController();
1916
+ const params = new URLSearchParams();
1917
+ params.set("mode", mode);
1918
+ params.set("ticket", ticket);
1919
+ params.set("prefetch", "1");
1920
+ const url =
1921
+ "/api/query/songs/" +
1922
+ encodeURIComponent(family) +
1923
+ "/" +
1924
+ encodeURIComponent(star) +
1925
+ "/" +
1926
+ encodeURIComponent(title) +
1927
+ "/?" +
1928
+ params.toString();
1929
+ fetch(url, { credentials: "same-origin", signal: prefetchAbort.signal })
1930
+ .then((res) => {
1931
+ if (res.status === 429) {
1932
+ markPrefetchFrozen(res.headers.get("Retry-After"));
1933
+ throw new Error("prefetch_frozen");
1934
+ }
1935
+ if (!res.ok) throw new Error("fetch_failed");
1936
+ return res.json();
1937
+ })
1938
+ .then((payload) => {
1939
+ if (!payload || !payload.ok) return;
1940
+ try {
1941
+ window.sessionStorage.setItem(
1942
+ prefetchHandoffKey(family, star, title, mode),
1943
+ JSON.stringify({ at: Date.now(), payload })
1944
+ );
1945
+ } catch (_error) {
1946
+ // Ignore storage failures.
1947
+ }
1948
+ })
1949
+ .catch(() => {});
1950
+ }
1951
+
1952
+ function ticketUrl(family, star, title) {
1953
+ const params = new URLSearchParams({
1954
+ family: family || "",
1955
+ star: star || "",
1956
+ title: title || "",
1957
+ });
1958
+ return `/api/query/ticket/?${params.toString()}`;
1959
+ }
1960
+
1961
+ function ensureTicket(button) {
1962
+ const existing = button.getAttribute("data-ticket") || "";
1963
+ if (existing) return Promise.resolve(existing);
1964
+ const family = button.getAttribute("data-family") || "";
1965
+ const star = button.getAttribute("data-star") || "";
1966
+ const title = button.getAttribute("data-title") || "";
1967
+ if (!family || !title) return Promise.resolve("");
1968
+ return fetch(ticketUrl(family, star, title), { credentials: "same-origin" })
1969
+ .then((res) => (res.ok ? res.json() : null))
1970
+ .then((data) => {
1971
+ const ticket = data && data.ok ? data.ticket || "" : "";
1972
+ if (ticket) button.setAttribute("data-ticket", ticket);
1973
+ return ticket;
1974
+ })
1975
+ .catch(() => "");
1976
+ }
1977
+
1978
+ function prefetchFromPick(button) {
1979
+ if (!button) return;
1980
+ const family = button.getAttribute("data-family") || "";
1981
+ const star = button.getAttribute("data-star") || "";
1982
+ const title = button.getAttribute("data-title") || "";
1983
+ const supportsDouble = button.getAttribute("data-supports-double") === "1";
1984
+ prefetchPreviewAssets(family);
1985
+ let prefetchMode = readPredictedMode();
1986
+ if (prefetchMode === "double" && !supportsDouble) prefetchMode = "single";
1987
+ if (!prefetchMode) return;
1988
+ ensureTicket(button).then((ticket) => {
1989
+ if (!ticket) return;
1990
+ prefetchSongDetail({ family, star, title, mode: prefetchMode, ticket });
1991
+ });
1992
+ }
1993
+
1994
+ function closeModal() {
1995
+ if (prefetchAbort) prefetchAbort.abort();
1996
+ modal.hidden = true;
1997
+ document.body.style.overflow = "";
1998
+ }
1999
+
2000
+ function openModal(button) {
2001
+ const title = button.getAttribute("data-title") || "";
2002
+ const artist = (button.getAttribute("data-artist") || "").trim();
2003
+ const family = button.getAttribute("data-family") || "";
2004
+ const familyLabel = button.getAttribute("data-family-label") || family;
2005
+ const star = button.getAttribute("data-star") || "";
2006
+ const bpm = button.getAttribute("data-bpm") || "";
2007
+ const ticket = button.getAttribute("data-ticket") || "";
2008
+ const base = button.getAttribute("data-detail-base") || "#";
2009
+ const supportsDouble = button.getAttribute("data-supports-double") === "1";
2010
+ const icon = button.getAttribute("data-icon") || "";
2011
+
2012
+ function applyTicketLinks(nextTicket) {
2013
+ const ticketQs = nextTicket ? `&ticket=${encodeURIComponent(nextTicket)}` : "";
2014
+ const returnTarget = `${window.location.pathname}${window.location.search}`;
2015
+ const returnQs =
2016
+ returnTarget.startsWith("/query/") &&
2017
+ !returnTarget.match(/^\/query\/[^/?#]+\/\d+\//)
2018
+ ? `&return=${encodeURIComponent(returnTarget)}`
2019
+ : "";
2020
+ singleLink.href = `${base}?mode=single${ticketQs}${returnQs}`;
2021
+ if (supportsDouble) {
2022
+ doubleLink.href = `${base}?mode=double${ticketQs}${returnQs}`;
2023
+ }
2024
+ }
2025
+
2026
+ applyTicketLinks(ticket);
2027
+ if (!ticket) {
2028
+ ensureTicket(button).then((next) => {
2029
+ if (next) applyTicketLinks(next);
2030
+ });
2031
+ }
2032
+
2033
+ titleEl.textContent = title;
2034
+ if (artistEl) {
2035
+ artistEl.textContent = artist;
2036
+ artistEl.hidden = !artist;
2037
+ }
2038
+ familyLabelEl.textContent = familyLabel;
2039
+ if (family) {
2040
+ familyLabelEl.setAttribute("data-family", family);
2041
+ } else {
2042
+ familyLabelEl.removeAttribute("data-family");
2043
+ }
2044
+ if (metaEl) {
2045
+ const parts = [];
2046
+ if (star) parts.push(`${star}⭐`);
2047
+ if (bpm) parts.push(`BPM ${bpm}`);
2048
+ metaEl.textContent = parts.join(" ");
2049
+ metaEl.hidden = parts.length === 0;
2050
+ }
2051
+ if (familyIconEl) {
2052
+ if (icon) {
2053
+ familyIconEl.src = icon;
2054
+ familyIconEl.alt = familyLabel;
2055
+ familyIconEl.hidden = false;
2056
+ familyIconEl.classList.toggle("mode-icon--ornate", icon.indexOf("dianfeng") !== -1);
2057
+ } else {
2058
+ familyIconEl.hidden = true;
2059
+ familyIconEl.removeAttribute("src");
2060
+ familyIconEl.classList.remove("mode-icon--ornate");
2061
+ }
2062
+ }
2063
+
2064
+ if (supportsDouble) {
2065
+ doubleLink.hidden = false;
2066
+ doubleLink.removeAttribute("tabindex");
2067
+ doubleLink.removeAttribute("aria-hidden");
2068
+ } else {
2069
+ doubleLink.removeAttribute("href");
2070
+ doubleLink.hidden = true;
2071
+ doubleLink.tabIndex = -1;
2072
+ doubleLink.setAttribute("aria-hidden", "true");
2073
+ }
2074
+ if (lockEl) lockEl.hidden = supportsDouble;
2075
+ prefetchFromPick(button);
2076
+
2077
+ modal.dataset.navLock = "";
2078
+ singleLink?.classList.remove("is-busy", "is-last");
2079
+ singleLink?.removeAttribute("title");
2080
+ doubleLink?.classList.remove("is-busy", "is-last");
2081
+ doubleLink?.removeAttribute("title");
2082
+
2083
+ modal.hidden = false;
2084
+ document.body.style.overflow = "hidden";
2085
+ }
2086
+
2087
+ function lockCategoryNav(event) {
2088
+ const link = event.currentTarget;
2089
+ if (link.hidden || !link.getAttribute("href") || link.getAttribute("href") === "#") {
2090
+ event.preventDefault();
2091
+ return;
2092
+ }
2093
+ if (modal.dataset.navLock === "1") {
2094
+ event.preventDefault();
2095
+ event.stopPropagation();
2096
+ return;
2097
+ }
2098
+ modal.dataset.navLock = "1";
2099
+ saveCategory(link.hasAttribute("data-category-double") ? "double" : "single");
2100
+ singleLink?.classList.add("is-busy");
2101
+ doubleLink?.classList.add("is-busy");
2102
+ }
2103
+
2104
+ if (singleLink) singleLink.addEventListener("click", lockCategoryNav);
2105
+ if (doubleLink) doubleLink.addEventListener("click", lockCategoryNav);
2106
+
2107
+ document.addEventListener("pointerdown", (event) => {
2108
+ const button = event.target.closest("[data-song-pick]");
2109
+ if (button) prefetchFromPick(button);
2110
+ }, { passive: true });
2111
+ document.addEventListener("click", (event) => {
2112
+ const button = event.target.closest("[data-song-pick]");
2113
+ if (button) openModal(button);
2114
+ });
2115
+
2116
+ modal.querySelectorAll("[data-category-close]").forEach((el) => {
2117
+ el.addEventListener("click", closeModal);
2118
+ });
2119
+
2120
+ document.addEventListener("keydown", (event) => {
2121
+ if (event.key === "Escape" && !modal.hidden) closeModal();
2122
+ });
2123
+ }
2124
+