给静态博客音乐播放器补一个单一播放页所有权
给 Hexo 博客加音乐播放器时,很快会遇到一个比 UI 更实际的问题:播放器到底归哪个页面管。
静态博客的页面跳转通常是整页刷新。首页点进文章详情页,原来的 HTMLAudioElement 会随页面一起销毁;新页面重新加载播放器脚本后,只能恢复曲目和进度,不能让刚才那段声音真正不断。要把它做成 SPA 式体验,可以引入 PJAX;要完全绕开页面生命周期,也可以做独立播放器窗口。但这两个方案都会把首版播放器从“博客小浮窗”推向更复杂的运行时。
我这次先把问题压到一个更小的目标:同一时间只允许一个页面拥有播放权。首页在播歌时,文章页新标签打开后只显示“音乐正在首页播放”,不会自动发声;读者在文章页点播放时,文章页接管,首页播放器停下。这个模型不能解决同标签页跳转不断歌,但能先把多页面同时播放这类体验问题管住。
把播放权当成短租约
播放器原来的持久化状态已经有一份 localStorage:曲目、队列、收藏、音量、播放模式和当前进度都在 sheng-blog:music-player:v1 里。这份状态适合长期保存,但不适合表达“当前谁正在播放”。播放权是短期状态,页面关闭、暂停、崩溃或被别的页面接管后都应该很快失效。
所以新增了一份单独的 owner 记录:
// sheng-blog/source/js/music-player/playback-ownership.js:120-130
buildOwner(playing) {
return {
version: 1,
instanceId: this.instanceId,
pageType: this.pageType,
pageUrl: this.pageUrl,
pageTitle: this.pageTitle,
trackId: this.currentTrackId,
playing: Boolean(playing),
updatedAt: Date.now()
};
}这里的 instanceId 是一次页面加载生成的临时身份,updatedAt 是租约心跳时间。播放器不会把 owner 当成永久真相,只把它当成“这几秒内还活着的播放页”。当前实现的心跳间隔是 2 秒,TTL 是 6.5 秒;正常页面暂停或卸载会主动释放,异常关闭时也会在 TTL 后自然过期。
这个 owner 记录和歌单状态分开后,长期数据和运行时协调就不会互相污染。导入导出只处理歌单和偏好,不会把某个旧页面的播放权一起带到另一个浏览器里。
接管只发生在用户点击播放时
浏览器本身限制自动播放,静态博客也不应该在读者刚打开文章页时突然出声。新页面加载播放器后,只读取外部 owner 并更新提示,不会调用 audio.play()。
真正的接管发生在用户点击播放按钮时:
// sheng-blog/source/js/music-player/playback-ownership.js:60-72
claim(params) {
const wasOwner = this.isOwner();
this.ownsPlayback = true;
this.currentTrackId = params && params.trackId ? params.trackId : this.currentTrackId;
const owner = this.buildOwner(true);
writeStoredOwner(this.window, owner);
this.startHeartbeat();
this.postMessage(wasOwner ? "owner-update" : "takeover", owner);
this.notifyExternalOwnerChange();
return owner;
}claim() 做三件事:
- 把当前页面写成新的 owner。
- 启动心跳,持续刷新
updatedAt。 - 通过
BroadcastChannel发一条takeover消息,让旧播放页立刻暂停。
localStorage 负责让新页面加载时能看见“已有页面在播放”,BroadcastChannel 负责让页面之间的接管足够及时。只用 storage 事件也能做,但旧页面可能要等到 storage 变化事件和本地检查;加上 channel 后,交互会更像一个明确的“让权”动作。
播放器核心只接入所有权模块
播放器主类仍然只拥有 Audio、DOM、状态和公开 API。跨页面细节没有直接散进渲染器,也没有塞进导入导出逻辑,而是在 boot() 时创建一个所有权控制器:
// sheng-blog/source/js/music-player/player.js:33-40
const stored = readStoredState();
this.dom = buildPlayerDom(this.document);
this.ownership = createPlaybackOwnership({
window: this.window,
document: this.document,
onExternalOwnerChange: (owner) => this.handleExternalOwnerChange(owner),
onTakeoverByOther: (owner) => this.pauseForExternalOwner(owner)
});播放入口只多了一步:真正调用 audio.play() 之前先声明所有权,播放失败时再释放。
// sheng-blog/source/js/music-player/player.js:203-224
playCurrent() {
const track = this.getCurrentTrack();
if (!track) {
this.setStatus("还没有可播放的歌曲。", true);
return;
}
if (this.ownership) {
this.ownership.claim({ trackId: track.id });
}
this.ensureAudioSource(track);
this.audio.play().then(() => {
this.setStatus("正在播放。", false);
}).catch((error) => {
if (this.ownership) {
this.ownership.release("playback-error");
}
this.setStatus(getPlaybackErrorText(error), true);
this.renderer.renderPlayState();
});
}旧页面收到接管消息后,只做一件事:暂停自己的 Audio,保存进度,然后把 UI 改成“音乐已切到文章页播放”。它不会再去删 owner,因为新的 owner 已经写好了;旧页面如果手欠把 owner 清掉,反而会把刚接管的新页面状态破坏掉。
// sheng-blog/source/js/music-player/player.js:277-286
pauseForExternalOwner(owner) {
if (!this.audio.paused) {
this.state.currentTime = this.getAudioCurrentTime();
this.audio.pause();
this.saveStateNow();
}
this.renderer.renderPlayState();
this.setStatus("音乐已切到" + getPlaybackOwnerLabel(owner) + "播放。", false);
}这个边界对后续维护很关键:所有权模块负责“谁应该让权”,播放器核心负责“怎么停止自己”。两个模块之间只有两个回调,不共享队列内部结构。
首页文章链接改成新标签页
如果首页文章卡片仍然在当前标签页打开,点击文章时首页页面会被浏览器销毁,正在播放的音频也会中断。单一 owner 协议只能协调还活着的页面,不能让被销毁的页面继续发声。
所以首页文章链接改成 _blank:
# sheng-blog/_config.fluid.yml:491-494
# 打开文章的标签方式
# The browser tag to open the post
# Available: _blank | _self
post_url_target: _blank这会让“首页播放、文章页阅读”成为更自然的默认路径:首页作为播放页继续存在,文章页加载播放器但不自动播放。读者如果希望把音乐控制切到文章页,直接在文章页点播放即可。
这个方案留下的边界
同标签页跳转仍然会中断音频。要解决这件事,需要让播放器所在的 JS 上下文不被普通导航销毁,常见方向是 PJAX 或独立播放器窗口。PJAX 会接管站内链接、替换正文区域、重新初始化目录和评论等页面脚本;独立窗口会把播放器生命周期从博客页面里拆出去,但会改变读者使用方式。首版没有引入这些东西,主要是为了保持 Hexo + Fluid 的静态站边界。
localStorage 不可用时,owner 记录也写不进去。页面之间如果仍支持 BroadcastChannel,显式接管还能通知旧页面暂停;但新页面初始加载时无法可靠看见“已有页面在播放”。这种情况下播放器会退回当前页面可用,不把博客正文执行拖垮。
页面异常崩溃时,旧 owner 不会主动释放。短租约的意义就在这里:新页面看到 updatedAt 超过 TTL 后,会把它当作失效记录处理,读者不需要手动清 localStorage。
可以迁移的判断
静态博客播放器最好把三类状态分开看:
- 歌单、收藏、音量和播放模式是长期状态,适合持久化和导入导出。
- 当前音频实例是页面内状态,只要普通导航发生,它就会消失。
- 播放权是跨页面短期状态,需要租约、心跳、过期和显式接管。
把这三类状态拆开后,很多体验决策会变得清楚。首版可以先保证“只有一个页面发声”;后面如果真的需要换页面不断歌,再把 PJAX 或独立窗口作为新的生命周期方案接进来。这样播放器核心不需要重写,只要把“页面不会被销毁”这件事交给更外层的导航策略。