背景

我的博客,原先通过hexo butterfly官方文档配置了全局吸底播放器,用的是网易云的API,挂了我的歌单,但是有几个痛点无法解决:

  1. VIP音乐无法播放
  2. 音质受到限制
  3. 播放器的播放完全受到网易云那边的限制,一旦API失效就无法使用

为此,我决心改造播放器,让其能够播放本地音乐歌单,这样子,我只要在本地存音乐就可以无限制播放,音质也有我自己决定。

原先的博客-VIP音乐无法播放


思路

  1. 需要将自己喜欢的音乐从各大音乐平台下载下来,通过格式转码为通用格式放置在本地(后期可以放在云上),
  2. 写一个脚本scripts\extract-metadata.js ,读取每首歌曲文件中内嵌的封面
  3. 再写一个脚本music-generator.js,扫描音乐文件,提取音乐文件中的歌曲名,作者,收集封面和歌词信息,生成一个总歌单文件 ’music-data.json‘,供前端播放器读取。
  4. 写一个source\js\music-player.js 脚本,实例化APlayer,渲染出带有封面、歌词、进度条的底部吸附播放器。

在此之前,我们必须先将之前配置好的音乐播放器删掉。

删除掉之前配置好的音乐播放器

  1. 在主题配置文件中myBlog\themes\hexo-theme-butterfly-dev\_config.yml删除掉音乐播放器APlaer配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    # APlayer 音乐配置
    aplayer:
    enable: true
    # 关键点:把 meting 设为 false,不再使用在线歌单
    meting: false
    # 如果你之前是在这里填了网易云歌单 ID,把它注释掉或删掉
    # meting_api:
    # 现在我们把本地文件写死在 audio 列表里
    audio:
    - name: '歌曲名1'
    artist: '歌手1'
    url: '/music/歌曲文件名1.mp3' # 注意这里要和你的文件名完全对应
    cover: '/img/cover1.jpg' # 封面图,如果没有可以删掉这行
    - name: '歌曲名2'
    artist: '歌手2'
    url: '/music/歌曲文件名2.mp3'
    - name: '歌曲名3'
    artist: '歌手3'
    url: '/music/歌曲文件名3.mp3'
  2. 在主题配置文件myBlog\themes\hexo-theme-butterfly-dev\_config.yml中删除掉inject注入的js配置:

    把这一块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # Inject
    # Insert the code to head (before '</head>' tag) and the bottom (before '</body>' tag)
    # 插入代码到头部 </head> 之前 和 底部 </body> 之前
    inject:
    head:
    - <link rel="stylesheet" href="/css/style.css"> #注入了一个样式,样式文件在"/source/css/style.css"
    - '<style type="text/css">#toggle-sidebar {bottom: 80px}</style>' #为音乐播放器留白
    bottom:
    # - <script src="xxxx"></script> #下面代码是注入了一个音乐播放器
    - <div class="aplayer no-destroy" data-id="7012604021" data-server="netease" data-type="playlist" data-fixed="true" data-mini="true" data-listFolded="false" data-order="random" data-preload="none" data-autoplay="true" muted></div>
    #7973959940,7012604021,tencent,netease
    - <script src="/js/snow.js"></script> #插入雪花特效

    改为:

    1
    2
    3
    4
    5
    6
    7
    inject:
    head:
    - <link rel="stylesheet" href="/css/style.css">
    - '<style type="text/css">#toggle-sidebar {bottom: 80px}</style>'
    bottom:
    # 把原来那行 aplayer 的 div 删掉,只保留下面这行
    - <script src="/js/snow.js"></script>
  3. 在主题配置文件myBlog\themes\hexo-theme-butterfly-dev\_config.yml中关掉’aplayerInject‘:

    1
    2
    3
    aplayerInject:
    enable: true #改成false
    per_page: true #false
  4. 在主题配置文件中myBlog\themes\hexo-theme-butterfly-dev\_config.yml降低pangu版本(旧版本更加兼容):

    1
    2
    # 把原来的 pangu: https://cdn.jsdelivr.net/npm/pangu/dist/browser/pangu.min.js 替换成下面这行:
    pangu: https://cdn.jsdelivr.net/npm/pangu@4.0.7/dist/browser/pangu.min.js
  5. 在主题配置文件myBlog\themes\hexo-theme-butterfly-dev\_config.yml中如果有这一行,则注销掉:

    1
    2
    3
    4
    # aplayer
    aplayer_css: https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.css
    aplayer_js: https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.js
    # meting_js: https://cdn.jsdelivr.net/gh/metowolf/MetingJS@1.2/dist/Meting.min.js <-- 前面加 #
  6. 在博客站点的配置文件myBlog\_config.yml中注释掉aplayer :

    1
    2
    3
    4
    5
    # aplayer: 
    # script_dir: js
    # style_dir: css
    # meting: false
    # asset_inject: false
  7. 确定主题文件的myBlog\themes\hexo-theme-butterfly-dev\layout\includes\layout.pug,如果有有关metting的内容则要删掉:

    1
    2
    3
    4
    5
    6
    head
    include ./head.pug
    link(rel="stylesheet" href="APlayer.min.css") // <-- 删掉这行
    div(id="aplayer") // <-- 删掉这行
    script(src="https://cdn.jsdelivr.net/npm/meting@2/dist/Meting.min.js" async) // <-- 删掉这行
    body
  8. 删除掉一些依赖插件:

    在myBlog文件夹中git bash here,在命令行运行:

    1
    2
    npm uninstall hexo-tag-aplayer
    npm uninstall hexo-aplayer

    运行下面代码确定插件已被成功删除:

    1
    npm ls hexo-tag-aplayer
  9. 确定主题文件myBlog\source\music\index.md中是否有引用APlayer,如果有则删掉:

    1
    3 | {% meting "8232291301" "tencent" "playlist" %} #删掉这个音乐标签

