添加音乐
2025-09-07  / site  / feature

该页面由AI翻译并经过人工校对,你可能想要 查看原文

前言

如果您想在网站上添加 APlayer,只需两步。

  1. 加载 APlayer,从 CDN 或本地加载。
  2. 初始化 APlayer。

但是,如果您有自定义需求,则需要做更多事情。

例如,我希望在网站的某些页面上显示一个fixed、不带歌词的 APlayer,而在 /music 页面上的自定义区域中嵌入一个带有歌词的normal APlayer。因为我希望在 /music 页面上显示歌词,而在其他页面上不显示。

为了控制 APlayer 在不同页面的显示方式,我在 _config.yml 中添加了 aplayer 配置。以下是我所做的。

添加音乐导航栏和aplayer配置

需要做两件事:

  1. 为 /music 页面添加导航栏
  2. 添加 aplayer 配置,用于控制 APlayer 的加载方式(参见 cdns)以及 APlayer 将在哪个页面上显示(参见 aplayer.hideWhen 和 aplayer.showWhen)。

以下配置中,APlayer 仅在页面为 /music、/、/about 或 post 时显示。在 /music 页面上,APlayer 会在自定义区域显示歌词,而在其他页面上则不显示歌词。

APlayer 的加载方式由 cnds 决定。默认情况下,它会从本地加载。如果您与 APlayer 的 cdn 连接速度很快,可以将 cnds.APlayer.enable 设置为 true。

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
navbar:
-
name: Timeline
enable: true
path: /timeline/
key: timeline

aplayer:
hideWhen:
- /archives/
- /categories/
- /tags/
- /albums/
- /timeline/
showWhen:
- mode: normal
type: music # page.type for /music
- mode: fixed
path: / # page.path for Home
- mode: fixed
type: about # page.type for /about
- mode: fixed
type: '' # page.type for post

cdns:
APlayer:
enable: false
url:
js: https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.js
css: https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.css

image-4.png

创建音乐页面

在 /source/[lang] 目录下创建 music/index.md 文件,并设置以下内容。

/source/en/music/index.md 的内容。

1
2
3
4
5
6
---
title: Music
lang: en
type: music
date: 2025-09-03 15:18:05
---

/source/zh-CN/music/index.md 的内容。

1
2
3
4
5
6
---
title: 音乐
lang: zh-CN
type: music
date: 2025-09-03 15:18:05
---

设置页面布局

post.ejs

更新 /themes/arch/layout 下的 post.ejs 文件,并添加如下内容。

1
2
3
4
5
6
<!-- music -->
<% if(page.type === 'music') { %>
<div class="container post-details album-index music-container">
<%- partial('_partial/aplayer') %>
</div>
<% } %>
image.png


更新 /themes/arch/layout 下的文件 layout.ejs 并添加如下内容。

1
2
3
4
5
<!-- 非music页面 -->
<!-- 是否展示以及展示方式,与theme config相关 -->
<% if (page.type !== 'music') { %>
<%- partial('_partial/aplayer') %>
<% } %>
image-1.png

aplayer.ejs

在 /themes/arch/layout/_patial 目录下创建 aplayer.ejs 文件,并设置以下内容。

  1. getMode 将根据 _config.yml 文件和当前页面返回 APlayer 的模式。
  2. theme.cdns.APlayer.enable 将决定 APlayer 的加载方式。
  3. aplayerConfig 是用于初始化 APlayer 的选项,displayLrc 将把默认歌词移动到自定义区域。
  4. originAudioList 保存的是音频资源的 URL,包括歌词、封面、音频。您可以将其修改为您自己的资源 URL。
