简介
Lumin 主题的音乐页面原本使用 APlayer 库实现单页播放——离开音乐页面,音乐就停了。经过重构,音乐播放器升级为全局跨页播放:无论你在博客的哪个页面,音乐都不会中断,右下角的迷你播放器挂件让你随时控制播放。
架构设计
双层播放器架构
系统采用全局播放器 + 页面播放器双层架构:
1┌─────────────────────────────────────────────┐
2│ baseof.html │
3│ ┌─────────────────────────────────────┐ │
4│ │ <audio id="global-audio-player"> │ │
5│ │ 全局 HTML5 Audio 元素 │ │
6│ │ 生命周期 = 整个站点会话 │ │
7│ └─────────────────────────────────────┘ │
8│ ┌─────────────────────────────────────┐ │
9│ │ global-audio.js (ES Module) │ │
10│ │ 管理全局播放器 + 右下角 Widget │ │
11│ └─────────────────────────────────────┘ │
12│ ┌─────────────────────────────────────┐ │
13│ │ toolbar.html (迷你播放器挂件) │ │
14│ │ 歌曲名(可点击回音乐页) + 控制按钮 │ │
15│ └─────────────────────────────────────┘ │
16└─────────────────────────────────────────────┘
17
18┌─────────────────────────────────────────────┐
19│ music/list.html │
20│ ┌─────────────────────────────────────┐ │
21│ │ 音乐页面 UI(唱片+歌词+列表) │ │
22│ │ 优先使用全局播放器,降级到 APlayer │ │
23│ └─────────────────────────────────────┘ │
24└─────────────────────────────────────────────┘
- 全局播放器:
<audio id="global-audio-player">放在baseof.html中,生命周期贯穿整个站点会话,swup 页面切换时不销毁 - 页面播放器:音乐页面检测到全局播放器存在时,直接控制全局 Audio 元素;否则降级使用 APlayer 实例
状态同步核心:window._gMusic
跨模块通信的关键是一个全局状态对象 window._gMusic:
1window._gMusic = {
2 src: '', // 当前歌曲 URL
3 name: '', // 歌曲名
4 artist: '', // 歌手
5 cover: '', // 封面
6 volume: 0.7, // 音量
7 position: 0, // 播放位置(秒)
8 playing: false, // 是否正在播放
9 index: 0, // 当前歌曲索引
10 playMode: 'list', // 播放模式:list/random/single
11 playlist: [], // 歌单数组
12 platform: '' // 当前平台
13};
所有模块(全局播放器、音乐页面、Widget)都读写同一个 _gMusic 对象,实现状态共享。
核心功能实现
1. 跨页播放
Swup 等 PJAX 库在页面切换时会销毁并重建 DOM,但 <audio> 元素放在 baseof.html 的全局位置,不受 swup 影响:
1// global-audio.js
2export function init() {
3 _s.audio = document.getElementById('global-audio-player');
4 // 绑定事件(只绑一次)
5 if (!_s.audio.dataset.hasGlobalEvents) {
6 _s.audio.addEventListener('timeupdate', onTimeUpdate);
7 _s.audio.addEventListener('play', onPlay);
8 _s.audio.addEventListener('pause', onPause);
9 _s.audio.addEventListener('ended', onEnded);
10 _s.audio.dataset.hasGlobalEvents = '1';
11 }
12}
页面切换时通过 swup:contentReplaced 事件重新绑定 Widget DOM(因为 Widget HTML 被重建了),但 Audio 元素和播放状态不受影响。
2. 多平台歌单
支持 QQ音乐、网易云音乐、酷狗音乐三个平台,通过 Meting API 获取歌单:
1# hugo.toml 配置
2[params.music]
3 defaultPlatform = "tencent"
4 defaultVolume = 0.7
5 [params.music.platforms.tencent]
6 enable = true
7 id = "你的歌单ID"
8 [params.music.platforms.netease]
9 enable = true
10 id = "你的歌单ID"
双 API 并行请求,谁先返回用谁:
1var url1 = 'https://api.i-meto.com/meting/api?server=' + platform + '&type=playlist&id=' + pid;
2var url2 = 'https://api.injahow.cn/meting/?server=' + platform + '&type=playlist&id=' + pid;
3[url1, url2].forEach(function(url) {
4 fetch(url).then(/* ... */).catch(/* ... */);
5});
切换平台时,同步更新全局播放器状态和歌单:
1function handleData(data) {
2 playlist = data.map(/* 构建歌单 */);
3 var ga = getGlobalAudio();
4 if (ga && playlist.length > 0) {
5 ga.src = playlist[0].url;
6 ga.pause();
7 window._gMusic.playlist = playlist;
8 window._gMusic.platform = currentPlatform;
9 // ... 更新其他状态
10 return; // 全局播放器模式下不创建 APlayer
11 }
12 // 降级:创建 APlayer 实例
13 initAPlayer(data);
14}
3. 播放模式
支持三种模式:顺序播放、随机播放、单曲循环。模式状态存储在 _gMusic.playMode 中,全局播放器和音乐页面共享:
1function toggleMode() {
2 var m = ['list', 'random', 'single'];
3 playMode = m[(m.indexOf(playMode) + 1) % 3];
4 if (window._gMusic) window._gMusic.playMode = playMode;
5}
歌曲播放结束时,全局播放器根据模式决定下一步:
1// global-audio.js
2function onEnded() {
3 var mode = window._gMusic ? (window._gMusic.playMode || 'list') : 'list';
4 if (mode === 'single') {
5 _s.audio.currentTime = 0;
6 _s.audio.play();
7 } else {
8 playNext(); // playNext 内部处理 random/list
9 }
10}
4. 歌词同步
歌词使用 LRC 格式解析,通过 250ms 定时器持续同步:
1function startGlobalSync() {
2 _syncTimer = setInterval(function() {
3 var ga = getGlobalAudio();
4 if (!ga || !ga.src) { clearInterval(_syncTimer); return; }
5 updProgFromGlobal(); // 更新进度条
6 updLrcHi(); // 高亮当前歌词
7 updMiniProg(); // 更新迷你进度条
8 }, 250);
9}
歌词高亮算法:遍历所有歌词行,找到 time <= currentTime 的最后一行,将其设为 active,之前的设为 sung,之后的设为 upcoming。
5. 进度条拖拽
进度条支持鼠标和触摸拖拽,优先操作全局播放器:
1function seekAt(e, bar) {
2 var globalAudio = getGlobalAudio();
3 if (globalAudio && isFinite(globalAudio.duration)) {
4 var pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
5 pct = Math.max(0, Math.min(1, pct));
6 globalAudio.currentTime = globalAudio.duration * pct;
7 return;
8 }
9 // 降级到 APlayer
10 if (ap && ap.audio) ap.seek(ap.audio.duration * pct);
11}
6. 页面切换状态恢复
当用户从其他页面切回音乐页面时,init() 从 _gMusic 恢复完整状态:
1function init() {
2 var globalState = window._gMusic;
3 if (globalState && globalState.playlist && globalState.playlist.length > 0) {
4 playlist = globalState.playlist;
5 currentIndex = globalState.index || 0;
6 playMode = globalState.playMode || 'list';
7 volume = globalState.volume !== undefined ? globalState.volume : 0.7;
8 // 恢复 UI:歌曲信息、播放列表、播放按钮、歌词、进度条
9 updateSongInfo(currentIndex);
10 renderPlaylist();
11 setTimeout(hiCur, 150); // 延迟居中滚动到当前歌曲
12 bindGlobalAudioEvents();
13 startGlobalSync();
14 loadLrc();
15 preloadDur();
16 return;
17 }
18 // 没有全局播放器,降级使用 APlayer
19 ensureLibs(function() { fetchPlaylist(); });
20}
7. 播放列表居中高亮
当前播放的歌曲在列表中高亮显示,并自动滚动到可视区域中央:
1function hiCur() {
2 var rows = document.querySelectorAll('#playlist-body tr');
3 for (var i = 0; i < rows.length; i++)
4 rows[i].classList.toggle('active', i === currentIndex);
5 if (!rows[currentIndex]) return;
6 var container = document.querySelector('.playlist-box');
7 if (!container) return;
8 // 使用 getBoundingClientRect 精确计算滚动位置
9 requestAnimationFrame(function() {
10 var rowRect = rows[currentIndex].getBoundingClientRect();
11 var boxRect = container.getBoundingClientRect();
12 var rowCenter = rowRect.top - boxRect.top + container.scrollTop + rowRect.height / 2;
13 container.scrollTop = Math.max(0, rowCenter - boxRect.height / 2);
14 });
15}
8. 迷你播放器 Widget
右下角悬浮的迷你播放器,显示当前歌曲信息,支持播放/暂停/上一首/下一首:
- 歌曲名可点击,跳转回音乐页面
- 鼠标悬停展开完整控制面板
- 音乐页面自动隐藏 Widget(避免重复控制)
- 播放时显示音频波形动画
关键技术点
避免双重播放
当全局播放器存在时,音乐页面不创建 APlayer 实例,直接操作全局 Audio 元素。handleData 中全局分支提前 return,跳过 initAPlayer()。
事件监听器去重
bindCtrl() 使用 _bound 标记防止重复绑定,swup 页面切换时重置标记:
1function bindCtrl() {
2 if (bindCtrl._bound) return;
3 bindCtrl._bound = true;
4 // ... 绑定事件
5}
6// swup 切换时重置
7document.addEventListener('swup:contentReplaced', function() {
8 bindCtrl._bound = false;
9});
ended 事件防冲突
全局播放器的 onEnded 和音乐页面的 ended 监听器都会触发。解决方案:全局播放器负责切歌逻辑,音乐页面只负责 UI 同步:
1// 音乐页面的 ended 监听器
2ga.addEventListener('ended', function() {
3 setTimeout(function() {
4 var newIdx = window._gMusic.index || 0;
5 if (newIdx !== currentIndex) {
6 currentIndex = newIdx;
7 updateSongInfo(currentIndex);
8 hiCur();
9 loadLrc();
10 }
11 }, 200);
12});
localStorage 歌单缓存
歌单数据缓存到 localStorage,避免重复请求 API:
1function getMuzCache(platform, pid) {
2 var raw = localStorage.getItem('muz_playlist_cache');
3 var cache = JSON.parse(raw);
4 if (cache.platform !== platform || cache.pid !== pid) return null;
5 return cache.data;
6}
文件结构
1layouts/
2├── _default/baseof.html # 全局 Audio 元素
3├── music/list.html # 音乐页面(播放器UI + 逻辑)
4└── partials/toolbar.html # 迷你播放器 Widget
5
6assets/
7├── js/modules/global-audio.js # 全局播放器模块
8└── css/main.css # 样式(含播放器样式)
配置说明
在 hugo.toml 中配置音乐功能:
1[params.music]
2 defaultPlatform = "tencent" # 默认平台:tencent/netease/kugou
3 defaultVolume = 0.7 # 默认音量 0-1
4 autoplay = false # 是否自动播放
5
6 [params.music.platforms.tencent]
7 enable = true
8 id = "你的QQ音乐歌单ID"
9
10 [params.music.platforms.netease]
11 enable = true
12 id = "你的网易云音乐歌单ID"
13
14 [params.music.platforms.kugou]
15 enable = false
16 id = ""
总结
全局音乐播放器的核心挑战在于跨页面状态同步——swup 页面切换会销毁 DOM,但 Audio 元素和 _gMusic 状态对象不受影响。通过全局 Audio + 状态对象 + 事件绑定 + 定时器轮询的组合方案,实现了无缝的跨页播放体验。
留言评论
期待你的想法评论加载中