前言

最近我为博客制作了一个悬浮在页面左下角的音乐播放器。它平时缩在侧边,只显示一个音符按钮;鼠标悬浮或点击后便会展开,可以切歌、调整音量、查看歌单,并选择列表循环、随机播放、顺序播放或单曲循环。

本来Butterfly主题是自带音乐播放器组件的,但是它的播放器有点大,挡住了我的部分内容。所以在AI鼎力相助之下,有了这个新的播放器。

这个播放器没有直接套用现成播放器的界面,而是使用原生 JavaScript、CSS 和浏览器的 <audio> 元素完成。这样做的好处是外观和交互都可以自由调整,也更容易让它与博客主题保持一致。

本文会分享当前版本的完整源码,并说明它如何接入 Hexo + Butterfly、为什么切换文章时音乐不会中断,以及如何迁移到普通 HTML、WordPress、Vue 或 React 网站。

先回答一个重要问题:它并不是 Hexo 专属。
播放器的核心只是浏览器原生的 HTML、CSS 和 JavaScript,任何能够引入静态资源的网站都可以使用。Hexo 只决定“文件放在哪里”和“怎样把它注入每个页面”。


一、播放器现在具备哪些功能

  • 左下角悬浮,收起时仅保留一个音符按钮
  • 悬浮或点击后展开,兼顾电脑端和触屏设备
  • 从网易云歌单读取歌曲、歌手、封面和音频地址
  • 播放、暂停、上一首、下一首和进度拖动
  • 音量调节与静音
  • 列表循环、随机播放、顺序播放和单曲循环
  • 展开歌单并直接选择曲目
  • 自动跳过只能播放约 30 秒的试听歌曲(这个功能目前还在调试中,并且我导入的那个歌单目前还是有很多网易云需要VIP的歌曲。所以有很多歌还是得听30s不能跳过……正在火速修改中)
  • 记住访客上一次选择的音量和播放模式
  • 支持亮色、暗色主题及移动端布局
  • 在使用 PJAX 的主题中切换页面时保持播放器和播放状态

播放器不会尝试绕过音乐平台的版权限制。需要会员授权、地区受限或没有公开播放地址的歌曲,仍可能无法完整播放。(就是刚刚上面说的还是有很多歌只能播放一段)


二、它只能用在 Hexo 吗

不能说“只能”,更准确的说法是:核心组件与网站生成器无关,接入方式与网站框架有关。

网站类型 能否使用 接入方式
普通 HTML 网站 可以 在页面中引入一个 CSS 和一个 JS 文件
Hexo 可以 将文件放入 source,再由主题配置注入
WordPress 可以 通过主题或 wp_enqueue_script 引入资源
Vue / React 可以 在最外层布局挂载一次,避免路由切换时重复初始化
MkDocs / Hugo 可以 把文件放入静态资源目录,并加入公共模板

当前代码中真正与 Butterfly 相关的只有两点:

  1. 我在 _config.butterfly.yml 中把资源注入所有页面。
  2. 我监听了 pjax:complete,处理 Butterfly 开启 PJAX 后的无刷新页面切换。

如果你的网站没有 PJAX,这段兼容逻辑不会造成影响;如果使用其他前端路由,则需要换成该框架对应的生命周期处理。


三、源码下载

你可以下载本站正在使用的完整版本:

压缩包内包含:

1
2
3
4
5
unmyic-music-player/
├── music-player.js
├── music-player.css
├── butterfly-inject.yml
└── README.txt

其中 music-player.js 负责创建界面、请求歌单和控制播放,music-player.css 负责全部外观与响应式布局,另外两个文件提供快速安装说明。

