add music
2025-09-07  / site  / feature

foreword

If you want to add APlayer to you site, just two steps.

  1. load APlayer, from cdn or local.
  2. init APlayer.

However, if you have customized requirements, more things need to be done.

For example, I want a fixed APlayer without lyrics on some pages of my site, while a normal APlayer with lyrics embedded in custom area on the /music page. Because I want to see lyrics at the /music page while not at other page.

In order to control how APlayer is displayed in different page, I add aplayer configuration to _config.yml. Here are what I have done.

add music navbar and aplayer config

There are two things:

  1. add navbar for /music page
  2. add aplayer configuration about how APlayer is loaded (see cdns) and on which page APlayer will be displayed (see aplayer.hideWhen, aplayer.showWhen).

In the following configuration, APlayer will be displayed only when page is /music, /, /about or post, and on /music page, APlayer will display lyrics on a custom area while will not display lyrics on other pages.

How APlayer is loaded will be determined by cnds. By default, it is loaded from local. If you have fast connection to APlayer's cdn, you can turn cnds.APlayer.enable to 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

create music page

Create file music/index.md under /source/[lang], set content as below.

Content of /source/en/music/index.md.

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

Content of /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
---

set page layout

post.ejs

Update file post.ejs under /themes/arch/layout and add content as below.

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


Update file layout.ejs under /themes/arch/layout and add content as below.

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

aplayer.ejs

Create file aplayer.ejs under /themes/arch/layout/_patial and set content as below.

  1. getMode will return mode of APlayer according to _config.yml and current page.
  2. theme.cdns.APlayer.enable will determine how APlayer is loaded.
  3. aplayerConfig is option to init APlayer, and displayLrc will move default lyrics to a custom area.
  4. originAudioList saves audio resource url, including lyrics, cover, audio. You may change it to your own resource 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" />

<% } %>

dowload APlayer

Download js and css of APlayer to /themes/arch/source/plugins from https://www.jsdelivr.com/package/npm/aplayer.

image-2.png

set page style

aplayer.css

Create file aplayer.css under /themes/arch/source/css and set content as below.
This style is for lyrics custom display area.

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

Update file main.styl under /themes/arch/source/css and add content as below.

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

add translation

Update en.yml and zh-CN.yml under /themes/arch/languages and set content as below.

Content of /themes/arch/languages/en.yaml.

1
Music: Music

Content of /themes/arch/languages/zh-CN.yml

1
Music: 音乐

save and restore playing status

This is v2 version of music. Playing status of music (including index, paused, currentTime, loop, order, see in Options ) will remain unchanged when page changes, which is implmented by localStorage. Another method like pjax is also feasible.

At first, I thought it quite easy. Just save to storage before page unloads and get from storage and update APlayer instance after page loads. But, the point is, autoplay may be disallowed depending on your device. When it's disallowed and audio should be playing, you have to hacking it to make audio play (see enalbeAutoplay below).

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


Another point is, current time of music may not be that from storage. For example, when you switch music, current time should not read from storage. When music is paused, drag the progress bar or click on the lyric row, current time should not be that from storage. So expectedPlayTime is used to record the right current time and seek to it when a music is played at the first time.

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;
}
})

Anyway, it doesn't seem as easy as I thought. If you want your music to resume exactly where you left off, it's best to ensure your device allows autoplay.

Code changes as below.

image-26.png


image-528.png


image-29.png


image-30.png

update audio progress when lyrics clicked

This is v3 version of music. At first I thought it impossible without parsing lyrics myself because time can not accessed when lyric line is clicked. But when I see source code of APlayer, I find parsed lyrics added to APlayer instance, so I can get lyrics of current played music by player.lrc.parsed[player.list.index] or player.lrc.current. That's exactly what I need.

image-20.png


Then just add click listener to lrcContainer and seek to the clicked line. But do notice please:

  1. event target may not be lyric row, so parent.classList.contains('aplayer-lrc-contents') is needed to ensure it.
  2. find childIndex by element of clicked row instead of innerText because there may be duplicated lyrics.
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

Code changes as below.

image-21.png


image-23.png


image-24.png

unable to jump when toc clicked

After APlayer is added, I occasionally find it can not jump when .toc-link is clicked. From DevTools I find a click listener from APlayer.min.js added to .toc-link. That's the fault of APlayer.

image-11.png


image-12.png


Then how to fix it? Well, just remove the listener.

However, I want to see whether there is already a solution, so I google it. Well, many results are useless. Luckily, I see this post, it turns out to be smoothscroll's fault. I google wangriyu github and finally find the original solution 👍 https://wangriyu.github.io/2018/06-Aplayer.html . Using decodeURIComponent to wrap location.hash or this.hash will fix this bug.

image-13.png


But it won't be helpful when APlayer is loaded from cdn. So I try to solve it by remove click listener of toc-link.

But there is a problem. A listener must be specific if you want to remove it using removeEventListener(type, listener). In order to get the listener of .toc-link, I hack addEventListener by using snippet below.

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, []);
}
};

Then use removeEventListenersByElement to remove all click listener of .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');
}

Code changes as below.

image-7.png


image-8.png


image-9.png


image-10.png

unable to update playback progress

When I use hexo server to start local server, I can't update playback progress by dragging the progress bar or seeking or clicking on the lyric row. Then I learn from Unable to set playback progress, and add a script "server:static": "npm run build && hexo server --static" to make it work.