image-14.png

 

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
<% function getMode(page, showWhen = [], hideWhen = []) {
const hideItem = hideWhen.find((path) => {
const regExp = path.startsWith('/') ? new RegExp(`^${page.lang}${path}`) : new RegExp(`^${page.lang}/${path}`)
return regExp.test(page.path);
});
if (hideItem) {
return;
}
const showItem = showWhen.find(({ mode, type, path }) => {
// 1.根据type匹配
// 1.1 post页面
if (type === '') {
return !page.type;
}
// 1.2 其他页面
if (type) {
// post页面,page.type不存在
return type === page.type
}
// 2. 根据path匹配
if (path) {
return path === '/' ? new RegExp(`^${page.lang}(\/|\/index(\.html)?)?$`).test(page.path) : page.path.startsWith(`${page.lang}/${path}`)
}
})
return showItem?.mode;
} %>
<% let mode = getMode(page, theme.aplayer?.showWhen, theme.aplayer?.hideWhen) %>
<% if (mode) { %>
<!-- link js css of APlayer -->
<% if (theme.cdns && theme.cdns.APlayer && theme.cdns.APlayer.enable) { %>
<link href="<%- theme.cdns.APlayer.url.css %>" rel="stylesheet">
<script defer type="text/javascript" src="<%- theme.cdns.APlayer.url.js %>"></script>
<% } else { %>
<%- css(['/plugins/APlayer.min.css'])%>
<%- js({ src: '/plugins/APlayer.min.js', defer: true, type: 'text/javascript' }) %>
<% } %>

<div id="aplayer"></div>

<script>
const mode = <%- JSON.stringify(mode || 'fixed') %>;
const themeColor = <%- JSON.stringify(theme.themeColor || '#ed7d32') %>;



const originAudioList = [
{
"name": "May It Be",
"artist": "Enya",
"url": "/assets/audio/May It Be-Enya.mp3",
"lrc": "/assets/audio/May It Be-Enya.lrc",
"cover": "/assets/audio/May It Be-Enya.jpg"
},
{
"name": "Jersey Girl",
"artist": "Bruce Springsteen",
"url": "/assets/audio/Jersey Girl-Bruce Springsteen.mp3",
"lrc": "/assets/audio/Jersey Girl-Bruce Springsteen.lrc",
"cover": "/assets/audio/Jersey Girl-Bruce Springsteen.jpg"
},
{
"name": "Misty Mountains",
"artist": "Geoff Castellucci",
"url": "/assets/audio/Misty Mountains-Geoff Castellucci.mp3",
"lrc": "/assets/audio/Misty Mountains-Geoff Castellucci.lrc",
"cover": "/assets/audio/Misty Mountains-Geoff Castellucci.jpg"
},
{
"name": "Someone There For Me",
"artist": "Rebecca Blaylock",
"url": "/assets/audio/Someone There For Me-Rebecca Blaylock.mp3",
"lrc": "/assets/audio/Someone There For Me-Rebecca Blaylock.lrc",
"cover": "/assets/audio/Someone There For Me-Rebecca Blaylock.jpg"
},
{
"name": "Voices",
"artist": "Cheap Trick",
"url": "/assets/audio/Voices-Cheap Trick.mp3",
"lrc": "/assets/audio/Voices-Cheap Trick.lrc",
"cover": "/assets/audio/Voices-Cheap Trick.jpg"
},
{
"name": "We Will Rock You",
"artist": "Queen",
"url": "/assets/audio/We Will Rock You-Queen.mp3",
"lrc": "/assets/audio/We Will Rock You-Queen.lrc",
"cover": "/assets/audio/We Will Rock You-Queen.jpg"
},
{
"name": "他不是你",
"artist": "连诗雅",
"url": "/assets/audio/他不是你-连诗雅.mp3",
"lrc": "/assets/audio/他不是你-连诗雅.lrc",
"cover": "/assets/audio/他不是你-连诗雅.jpg"
},
{
"name": "千千阕歌",
"artist": "陈慧娴",
"url": "/assets/audio/千千阕歌-陈慧娴.mp3",
"lrc": "/assets/audio/千千阕歌-陈慧娴.lrc",
"cover": "/assets/audio/千千阕歌-陈慧娴.jpg"
},
{
"name": "可惜我是水瓶座",
"artist": "杨千嬅",
"url": "/assets/audio/可惜我是水瓶座-杨千嬅.mp3",
"lrc": "/assets/audio/可惜我是水瓶座-杨千嬅.lrc",
"cover": "/assets/audio/可惜我是水瓶座-杨千嬅.jpg"
},
{
"name": "喜欢你",
"artist": "Beyond",
"url": "/assets/audio/喜欢你-Beyond.mp3",
"lrc": "/assets/audio/喜欢你-Beyond.lrc",
"cover": "/assets/audio/喜欢你-Beyond.jpg"
},
{
"name": "富士山下",
"artist": "陈奕迅",
"url": "/assets/audio/富士山下-陈奕迅.mp3",
"lrc": "/assets/audio/富士山下-陈奕迅.lrc",
"cover": "/assets/audio/富士山下-陈奕迅.jpg"
},
{
"name": "小城大事",
"artist": "杨千嬅",
"url": "/assets/audio/小城大事-杨千嬅.mp3",
"lrc": "/assets/audio/小城大事-杨千嬅.lrc",
"cover": "/assets/audio/小城大事-杨千嬅.jpg"
},
{
"name": "少女的祈祷",
"artist": "杨千嬅",
"url": "/assets/audio/少女的祈祷-杨千嬅.mp3",
"lrc": "/assets/audio/少女的祈祷-杨千嬅.lrc",
"cover": "/assets/audio/少女的祈祷-杨千嬅.jpg"
},
{
"name": "晚风心里吹",
"artist": "李克勤",
"url": "/assets/audio/晚风心里吹-李克勤.mp3",
"lrc": "/assets/audio/晚风心里吹-李克勤.lrc",
"cover": "/assets/audio/晚风心里吹-李克勤.jpg"
},
{
"name": "月半小夜曲",
"artist": "李克勤",
"url": "/assets/audio/月半小夜曲-李克勤.mp3",
"lrc": "/assets/audio/月半小夜曲-李克勤.lrc",
"cover": "/assets/audio/月半小夜曲-李克勤.jpg"
},
{
"name": "海阔天空",
"artist": "Beyond",
"url": "/assets/audio/海阔天空-Beyond.mp3",
"lrc": "/assets/audio/海阔天空-Beyond.lrc",
"cover": "/assets/audio/海阔天空-Beyond.jpg"
},
{
"name": "红豆",
"artist": "王菲",
"url": "/assets/audio/红豆-王菲.mp3",
"lrc": "/assets/audio/红豆-王菲.lrc",
"cover": "/assets/audio/红豆-王菲.jpg"
},
]

const container = document.getElementById("aplayer")
const lrcContainerSelector = `.aplayer-lrc-contents`;
let player;
let isUserScrolling = false;
let scrollTimer = null;
// 检查用户是否正在滚动歌词的throttle间隔
let userScrollCheckDelay = 1000;
// 自动滚动歌词的throttle间隔
let scrollIntoViewDelay = 800;

const aplayerConfig = {
container,
autoplay: false,
theme: themeColor,
loop: "all",
order: "list",
preload: "auto",
volume: 0.7,
mutex: true,
listFolded: false,
// use string instead of number
listMaxHeight: '200px',
lrcType: 3,
}
let isNormalMode = true
const modesWithoutLrc = ['fixed', 'mini']
if (modesWithoutLrc.includes(mode)) {
aplayerConfig[mode] = true
isNormalMode = false
}

let audioList = originAudioList;
if (!isNormalMode) {
audioList = audioList.map((item) => ({ ...item, lrc: '' }))
}
aplayerConfig.audio = audioList

document.addEventListener("DOMContentLoaded", initAPlayer);

function initAPlayer() {
player = new APlayer(aplayerConfig);

// music页面,以normal mode播放,在自定义的歌词区域展示歌词
if (isNormalMode) {
displayLrc()
}
}

// 创建新的歌词区域展示歌词
function displayLrc() {
if (!player) {
return;
}
const lrcContainer = document.createElement('div')
lrcContainer.classList.add('custom-lrc-container')
const defaultLrcContainer = document.querySelector('.aplayer-lrc')
// 将原有的歌词容器aplayer-lrc,添加到新的歌词容器custom-lrc-containe里
if (defaultLrcContainer) {
lrcContainer.appendChild(defaultLrcContainer)
container.appendChild(lrcContainer)
}

const lyricContainer = document.querySelector(lrcContainerSelector);

lyricContainer.addEventListener('scroll', throttle(userScrollHandler, userScrollCheckDelay));

player.on('timeupdate', throttle(scrollCurrentLineIntoView, scrollIntoViewDelay))
}

function userScrollHandler() {
// 标记用户滚动开始,停止歌词自动滚动
isUserScrolling = true;
// 清除之前的定时器
if (scrollTimer) clearTimeout(scrollTimer);
// 用户停止滚动后,延迟一段时间后重置标志位
scrollTimer = setTimeout(() => {
isUserScrolling = false;
}, userScrollCheckDelay);
}

function scrollCurrentLineIntoView() {
// 未播放或用户正在滚动,则停止自动滚动
if (player?.audio.paused || isUserScrolling) {
return;
}

const lyricContainer = document.querySelector(lrcContainerSelector);
const currentLine = document.querySelector('.aplayer-lrc-current');
if (!currentLine) return;

// 获取歌词容器和当前歌词行的高度
const containerHeight = lyricContainer.clientHeight;
const lineHeight = currentLine.offsetHeight;

// 计算当前歌词行的顶部偏移量
const lineOffsetTop = currentLine.offsetTop;

// 计算滚动到正中心的位置
let scrollPosition = lineOffsetTop - (containerHeight / 2) + (lineHeight / 2);

if (scrollPosition < 0) {
scrollPosition = 0
}
// 设置滚动位置
lyricContainer.scrollTo({
top: scrollPosition,
behavior: "smooth"
})
}

function throttle(fn, delay = 400) {
let timer = null

return function () {
if (timer) {
return
}
timer = setTimeout(() => {
fn.apply(this, arguments)
timer = null
}, delay)
}

}
</script>

<link rel="stylesheet" href="/css/aplayer.css" />

<% } %>