为了方便直接复制,下面也贴出当前版本的完整代码。本站已将过长代码块的显示高度限制为 400px,阅读时可以点击代码块下方的展开按钮查看全部内容,也可以使用右上角的复制按钮一键复制。

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
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
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
/**
* 自定义网易云歌单播放器。
* 网易云接口仅用于获取歌曲信息与音频地址,界面和播放逻辑均由本站控制。
*/
(function () {
'use strict';

var PLAYLIST_ID = '2791970710';
var PLAYLIST_LIMIT = 100;
var METING_API =
'https://api.i-meto.com/meting/api' +
'?server=netease&type=playlist&id=' + PLAYLIST_ID;

var root = null;
var audio = null;
var tracks = [];
var currentIndex = 0;
var playRequested = false;
var closeTimer = null;
var skippedPreviewUrls = {};
var mountObserver = null;
var playbackMode = 'list';
var lastAudibleVolume = 0.55;

var PLAYBACK_MODES = {
list: '列表循环',
shuffle: '随机播放',
sequential: '顺序播放',
single: '单曲循环'
};

function readSetting(key) {
try {
return window.localStorage.getItem(key);
} catch (error) {
return null;
}
}

function saveSetting(key, value) {
try {
window.localStorage.setItem(key, String(value));
} catch (error) {
/* 隐私模式下无法写入时,仍允许本次访问正常使用。 */
}
}

function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
var minutes = Math.floor(seconds / 60);
var remainder = Math.floor(seconds % 60);
return String(minutes).padStart(2, '0') + ':' +
String(remainder).padStart(2, '0');
}

function normalizeTracks(rawTracks) {
if (!Array.isArray(rawTracks)) return [];

return rawTracks
.slice(0, PLAYLIST_LIMIT)
.filter(function (track) {
return track && track.url;
})
.map(function (track) {
return {
name: track.title || '未知歌曲',
artist: track.author || '未知歌手',
url: track.url,
cover: track.pic || ''
};
});
}

function createInterface() {
root = document.createElement('div');
root.id = 'custom-music-player';
root.className = 'custom-music-player is-loading';
root.innerHTML =
'<section class="cmp-panel" aria-label="音乐播放器">' +
'<div class="cmp-current">' +
'<div class="cmp-cover-wrap">' +
'<img class="cmp-cover" alt="" src="" referrerpolicy="no-referrer">' +
'<span class="cmp-cover-fallback" aria-hidden="true">♫</span>' +
'</div>' +
'<div class="cmp-meta">' +
'<div class="cmp-title">正在载入歌单</div>' +
'<div class="cmp-artist">网易云音乐</div>' +
'</div>' +
'</div>' +
'<div class="cmp-progress-row">' +
'<span class="cmp-time-current">00:00</span>' +
'<input class="cmp-progress" type="range" min="0" max="100" value="0" step="0.1" aria-label="播放进度">' +
'<span class="cmp-time-total">00:00</span>' +
'</div>' +
'<div class="cmp-controls">' +
'<button class="cmp-control cmp-prev" type="button" aria-label="上一首" title="上一首">‹</button>' +
'<button class="cmp-control cmp-play" type="button" aria-label="播放" title="播放">▶</button>' +
'<button class="cmp-control cmp-next" type="button" aria-label="下一首" title="下一首">›</button>' +
'<button class="cmp-control cmp-list-toggle" type="button" aria-label="显示歌单" aria-expanded="false" title="歌单">☷</button>' +
'</div>' +
'<div class="cmp-options">' +
'<div class="cmp-volume-control">' +
'<button class="cmp-volume-toggle" type="button" aria-label="静音" title="静音">🔊</button>' +
'<input class="cmp-volume" type="range" min="0" max="1" value="0.55" step="0.01" aria-label="音量">' +
'</div>' +
'<label class="cmp-mode-wrap">' +
'<span class="cmp-mode-icon" aria-hidden="true">↻</span>' +
'<select class="cmp-mode" aria-label="播放模式">' +
'<option value="list">列表循环</option>' +
'<option value="shuffle">随机播放</option>' +
'<option value="sequential">顺序播放</option>' +
'<option value="single">单曲循环</option>' +
'</select>' +
'</label>' +
'</div>' +
'<div class="cmp-status" role="status" aria-live="polite">正在连接网易云歌单…</div>' +
'<div class="cmp-playlist" hidden>' +
'<ol class="cmp-playlist-items"></ol>' +
'</div>' +
'</section>' +
'<button class="cmp-toggle" type="button" aria-label="展开音乐播放器" aria-expanded="false" title="音乐播放器">' +
'<span class="cmp-toggle-icon" aria-hidden="true">♫</span>' +
'</button>';

audio = document.createElement('audio');
audio.preload = 'metadata';
root.appendChild(audio);
document.body.appendChild(root);

var savedMode = readSetting('cmp-playback-mode');
if (PLAYBACK_MODES[savedMode]) playbackMode = savedMode;

var savedVolume = Number(readSetting('cmp-volume'));
audio.volume = Number.isFinite(savedVolume) &&
savedVolume >= 0 &&
savedVolume <= 1
? savedVolume
: 0.55;
if (audio.volume > 0) lastAudibleVolume = audio.volume;

root.querySelector('.cmp-mode').value = playbackMode;
root.querySelector('.cmp-volume').value = String(audio.volume);
updateVolumeButton();

bindInterfaceEvents();
bindAudioEvents();
}

function setOpen(open) {
if (!root) return;
root.classList.toggle('is-open', open);

var toggle = root.querySelector('.cmp-toggle');
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute(
'aria-label',
open ? '收起音乐播放器' : '展开音乐播放器'
);
root.querySelector('.cmp-toggle-icon').textContent = open ? '‹' : '♫';
}

function setStatus(message, isError) {
if (!root) return;
var status = root.querySelector('.cmp-status');
status.textContent = message || '';
status.classList.toggle('is-error', Boolean(isError));
}

function updatePlayButton() {
if (!root) return;
var button = root.querySelector('.cmp-play');
var playing = audio && !audio.paused;
button.textContent = playing ? '❚❚' : '▶';
button.setAttribute('aria-label', playing ? '暂停' : '播放');
button.title = playing ? '暂停' : '播放';
root.classList.toggle('is-playing', playing);
}

function updateVolumeButton() {
if (!root || !audio) return;
var button = root.querySelector('.cmp-volume-toggle');
var effectiveVolume = audio.muted ? 0 : audio.volume;
var icon = effectiveVolume === 0
? '🔇'
: effectiveVolume < 0.5 ? '🔉' : '🔊';

button.textContent = icon;
button.setAttribute(
'aria-label',
effectiveVolume === 0 ? '恢复音量' : '静音'
);
button.title = effectiveVolume === 0 ? '恢复音量' : '静音';
}

function renderPlaylist() {
var list = root.querySelector('.cmp-playlist-items');
list.textContent = '';

tracks.forEach(function (track, index) {
var item = document.createElement('li');
var button = document.createElement('button');
button.type = 'button';
button.className = 'cmp-track';
button.dataset.index = String(index);
button.innerHTML =
'<span class="cmp-track-index">' + (index + 1) + '</span>' +
'<span class="cmp-track-text">' +
'<span class="cmp-track-title"></span>' +
'<span class="cmp-track-artist"></span>' +
'</span>';
button.querySelector('.cmp-track-title').textContent = track.name;
button.querySelector('.cmp-track-artist').textContent = track.artist;
item.appendChild(button);
list.appendChild(item);
});
}

function updateActiveTrack() {
if (!root) return;
var buttons = root.querySelectorAll('.cmp-track');
Array.prototype.forEach.call(buttons, function (button, index) {
var active = index === currentIndex;
button.classList.toggle('is-active', active);
button.setAttribute('aria-current', active ? 'true' : 'false');
});
}

function loadTrack(index, shouldPlay) {
if (!tracks.length) return;

currentIndex = (index + tracks.length) % tracks.length;
var track = tracks[currentIndex];
var cover = root.querySelector('.cmp-cover');

root.querySelector('.cmp-title').textContent = track.name;
root.querySelector('.cmp-artist').textContent = track.artist;
cover.src = track.cover;
cover.alt = track.name + ' 封面';
root.classList.remove('has-cover');
root.querySelector('.cmp-progress').value = '0';
root.querySelector('.cmp-time-current').textContent = '00:00';
root.querySelector('.cmp-time-total').textContent = '00:00';

audio.src = track.url;
audio.load();
updateActiveTrack();
setStatus('第 ' + (currentIndex + 1) + ' 首,共 ' + tracks.length + ' 首');

if (shouldPlay) {
playRequested = true;
playCurrent();
} else {
updatePlayButton();
}
}

function isPreviewTrack() {
return Number.isFinite(audio.duration) &&
audio.duration >= 28 &&
audio.duration <= 32.5;
}

function skipPreviewIfNeeded() {
var track = tracks[currentIndex];
if (!playRequested || !track || !isPreviewTrack()) return false;
if (skippedPreviewUrls[track.url]) return false;

skippedPreviewUrls[track.url] = true;
setStatus('已跳过 30 秒试听歌曲:' + track.name);
window.setTimeout(function () {
nextTrack(true, 'skip');
}, 350);
return true;
}

function playCurrent() {
if (!tracks.length) return;
playRequested = true;

if (skipPreviewIfNeeded()) return;

var promise = audio.play();
if (promise && typeof promise.catch === 'function') {
promise.catch(function () {
setStatus('当前歌曲无法播放,正在尝试下一首…', true);
window.setTimeout(function () {
nextTrack(true, 'skip');
}, 650);
});
}
}

function randomTrackIndex() {
if (tracks.length < 2) return currentIndex;
var nextIndex = currentIndex;
while (nextIndex === currentIndex) {
nextIndex = Math.floor(Math.random() * tracks.length);
}
return nextIndex;
}

function nextTrack(shouldPlay, reason) {
reason = reason || 'manual';

if (reason === 'ended' && playbackMode === 'single') {
audio.currentTime = 0;
playCurrent();
return;
}

if (
reason === 'ended' &&
playbackMode === 'sequential' &&
currentIndex === tracks.length - 1
) {
playRequested = false;
audio.pause();
audio.currentTime = 0;
updatePlayButton();
setStatus('顺序播放已完成');
return;
}

var nextIndex = playbackMode === 'shuffle'
? randomTrackIndex()
: currentIndex + 1;
loadTrack(nextIndex, shouldPlay);
}

function previousTrack() {
if (audio.currentTime > 5) {
audio.currentTime = 0;
return;
}
loadTrack(currentIndex - 1, playRequested);
}

function togglePlaylist() {
var playlist = root.querySelector('.cmp-playlist');
var button = root.querySelector('.cmp-list-toggle');
var willOpen = playlist.hidden;

playlist.hidden = !willOpen;
button.classList.toggle('is-active', willOpen);
button.setAttribute('aria-expanded', String(willOpen));
button.setAttribute('aria-label', willOpen ? '隐藏歌单' : '显示歌单');

if (willOpen) {
var active = root.querySelector('.cmp-track.is-active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
}

function bindInterfaceEvents() {
var toggle = root.querySelector('.cmp-toggle');

toggle.addEventListener('click', function () {
setOpen(!root.classList.contains('is-open'));
});

root.querySelector('.cmp-play').addEventListener('click', function () {
if (!tracks.length) return;
if (audio.paused) {
playCurrent();
} else {
playRequested = false;
audio.pause();
}
});

root.querySelector('.cmp-prev').addEventListener('click', previousTrack);
root.querySelector('.cmp-next').addEventListener('click', function () {
nextTrack(playRequested, 'manual');
});
root.querySelector('.cmp-list-toggle').addEventListener('click', togglePlaylist);

root.querySelector('.cmp-volume').addEventListener('input', function (event) {
var volume = Number(event.target.value);
audio.muted = false;
audio.volume = volume;
if (volume > 0) lastAudibleVolume = volume;
saveSetting('cmp-volume', volume);
updateVolumeButton();
});

root.querySelector('.cmp-volume-toggle').addEventListener('click', function () {
if (audio.muted || audio.volume === 0) {
audio.muted = false;
audio.volume = lastAudibleVolume || 0.55;
root.querySelector('.cmp-volume').value = String(audio.volume);
} else {
lastAudibleVolume = audio.volume;
audio.muted = true;
}
saveSetting('cmp-volume', audio.muted ? 0 : audio.volume);
updateVolumeButton();
});

root.querySelector('.cmp-mode').addEventListener('change', function (event) {
var selectedMode = event.target.value;
if (!PLAYBACK_MODES[selectedMode]) return;
playbackMode = selectedMode;
saveSetting('cmp-playback-mode', playbackMode);
setStatus('播放模式:' + PLAYBACK_MODES[playbackMode]);
});

root.querySelector('.cmp-progress').addEventListener('input', function (event) {
if (!Number.isFinite(audio.duration)) return;
audio.currentTime = audio.duration * (Number(event.target.value) / 100);
});

root.querySelector('.cmp-playlist-items').addEventListener('click', function (event) {
var button = event.target.closest('.cmp-track');
if (!button) return;
loadTrack(Number(button.dataset.index), true);
});

var cover = root.querySelector('.cmp-cover');
cover.addEventListener('load', function () {
root.classList.add('has-cover');
});
cover.addEventListener('error', function () {
root.classList.remove('has-cover');
});

if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
root.addEventListener('mouseenter', function () {
window.clearTimeout(closeTimer);
setOpen(true);
});
root.addEventListener('mouseleave', function () {
closeTimer = window.setTimeout(function () {
setOpen(false);
}, 500);
});
}
}

function bindAudioEvents() {
audio.addEventListener('play', updatePlayButton);
audio.addEventListener('pause', updatePlayButton);
audio.addEventListener('ended', function () {
nextTrack(true, 'ended');
});
audio.addEventListener('loadedmetadata', function () {
root.querySelector('.cmp-time-total').textContent =
formatTime(audio.duration);
skipPreviewIfNeeded();
});
audio.addEventListener('timeupdate', function () {
var ratio = Number.isFinite(audio.duration) && audio.duration > 0
? (audio.currentTime / audio.duration) * 100
: 0;
root.querySelector('.cmp-progress').value = String(ratio);
root.querySelector('.cmp-time-current').textContent =
formatTime(audio.currentTime);
});
audio.addEventListener('error', function () {
if (!playRequested) {
setStatus('当前歌曲暂时无法播放', true);
return;
}
setStatus('当前歌曲不可用,正在跳到下一首…', true);
window.setTimeout(function () {
nextTrack(true, 'skip');
}, 650);
});
}

function loadPlaylist() {
window.fetch(METING_API, { mode: 'cors' })
.then(function (response) {
if (!response.ok) {
throw new Error('Playlist request failed: ' + response.status);
}
return response.json();
})
.then(function (data) {
tracks = normalizeTracks(data);
if (!tracks.length) throw new Error('No playable tracks returned');

renderPlaylist();
root.classList.remove('is-loading');
root.classList.add('is-ready');
loadTrack(0, false);
})
.catch(function (error) {
root.classList.remove('is-loading');
root.classList.add('has-error');
root.querySelector('.cmp-title').textContent = '歌单加载失败';
root.querySelector('.cmp-artist').textContent = '请稍后重试';
setStatus('无法连接网易云歌单', true);
console.error('[custom-music-player]', error);
});
}

function initialize() {
var existing = document.getElementById('custom-music-player');
if (existing) {
root = existing;
audio = existing.querySelector('audio');
return;
}
createInterface();
loadPlaylist();
}

function ensurePlayerMounted() {
if (!document.body) return;

if (!root) {
initialize();
return;
}

if (!root.isConnected || root.parentElement !== document.body) {
document.body.appendChild(root);
}

root.hidden = false;
root.setAttribute('data-current-page', window.location.pathname);
}

function handlePageReady() {
window.requestAnimationFrame(function () {
ensurePlayerMounted();
});
}

function observePlayerMount() {
if (mountObserver || !document.body) return;
mountObserver = new MutationObserver(function () {
if (root && !root.isConnected) ensurePlayerMounted();
});
mountObserver.observe(document.body, { childList: true });
}

document.addEventListener('pjax:complete', handlePageReady);
window.addEventListener('pageshow', handlePageReady);

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
initialize();
observePlayerMount();
});
} else {
initialize();
observePlayerMount();
}

