@windcoder/astro-theme-orchid 0.0.0-stage → 0.2.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 (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
@@ -0,0 +1,940 @@
1
+ ---
2
+ /**
3
+ * 音乐播放器(§4.36):local 模式悬浮播放器,自研播放内核。
4
+ * - 根节点 transition:persist:VT 换页 DOM 保留,播放不中断,脚本单次初始化
5
+ * - LRC 支持 .lrc 路径(fetch)或内联文本;一行多时间戳
6
+ * - 播放模式三态:list 列表循环 / one 单曲循环 / random 随机
7
+ * - 音量记忆 localStorage('qy-music-vol');自动播放被浏览器拦截仅告警(预期行为)
8
+ */
9
+ interface Track {
10
+ name: string;
11
+ artist?: string;
12
+ url: string;
13
+ cover?: string;
14
+ lrc?: string;
15
+ }
16
+
17
+ interface Props {
18
+ music: {
19
+ position: "left" | "right";
20
+ volume: number;
21
+ playMode: "list" | "one" | "random";
22
+ showLyrics: boolean;
23
+ playlist: Track[];
24
+ };
25
+ }
26
+
27
+ const { music } = Astro.props;
28
+
29
+ import { t } from "../i18n/index.js";
30
+
31
+ // 客户端动态文案(模式切换 / 播放暂停 aria 需要运行时替换)
32
+ const labels = {
33
+ play: t("musicPlay"),
34
+ pause: t("musicPause"),
35
+ modeList: t("musicModeList"),
36
+ modeOne: t("musicModeOne"),
37
+ modeRandom: t("musicModeRandom"),
38
+ noLyrics: t("musicNoLyrics"),
39
+ };
40
+ ---
41
+
42
+ <div
43
+ class="music-player"
44
+ id="musicPlayer"
45
+ transition:persist="music-player"
46
+ data-position={music.position}
47
+ data-volume={music.volume}
48
+ data-play-mode={music.playMode}
49
+ data-playlist={JSON.stringify(music.playlist)}
50
+ data-labels={JSON.stringify(labels)}
51
+ >
52
+ <!-- 收起态悬浮球:封面旋转 / 无封面唱片图标 -->
53
+ <button
54
+ class="mp-fab"
55
+ type="button"
56
+ aria-expanded="false"
57
+ aria-label={t("musicOpen")}
58
+ title={t("musicOpen")}
59
+ >
60
+ {music.playlist[0]?.cover ? (
61
+ <img src={music.playlist[0].cover} alt="" loading="lazy" />
62
+ ) : (
63
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
64
+ )}
65
+ </button>
66
+
67
+ <!-- 展开态面板 -->
68
+ <div class="mp-panel" role="region" aria-label={t("musicOpen")}>
69
+ <div class="mp-head">
70
+ <div class="mp-cover">
71
+ {music.playlist[0]?.cover ? (
72
+ <img src={music.playlist[0].cover} alt="" loading="lazy" />
73
+ ) : (
74
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
75
+ )}
76
+ </div>
77
+ <div class="mp-meta">
78
+ <div class="mp-name">{music.playlist[0]?.name ?? t("musicEmpty")}</div>
79
+ <div class="mp-artist">{music.playlist[0]?.artist ?? ""}</div>
80
+ </div>
81
+ <button class="mp-btn mp-close" type="button" aria-label={t("musicClose")}>
82
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
83
+ </button>
84
+ </div>
85
+
86
+ <div class="mp-progress" role="slider" aria-label={t("musicSeek")} aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
87
+ <div class="mp-progress-fill"></div>
88
+ </div>
89
+ <div class="mp-time"><span class="mp-time-cur">00:00</span><span class="mp-time-sep">/</span><span class="mp-time-total">00:00</span></div>
90
+
91
+ <div class="mp-controls">
92
+ <button class="mp-btn mp-mode" type="button" data-mode="list" title={t("musicModeList")} aria-label={t("musicModeList")}>
93
+ <svg class="ic-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>
94
+ <svg class="ic-one" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" hidden><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/></svg>
95
+ <svg class="ic-random" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" hidden><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.8-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/></svg>
96
+ </button>
97
+ <button class="mp-btn mp-prev" type="button" aria-label={t("musicPrev")}>
98
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 8 12l10 6V6Z" fill="currentColor" stroke="none"/><path d="M6 5v14"/></svg>
99
+ </button>
100
+ <button class="mp-btn mp-play" type="button" aria-label={t("musicPlay")}>
101
+ <svg class="ic-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
102
+ <svg class="ic-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" hidden><rect x="6.5" y="5" width="3.5" height="14" rx="1"/><rect x="14" y="5" width="3.5" height="14" rx="1"/></svg>
103
+ </button>
104
+ <button class="mp-btn mp-next" type="button" aria-label={t("musicNext")}>
105
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 6 10 6L6 18V6Z" fill="currentColor" stroke="none"/><path d="M18 5v14"/></svg>
106
+ </button>
107
+ <div class="mp-vol">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z" fill="currentColor" stroke="none"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/></svg>
109
+ <input class="mp-vol-range" type="range" min="0" max="1" step="0.01" aria-label={t("musicVolume")} />
110
+ </div>
111
+ </div>
112
+
113
+ {
114
+ music.showLyrics && (
115
+ <div class="mp-extra">
116
+ <button class="mp-btn mp-lrc-toggle" type="button" aria-label={t("musicLyrics")} title={t("musicLyrics")}>
117
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8"/><path d="M8 12.5h5"/></svg>
118
+ </button>
119
+ <span class="mp-extra-hint">{t("musicLyrics")}</span>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ <div class="mp-lrc" hidden>
125
+ <div class="mp-lrc-scroll" role="listbox" aria-label={t("musicLyrics")}></div>
126
+ </div>
127
+ <div class="mp-playlist" hidden>
128
+ <div class="mp-playlist-scroll" role="listbox" aria-label={t("musicPlaylist")}></div>
129
+ </div>
130
+
131
+ <audio preload="metadata"></audio>
132
+ </div>
133
+ </div>
134
+
135
+ <style is:global>
136
+ /* 注意:不能用 scoped —— renderLrc/renderPlaylist 运行时 createElement 的节点(.mp-lrc-line/.mp-track/.mp-eq 等)
137
+ 不带 data-astro-cid 属性,scoped 样式不命中;所有选择器已带 .music-player/.mp- 前缀,is:global 无污染风险。 */
138
+ .music-player {
139
+ position: fixed;
140
+ bottom: 20px;
141
+ z-index: 255;
142
+ font-family: var(--font-body);
143
+ }
144
+ .music-player[data-position="left"] {
145
+ left: 20px;
146
+ }
147
+ .music-player[data-position="right"] {
148
+ right: 20px;
149
+ }
150
+ .music-player audio {
151
+ display: none;
152
+ }
153
+
154
+ /* ---------- 悬浮球 ---------- */
155
+ .mp-fab {
156
+ width: 48px;
157
+ height: 48px;
158
+ border-radius: 50%;
159
+ border: 1px solid var(--border);
160
+ background: var(--surface);
161
+ color: var(--accent);
162
+ box-shadow: var(--shadow-md);
163
+ cursor: pointer;
164
+ overflow: hidden;
165
+ display: grid;
166
+ place-items: center;
167
+ padding: 0;
168
+ transition: transform 0.2s ease;
169
+ }
170
+ .mp-fab:hover {
171
+ transform: scale(1.06);
172
+ }
173
+ .mp-fab svg {
174
+ width: 22px;
175
+ height: 22px;
176
+ }
177
+ .mp-fab img,
178
+ .mp-panel .mp-cover img {
179
+ width: 100%;
180
+ height: 100%;
181
+ object-fit: cover;
182
+ }
183
+ .music-player.playing .mp-fab img,
184
+ .music-player.playing .mp-cover img {
185
+ animation: mp-spin 12s linear infinite;
186
+ }
187
+ @keyframes mp-spin {
188
+ to {
189
+ transform: rotate(360deg);
190
+ }
191
+ }
192
+ @media (prefers-reduced-motion: reduce) {
193
+ .music-player.playing .mp-fab img,
194
+ .music-player.playing .mp-cover img {
195
+ animation: none;
196
+ }
197
+ }
198
+
199
+ /* ---------- 面板 ---------- */
200
+ .mp-panel {
201
+ position: absolute;
202
+ bottom: 0;
203
+ width: 300px;
204
+ max-width: calc(100vw - 32px);
205
+ background: var(--surface);
206
+ border: 1px solid var(--border);
207
+ border-radius: var(--radius);
208
+ box-shadow: var(--shadow-md);
209
+ padding: 14px 16px 12px;
210
+ opacity: 0;
211
+ visibility: hidden;
212
+ transform: translateY(8px);
213
+ transition:
214
+ opacity 0.22s ease,
215
+ transform 0.22s ease,
216
+ visibility 0.22s;
217
+ }
218
+ .music-player[data-position="left"] .mp-panel {
219
+ left: 0;
220
+ }
221
+ .music-player[data-position="right"] .mp-panel {
222
+ right: 0;
223
+ }
224
+ .music-player.open .mp-panel {
225
+ opacity: 1;
226
+ visibility: visible;
227
+ transform: translateY(0);
228
+ }
229
+ .music-player.open .mp-fab {
230
+ opacity: 0;
231
+ pointer-events: none;
232
+ }
233
+
234
+ .mp-head {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 10px;
238
+ min-width: 0;
239
+ }
240
+ .mp-cover {
241
+ width: 48px;
242
+ height: 48px;
243
+ flex: none;
244
+ border-radius: var(--radius-sm);
245
+ overflow: hidden;
246
+ background: var(--bg-soft);
247
+ color: var(--muted);
248
+ display: grid;
249
+ place-items: center;
250
+ }
251
+ .mp-cover svg {
252
+ width: 20px;
253
+ height: 20px;
254
+ }
255
+ .mp-meta {
256
+ flex: 1;
257
+ min-width: 0;
258
+ }
259
+ .mp-name {
260
+ font-size: 14px;
261
+ font-weight: 600;
262
+ color: var(--ink);
263
+ white-space: nowrap;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ }
267
+ .mp-artist {
268
+ font-size: 12px;
269
+ color: var(--muted);
270
+ white-space: nowrap;
271
+ overflow: hidden;
272
+ text-overflow: ellipsis;
273
+ margin-top: 2px;
274
+ }
275
+
276
+ /* ---------- 进度 / 时间 ---------- */
277
+ .mp-progress {
278
+ position: relative;
279
+ height: 12px;
280
+ margin-top: 12px;
281
+ cursor: pointer;
282
+ touch-action: none;
283
+ }
284
+ .mp-progress::before {
285
+ content: "";
286
+ position: absolute;
287
+ left: 0;
288
+ right: 0;
289
+ top: 50%;
290
+ height: 4px;
291
+ transform: translateY(-50%);
292
+ border-radius: 999px;
293
+ background: var(--bg-soft);
294
+ }
295
+ .mp-progress-fill {
296
+ position: absolute;
297
+ left: 0;
298
+ top: 50%;
299
+ height: 4px;
300
+ width: 0%;
301
+ transform: translateY(-50%);
302
+ border-radius: 999px;
303
+ background: var(--accent);
304
+ pointer-events: none;
305
+ }
306
+ .mp-time {
307
+ display: flex;
308
+ gap: 4px;
309
+ justify-content: flex-end;
310
+ font-family: var(--font-mono);
311
+ font-size: 11px;
312
+ color: var(--muted);
313
+ margin-top: 2px;
314
+ }
315
+
316
+ /* ---------- 控制行 ---------- */
317
+ .mp-controls {
318
+ display: flex;
319
+ align-items: center;
320
+ gap: 6px;
321
+ margin-top: 8px;
322
+ }
323
+ .mp-btn {
324
+ display: grid;
325
+ place-items: center;
326
+ width: 30px;
327
+ height: 30px;
328
+ border: 0;
329
+ border-radius: 50%;
330
+ background: transparent;
331
+ color: var(--ink-2);
332
+ cursor: pointer;
333
+ padding: 0;
334
+ transition:
335
+ color 0.15s ease,
336
+ background 0.15s ease;
337
+ }
338
+ .mp-btn:hover {
339
+ color: var(--accent);
340
+ background: var(--bg-soft);
341
+ }
342
+ .mp-btn svg {
343
+ width: 17px;
344
+ height: 17px;
345
+ }
346
+ .mp-play {
347
+ width: 40px;
348
+ height: 40px;
349
+ background: var(--accent);
350
+ color: #fff;
351
+ margin: 0 4px;
352
+ }
353
+ .mp-play:hover {
354
+ background: var(--accent-deep);
355
+ color: #fff;
356
+ }
357
+ .mp-play svg {
358
+ width: 20px;
359
+ height: 20px;
360
+ }
361
+ .mp-vol {
362
+ display: flex;
363
+ align-items: center;
364
+ gap: 4px;
365
+ margin-left: auto;
366
+ color: var(--muted);
367
+ }
368
+ .mp-vol > svg {
369
+ width: 15px;
370
+ height: 15px;
371
+ flex: none;
372
+ }
373
+ .mp-vol-range {
374
+ width: 62px;
375
+ accent-color: var(--accent);
376
+ height: 3px;
377
+ cursor: pointer;
378
+ }
379
+
380
+ /* ---------- 歌词/列表入口 ---------- */
381
+ .mp-extra {
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ margin-top: 6px;
386
+ padding-top: 8px;
387
+ border-top: 1px solid var(--border);
388
+ }
389
+ .mp-extra-hint {
390
+ font-size: 11px;
391
+ color: var(--muted);
392
+ }
393
+ .mp-btn.active {
394
+ color: var(--accent);
395
+ background: var(--bg-soft);
396
+ }
397
+
398
+ /* ---------- 歌词抽屉 ---------- */
399
+ .mp-lrc {
400
+ margin-top: 10px;
401
+ }
402
+ .mp-lrc-scroll {
403
+ max-height: 180px;
404
+ overflow-y: auto;
405
+ padding: 4px 2px;
406
+ scrollbar-width: thin;
407
+ }
408
+ .mp-lrc-line {
409
+ padding: 5px 6px;
410
+ font-size: 12.5px;
411
+ line-height: 1.5;
412
+ color: var(--muted);
413
+ border-radius: 6px;
414
+ cursor: pointer;
415
+ transition:
416
+ color 0.2s ease,
417
+ background 0.2s ease;
418
+ }
419
+ .mp-lrc-line:hover {
420
+ background: var(--bg-soft);
421
+ }
422
+ .mp-lrc-line.active {
423
+ color: var(--accent);
424
+ font-weight: 600;
425
+ }
426
+ .mp-lrc-empty {
427
+ padding: 14px 6px;
428
+ text-align: center;
429
+ font-size: 12px;
430
+ color: var(--muted);
431
+ }
432
+
433
+ /* ---------- 播放列表抽屉 ---------- */
434
+ .mp-playlist {
435
+ margin-top: 10px;
436
+ }
437
+ .mp-playlist-scroll {
438
+ max-height: 180px;
439
+ overflow-y: auto;
440
+ padding: 2px;
441
+ scrollbar-width: thin;
442
+ }
443
+ .mp-track {
444
+ display: flex;
445
+ align-items: center;
446
+ gap: 8px;
447
+ padding: 7px 8px;
448
+ border-radius: var(--radius-sm);
449
+ cursor: pointer;
450
+ font-size: 13px;
451
+ color: var(--ink-2);
452
+ transition: background 0.15s ease;
453
+ }
454
+ .mp-track:hover {
455
+ background: var(--bg-soft);
456
+ }
457
+ .mp-track.active {
458
+ color: var(--accent);
459
+ font-weight: 600;
460
+ }
461
+ .mp-track .mp-track-artist {
462
+ margin-left: auto;
463
+ font-size: 11px;
464
+ font-weight: 400;
465
+ color: var(--muted);
466
+ white-space: nowrap;
467
+ overflow: hidden;
468
+ text-overflow: ellipsis;
469
+ max-width: 40%;
470
+ }
471
+ /* 播放中的均衡器动画 */
472
+ .mp-eq {
473
+ display: inline-flex;
474
+ align-items: flex-end;
475
+ gap: 2px;
476
+ height: 12px;
477
+ flex: none;
478
+ }
479
+ .mp-eq i {
480
+ width: 2.5px;
481
+ border-radius: 1px;
482
+ background: var(--accent);
483
+ animation: mp-eq 0.9s ease-in-out infinite;
484
+ }
485
+ .mp-eq i:nth-child(1) {
486
+ height: 60%;
487
+ }
488
+ .mp-eq i:nth-child(2) {
489
+ height: 100%;
490
+ animation-delay: 0.25s;
491
+ }
492
+ .mp-eq i:nth-child(3) {
493
+ height: 45%;
494
+ animation-delay: 0.5s;
495
+ }
496
+ @keyframes mp-eq {
497
+ 0%,
498
+ 100% {
499
+ transform: scaleY(0.5);
500
+ }
501
+ 50% {
502
+ transform: scaleY(1);
503
+ }
504
+ }
505
+ @media (prefers-reduced-motion: reduce) {
506
+ .mp-eq i {
507
+ animation: none;
508
+ }
509
+ }
510
+ </style>
511
+
512
+ <script>
513
+ // persist 常驻节点:模块单次执行 + 节点级 guard,VT 换页不重初始化
514
+ function initMusicPlayer() {
515
+ const root = document.getElementById("musicPlayer");
516
+ if (!root || root.dataset.mpInit === "1") return;
517
+ root.dataset.mpInit = "1";
518
+
519
+ let playlist: Array<{
520
+ name: string;
521
+ artist?: string;
522
+ url: string;
523
+ cover?: string;
524
+ lrc?: string;
525
+ }> = [];
526
+ try {
527
+ playlist = JSON.parse(root.dataset.playlist || "[]");
528
+ } catch {
529
+ /* ignore */
530
+ }
531
+ if (!playlist.length) return;
532
+
533
+ const labels = (() => {
534
+ try {
535
+ return JSON.parse(root.dataset.labels || "{}");
536
+ } catch {
537
+ return {};
538
+ }
539
+ })() as Record<string, string>;
540
+
541
+ const $ = <T extends HTMLElement>(sel: string) =>
542
+ root.querySelector(sel) as T | null;
543
+ const audio = $("audio");
544
+ const fab = $(".mp-fab");
545
+ const panel = $(".mp-panel");
546
+ if (!audio || !fab || !panel) return;
547
+
548
+ const nameEl = $(".mp-name")!;
549
+ const artistEl = $(".mp-artist")!;
550
+ const coverImgs = root.querySelectorAll<HTMLImageElement>(
551
+ ".mp-fab img, .mp-cover img",
552
+ );
553
+ const coverFallbacks = root.querySelectorAll<SVGElement>(
554
+ ".mp-fab svg, .mp-cover svg",
555
+ );
556
+ const progress = $(".mp-progress")!;
557
+ const fill = $(".mp-progress-fill")!;
558
+ const timeCur = $(".mp-time-cur")!;
559
+ const timeTotal = $(".mp-time-total")!;
560
+ const playBtn = $(".mp-play")!;
561
+ const icPlay = $(".ic-play")!;
562
+ const icPause = $(".ic-pause")!;
563
+ const modeBtn = $(".mp-mode")!;
564
+ const modeIcons = {
565
+ list: $(".mp-mode .ic-list")!,
566
+ one: $(".mp-mode .ic-one")!,
567
+ random: $(".mp-mode .ic-random")!,
568
+ };
569
+ const volRange = $<HTMLInputElement>(".mp-vol-range")!;
570
+ const lrcToggle = $(".mp-lrc-toggle");
571
+ const lrcDrawer = $(".mp-lrc")!;
572
+ const lrcScroll = $(".mp-lrc-scroll")!;
573
+ const listDrawer = $(".mp-playlist")!;
574
+ const listScroll = $(".mp-playlist-scroll")!;
575
+
576
+ /* ---------- 状态 ---------- */
577
+ const MODES = ["list", "one", "random"] as const;
578
+ type Mode = (typeof MODES)[number];
579
+ const initialMode = root.dataset.playMode as Mode;
580
+ let mode: Mode = MODES.includes(initialMode) ? initialMode : "list";
581
+ let idx = 0;
582
+ let playing = false;
583
+ let failStreak = 0;
584
+ let lyrics: Array<{ t: number; text: string }> = [];
585
+ let lrcActive = -1;
586
+ let userScrollUntil = 0;
587
+
588
+ /* ---------- 工具 ---------- */
589
+ const fmt = (sec: number) => {
590
+ if (!Number.isFinite(sec)) return "00:00";
591
+ const m = Math.floor(sec / 60);
592
+ const s = Math.floor(sec % 60);
593
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
594
+ };
595
+
596
+ // 音量恢复(localStorage 优先于配置初值)
597
+ let vol = Number(root.dataset.volume) || 0.7;
598
+ try {
599
+ const saved = localStorage.getItem("qy-music-vol");
600
+ if (saved !== null) vol = Math.min(1, Math.max(0, Number(saved)));
601
+ } catch {
602
+ /* ignore */
603
+ }
604
+ audio.volume = vol;
605
+ volRange.value = String(vol);
606
+
607
+ const setVolume = (v: number) => {
608
+ vol = Math.min(1, Math.max(0, v));
609
+ audio.volume = vol;
610
+ try {
611
+ localStorage.setItem("qy-music-vol", String(vol));
612
+ } catch {
613
+ /* ignore */
614
+ }
615
+ };
616
+
617
+ /* ---------- LRC ---------- */
618
+ function parseLrc(text: string) {
619
+ const out: Array<{ t: number; text: string }> = [];
620
+ const re = /\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g;
621
+ for (const raw of text.split(/\r?\n/)) {
622
+ re.lastIndex = 0;
623
+ let m: RegExpExecArray | null;
624
+ const stamps: number[] = [];
625
+ while ((m = re.exec(raw))) {
626
+ const ms = m[3]
627
+ ? m[3].length >= 3
628
+ ? Number(m[3]) / 1000
629
+ : Number(m[3]) / 100
630
+ : 0;
631
+ stamps.push(Number(m[1]) * 60 + Number(m[2]) + ms);
632
+ }
633
+ const content = raw.replace(re, "").trim();
634
+ if (!stamps.length || !content) continue;
635
+ for (const t of stamps) out.push({ t, text: content });
636
+ }
637
+ return out.sort((a, b) => a.t - b.t);
638
+ }
639
+
640
+ async function loadLyrics(track: (typeof playlist)[number]) {
641
+ lyrics = [];
642
+ lrcActive = -1;
643
+ renderLrc();
644
+ if (!track.lrc) return;
645
+ try {
646
+ const text = /^(https?:)?\/\/|^\/|\.lrc($|\?)/i.test(track.lrc)
647
+ ? await (await fetch(track.lrc)).text()
648
+ : track.lrc;
649
+ lyrics = parseLrc(text);
650
+ } catch {
651
+ lyrics = [];
652
+ }
653
+ renderLrc();
654
+ }
655
+
656
+ function renderLrc() {
657
+ lrcScroll.innerHTML = "";
658
+ if (!lyrics.length) {
659
+ const empty = document.createElement("div");
660
+ empty.className = "mp-lrc-empty";
661
+ empty.textContent = labels.noLyrics || "…";
662
+ lrcScroll.appendChild(empty);
663
+ return;
664
+ }
665
+ for (const line of lyrics) {
666
+ const div = document.createElement("div");
667
+ div.className = "mp-lrc-line";
668
+ div.dataset.time = String(line.t);
669
+ div.textContent = line.text;
670
+ lrcScroll.appendChild(div);
671
+ }
672
+ }
673
+
674
+ function syncLrc(cur: number) {
675
+ if (!lyrics.length) return;
676
+ let i = lyrics.length - 1;
677
+ while (i >= 0 && lyrics[i]!.t > cur) i--;
678
+ if (i === lrcActive) return;
679
+ lrcActive = i;
680
+ const lines = lrcScroll.children;
681
+ for (let k = 0; k < lines.length; k++) {
682
+ lines[k]?.classList.toggle("active", k === i);
683
+ }
684
+ const active = lines[i] as HTMLElement | undefined;
685
+ // 用户手动滚动 3s 内不回吸
686
+ if (active && Date.now() > userScrollUntil) {
687
+ lrcScroll.scrollTo({
688
+ top:
689
+ active.offsetTop - lrcScroll.clientHeight / 2 + active.offsetHeight / 2,
690
+ behavior: "smooth",
691
+ });
692
+ }
693
+ }
694
+
695
+ /* ---------- 播放列表 UI ---------- */
696
+ function renderPlaylist() {
697
+ listScroll.innerHTML = "";
698
+ playlist.forEach((tr, i) => {
699
+ const row = document.createElement("div");
700
+ row.className = "mp-track" + (i === idx ? " active" : "");
701
+ row.dataset.index = String(i);
702
+ if (i === idx && playing) {
703
+ const eq = document.createElement("span");
704
+ eq.className = "mp-eq";
705
+ eq.innerHTML = "<i></i><i></i><i></i>";
706
+ row.appendChild(eq);
707
+ }
708
+ const name = document.createElement("span");
709
+ name.textContent = tr.name;
710
+ row.appendChild(name);
711
+ if (tr.artist) {
712
+ const art = document.createElement("span");
713
+ art.className = "mp-track-artist";
714
+ art.textContent = tr.artist;
715
+ row.appendChild(art);
716
+ }
717
+ listScroll.appendChild(row);
718
+ });
719
+ }
720
+
721
+ /* ---------- 核心播放 ---------- */
722
+ function syncModeUI() {
723
+ modeBtn.dataset.mode = mode;
724
+ for (const k of MODES) {
725
+ modeIcons[k].toggleAttribute("hidden", k !== mode);
726
+ }
727
+ const label =
728
+ mode === "list" ? labels.modeList : mode === "one" ? labels.modeOne : labels.modeRandom;
729
+ modeBtn.title = label;
730
+ modeBtn.setAttribute("aria-label", label);
731
+ }
732
+
733
+ function syncPlayUI() {
734
+ icPlay.toggleAttribute("hidden", playing);
735
+ icPause.toggleAttribute("hidden", !playing);
736
+ playBtn.setAttribute("aria-label", playing ? labels.pause : labels.play);
737
+ root.classList.toggle("playing", playing);
738
+ renderPlaylist();
739
+ }
740
+
741
+ function loadTrack(i: number, autoplay: boolean) {
742
+ idx = ((i % playlist.length) + playlist.length) % playlist.length;
743
+ const tr = playlist[idx]!;
744
+ // 注意:不在这里重置 failStreak(error → playNext → loadTrack 会清零,
745
+ // 防死循环失效);只在播放成功回调里清零
746
+ audio.src = tr.url;
747
+ nameEl.textContent = tr.name;
748
+ artistEl.textContent = tr.artist || "";
749
+ // 封面:有封面显图藏图标,反之亦然
750
+ coverImgs.forEach((img) => {
751
+ if (tr.cover) img.src = tr.cover;
752
+ img.toggleAttribute("hidden", !tr.cover);
753
+ });
754
+ coverFallbacks.forEach((svg) => svg.toggleAttribute("hidden", !!tr.cover));
755
+ fill.style.width = "0%";
756
+ timeCur.textContent = "00:00";
757
+ timeTotal.textContent = "00:00";
758
+ progress.setAttribute("aria-valuenow", "0");
759
+ lrcActive = -1;
760
+ loadLyrics(tr);
761
+ renderPlaylist();
762
+ if (autoplay) {
763
+ const v = idx; // 竞态防护:捕获本次序号
764
+ audio
765
+ .play()
766
+ .then(() => {
767
+ if (v === idx) failStreak = 0;
768
+ })
769
+ .catch((err: unknown) => {
770
+ if (v !== idx) return; // 过期 play 忽略(快速切歌)
771
+ if ((err as DOMException)?.name === "AbortError") return;
772
+ if ((err as DOMException)?.name === "NotAllowedError") {
773
+ console.warn("[music] 自动播放被浏览器拦截,请手动点击播放");
774
+ return;
775
+ }
776
+ console.warn("[music] 播放失败:", tr.url);
777
+ playNext(true);
778
+ });
779
+ }
780
+ }
781
+
782
+ function playNext(auto: boolean) {
783
+ if (auto && mode === "one") {
784
+ audio.currentTime = 0;
785
+ audio.play().catch(() => {});
786
+ return;
787
+ }
788
+ const next =
789
+ mode === "random" && playlist.length > 1
790
+ ? (idx + 1 + Math.floor(Math.random() * (playlist.length - 1))) %
791
+ playlist.length
792
+ : idx + 1;
793
+ loadTrack(next, true);
794
+ }
795
+
796
+ function playPrev() {
797
+ const prev =
798
+ mode === "random" && playlist.length > 1
799
+ ? Math.floor(Math.random() * playlist.length)
800
+ : idx - 1;
801
+ loadTrack(prev, true);
802
+ }
803
+
804
+ function togglePlay() {
805
+ if (playing) {
806
+ audio.pause();
807
+ } else if (!audio.src) {
808
+ loadTrack(idx, true);
809
+ } else {
810
+ audio.play().catch(() => {});
811
+ }
812
+ }
813
+
814
+ /* ---------- 事件绑定 ---------- */
815
+ fab.addEventListener("click", () => {
816
+ root.classList.add("open");
817
+ fab.setAttribute("aria-expanded", "true");
818
+ });
819
+ $(".mp-close")?.addEventListener("click", () => {
820
+ root.classList.remove("open");
821
+ fab.setAttribute("aria-expanded", "false");
822
+ });
823
+ playBtn.addEventListener("click", togglePlay);
824
+ $(".mp-prev")?.addEventListener("click", playPrev);
825
+ $(".mp-next")?.addEventListener("click", () => playNext(false));
826
+ modeBtn.addEventListener("click", () => {
827
+ mode = MODES[(MODES.indexOf(mode) + 1) % MODES.length]!;
828
+ syncModeUI();
829
+ });
830
+ volRange.addEventListener("input", () => setVolume(Number(volRange.value)));
831
+
832
+ // 歌词 / 列表抽屉互斥展开(点谁开谁,再点收起,另一个自动关闭)
833
+ const toggleDrawer = (which: "lrc" | "list") => {
834
+ const wantOpen = which === "lrc" ? lrcDrawer.hidden : listDrawer.hidden;
835
+ lrcDrawer.hidden = !(which === "lrc" && wantOpen);
836
+ listDrawer.hidden = !(which === "list" && wantOpen);
837
+ lrcToggle?.classList.toggle("active", which === "lrc" && wantOpen);
838
+ if (which === "lrc" && wantOpen) renderLrc();
839
+ };
840
+ lrcToggle?.addEventListener("click", () => toggleDrawer("lrc"));
841
+ // 列表入口:面板头部曲名区点击展开列表
842
+ $(".mp-meta")?.addEventListener("click", () => toggleDrawer("list"));
843
+
844
+ // 进度条点击 / 键盘 seek
845
+ const seekFromEvent = (e: MouseEvent) => {
846
+ const rect = progress.getBoundingClientRect();
847
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
848
+ if (Number.isFinite(audio.duration)) {
849
+ audio.currentTime = ratio * audio.duration;
850
+ }
851
+ };
852
+ progress.addEventListener("click", seekFromEvent);
853
+ progress.addEventListener("keydown", (e) => {
854
+ if (!Number.isFinite(audio.duration)) return;
855
+ if (e.key === "ArrowRight") {
856
+ e.preventDefault();
857
+ audio.currentTime = Math.min(audio.duration, audio.currentTime + 5);
858
+ }
859
+ if (e.key === "ArrowLeft") {
860
+ e.preventDefault();
861
+ audio.currentTime = Math.max(0, audio.currentTime - 5);
862
+ }
863
+ });
864
+
865
+ // 歌词点击跳转;用户滚动保护
866
+ lrcScroll.addEventListener("click", (e) => {
867
+ const target = (e.target as HTMLElement).closest(".mp-lrc-line");
868
+ if (!target) return;
869
+ const t = Number(target.dataset.time);
870
+ if (Number.isFinite(t)) audio.currentTime = t;
871
+ });
872
+ lrcScroll.addEventListener("wheel", () => (userScrollUntil = Date.now() + 3000), {
873
+ passive: true,
874
+ });
875
+ lrcScroll.addEventListener(
876
+ "touchstart",
877
+ () => (userScrollUntil = Date.now() + 3000),
878
+ { passive: true },
879
+ );
880
+
881
+ // 播放列表点击切歌
882
+ listScroll.addEventListener("click", (e) => {
883
+ const row = (e.target as HTMLElement).closest(".mp-track") as HTMLElement | null;
884
+ if (!row) return;
885
+ const i = Number(row.dataset.index);
886
+ if (i === idx && playing) {
887
+ audio.pause();
888
+ } else {
889
+ loadTrack(i, true);
890
+ }
891
+ });
892
+
893
+ // audio 生命周期
894
+ audio.addEventListener("play", () => {
895
+ playing = true;
896
+ syncPlayUI();
897
+ });
898
+ audio.addEventListener("pause", () => {
899
+ playing = false;
900
+ syncPlayUI();
901
+ });
902
+ audio.addEventListener("loadedmetadata", () => {
903
+ timeTotal.textContent = fmt(audio.duration);
904
+ });
905
+ audio.addEventListener("timeupdate", () => {
906
+ const d = audio.duration;
907
+ if (Number.isFinite(d) && d > 0) {
908
+ const pct = (audio.currentTime / d) * 100;
909
+ fill.style.width = `${pct}%`;
910
+ progress.setAttribute("aria-valuenow", String(Math.round(pct)));
911
+ }
912
+ timeCur.textContent = fmt(audio.currentTime);
913
+ syncLrc(audio.currentTime);
914
+ });
915
+ audio.addEventListener("ended", () => playNext(true));
916
+ audio.addEventListener("error", () => {
917
+ // 连续失败超过曲目数则停止,避免坏列表死循环
918
+ if (++failStreak >= playlist.length) {
919
+ console.warn("[music] 连续多曲加载失败,已停止自动切歌");
920
+ playing = false;
921
+ syncPlayUI();
922
+ return;
923
+ }
924
+ if (audio.src) playNext(true);
925
+ });
926
+
927
+ /* ---------- 初始渲染 ---------- */
928
+ syncModeUI();
929
+ syncPlayUI();
930
+ renderLrc();
931
+ renderPlaylist();
932
+ loadTrack(0, false);
933
+ }
934
+
935
+ if (document.readyState === "loading") {
936
+ document.addEventListener("DOMContentLoaded", initMusicPlayer, { once: true });
937
+ } else {
938
+ initMusicPlayer();
939
+ }
940
+ </script>