下载 APlayer

https://www.jsdelivr.com/package/npm/aplayer 下载 APlayer 的 js 和 css 文件到 /themes/arch/source/plugins 目录。

image-2.png

设置页面样式

aplayer.css

在 /themes/arch/source/css 目录下创建 aplayer.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
.aplayer.aplayer-withlrc .aplayer-info {
display: flex;
flex-direction: column;
justify-content: space-between;
}

.custom-lrc-container {
padding-top: 20px;
}

.custom-lrc-container .aplayer-lrc {
position: relative;
text-align: center;
height: auto;
margin: 0;
padding: 0;
}

.custom-lrc-container .aplayer-lrc .aplayer-lrc-contents {
box-sizing: border-box;
max-height: 400px;
overflow: auto;
transform: translateY(0) !important;
}

.custom-lrc-container .aplayer-lrc::before,
.custom-lrc-container .aplayer-lrc::after {
content: none;
}

.custom-lrc-container .aplayer-lrc p {
font-size: 14px;
padding-bottom: 10px !important;
height: 18px !important;
line-height: 18px !important;
opacity: 1;
}

.custom-lrc-container p.aplayer-lrc-current {
font-weight: 700;
display: inline-block;
font-size: 16px;
}

main.styl

更新 /themes/arch/source/css 目录下的 main.styl 文件,并添加以下内容。