window.__unmyicMusicPlayer = {
ensureMounted: ensurePlayerMounted,
open: function () { setOpen(true); },
close: function () { setOpen(false); }
};
})();

music-player.css

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
:root {
--cmp-accent: #3b82f6;
--cmp-accent-strong: #2563eb;
--cmp-panel-width: min(350px, calc(100vw - 68px));
}

.custom-music-player {
position: fixed;
left: 0;
bottom: 24px;
z-index: 9998;
width: var(--cmp-panel-width);
color: #243246;
font-size: 14px;
line-height: 1.4;
pointer-events: none;
}

body > #custom-music-player {
display: block !important;
visibility: visible !important;
}

.custom-music-player audio {
display: none;
}

.cmp-panel {
width: var(--cmp-panel-width);
box-sizing: border-box;
padding: 14px;
border: 1px solid rgba(59, 130, 246, 0.18);
border-radius: 0 18px 18px 0;
background: rgba(250, 252, 255, 0.94);
box-shadow: 0 14px 42px rgba(25, 55, 96, 0.22);
backdrop-filter: blur(18px) saturate(130%);
-webkit-backdrop-filter: blur(18px) saturate(130%);
transform: translateX(calc(-100% - 18px));
opacity: 0;
pointer-events: none;
transition: transform 0.32s cubic-bezier(.22, .8, .25, 1),
opacity 0.22s ease;
}