这样就将之前引入的APlayer彻底删干净了,接下来可以引入我们自己的本地播放APlayer了。

引入本地音乐的全局吸底播放器

  1. 创建脚本:myblog\scripts\music-generator.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    const fs = require('fs');
    const path = require('path');

    hexo.extend.filter.register('after_generate', function () {
    const rootMusicDir = path.join(hexo.base_dir, 'music');
    const mp3Dir = path.join(rootMusicDir, 'mp3');
    const coverDir = path.join(rootMusicDir, 'cover');
    const lrcDir = path.join(rootMusicDir, 'lrc');

    const publicMusicDir = path.join(hexo.public_dir, 'music');
    const publicMp3Dir = path.join(publicMusicDir, 'mp3');
    const publicCoverDir = path.join(publicMusicDir, 'cover');
    const publicLrcDir = path.join(publicMusicDir, 'lrc');

    if (!fs.existsSync(mp3Dir)) {
    console.log('\n[Music Generator] 警告:没有找到 music/mp3 文件夹!');
    return;
    }

    [publicMusicDir, publicMp3Dir, publicCoverDir, publicLrcDir].forEach(dir => {
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    });

    const files = fs.readdirSync(mp3Dir);
    const audioList = [];

    files.forEach(file => {
    if (path.extname(file).toLowerCase() !== '.mp3') return;

    const basename = path.basename(file, '.mp3');
    let name = basename;
    let artist = '未知歌手';
    if (basename.includes(' - ')) {
    const parts = basename.split(' - ');
    artist = parts[0].trim();
    name = parts.slice(1).join(' - ').trim();
    }

    // 封面(支持 jpg / jpeg / png / webp)
    let coverPath = '';
    for (const ext of ['.jpg', '.jpeg', '.png', '.webp']) {
    const candidate = path.join(coverDir, basename + ext);
    if (fs.existsSync(candidate)) {
    fs.copyFileSync(candidate, path.join(publicCoverDir, basename + ext));
    coverPath = '/music/cover/' + encodeURIComponent(basename) + ext;
    break;
    }
    }

    // 歌词
    let lrcPath = '';
    const lrcFile = path.join(lrcDir, basename + '.lrc');
    if (fs.existsSync(lrcFile)) {
    fs.copyFileSync(lrcFile, path.join(publicLrcDir, basename + '.lrc'));
    lrcPath = '/music/lrc/' + encodeURIComponent(basename) + '.lrc';
    }

    fs.copyFileSync(path.join(mp3Dir, file), path.join(publicMp3Dir, file));

    audioList.push({
    name: name,
    artist: artist,
    url: '/music/mp3/' + encodeURIComponent(file),
    cover: coverPath,
    lrc: lrcPath
    });
    });

    fs.writeFileSync(
    path.join(publicMusicDir, 'music-data.json'),
    JSON.stringify(audioList, null, 2),
    'utf-8'
    );

    const withCover = audioList.filter(s => s.cover).length;
    const withLrc = audioList.filter(s => s.lrc).length;
    console.log(`\n[Music Generator] 共 ${audioList.length} 首歌,其中 ${withCover} 首有封面,${withLrc} 首有歌词。`);
    });

  1. 创建myblog\source\js\music-player.js:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    function initAPlayer() {
    // 核心防重启逻辑:只要 window.ap 存在,绝对不重新创建
    if (window.ap) return;

    var container = document.getElementById('aplayer-global');
    if (!container) {
    container = document.createElement('div');
    container.id = 'aplayer-global';
    container.className = 'aplayer no-destroy';
    document.body.appendChild(container);
    }

    fetch('/music/music-data.json')
    .then(response => response.json())
    .then(audioList => {
    window.ap = new APlayer({
    container: container,
    fixed: true,
    mini: true,
    autoplay: false,
    theme: '#FADFA3',
    loop: 'all',
    order: 'random',
    preload: 'metadata',
    volume: 0.7,
    audio: audioList
    });
    console.log("播放器加载成功,共" + audioList.length + "首歌");
    })
    .catch(error => {
    console.error('加载音乐列表失败:', error);
    });
    }

    // 首次加载
    document.addEventListener('DOMContentLoaded', function () {
    initAPlayer();
    });

    // Pjax 切换页面时
    document.addEventListener('pjax:complete', function () {
    // 只要 window.ap 还在,音乐就不会断,什么都不做!
    // 只有当容器真的被误删时,才补一个容器回去,但不重新创建实例。
    if (!window.ap) {
    initAPlayer();
    } else if (!document.getElementById('aplayer-global')) {
    // 极端情况:Pjax 把容器弄丢了,我们用 JS 把它塞回 body 末尾
    var container = document.createElement('div');
    container.id = 'aplayer-global';
    container.className = 'aplayer no-destroy';
    document.body.appendChild(container);
    // 此时 window.ap 还在播放,我们不再初始化,让容器跟实体重新关联即可
    }
    });

  1. 在butterfly主题inject该播放器,同时加上缺失的 CSS,方法是在主题配置文件中修改inject:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    # Inject
    # Insert the code to head (before '</head>' tag) and the bottom (before '</body>' tag)
    # 插入代码到头部 </head> 之前 和 底部 </body> 之前
    inject:
    head:
    #注入了一个样式,样式文件在"/source/css/style.css"
    - <link rel="stylesheet" href="/css/style.css">
    #为音乐播放器留白
    - '<style type="text/css">#toggle-sidebar {bottom: 80px}</style>'
    # 自制播放器,新增这一行:引入 APlayer 的 CSS
    - '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.css">'
    bottom:
    # - <script src="xxxx"></script> #下面代码是注入了一个音乐播放器
    #- <div class="aplayer no-destroy" data-id="7012604021" data-server="netease" data-type="playlist" data-fixed="true" data-mini="true" data-listFolded="false" data-order="random" data-preload="none" data-autoplay="true" muted></div>
    #7973959940,7012604021,tencent,netease
    - <script src="/js/snow.js"></script> #插入雪花特效
    #########先不用网易云API,设置一个自定义本地曲库播放器
    - '<div id="aplayer-global" class="aplayer no-destroy"></div>' # 必须先引入 APlayer 的库
    - '<script src="https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.js"></script>' # 然后引入我们自定义的初始化脚本
    - <script src="/js/music-player.js"></script>

  1. 创建脚本:scripts\extract-metadata.js(之后提取每首mp3的封面,歌词(如果有的话))

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    const fs = require('fs');
    const path = require('path');

    // 路径配置
    const baseDir = path.join(__dirname, '..');
    const mp3Dir = path.join(baseDir, 'music', 'mp3');
    const coverDir = path.join(baseDir, 'music', 'cover');
    const lrcDir = path.join(baseDir, 'music', 'lrc');

    if (!fs.existsSync(coverDir)) fs.mkdirSync(coverDir, { recursive: true });
    if (!fs.existsSync(lrcDir)) fs.mkdirSync(lrcDir, { recursive: true });

    async function extractAll() {
    // 动态导入 ESM 模块
    const mm = await import('music-metadata');
    // 兼容不同的导出方式
    const parseFile = mm.parseFile || (mm.default && mm.default.parseFile);
    if (!parseFile) {
    console.error('无法找到 parseFile 方法,请检查 music-metadata 版本');
    return;
    }

    if (!fs.existsSync(mp3Dir)) {
    console.log("⚠️ 找不到 music/mp3 文件夹,请检查路径!");
    return;
    }

    const files = fs.readdirSync(mp3Dir).filter(f => f.toLowerCase().endsWith('.mp3'));
    console.log(`\n开始扫描 ${files.length} 首歌曲...`);

    let coverCount = 0;
    let lrcCount = 0;

    for (const file of files) {
    const filePath = path.join(mp3Dir, file);
    const basename = path.basename(file, '.mp3');

    try {
    const metadata = await parseFile(filePath);
    const common = metadata.common;

    // 提取封面
    if (common.picture && common.picture.length > 0) {
    const pic = common.picture[0];
    const ext = pic.format.includes('png') ? 'png' : 'jpg';
    const coverPath = path.join(coverDir, `${basename}.${ext}`);
    if (!fs.existsSync(coverPath)) {
    fs.writeFileSync(coverPath, pic.data);
    coverCount++;
    console.log(` 🖼️ 提取封面: ${basename}.${ext}`);
    }
    }

    // 提取歌词
    if (common.lyrics && common.lyrics.length > 0) {
    const lyricObj = common.lyrics.find(l => l.text && l.text.trim() !== '');
    if (lyricObj && lyricObj.text) {
    const lrcPath = path.join(lrcDir, `${basename}.lrc`);
    if (!fs.existsSync(lrcPath)) {
    fs.writeFileSync(lrcPath, lyricObj.text, 'utf-8');
    lrcCount++;
    console.log(` 📝 提取歌词: ${basename}.lrc`);
    }
    }
    }
    } catch (err) {
    console.error(` ❌ 处理失败: ${file} - ${err.message}`);
    }
    }

    console.log(`\n✅ 提取完成!共提取了 ${coverCount} 张封面,${lrcCount} 份歌词。`);
    console.log("现在请运行 hexo clean && hexo g && hexo s 来更新播放器。\n");
    }

    // 只在手动运行 node 时执行,Hexo 加载时自动跳过
    if (typeof hexo === 'undefined' && require.main === module) {
    extractAll();
    }