1
2
3
.music-container .custom-lrc-container p.aplayer-lrc-current {
color: $theme-color !important;
}
image-3.png

添加翻译

更新 /themes/arch/languages 下的 en.yml 和 zh-CN.yml 文件,并按如下方式设置内容。

/themes/arch/languages/en.yaml 的内容。

1
Music: Music

/themes/arch/languages/zh-CN.yaml 的内容。

1
Music: 音乐

保存和恢复播放状态

这是音乐的 v2 版本。音乐的播放状态(包括index, paused, currentTime, loop, order,请参阅 Options)在页面切换时保持不变,这由 localStorage 实现。另一种方法,例如 pjax 也是可行的。

起初,我认为这很简单。只需在页面unload前将音乐保存到存储中,并在页面load后从存储中获取音乐并更新 APlayer 实例即可。但是,关键在于,某些设备可能禁用自动播放。当禁用自动播放但音频应该正在播放时,必须hack才能播放音频(请参阅下面的enalbeAutoplay)。

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
function canAutoplay(){
if (!player) {
return;
}
console.log('canAutoplay');
// https://developer.chrome.com/blog/autoplay?hl=zh-cn#best_practices_for_web_developers
player.audio.play().then(() => {
console.log('yes');
}).catch((err) => {
console.log('no');
console.error(err);
enalbeAutoplay();
})
}

function enalbeAutoplay(){
if (!player) {
return;
}
const onceFn = () => {
player.play();
document.removeEventListener('click', onceFn)
}
Message.success(autoplayDisallowed, noticeDelay)
document.addEventListener('click', onceFn)
}
image-31.png


image-32.png


还有一点需要注意,音乐的当前时间可能与存储时间不一致。例如,切换音乐时,当前时间不应该从存储中读取。音乐暂停时拖动进度条或点击歌词行,当前时间也不应该与存储时间一致。因此,expectedPlayTime 用于记录正确的当前时间,并在音乐首次播放时跳转到该时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
player.on('seeked', () => {
expectedPlayTime = player.audio.currentTime;
})

player.on('play', () => {
const { index, currentTime, paused } = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (!hasSeekedOnFirstPlay && expectedPlayTime > 0) {
if (index === player.list.index) {
player.seek(expectedPlayTime);
} else {
player.seek(0);
}
hasSeekedOnFirstPlay = true;
}
})