.custom-music-player.is-open .cmp-panel {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}

.cmp-toggle {
position: absolute;
left: 0;
bottom: 12px;
display: grid;
width: 46px;
height: 52px;
padding: 0;
place-items: center;
border: 0;
border-radius: 0 15px 15px 0;
background: linear-gradient(145deg, #60a5fa, #2563eb);
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.32);
color: #fff;
cursor: pointer;
pointer-events: auto;
transition: left 0.32s cubic-bezier(.22, .8, .25, 1),
transform 0.2s ease,
box-shadow 0.2s ease;
}

.custom-music-player.is-open .cmp-toggle {
left: calc(var(--cmp-panel-width) + 10px);
border-radius: 14px;
}

.cmp-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 11px 30px rgba(37, 99, 235, 0.42);
}

.cmp-toggle:focus-visible,
.cmp-control:focus-visible,
.cmp-track:focus-visible,
.cmp-progress:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.35);
outline-offset: 2px;
}

.cmp-toggle-icon {
font-size: 25px;
line-height: 1;
}

.custom-music-player.is-playing:not(.is-open) .cmp-toggle-icon {
animation: cmp-note-pulse 1.2s ease-in-out infinite;
}

.cmp-current {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}

.cmp-cover-wrap {
position: relative;
flex: 0 0 56px;
width: 56px;
height: 56px;
overflow: hidden;
border-radius: 14px;
background: linear-gradient(145deg, #dbeafe, #93c5fd);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}

.cmp-cover,
.cmp-cover-fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}

.cmp-cover {
display: none;
object-fit: cover;
}

.has-cover .cmp-cover {
display: block;
}

.cmp-cover-fallback {
display: grid;
place-items: center;
color: #fff;
font-size: 26px;
}

.has-cover .cmp-cover-fallback {
display: none;
}

.is-playing .cmp-cover {
animation: cmp-cover-breathe 2.8s ease-in-out infinite;
}

.cmp-meta {
min-width: 0;
flex: 1;
}

.cmp-title,
.cmp-artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.cmp-title {
margin-bottom: 5px;
color: #1e293b;
font-size: 15px;
font-weight: 650;
}

.cmp-artist {
color: #718096;
font-size: 12px;
}

.cmp-progress-row {
display: grid;
grid-template-columns: 38px 1fr 38px;
align-items: center;
gap: 7px;
margin-top: 13px;
color: #718096;
font-size: 10px;
font-variant-numeric: tabular-nums;
}

.cmp-time-total {
text-align: right;
}

.cmp-progress {
width: 100%;
height: 4px;
margin: 0;
border-radius: 999px;
background: #dbe6f4;
cursor: pointer;
accent-color: var(--cmp-accent);
}

.cmp-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 10px;
}