用法:

  • [ ] ​ 首先需要先在终端安装插件:

    1
    npm install music-metadata --save
  • [ ] ​ 在之后有歌曲之后可以在终端运行脚本提取歌曲信息:

    1
    node scripts/extract-metadata.js

添加歌曲

  1. 建立文件夹myBlog\music\mp3
  2. 将你喜欢的歌从各大音乐软件(网易云,QQ音乐……)下载下来,那之后用UnlockMusic解析,将你喜欢的歌曲放入文件夹中

运行

在终端运行以下命令:

1
2
3
4
node scripts/extract-metadata.js
hexo clean
hexo g
hexo s

后记

目前这样做完之后有两个问题需要被解决:

  1. MP3文件中没有歌词,这样设置的音乐播放器无法显示歌词(可能可以用MusicTag软件将歌词嵌入mp3文件中再运行extract-metadata.js
  2. 会失去原来的音乐页面(虽然本来也名存实亡)

音乐界面恢复

在myBlog\source\music\index.md里面添加以下html代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
---
title: 音乐
date: 2026-10-02 00:00:00
type: music
comments: false
---

<style>
#music-page-container { max-width: 900px; margin: 0 auto; padding: 20px; }
.music-player-card {
display: flex; gap: 30px; padding: 30px;
background: rgba(128,128,128,0.08);
border-radius: 16px; margin-bottom: 30px; align-items: center;
position: relative;
}
.music-player-cover {
width: 200px; height: 200px; border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 24px rgba(0,0,0,0.2); flex-shrink: 0;
background: rgba(128,128,128,0.2);
display: flex; align-items: center; justify-content: center;
position: relative;
}
.music-player-cover img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
.music-player-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.music-player-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.music-player-title { font-size: 22px; font-weight: 700; margin: 0; }
.music-player-artist { font-size: 15px; color: #888; }
.music-header-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.music-search-box {
display: flex; align-items: center;
background: rgba(128,128,128,0.12);
border-radius: 10px; padding: 0 10px; transition: all 0.2s;
}
.music-search-box:focus-within { background: rgba(73,177,245,0.15); }
.music-search-box svg { width: 15px; height: 15px; fill: #888; flex-shrink: 0; }
.music-search-input {
border: none; background: transparent; outline: none;
padding: 7px 8px; font-size: 13px; color: inherit; width: 130px;
}
.music-search-input::placeholder { color: #999; }
.music-count-badge {
font-size: 12px; color: #888; background: rgba(128,128,128,0.15);
padding: 4px 10px; border-radius: 12px; white-space: nowrap;
}
.music-progress-wrapper { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #888; }
.music-progress-bar {
flex: 1; height: 8px; border-radius: 4px; background: rgba(128,128,128,0.3);
position: relative; cursor: pointer;
}
.music-progress-inner {
height: 100%; border-radius: 4px;
background: linear-gradient(90deg, #49b1f5, #00c4b6);
width: 0%; position: relative; pointer-events: none;
}
.music-progress-inner::after {
content: ''; position: absolute; right: -7px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 0 5px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s;
}
.music-progress-bar:hover .music-progress-inner::after { opacity: 1; }
.music-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.music-control-btn {
background: none; border: none; color: #49b1f5;
cursor: pointer; padding: 8px; border-radius: 50%; transition: all 0.2s;
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px;
}
.music-control-btn svg { width: 22px; height: 22px; fill: currentColor; display: block; }
.music-control-btn:hover { background: rgba(73,177,245,0.15); }
.music-control-btn.main-btn {
width: 52px; height: 52px;
background: linear-gradient(135deg, #49b1f5, #00c4b6);
color: #fff; box-shadow: 0 4px 12px rgba(73,177,245,0.4);
}
.music-control-btn.main-btn svg { width: 26px; height: 26px; }
.music-control-btn.main-btn:hover { transform: scale(1.05); }
.music-volume-wrapper { display: flex; align-items: center; gap: 8px; margin-left: 6px; }
.music-volume-bar {
width: 90px; height: 6px; border-radius: 3px; background: rgba(128,128,128,0.3);
position: relative; cursor: pointer;
}
.music-volume-inner {
height: 100%; border-radius: 3px;
background: linear-gradient(90deg, #49b1f5, #00c4b6);
width: 70%; position: relative; pointer-events: none;
}
.music-volume-inner::after {
content: ''; position: absolute; right: -6px; top: 50%; transform: translateY(-50%);
width: 12px; height: 12px; border-radius: 50%; background: #fff;
box-shadow: 0 0 4px rgba(0,0,0,0.4);
}
.music-list { display: flex; flex-direction: column; gap: 12px; }
.music-item {
display: flex; align-items: center; padding: 12px 16px; border-radius: 10px;
cursor: pointer; transition: all 0.25s ease; background: rgba(128,128,128,0.06);
}
.music-item:hover { background: rgba(73,177,245,0.15); transform: translateX(5px); }
.music-item.playing { background: rgba(73,177,245,0.25); box-shadow: 0 0 10px rgba(73,177,245,0.3); }
.music-index {
width: 30px; flex-shrink: 0; text-align: center;
font-size: 13px; color: #888; font-variant-numeric: tabular-nums;
font-weight: 500;
}
.music-item.playing .music-index { color: #49b1f5; font-weight: 700; }
.music-cover {
width: 50px; height: 50px; border-radius: 8px; overflow: hidden; flex-shrink: 0;
margin-right: 15px; background: rgba(128,128,128,0.15);
display: flex; align-items: center; justify-content: center;
position: relative;
}
.music-cover img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
.music-cover-placeholder { font-size: 22px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.music-info { flex: 1; min-width: 0; }
.music-name { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; }
.music-artist { font-size: 13px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.music-play-icon { font-size: 18px; color: #49b1f5; margin-left: 10px; opacity: 0; transition: opacity 0.25s; }
.music-item:hover .music-play-icon, .music-item.playing .music-play-icon { opacity: 1; }
.music-pagination {
display: flex; justify-content: center; align-items: center; gap: 15px;
margin-top: 25px; padding: 15px 0; color: #888; font-size: 14px;
}
.music-pagination button {
background: rgba(128,128,128,0.1); border: none; color: #49b1f5;
padding: 6px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s;
}
.music-pagination button:hover:not(:disabled) { background: rgba(73,177,245,0.2); }
.music-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.music-empty {
text-align: center; padding: 40px 20px; color: #888; font-size: 14px;
}
@media (max-width: 768px) {
.music-player-card { flex-direction: column; text-align: center; }
.music-player-header { flex-direction: column; gap: 10px; align-items: center; }
.music-header-right { width: 100%; justify-content: center; }
.music-search-input { width: 100px; }
.music-player-cover { width: 150px; height: 150px; }
.music-volume-bar { width: 60px; }
.music-index { width: 22px; font-size: 12px; }
}
</style>

<div id="music-page-container">
<div class="music-player-card">
<div class="music-player-cover" id="mp-cover"></div>
<div class="music-player-info">
<div class="music-player-header">
<div>
<div class="music-player-title" id="mp-title">未在播放</div>
<div class="music-player-artist" id="mp-artist">点击下方歌曲开始播放</div>
</div>
<div class="music-header-right">
<div class="music-search-box">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="mp-search" class="music-search-input" placeholder="歌名/歌手/序号">
</div>
<div class="music-count-badge" id="mp-count">共 0 首</div>
</div>
</div>
<div class="music-progress-wrapper">
<span id="mp-current">00:00</span>
<div class="music-progress-bar" id="mp-progress-bar">
<div class="music-progress-inner" id="mp-progress-inner"></div>
</div>
<span id="mp-duration">00:00</span>
</div>
<div class="music-controls">
<button class="music-control-btn" id="mp-prev" title="上一首"></button>
<button class="music-control-btn main-btn" id="mp-toggle" title="播放/暂停"></button>
<button class="music-control-btn" id="mp-next" title="下一首"></button>
<button class="music-control-btn" id="mp-mode" title="播放模式"></button>
<div class="music-volume-wrapper">
<button class="music-control-btn" id="mp-mute" title="静音"></button>
<div class="music-volume-bar" id="mp-volume-bar">
<div class="music-volume-inner" id="mp-volume-inner"></div>
</div>
</div>
</div>
</div>
</div>
<div class="music-list" id="music-list"></div>
<div class="music-pagination" id="music-pagination" style="display: none;">
<button id="mp-page-prev">上一页</button>
<span id="mp-page-info">第 1 / 1 页</span>
<button id="mp-page-next">下一页</button>
</div>
</div>

<script>
(function () {
var eventsBound = false;
var isSeeking = false;
var currentPage = 1;
var pageSize = 10;
var allSongs = [];
var filteredSongs = [];

var ICONS = {
play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>',
prev: '<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>',
next: '<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>',
list: '<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>',
// 恢复交叉箭头图标
random: '<svg viewBox="0 0 24 24"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>',
single: '<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>',
volumeHigh: '<svg viewBox="0 0 24 24"><path 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.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>',
volumeLow: '<svg viewBox="0 0 24 24"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>',
volumeMute: '<svg viewBox="0 0 24 24"><path 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.71zM4.27 3L3 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.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>'
};

var modes = ['list', 'random', 'single'];
var modeIcons = { list: ICONS.list, random: ICONS.random, single: ICONS.single };
var modeTitles = { list: '列表循环', random: '随机播放', single: '单曲循环' };
var currentModeIndex = 0;
var lastVolume = 0.7;

function formatTime(s) {
if (!s || isNaN(s)) return '00:00';
var m = Math.floor(s / 60), sec = Math.floor(s % 60);
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
function setBtnIcon(btn, svg) { if (btn) btn.innerHTML = svg; }

function fillCoverContainer(container, song) {
container.innerHTML = '';
if (song && song.cover) {
var img = document.createElement('img');
img.src = song.cover;
img.alt = song.name || '';
img.loading = 'eager';
img.onerror = function () {
this.style.display = 'none';
var ph = document.createElement('span');
ph.className = 'music-cover-placeholder';
ph.textContent = '🎵';
if (this.parentNode) this.parentNode.appendChild(ph);
};
container.appendChild(img);
} else {
var ph2 = document.createElement('span');
ph2.className = 'music-cover-placeholder';
ph2.textContent = '🎵';
container.appendChild(ph2);
}
}

function bindDragBar(barEl, onDrag, onEnd) {
if (!barEl) return;
var dragging = false;
function getClientX(e) { return (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX; }
function calcPct(e) {
var rect = barEl.getBoundingClientRect();
var x = getClientX(e) - rect.left;
return Math.max(0, Math.min(1, x / rect.width));
}
function onDown(e) { dragging = true; onDrag(calcPct(e)); e.preventDefault(); }
function onMove(e) { if (!dragging) return; onDrag(calcPct(e)); e.preventDefault(); }
function onUp(e) { if (!dragging) return; dragging = false; onEnd && onEnd(calcPct(e)); }
barEl.addEventListener('mousedown', onDown);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
barEl.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
}

// ---- 过滤逻辑(新增序号匹配)----
function applyFilter(query) {
if (!query) {
filteredSongs = allSongs.map(function (s, i) { return { song: s, realIndex: i }; });
return;
}
var q = query.toLowerCase();
filteredSongs = [];
allSongs.forEach(function (s, i) {
var nameMatch = s.name && s.name.toLowerCase().indexOf(q) !== -1;
var artistMatch = s.artist && s.artist.toLowerCase().indexOf(q) !== -1;
// 新增:按序号匹配(用户输入的字符串和 realIndex+1 完全相等 或者 以它开头)
var indexStr = String(i + 1);
var indexMatch = indexStr === q;
if (nameMatch || artistMatch || indexMatch) {
filteredSongs.push({ song: s, realIndex: i });
}
});
}

function updateNowPlaying() {
if (!window.ap || !window.ap.list) return;
var idx = window.ap.list.index;
var song = window.ap.list.audios[idx];
if (!song) return;
var t = document.getElementById('mp-title');
var a = document.getElementById('mp-artist');
var c = document.getElementById('mp-cover');
if (t) t.textContent = song.name || '未知歌曲';
if (a) a.textContent = song.artist || '未知歌手';
if (c) fillCoverContainer(c, song);
document.querySelectorAll('#music-list .music-item').forEach(function (item) {
var realIdx = parseInt(item.getAttribute('data-index'), 10);
item.classList.toggle('playing', realIdx === idx);
});
}

function updateProgress() {
if (isSeeking) return;
if (!window.ap || !window.ap.audio) return;
var audio = window.ap.audio;
var cur = audio.currentTime || 0;
var dur = audio.duration || 0;
var pct = dur > 0 ? (cur / dur) * 100 : 0;
var inner = document.getElementById('mp-progress-inner');
var curEl = document.getElementById('mp-current');
var durEl = document.getElementById('mp-duration');
if (inner) inner.style.width = pct + '%';
if (curEl) curEl.textContent = formatTime(cur);
if (durEl) durEl.textContent = formatTime(dur);
}

function updatePlayBtn() {
var btn = document.getElementById('mp-toggle');
if (!btn || !window.ap || !window.ap.audio) return;
setBtnIcon(btn, window.ap.audio.paused ? ICONS.play : ICONS.pause);
}

function updateVolumeUI() {
if (!window.ap || !window.ap.audio) return;
var v = window.ap.audio.volume;
var inner = document.getElementById('mp-volume-inner');
var muteBtn = document.getElementById('mp-mute');
if (inner) inner.style.width = (v * 100) + '%';
if (muteBtn) {
if (v === 0) setBtnIcon(muteBtn, ICONS.volumeMute);
else if (v < 0.4) setBtnIcon(muteBtn, ICONS.volumeLow);
else setBtnIcon(muteBtn, ICONS.volumeHigh);
}
}

function updateModeBtn() {
var btn = document.getElementById('mp-mode');
if (!btn) return;
var mode = modes[currentModeIndex];
btn.innerHTML = modeIcons[mode];
btn.title = '播放模式:' + modeTitles[mode];
}

function renderPage() {
var listEl = document.getElementById('music-list');
if (!listEl) return;
listEl.innerHTML = '';

if (filteredSongs.length === 0) {
var empty = document.createElement('div');
empty.className = 'music-empty';
empty.textContent = '没有找到匹配的歌曲';
listEl.appendChild(empty);
return;
}

var start = (currentPage - 1) * pageSize;
var end = Math.min(start + pageSize, filteredSongs.length);
for (var i = start; i < end; i++) {
(function (entry) {
var song = entry.song;
var realIndex = entry.realIndex;

var item = document.createElement('div');
item.className = 'music-item';
item.setAttribute('data-index', realIndex);

var idxEl = document.createElement('div');
idxEl.className = 'music-index';
idxEl.textContent = (realIndex + 1);
item.appendChild(idxEl);

var coverWrap = document.createElement('div');
coverWrap.className = 'music-cover';
fillCoverContainer(coverWrap, song);
item.appendChild(coverWrap);

var info = document.createElement('div');
info.className = 'music-info';
var nm = document.createElement('div');
nm.className = 'music-name';
nm.textContent = song.name;
var ar = document.createElement('div');
ar.className = 'music-artist';
ar.textContent = song.artist;
info.appendChild(nm); info.appendChild(ar);
item.appendChild(info);

var icon = document.createElement('div');
icon.className = 'music-play-icon';
icon.textContent = '▶';
item.appendChild(icon);

item.addEventListener('click', function () { playSong(realIndex); });
listEl.appendChild(item);
})(filteredSongs[i]);
}
updateNowPlaying();
}

function renderPagination() {
var totalPages = Math.ceil(filteredSongs.length / pageSize) || 1;
var paginationEl = document.getElementById('music-pagination');
var infoEl = document.getElementById('mp-page-info');
var prevBtn = document.getElementById('mp-page-prev');
var nextBtn = document.getElementById('mp-page-next');
if (paginationEl) paginationEl.style.display = filteredSongs.length > pageSize ? 'flex' : 'none';
if (infoEl) infoEl.textContent = '第 ' + currentPage + ' / ' + totalPages + ' 页';
if (prevBtn) prevBtn.disabled = currentPage <= 1;
if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
}

function loadMusicData() {
fetch('/music/music-data.json')
.then(function (r) { return r.json(); })
.then(function (list) {
allSongs = list;
applyFilter('');
var countEl = document.getElementById('mp-count');
if (countEl) countEl.textContent = '共 ' + list.length + ' 首';
console.log('🎵 歌单加载成功,共 ' + list.length + ' 首');
renderPage();
renderPagination();
})
.catch(function (err) { console.error('歌单加载失败:', err); });
}

function playSong(index) {
if (!window.ap || !window.ap.list) { setTimeout(function () { playSong(index); }, 300); return; }
window.ap.list.switch(index);
window.ap.play();
}

function bindControls() {
var toggle = document.getElementById('mp-toggle');
var prev = document.getElementById('mp-prev');
var next = document.getElementById('mp-next');
var modeBtn = document.getElementById('mp-mode');
var muteBtn = document.getElementById('mp-mute');
var progressBar = document.getElementById('mp-progress-bar');
var volumeBar = document.getElementById('mp-volume-bar');
var pagePrev = document.getElementById('mp-page-prev');
var pageNext = document.getElementById('mp-page-next');
var searchInput = document.getElementById('mp-search');

setBtnIcon(toggle, ICONS.play);
setBtnIcon(prev, ICONS.prev);
setBtnIcon(next, ICONS.next);
setBtnIcon(muteBtn, ICONS.volumeHigh);
updateModeBtn();

if (toggle) toggle.onclick = function () { window.ap && window.ap.toggle(); };
if (prev) prev.onclick = function () { window.ap && window.ap.skipBack(); };
if (next) next.onclick = function () { window.ap && window.ap.skipForward(); };
if (modeBtn) modeBtn.onclick = function () {
currentModeIndex = (currentModeIndex + 1) % modes.length;
if (window.ap) window.ap.order = modes[currentModeIndex];
updateModeBtn();
};
if (muteBtn) muteBtn.onclick = function () {
if (!window.ap || !window.ap.audio) return;
if (window.ap.audio.volume > 0) { lastVolume = window.ap.audio.volume; window.ap.volume(0); }
else { window.ap.volume(lastVolume || 0.7); }
updateVolumeUI();
};

if (searchInput) {
searchInput.addEventListener('input', function (e) {
applyFilter(e.target.value.trim());
currentPage = 1;
renderPage();
renderPagination();
});
}

if (pagePrev) pagePrev.onclick = function () { if (currentPage > 1) { currentPage--; renderPage(); renderPagination(); } };
if (pageNext) pageNext.onclick = function () {
var totalPages = Math.ceil(filteredSongs.length / pageSize);
if (currentPage < totalPages) { currentPage++; renderPage(); renderPagination(); }
};

bindDragBar(progressBar,
function (pct) {
isSeeking = true;
var inner = document.getElementById('mp-progress-inner');
if (inner) inner.style.width = (pct * 100) + '%';
if (window.ap && window.ap.audio && window.ap.audio.duration) {
var curEl = document.getElementById('mp-current');
if (curEl) curEl.textContent = formatTime(pct * window.ap.audio.duration);
}
},
function (pct) {
if (window.ap && window.ap.audio && window.ap.audio.duration) window.ap.seek(pct * window.ap.audio.duration);
isSeeking = false;
}
);

bindDragBar(volumeBar,
function (pct) {
if (window.ap && window.ap.audio) { window.ap.volume(pct); if (pct > 0) lastVolume = pct; }
updateVolumeUI();
},
function (pct) {
if (window.ap && window.ap.audio) window.ap.volume(pct);
updateVolumeUI();
}
);
}

function bindPlayerEvents() {
if (!window.ap) { setTimeout(bindPlayerEvents, 500); return; }
if (eventsBound) { updateNowPlaying(); updateProgress(); updatePlayBtn(); updateVolumeUI(); return; }
eventsBound = true;
window.ap.on('play', function () { updatePlayBtn(); updateNowPlaying(); });
window.ap.on('pause', function () { updatePlayBtn(); });
window.ap.on('listswitch', function () { updateNowPlaying(); updateProgress(); });
window.ap.on('loadedmetadata', updateProgress);
window.ap.on('timeupdate', updateProgress);
window.ap.on('seeked', updateProgress);
window.ap.on('ended', updatePlayBtn);
window.ap.on('volumechange', updateVolumeUI);
updateNowPlaying(); updateProgress(); updatePlayBtn(); updateVolumeUI();
}

function init() {
var searchInput = document.getElementById('mp-search');
if (searchInput) searchInput.value = '';
currentPage = 1;
loadMusicData();
bindControls();
bindPlayerEvents();
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();

document.addEventListener('pjax:complete', function () { setTimeout(init, 100); });
})();
</script>

Enjoy your music !