无论如何,这似乎并不像我想象的那么简单。如果你想让音乐从上次中断的地方准确继续播放,最好确保你的设备允许自动播放。

代码更改如下。

image-26.png


image-528.png


image-29.png


image-30.png

点击歌词时更新音频进度

这是音乐的 v3 版本。起初我以为如果不自己解析歌词就无法实现,因为点击歌词行时无法获取时间。但是当我查看 APlayer 的源代码时,我发现解析后的歌词被添加到了 APlayer 实例中,所以我可以通过 player.lrc.parsed[player.list.index]player.lrc.current 获取当前播放音乐的歌词。这正是我需要的。

image-20.png


然后只需在 lrcContainer 中添加点击监听器,并跳转到被点击的那一行即可。但请注意:

  1. 事件目标可能不是歌词行,因此需要使用 parent.classList.contains('aplayer-lrc-contents') 来确保目标行是歌词行。
  2. 由于可能存在重复的歌词,因此请使用被点击行的元素而不是 innerText 来查找 childIndex。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function seekToClickedLine(e) {
if (!player) {
return;
}
const parent = e.target.parentNode;
// make sure clicked element is p
if (!parent.classList.contains('aplayer-lrc-contents')) {
return;
}
// find clicked element's index in its parent
const childIndex = [...parent.children].findIndex(child => child === e.target);
if (childIndex === -1) {
return
}
// lrc for current music
const lrc = player.lrc.parsed[player.list.index];
const item = lrc[childIndex];
console.log(childIndex, item);
if (item) {
player.seek(item[0])
}
}
image-22.png

代码更改如下。

image-21.png


image-23.png


image-24.png

点击toc无法跳转

添加 APlayer 后,我偶尔会发现点击 .toc-link 时无法跳转。我在 DevTools 中发现 APlayer.min.js 有一个点击监听器添加到了 .toc-link 中。这是 APlayer 的问题。

image-11.png


image-12.png


那么该如何修复呢?嗯,移除监听器就行了。

不过,我想看看有没有解决方案,于是去谷歌搜索了一下。结果发现很多结果都没用。还好我看到了这篇文章,结果发现是smoothscroll的问题。我又去谷歌了wangriyu github,终于找到了原始解决方案👍 https://wangriyu.github.io/2018/06-Aplayer.html 。用decodeURIComponent来包裹location.hash或this.hash可以修复这个bug。

image-13.png


但是当 APlayer 从 CDN 加载时,它就没用了。所以我尝试通过移除 toc-link 的点击监听器来解决这个问题。

但是有一个问题。如果要使用 removeEventListener(type, listener) 移除监听器,监听器必须是特定的。为了获取 .toc-link 的监听器,我使用下面的代码片段来修改 addEventListener 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const oldAddEventListener = Element.prototype.addEventListener;
const eventListenersMap = new WeakMap();
Element.prototype.addEventListener = function(type, handler, options) {
if (!eventListenersMap.has(this)) {
eventListenersMap.set(this, []);
}
eventListenersMap.get(this).push({ type, handler, options });
oldAddEventListener.call(this, type, handler, options);
};

// 移除el指定type的监听器,如果不传type,则移除el所有的监听器
window.removeEventListenersByElement = function(el, type) {
const allListeners = eventListenersMap.get(el) || [];
const removedListeners = type ? allListeners.filter(l => l.type === type) : allListeners.slice();
removedListeners.forEach(l => el.removeEventListener(l.type, l.handler, l.options));
// 更新缓存
if (type) {
eventListenersMap.set(el, allListeners.filter(l => l.type !== type));
} else {
eventListenersMap.set(el, []);
}
};

然后使用 removeEventListenersByElement 删除所有 .toc-link 的点击监听器。

1
2
3
4
5
6
7
8
9
// 修复引入的APlayer.min.js,造成toc-link无法跳转
function fixTocLinkNotJump() {
let catLinkList = [...document.getElementsByClassName("toc-link")];
for(const catLink of catLinkList) {
const eventName = 'click';
removeEventListenersByElement(catLink, eventName)
}
// console.log('removeEventListenersByElement toc-link');
}

代码更改如下。

image-7.png


image-8.png


image-9.png


image-10.png

无法更新播放进度

当我使用 hexo server 启动本地服务器时,无法通过拖动进度条、定位或点击歌词行来更新播放进度。后来我参考了 Unable to set playback progress,并添加了脚本 "server:static": "npm run build && hexo server --static" 来解决。