.cmp-control {
display: grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: #52647b;
cursor: pointer;
font-family: inherit;
font-size: 24px;
line-height: 1;
transition: color 0.18s ease, background 0.18s ease,
transform 0.18s ease;
}

.cmp-control:hover,
.cmp-control.is-active {
background: rgba(59, 130, 246, 0.1);
color: var(--cmp-accent-strong);
}

.cmp-control:active {
transform: scale(0.92);
}

.cmp-play {
width: 42px;
height: 42px;
padding-left: 2px;
background: linear-gradient(145deg, #60a5fa, #2563eb);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.28);
color: #fff;
font-size: 15px;
}

.cmp-play:hover {
background: linear-gradient(145deg, #74b2ff, #2c6ff0);
color: #fff;
}

.cmp-list-toggle {
margin-left: 9px;
font-size: 22px;
}

.cmp-options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
padding: 7px 9px;
border-radius: 11px;
background: rgba(59, 130, 246, 0.06);
}

.cmp-volume-control {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 6px;
}

.cmp-volume-toggle {
width: 25px;
height: 25px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
font-size: 14px;
line-height: 1;
}

.cmp-volume {
width: 88px;
min-width: 45px;
height: 4px;
margin: 0;
background: #dbe6f4;
cursor: pointer;
accent-color: var(--cmp-accent);
}

.cmp-mode-wrap {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 3px;
color: #63748a;
}

.cmp-mode-icon {
font-size: 15px;
}

.cmp-mode {
max-width: 86px;
padding: 3px 19px 3px 5px;
border: 0;
border-radius: 7px;
background-color: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
}

.cmp-mode:hover,
.cmp-mode:focus {
background-color: rgba(59, 130, 246, 0.1);
color: var(--cmp-accent-strong);
}

.cmp-status {
min-height: 16px;
margin-top: 7px;
overflow: hidden;
color: #8a99ad;
font-size: 10px;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
}

.cmp-status.is-error {
color: #dc6b6b;
}

.cmp-playlist {
max-height: 274px;
margin: 10px -5px -4px;
overflow-y: auto;
border-top: 1px solid rgba(100, 116, 139, 0.13);
scrollbar-color: rgba(59, 130, 246, 0.55) transparent;
scrollbar-width: thin;
}

.cmp-playlist[hidden] {
display: none;
}

.cmp-playlist-items {
margin: 0;
padding: 7px 0 0;
list-style: none;
}

.cmp-track {
display: grid;
width: 100%;
grid-template-columns: 27px minmax(0, 1fr);
align-items: center;
gap: 7px;
padding: 7px 8px;
border: 0;
border-radius: 9px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
transition: background 0.16s ease, color 0.16s ease;
}

.cmp-track:hover,
.cmp-track.is-active {
background: rgba(59, 130, 246, 0.1);
color: var(--cmp-accent-strong);
}

.cmp-track-index {
color: #94a3b8;
font-size: 11px;
text-align: center;
}

.cmp-track-text {
display: block;
min-width: 0;
}

.cmp-track-title,
.cmp-track-artist {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.cmp-track-title {
font-size: 12px;
font-weight: 550;
}

.cmp-track-artist {
margin-top: 2px;
color: #8b98aa;
font-size: 10px;
}

[data-theme='dark'] .custom-music-player {
color: rgba(255, 255, 255, 0.84);
}

[data-theme='dark'] .cmp-panel {
border-color: rgba(96, 165, 250, 0.18);
background: rgba(20, 29, 43, 0.94);
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.42);
}

[data-theme='dark'] .cmp-title {
color: #edf5ff;
}

[data-theme='dark'] .cmp-artist,
[data-theme='dark'] .cmp-progress-row,
[data-theme='dark'] .cmp-status,
[data-theme='dark'] .cmp-track-artist {
color: #94a5ba;
}

[data-theme='dark'] .cmp-control {
color: #afbed1;
}

[data-theme='dark'] .cmp-control:hover,
[data-theme='dark'] .cmp-control.is-active {
background: rgba(96, 165, 250, 0.14);
color: #93c5fd;
}

[data-theme='dark'] .cmp-play {
color: #fff;
}

[data-theme='dark'] .cmp-progress {
background: #334155;
}

[data-theme='dark'] .cmp-options {
background: rgba(96, 165, 250, 0.08);
}

[data-theme='dark'] .cmp-volume {
background: #334155;
}

[data-theme='dark'] .cmp-mode-wrap,
[data-theme='dark'] .cmp-mode {
color: #afbed1;
}

[data-theme='dark'] .cmp-mode option {
background: #1d2939;
color: #edf5ff;
}

[data-theme='dark'] .cmp-playlist {
border-top-color: rgba(255, 255, 255, 0.08);
}

[data-theme='dark'] .cmp-track:hover,
[data-theme='dark'] .cmp-track.is-active {
background: rgba(96, 165, 250, 0.13);
color: #93c5fd;
}

@keyframes cmp-note-pulse {
0%, 100% { transform: scale(1); opacity: 0.9; }
50% { transform: scale(1.15); opacity: 1; }
}

@keyframes cmp-cover-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.045); }
}

@media (max-width: 600px) {
:root {
--cmp-panel-width: calc(100vw - 64px);
}

.custom-music-player {
bottom: 18px;
}

.cmp-panel {
max-height: min(620px, calc(100vh - 100px));
}

.cmp-playlist {
max-height: min(250px, calc(100vh - 330px));
}

.custom-music-player.is-open .cmp-toggle {
left: calc(var(--cmp-panel-width) + 7px);
}
}

@media (prefers-reduced-motion: reduce) {
.cmp-panel,
.cmp-toggle {
transition: none !important;
}

.cmp-toggle-icon,
.cmp-cover {
animation: none !important;
}
}

四、它是怎样工作的

1. 使用浏览器原生 Audio

播放器的核心并不是某个第三方 UI 库,而是一个原生音频对象:

1
2
var audio = document.createElement('audio');
audio.preload = 'metadata';

JavaScript 根据当前歌曲修改 audio.src,并监听 playpausetimeupdateended 等事件,将真实播放状态同步到界面。

这种方式减少了对播放器框架的依赖,但浏览器的自动播放限制仍然存在:访客通常需要先点击一次播放按钮,网页才有权播放声音。

2. 动态创建界面

脚本会自行创建播放器并放入 document.body,因此接入时不需要在每个页面手写播放器容器:

1
2
3
4
5
6
var root = document.createElement('div');
root.id = 'custom-music-player';
root.className = 'custom-music-player is-loading';

// 此处省略播放器内部结构
document.body.appendChild(root);

这样特别适合静态博客:只要公共模板成功加载脚本,每篇文章都会自动得到同一个组件。

3. 从歌单读取曲目

当前版本使用第三方 Meting 兼容接口读取网易云歌单:

1
2
3
4
5
var PLAYLIST_ID = '12345678';
var PLAYLIST_LIMIT = 100;
var METING_API =
'https://api.i-meto.com/meting/api' +
'?server=netease&type=playlist&id=' + PLAYLIST_ID;

修改 PLAYLIST_ID 就可以换成自己的歌单。歌单链接通常类似:

1
https://music.163.com/playlist?id=12345678

其中 id= 后面的数字就是歌单 ID。PLAYLIST_LIMIT 用来限制一次加载的曲目数量,歌单很大或移动网络较慢时,可以适当调低。

这个接口不是网易云音乐官方接口,它可能受到接口服务状态、跨域策略或音乐平台规则变化的影响。正式长期使用时,建议准备自己可控的接口或使用有授权的音频文件。

4. 播放模式

播放器维护四种模式:

1
2
3
4
5
6
var PLAYBACK_MODES = {
list: '列表循环',
shuffle: '随机播放',
sequential: '顺序播放',
single: '单曲循环'
};

模式和音量会保存到 localStorage。访客下次打开网站时,播放器仍会使用上一次的偏好。

5. 跳过 30 秒试听歌曲(还在努力实现中……)

部分受版权限制的歌曲只能取得大约 30 秒的试听地址。当前代码会识别持续时间在约 28~32.5 秒之间的音频,并在播放时自动跳到下一首:

1
2
3
4
5
function isPreviewTrack() {
return Number.isFinite(audio.duration) &&
audio.duration >= 28 &&
audio.duration <= 32.5;
}

这只是改善连续播放体验,并不能让受限歌曲变成完整版。访客自己的网易云 VIP 身份也不会自动传递给一个独立博客中的 <audio> 元素。


五、部署到 Hexo + Butterfly

1. 放置静态文件

将下载包中的两个源码文件放到 Hexo 项目:

1
2
3
4
5
6
你的博客/
└── source/
├── css/
│ └── music-player.css
└── js/
└── music-player.js

Hexo 构建后,它们会分别成为:

1
2
/css/music-player.css
/js/music-player.js

2. 修改歌单

打开 source/js/music-player.js,修改顶部配置:

1
2
var PLAYLIST_ID = '你的网易云歌单ID';
var PLAYLIST_LIMIT = 100;

如果你使用自己的音乐接口,也可以替换 METING_API,但返回数据需要包含曲名、歌手、音频地址和封面等字段,或者同步修改 normalizeTracks() 中的字段映射。

3. 注入 Butterfly 的所有页面

打开博客根目录的 _config.butterfly.yml,在 inject 中加入:

1
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/css/music-player.css">
bottom:
- <script defer src="/js/music-player.js"></script>

如果你以前启用过 APlayer 或主题自带的 Meting 播放器,为避免出现两个按钮,可以关闭主题的自动注入:

1
2
3
aplayerInject:
enable: false
per_page: true

请注意 YAML 的缩进;同一层级应保持一致,不要使用 Tab。

4. 本地检查

在博客根目录运行:

1
2
3
npm run clean
npm run build
npm run server

然后打开 http://localhost:4000/。除了首页,还应该进入一篇文章、分类页和标签页检查播放器是否都存在。

确认无误后,再使用你自己的部署命令:

1
npm run deploy

六、为什么切换文章后音乐不会消失

Butterfly 可以通过 PJAX 只替换页面主体,而不进行完整刷新。但不同主题或插件可能会重建 body 中的一部分节点,导致普通悬浮组件被移除。

当前版本使用三层保护:

1
2
document.addEventListener('pjax:complete', handlePageReady);
window.addEventListener('pageshow', handlePageReady);
  • 首次打开页面时初始化
  • PJAX 切换完成后确认播放器仍在页面中
  • 浏览器从前进/后退缓存恢复时重新确认

同时使用 MutationObserver 观察页面结构。如果播放器节点被主题移除,就把原来的节点重新挂回页面,而不是创建新的播放器。这一点很重要:复用原节点才能保留同一个 <audio>、当前歌曲、进度和播放状态。

因此,“每个页面都有播放器”和“切换页面时不断歌”其实是两个不同要求:

  • 全局注入,保证任何地址直接打开时都能初始化。
  • 保留同一个实例,保证站内切换时音乐不中断。

七、部署到普通 HTML 网站

如果不是 Hexo,只需把两个文件上传到网站,并在所有需要播放器的页面中引入:

1
2
3
4
5
6
7
8
<head>
<link rel="stylesheet" href="/assets/music-player.css">
</head>
<body>
<!-- 你的页面内容 -->

<script defer src="/assets/music-player.js"></script>
</body>

不需要额外添加 HTML 容器。脚本会在 DOM 准备完成后自动创建播放器。

多页面网站每次跳转都会真正刷新,音乐也会随之中断。这不是播放器失效,而是浏览器销毁了整个旧页面。若希望跨页面不断歌,需要使用 PJAX、单页应用路由,或将播放器放在不会被替换的外层页面中。


八、迁移到 WordPress、Vue 或 React

WordPress

可以在子主题的 functions.php 中用 wp_enqueue_style()wp_enqueue_script() 引入文件,或者通过主题提供的“自定义代码”功能全站加载资源。不要直接修改会随升级被覆盖的父主题文件。

Vue / React

最简单的方法是仍将这个脚本作为静态资源引入,并确保只执行一次。更符合框架习惯的做法,则是把 DOM 模板改写成组件:

  • 将播放器放在应用最外层布局,而不是路由页面内部
  • 将歌曲、播放状态和模式存入全局状态
  • 在组件卸载时清理事件监听
  • 不再监听 pjax:complete,改用框架的路由或组件生命周期

无论使用哪种框架,核心的 Audio 控制、歌单数据格式和 CSS 设计都可以继续复用。


九、自定义外观

播放器的颜色、尺寸、阴影和展开距离都集中在 music-player.css 中。建议先从这些选择器开始:

1
2
3
4
5
6
7
8
9
10
11
.custom-music-player {
/* 整个组件的位置和尺寸 */
}

.cmp-launcher {
/* 收起时的音符按钮 */
}

.cmp-panel {
/* 展开后的播放器面板 */
}

当前样式也提供暗色主题适配。不同主题标记暗色模式的方式不完全相同,如果你的主题使用 data-theme="dark",可以这样覆盖:

1
2
3
4
[data-theme='dark'] .cmp-panel {
background: rgba(18, 24, 38, 0.92);
color: #eef5ff;
}

修改后可以为资源地址增加版本号,避免浏览器继续使用旧缓存:

1
2
- <link rel="stylesheet" href="/css/music-player.css?v=2">
- <script defer src="/js/music-player.js?v=2"></script>

十、常见问题

1. 只有首页有播放器

通常是脚本只写进了首页模板,而没有全站注入。Butterfly 用户应检查 _config.butterfly.ymlinject.bottom,并在修改后执行一次 npm run clean

2. 从首页进入文章后播放器消失

这通常与 PJAX 替换页面节点有关。请保留源码中的 pjax:completepageshow 和节点恢复逻辑,并确保没有另一段脚本重复删除播放器。

3. 面板出现了,但歌单全黑或一直加载

优先打开浏览器开发者工具的 Network 和 Console,检查:

  • 歌单接口是否能够访问
  • 请求是否被跨域策略拦截
  • 接口是否返回错误或空数组
  • 当前网络是否限制了音乐域名
  • HTTPS 网站是否请求了不安全的 HTTP 资源

歌单曲目很多会让加载稍慢,但通常不会让整个界面永久全黑。

4. VIP 歌曲为什么只能播放 30 秒

博客拿到的是接口公开返回的试听地址,不会携带访客在网易云客户端中的会员登录态。当前播放器会跳过识别到的 30 秒试听曲目,但无法取得其完整版。

5. 手机端不能通过悬浮展开

触屏设备没有真正的 hover 状态,因此必须保留点击展开逻辑。也要避免让其他侧边按钮覆盖播放器的 z-index 和点击区域。

6. 为什么浏览器不允许自动播放

现代浏览器通常禁止网页在没有用户操作时自动播放有声媒体。让访客主动点击播放,是兼容性最好也最尊重访问体验的方案。


十一、使用与版权说明

这份代码可以作为个人博客组件进行学习、修改和二次开发。代码本身不包含音乐文件,也不提供破解或绕过会员限制的能力。

部署时请注意:

  • 只播放自己拥有版权、得到授权或平台允许公开播放的内容
  • 不要把第三方接口的可用性视为永久保证
  • 对公开站点准备接口故障时的降级提示
  • 尊重访客,不默认自动播放有声音乐

结语

这个播放器最值得复用的并不只是它的外观,而是“把组件真正变成全站组件”的思路:原生 Audio 管理播放、CSS 管理呈现、公共模板负责注入、页面生命周期负责持久化。

因此,无论你使用 Hexo、Hugo、MkDocs、WordPress,还是自己编写的前端应用,都可以从同一份源码出发。Hexo 让部署过程更方便,但它不是实现播放器的前提。

如果你准备修改源码,建议先从歌单 ID、主题色和面板尺寸开始,再逐步调整数据接口与框架生命周期。愿每一次翻阅与写作,都有合适的声音陪伴。


AI 内容声明

本文包含 AI 辅助生成内容。文章的结构整理、部分文字表述与播放器代码由 AI 协助完成,并经过作者审阅、修改和实际部署验证。