音乐播放器(本地与 Meting 模式)
Mizuki 主题内置的全站背景音乐播放器能力:由 musicPlayerConfig 配置驱动,支持 本地播放列表(local) 与 Meting API 动态歌单(meting) 两种模式,核心逻辑集中在自研的 MusicPlayerStore 状态机中,通过单一 HTMLAudioElement 完成音频播放,并向多个 Svelte UI 组件(悬浮播放器、FAB 面板、侧栏播放器)广播不可变状态快照。
Purpose and Scope
本页覆盖音乐播放器这一完整能力的端到端实现:
- 配置层:
src/config/musicConfig.ts与src/types/config.ts中的MusicPlayerConfig类型契约 - 状态与播放引擎:
src/stores/musicPlayerStore.ts中的MusicPlayerStore类(发布-订阅、音频事件、播放列表加载、错误恢复) - 模式分支:local 模式的
LOCAL_PLAYLIST加载路径,与 meting 模式的 API 模板替换 + 字段容错转换路径 - 运行时行为:自动播放拦截恢复、播放错误自动跳歌、音量持久化、循环/随机模式的内部状态语义
以下内容有意留给兄弟页面,本文不展开:
- 通用悬浮按钮(FAB)组的整体架构与动画 → 见
sidebar-widgets体系下 FAB 相关页面 - 侧栏组件体系(
MusicSidebarWidget.astro挂载与 SSR/客户端水合流程)的通用机制 → 见侧栏小部件总览 - Live2D 看板娘、评论系统等其他小部件 → 各自独立页面
说明:UI 组件层(
MusicPlayer.svelte、FabMusicPanel.svelte、MusicFabButton.svelte、SidebarMusicClient.svelte、useSidebarMusicUI.ts)的内部模板与样式实现不在本文逐一展开,本文聚焦它们共同依赖的播放引擎契约。测试入口为tests/music-player-loading.test.mjs。
Overview
这个组件做什么
MusicPlayerStore 是一个不依赖任何框架状态库(如 Pinia/Zustand)的手写单例 Store。它同时承担四个角色:
- 播放列表提供者:根据
mode配置,要么使用打包内置的LOCAL_PLAYLIST常量,要么在运行时向 Meting API 发起fetch拉取歌单 JSON - 音频引擎封装:持有一个
preload = "none"的HTMLAudioElement,集中处理play / pause / timeupdate / ended / error / loadeddata / loadstart七类事件 - 状态广播中枢:维护
MusicPlayerState,通过subscribe()向 UI 组件推送深拷贝快照,避免组件直接改写内部状态 - 浏览器策略适配器:处理自动播放被浏览器
NotAllowedError拦截后的"首次交互重试",以及单曲播放失败后的"自动跳下一首"
为什么这样设计
- 不引入状态库:播放器状态(进度、加载、错误)更新频率极高(
timeupdate每秒触发多次),手写Set<listener>+ 快照广播是最轻量的方案,避免框架响应式系统的额外开销 preload = "none":播放器是页面加载后才可能使用的增强功能,禁止浏览器预加载音频,保证首屏性能不受音频资源拖累- 模式用配置而非代码切换:
mode是运行时读取的普通字段,且在 Store 内部对每个 Meting 参数都做了??兜底,即使配置文件被删空,播放器仍能以默认 Meting 参数工作(容错优先) createSnapshot()深拷贝currentSong与playlist:防止 UI 拿到可变引用后意外污染 Store 内部状态,这是防御性不可变性的体现
Architecture
架构分层说明:
| 层 | 组件 | 职责 |
|---|---|---|
| 配置层 | musicPlayerConfig / MusicPlayerConfig | 声明式开关与模式选择,类型由 src/types/config.ts 约束 |
| 状态层 | MusicPlayerStore | 唯一的状态所有者与副作用执行者,UI 不直接触碰 HTMLAudioElement |
| 数据常量层 | music-player/constants.ts | LOCAL_PLAYLIST(本地歌单)、SKIP_ERROR_DELAY(跳歌延迟)、STORAGE_KEY_VOLUME(音量存储键)、DEFAULT_COVER_URL / DEFAULT_SONG(兜底值) |
| UI 层 | 5 个 Svelte/Astro 组件 | 三个入口形态:独立悬浮播放器(default)、FAB 集成(fab)、侧栏小部件 |
| 运行时 | HTMLAudioElement + localStorage | 浏览器原生音频能力与音量持久化 |
| 外部 | Meting API | meting 模式下的歌单数据源(第三方中转 API) |
关键依赖方向是单向的:配置 → Store → 音频元素/网络/存储;UI 组件只订阅 Store。MusicPlayerStore 是唯一会调用 audio.play() / audio.pause() / audio.src = ... 的地方,这保证了并发操作(例如用户快速连点切歌)都有统一的序列化处理路径。
主实现:MusicPlayerStore 深度解析
内部状态与初始化门槛
MusicPlayerState 接口定义了全部 17 个状态字段,初始值由 createInitialState() 给出:
1export interface MusicPlayerState {
2 currentSong: Song;
3 playlist: Song[];
4 currentIndex: number;
5 isPlaying: boolean;
6 isLoading: boolean;
7 currentTime: number;
8 duration: number;
9 volume: number;
10 isMuted: boolean;
11 isShuffled: boolean;
12 isRepeating: RepeatMode;
13 showPlaylist: boolean;
14 errorMessage: string;
15 showError: boolean;
16 isExpanded: boolean;
17 isHidden: boolean;
18 autoplayFailed: boolean;
19 willAutoPlay: boolean;
20}Source: musicPlayerStore.ts
其中两个"意图型"字段值得特别注意,它们不是 UI 直接展示的数据,而是引擎内部的控制信号:
autoplayFailed:标记play()调用被浏览器自动播放策略拒绝(NotAllowedError)willAutoPlay:标记"用户意图是播放",用于错误自动跳歌循环中的终止判断(用户主动暂停后,跳歌定时器到期时应放弃)
initialize() 是唯一的入口,有三重保护:
1async initialize(): Promise<void> {
2 if (typeof window === "undefined" || this.isInitialized) {
3 return;
4 }
5 this.isInitialized = true;
6
7 if (!musicPlayerConfig.enable) {
8 return;
9 }
10
11 this.audio = new Audio();
12 this.audio.preload = "none";
13 this.setupAudioListeners();
14 this.loadVolumeFromStorage();
15 this.registerInteractionHandler();
16 await this.loadPlaylist();
17}Source: musicPlayerStore.ts
设计意图:
typeof window === "undefined":Astro 的.astro组件可能在 SSR 阶段执行,此检查保证new Audio()永远只在浏览器执行isInitialized幂等标记:多个 UI 组件(悬浮播放器、侧栏、FAB)都会各自调用initialize(),幂等性避免重复创建HTMLAudioElement导致的多路音频enable检查放在new Audio()之前:配置关闭时零副作用,连音频对象都不创建
发布-订阅模型:快照广播
1private createSnapshot(): MusicPlayerState {
2 return {
3 ...this.state,
4 currentSong: { ...this.state.currentSong },
5 playlist: this.state.playlist.map((song) => ({ ...song })),
6 };
7}
8
9subscribe(listener: (state: MusicPlayerState) => void): () => void {
10 this.listeners.add(listener);
11 listener(this.createSnapshot());
12 return () => {
13 this.listeners.delete(listener);
14 };
15}Source: musicPlayerStore.ts
三个实现细节:
subscribe()立即用快照回调一次:组件挂载即拿到当前状态,无需额外的getState()首拉,简化 UI 侧代码getState()也返回快照而非引用:即使通过 getter 访问也拿不到可变内部引用- 返回取消函数:Svelte 组件
onDestroy时调用即可解绑,防止内存泄漏与僵尸更新
音频事件绑定与派生状态
setupAudioListeners() 一次性注册 7 个原生事件,把 DOM 事件翻译为 Store 状态变更:
1this.audio.addEventListener("play", () => {
2 this.state.isPlaying = true;
3 this.state.autoplayFailed = false; // 成功播放即清除失败标记
4 this.broadcastState();
5});
6
7this.audio.addEventListener("timeupdate", () => {
8 if (this.audio) {
9 this.state.currentTime = this.audio.currentTime;
10 this.broadcastState();
11 }
12});
13
14this.audio.addEventListener("ended", () => {
15 this.handleAudioEnded();
16});
17
18this.audio.addEventListener("error", () => {
19 this.handleAudioError();
20});Source: musicPlayerStore.ts
play 事件处理器清除 autoplayFailed 的时机很讲究:不是在调用 play() 时清,而是在浏览器确认真正开始播放时才清。这样一旦交互重试成功,后续任何状态都不再携带"曾被拦截"的记忆。
核心流程
歌单加载:local 与 meting 的分叉点
loadPlaylist() 是两种模式的总开关,读取配置时对每个参数都做了 ?? 兜底:
1private async loadPlaylist(): Promise<void> {
2 const mode = musicPlayerConfig.mode ?? "meting";
3 const meting_api =
4 musicPlayerConfig.meting_api ??
5 "https://www.bilibili.uno/api?server=:server&type=:type&id=:id&auth=:auth&r=:r";
6 const meting_id = musicPlayerConfig.id ?? "14164869977";
7 const meting_server = musicPlayerConfig.server ?? "netease";
8 const meting_type = musicPlayerConfig.type ?? "playlist";
9
10 if (mode === "meting") {
11 await this.fetchMetingPlaylist(
12 meting_api,
13 meting_server,
14 meting_type,
15 meting_id,
16 );
17 } else {
18 this.loadLocalPlaylist();
19 }
20}Source: musicPlayerStore.ts
注意一个有趣的差异:配置文件中的默认值是 mode: "local",而 Store 内部的兜底默认值是 mode ?? "meting"。即——只要 musicConfig.ts 存在且未删掉 mode 字段,行为以配置文件为准(local);只有当配置字段彻底缺失时,引擎才回退到 meting 模式。这是"配置文件覆盖引擎内置默认"的两级默认设计。
meting 模式:URL 模板替换与容错字段转换
1private async fetchMetingPlaylist(
2 api: string,
3 server: string,
4 type: string,
5 id: string,
6): Promise<void> {
7 if (!api || !id) {
8 return;
9 }
10
11 this.state.isLoading = true;
12 this.broadcastState();
13
14 const apiUrl = api
15 .replace(":server", server)
16 .replace(":type", type)
17 .replace(":id", id)
18 .replace(":auth", "")
19 .replace(":r", Date.now().toString());
20
21 try {
22 const res = await fetch(apiUrl);
23 if (!res.ok) {
24 throw new Error("meting api error");
25 }
26 const list: Record<string, unknown>[] = await res.json();
27 this.state.playlist = list.map((song) => this.convertMetingSong(song));
28 this.state.isLoading = false;
29
30 if (this.state.playlist.length > 0) {
31 this.selectSong(this.state.playlist[0], false);
32 }
33 } catch (_e) {
34 this.showError(i18n(Key.musicPlayerErrorPlaylist));
35 this.state.isLoading = false;
36 }
37 this.broadcastState();
38}Source: musicPlayerStore.ts
实现要点:
- 模板占位符替换:
:server/:type/:id/:auth/:r五个占位符,其中:auth被替换为空串(当前不支持签名 API),:r替换为Date.now()——这是缓存破坏手段,避免 CDN/浏览器缓存返回过期歌单 :r放在最后替换:字符串替换链式执行,若 URL 中含多个相同占位符,String.replace只替换首个匹配if (!api || !id) return静默失败:配置不完整时不报错不弹提示,直接保持空列表状态- 初始
selectSong(playlist[0], false):预选中第一首但不自动播放(第二个参数autoPlay = false),尊重浏览器自动播放策略
convertMetingSong() 是对不可控第三方数据做全面防御的典型示例,每个字段都有类型守卫与兜底:
1private convertMetingSong(song: Record<string, unknown>): Song {
2 const name = typeof song.name === "string" ? song.name : undefined;
3 const songTitle = typeof song.title === "string" ? song.title : undefined;
4 const title = name ?? songTitle ?? i18n(Key.unknownSong);
5 const artistField =
6 typeof song.artist === "string" ? song.artist : undefined;
7 const author = typeof song.author === "string" ? song.author : undefined;
8 const artist = artistField ?? author ?? i18n(Key.unknownArtist);
9 let dur = (song.duration as number | undefined) ?? 0;
10 if (typeof dur === "string") {
11 dur = Number.parseInt(dur, 10);
12 }
13 if (dur > 10000) {
14 dur = Math.floor(dur / 1000);
15 }
16 if (!Number.isFinite(dur) || dur <= 0) {
17 dur = 0;
18 }
19
20 return {
21 id:
22 typeof song.id === "string"
23 ? Number.parseInt(song.id, 10)
24 : ((song.id as number | undefined) ?? 0),
25 title,
26 artist,
27 cover: (song.pic as string | undefined) || DEFAULT_COVER_URL,
28 url: (song.url as string | undefined) ?? "",
29 duration: dur,
30 };
31}Source: musicPlayerStore.ts
字段容错策略一览:
| Meting 原始字段 | 备选字段链 | 最终兜底 | 说明 |
|---|---|---|---|
name | title | i18n(Key.unknownSong) | 不同的 Meting 实现返回 name 或 title,两者都被接受 |
artist | author | i18n(Key.unknownArtist) | 同上,双字段兼容 |
duration | — | 0 | 字符串数字自动 parseInt;> 10000 视为毫秒并除以 1000(不同 API 返回秒或毫秒,用阈值启发式区分) |
id | — | 0 | 字符串/数字双形态解析 |
pic | — | DEFAULT_COVER_URL | 缺图时使用内置占位封面 |
url | — | "" | 空串会导致 selectSong 直接 return(无源不可播) |
这个"毫秒 vs 秒"的启发式(dur > 10000 则除以 1000)是一个务实的工程折衷:不可能要求所有第三方 Meting API 统一时长单位,于是用一个不可能出现在"秒"语义中的大数值作为分界线。
local 模式:内置播放列表
1private loadLocalPlaylist(): void {
2 this.state.playlist = [...LOCAL_PLAYLIST];
3 if (this.state.playlist.length === 0) {
4 this.showError("本地播放列表为空");
5 } else {
6 this.selectSong(this.state.playlist[0], false);
7 }
8}Source: musicPlayerStore.ts
local 模式是同步的:不涉及网络,直接浅拷贝 LOCAL_PLAYLIST 常量([...LOCAL_PLAYLIST] 防止后续对 state.playlist 的操作反向修改常量)。空列表时通过 showError() 提示——注意这里的提示文案是硬编码中文字符串而非 i18n 键,与 meting 路径使用 i18n(Key.musicPlayerErrorPlaylist) 的国际化处理不一致(这是源码现状,可作为改进点)。
播放请求管线:selectSong → ensureAudioSource → play
切歌到真正出声,经过三步管线:
1private selectSong(song: Song, autoPlay = true): void {
2 if (!song.url) {
3 return;
4 }
5 const songChanged = song.url !== this.state.currentSong.url;
6 if (songChanged) {
7 this.releaseAudioSource();
8 this.state.currentSong = { ...song };
9 this.state.currentTime = 0;
10 this.state.duration = song.duration;
11 this.state.isLoading = false;
12 this.pendingSeekTime = null;
13 }
14 this.state.willAutoPlay = autoPlay;
15 if (autoPlay) {
16 this.requestPlayback(false);
17 }
18 this.broadcastState();
19}Source: musicPlayerStore.ts
songChanged 判重是性能关键:用 url 作为歌曲身份标识,点击同一首歌时不重置进度、不重新加载音频,避免了"点当前播放项导致从头开始"的糟糕体验。
1private releaseAudioSource(): void {
2 if (!this.audio || !this.loadedSourceUrl) {
3 return;
4 }
5 this.audio.pause();
6 this.audio.removeAttribute("src");
7 this.audio.load();
8 this.loadedSourceUrl = "";
9}
10
11private ensureAudioSource(): boolean {
12 if (!this.audio || !this.state.currentSong.url) {
13 return false;
14 }
15
16 const sourceUrl = resolveAssetUrl(this.state.currentSong.url);
17 if (sourceUrl === this.loadedSourceUrl) {
18 return true;
19 }
20
21 this.loadedSourceUrl = sourceUrl;
22 this.state.isLoading = true;
23 this.audio.src = sourceUrl;
24 this.audio.load();
25 return true;
26}Source: musicPlayerStore.ts
releaseAudioSource() 三连击(pause → removeAttribute("src") → load())是彻底释放当前音频连接的标准做法:仅 src = "" 不足以终止某些浏览器中仍在进行的媒体下载,removeAttribute + load() 组合才能真正断开底层媒体管线。这在 Meting 场景尤其重要——歌曲 URL 指向外部 CDN,不断开连接会累积网络资源占用。
ensureAudioSource() 引入 loadedSourceUrl 影子记录 + resolveAssetUrl() 资源地址解析(src/utils/asset-url.ts 提供的工具,用于适配 base path 等部署差异)。只有 URL 变化时才重新 audio.load(),同一源重复播放请求直接命中缓存分支返回 true。
自动播放被拦截后的恢复机制
1private requestPlayback(resetErrorBudget: boolean): void {
2 if (!this.audio || !this.state.currentSong.url) {
3 return;
4 }
5 if (resetErrorBudget) {
6 this.resetErrorRetryBudget();
7 }
8 this.state.willAutoPlay = true;
9 if (!this.ensureAudioSource()) {
10 return;
11 }
12
13 const playPromise = this.audio.play();
14 if (playPromise !== undefined) {
15 playPromise.catch((error: unknown) => {
16 if (error instanceof Error && error.name === "NotAllowedError") {
17 this.state.autoplayFailed = true;
18 this.state.isPlaying = false;
19 this.broadcastState();
20 }
21 });
22 }
23}Source: musicPlayerStore.ts
配合初始化时注册的一次性交互监听:
1private registerInteractionHandler(): void {
2 const handler = () => {
3 if (this.state.autoplayFailed) {
4 this.requestPlayback(false);
5 }
6 };
7 document.addEventListener("click", handler, { once: true });
8 document.addEventListener("keydown", handler, { once: true });
9 this.unregisterInteraction = () => {
10 document.removeEventListener("click", handler);
11 document.removeEventListener("keydown", handler);
12 };
13}Source: musicPlayerStore.ts
这是应对浏览器自动播放策略(Chrome/Safari 均禁止无用户手势的 play())的双阶段握手:
- 尝试阶段:初始化后若配置引导自动播放,直接调用
audio.play()。若浏览器拒绝,Promise reject 且error.name === "NotAllowedError",Store 只标记autoplayFailed = true,不弹错误(这不是错误,是预期内的浏览器行为) - 恢复阶段:预先在
document上挂了click/keydown的{ once: true }监听。用户在页面上任意一次点击或按键(即获得"用户手势"资格)时,检查autoplayFailed——若为真则重新请求播放。once: true保证只消费一次手势,不残留监听
只精确匹配 NotAllowedError:其他失败(如网络错误)走 audio 的 error 事件通道,不进入自动播放恢复逻辑,两类失败路径严格隔离。
播放错误自动跳歌:错误预算机制
1private handleAudioError(): void {
2 this.state.isLoading = false;
3 this.state.isPlaying = false;
4 this.showError(i18n(Key.musicPlayerErrorSong));
5 if (this.errorRetryTimer !== null) {
6 return;
7 }
8
9 this.playbackErrorCount += 1;
10 const maxAttempts = Math.max(1, this.state.playlist.length);
11 if (
12 this.state.willAutoPlay &&
13 this.state.playlist.length > 1 &&
14 this.playbackErrorCount < maxAttempts
15 ) {
16 this.errorRetryTimer = setTimeout(() => {
17 this.errorRetryTimer = null;
18 if (!this.state.willAutoPlay) {
19 return;
20 }
21 this.advanceToNext(true);
22 }, SKIP_ERROR_DELAY);
23 } else {
24 this.state.willAutoPlay = false;
25 }
26 this.broadcastState();
27}
28
29private resetErrorRetryBudget(): void {
30 this.playbackErrorCount = 0;
31 if (this.errorRetryTimer !== null) {
32 clearTimeout(this.errorRetryTimer);
33 this.errorRetryTimer = null;
34 }
35}Source: musicPlayerStore.ts 与 musicPlayerStore.ts
Meting 模式下歌单来自外部 API,单首歌的 CDN 链接失效是常态而非异常。这个机制用"错误预算"防止无限跳歌循环:
- 预算上限
maxAttempts = playlist.length:最多把整个歌单试完一轮,全部失败则停机(willAutoPlay = false),不会无限循环 Math.max(1, ...)下限保护:空歌单时预算至少为 1,避免出现0 < 0永假导致死循环或永真异常errorRetryTimer单飞锁:if (this.errorRetryTimer !== null) return;确保同一时刻只有一个跳歌定时器。用户快速切多首歌都失败时,不会叠出多个并发定时器互相干扰- 定时器到期时二次检查
willAutoPlay:若等待期间用户手动暂停了(toggle()把willAutoPlay置 false),到期后直接放弃跳歌——尊重用户最新意图 resetErrorRetryBudget():在用户手动play()/toggle()恢复播放时清零预算并取消未决定时器,给用户"全新的重试机会"而非继续消耗旧预算
循环模式与曲目结束处理
1private handleAudioEnded(): void {
2 if (this.state.isRepeating === 1) {
3 if (this.audio) {
4 this.audio.currentTime = 0;
5 this.audio.play().catch(() => {});
6 }
7 this.broadcastState();
8 } else {
9 this.next(true);
10 }
11}Source: musicPlayerStore.ts
RepeatMode 是数值型枚举(类型定义于 src/components/widgets/music-player/types.ts),从代码语义可读出:
isRepeating 值 | 语义 | ended 时行为 |
|---|---|---|
0 | 关闭循环 | 调用 next(true) 自动进入下一首 |
1 | 单曲循环 | currentTime = 0 后重播(.catch(() => {}) 静默吞掉可能的自动播放拒绝,因为此时已有用户手势) |
2 | 列表循环 | 同样走 next(true)(next() 内部处理索引回绕) |
注意 play().catch(() => {}) 的静默处理——单曲循环的 ended 事件发生在用户已交互过的会话中,理论上不会被策略拦截,但防御性吞错保证极端情况下 UI 不弹出未处理 Promise 拒绝警告。
音量持久化与 pendingSeekTime
1private loadVolumeFromStorage(): void {
2 if (typeof localStorage !== "undefined") {
3 const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME);
4 if (savedVolume) {
5 const volume = Number.parseFloat(savedVolume);
6 if (!Number.isNaN(volume) && volume >= 0 && volume <= 1) {
7 this.state.volume = volume;
8 this.state.isMuted = volume === 0;
9 if (this.audio) {
10 this.audio.volume = volume;
11 this.audio.muted = this.state.isMuted;
12 }
13 }
14 }
15 }
16}Source: musicPlayerStore.ts
音量从 localStorage(键名 STORAGE_KEY_VOLUME)恢复,校验四连:存在性 → parseFloat 非 NaN → >= 0 → <= 1。边界约定:volume === 0 被视为静音(isMuted = true),即"存了 0 就按静音呈现"。
pendingSeekTime 解决的是时序竞态:UI 可能在音频元数据加载完成前发起 seek。handleAudioLoaded()(loadeddata 事件)中检查暂存的 pendingSeekTime,取 Math.min(pendingSeekTime, duration) 钳制越界值后再真正赋给 audio.currentTime,并同步 state.currentTime。同时该方法只在 duration > 1 时才更新时长——过滤掉 Infinity / NaN / 流式媒体返回的非法时长值。
1private handleAudioLoaded(): void {
2 this.state.isLoading = false;
3 if (this.audio?.duration && this.audio.duration > 1) {
4 this.state.duration = Math.floor(this.audio.duration);
5 this.state.currentSong = {
6 ...this.state.currentSong,
7 duration: this.state.duration,
8 };
9 }
10
11 if (this.audio && this.pendingSeekTime !== null) {
12 const seekTime = Math.min(this.pendingSeekTime, this.state.duration);
13 this.audio.currentTime = seekTime;
14 this.state.currentTime = seekTime;
15 this.pendingSeekTime = null;
16 }
17 this.broadcastState();
18}Source: musicPlayerStore.ts
数据模型
Song 类型(src/components/widgets/music-player/types.ts)是两种模式统一的曲目模型,convertMetingSong() 的输出即此结构:
1// src/types/config.ts 中的配置契约
2floatingEntryMode?: "default" | "fab"; // 悬浮入口模式:默认独立播放器或集成到 FAB 组
3mode: "meting" | "local"; // 音乐播放器模式
4meting_api: string; // Meting API 地址Source: config.ts
使用示例
配置声明(local 模式,主题默认)
1import type { MusicPlayerConfig } from "../types/config";
2
3// 音乐播放器配置
4export const musicPlayerConfig: MusicPlayerConfig = {
5 enable: true, // 启用音乐播放器功能
6 showFloatingPlayer: true, // 显示悬浮播放器 UI
7 floatingEntryMode: "fab", // 悬浮入口模式:"default" 为独立悬浮播放器,"fab" 为集成到通用 FAB 组
8 mode: "local", // 音乐播放器模式,可选 "local" 或 "meting"
9 meting_api:
10 "https://meting.mysqil.com/api?server=:server&type=:type&id=:id&auth=:auth&r=:r", // Meting API 地址
11 id: "14164869977", // 歌单ID
12 server: "netease", // 音乐源服务器。有的meting的api源支持更多平台,一般来说,netease=网易云音乐, tencent=QQ音乐, kugou=酷狗音乐, xiami=虾米音乐, baidu=百度音乐
13 type: "playlist", // 撮单类型
14};Source: musicConfig.ts
切换到 meting 模式只需把 mode: "local" 改为 mode: "meting",其余 Meting 参数即生效。配置在 src/config/index.ts 中以 musicPlayerConfig 名义统一导出(该文件注释明确标注 musicPlayerConfig │ musicConfig.ts │ 音乐播放器(本地 / Meting 模式) 的映射关系)。
UI 组件消费 Store 的模式(API 视角)
从 Store 暴露的公共 API 可推断 UI 组件的标准接入方式:
1// 1. 组件挂载时初始化(幂等,可被多个组件安全调用)
2await musicPlayerStore.initialize();
3
4// 2. 订阅状态,立即收到首份快照
5const unsubscribe = musicPlayerStore.subscribe((state) => {
6 // state 是深拷贝快照:currentSong / playlist 均为副本
7 // 可安全用于 Svelte 响应式赋值
8});
9
10// 3. 通过命令式方法驱动播放
11musicPlayerStore.toggle(); // 播放/暂停切换(暂停时重置错误预算)
12musicPlayerStore.play(); // 播放(有守卫:无 audio 或无 url 时直接 return)
13musicPlayerStore.hideError(); // 手动关闭错误提示浮层
14const audio = musicPlayerStore.getAudio(); // 需要直接操作原生元素时(如 seek)
15
16// 4. 组件卸载时解绑
17unsubscribe();Source: musicPlayerStore.ts(getState / getAudio / subscribe 的签名)
上述调用序列是依据 Store 公共方法签名与源码注释归纳的标准用法;具体组件内的模板绑定请参见 UI 组件源文件(MusicPlayer.svelte、SidebarMusicClient.svelte 等)。
切歌与判重(selectSong 内部路径)
1private selectSong(song: Song, autoPlay = true): void {
2 if (!song.url) {
3 return; // 无源歌曲直接忽略,不报错
4 }
5 const songChanged = song.url !== this.state.currentSong.url;
6 if (songChanged) {
7 this.releaseAudioSource(); // 先断开旧源,再换状态
8 this.state.currentSong = { ...song };
9 this.state.currentTime = 0;
10 this.state.duration = song.duration;
11 this.state.isLoading = false;
12 this.pendingSeekTime = null;
13 }
14 this.state.willAutoPlay = autoPlay;
15 if (autoPlay) {
16 this.requestPlayback(false);
17 }
18 this.broadcastState();
19}Source: musicPlayerStore.ts
配置选项
| 选项 | 类型 | 默认值(musicConfig.ts) | 引擎内置兜底 | 说明 |
|---|---|---|---|---|
enable | boolean | true | — | 总开关。false 时 initialize() 在创建 Audio 前直接返回,零副作用 |
showFloatingPlayer | boolean | true | — | 是否渲染悬浮播放器 UI(控制 UI 层可见性) |
floatingEntryMode | "default" | "fab" | "fab" | — | 悬浮入口形态:独立悬浮播放器 或 集成进通用 FAB 组 |
mode | "local" | "meting" | "local" | "meting" | 播放列表来源模式(两级默认,见上文"核心流程") |
meting_api | string | "https://meting.mysqil.com/api?server=:server&type=:type&id=:id&auth=:auth&r=:r" | "https://www.bilibili.uno/api?server=:server&type=:type&id=:id&auth=:auth&r=:r" | Meting API 模板地址,支持 5 个占位符 |
id | string | "14164869977" | "14164869977" | 歌单 ID(Meting 查询目标) |
server | string | "netease" | "netease" | 音乐源平台:netease/tencent/kugou/xiami/baidu(以具体 API 支持为准) |
type | string | "playlist" | "playlist" | Meting 查询类型 |
Source: musicConfig.ts 与 musicPlayerStore.ts
API 参考
以下为 MusicPlayerStore(src/stores/musicPlayerStore.ts)的公共 API。私有方法已在上文流程解析中说明,此处只列对外契约。
initialize(): Promise<void>
初始化播放器。SSR 环境或已初始化时直接返回;enable 为 false 时在创建 HTMLAudioElement 前短路返回。成功路径依次执行:创建 Audio(preload = "none")→ 绑定 7 类音频事件 → 恢复 localStorage 音量 → 注册一次性交互监听 → 异步加载歌单。幂等,多个 UI 组件重复调用安全。
subscribe(listener: (state: MusicPlayerState) => void): () => void
订阅状态变更。注册后立即以当前状态快照回调一次;每次 broadcastState() 推送深拷贝快照(currentSong 与 playlist 均为副本)。返回取消订阅函数。
getState(): MusicPlayerState
返回当前状态的深拷贝快照(与 subscribe 推送的数据同构),永不暴露内部可变引用。
getAudio(): HTMLAudioElement | null
返回内部原生音频元素。未初始化(或 SSR)时为 null。用于需要原生能力的场景(如精确 seek)。
toggle(): void
播放/暂停切换。播放中:置 willAutoPlay = false、重置错误预算、暂停。暂停中:requestPlayback(true)(重置错误预算后请求播放)。无 audio 或当前歌曲无 url 时静默返回。
play(): void
请求播放。有守卫条件(无 audio / 无 url 直接 return),不重置错误预算(区别于 toggle 恢复播放的路径)。
hideError(): void
关闭错误提示(showError = false)并广播。配合 showError() 的 3 秒自动关闭机制(见下文失败模式)。
next(autoPlay: boolean): void
切换下一首(结合 isShuffled 随机模式与索引回绕,由 ended 事件与错误跳歌流程调用;具体索引算法位于文件后半段,advanceToNext 为其内部封装)。
Failure Modes, Edge Cases & Concurrency
错误提示的自动关闭
1private showError(message: string): void {
2 this.state.errorMessage = message;
3 this.state.showError = true;
4 setTimeout(() => {
5 this.state.showError = false;
6 this.broadcastState();
7 }, 3000);
8 this.broadcastState();
9}Source: musicPlayerStore.ts
错误提示展示 3 秒后自动消失;期间用户可通过 hideError() 手动关闭。注意:连续两次 showError 会产生两个并行定时器,后触发的定时器到期时无条件关闭提示——3 秒窗口以最后一次错误为准,这是可接受的简化。
失败模式汇总表
| 失败场景 | 检测点 | 处理方式 | 用户感知 |
|---|---|---|---|
| Meting API 网络失败 / 非 2xx | fetch reject 或 !res.ok | showError(i18n(Key.musicPlayerErrorPlaylist)),isLoading = false | 3 秒错误提示(国际化文案) |
| Meting 返回字段缺失/类型异常 | convertMetingSong 逐字段守卫 | 字段级兜底(未知歌名/未知歌手/默认封面/时长 0) | 仍能播放,显示占位信息 |
歌单为空(meting 返回 []) | playlist.length > 0 判断 | 不 selectSong,保持空列表 | 无首曲预选 |
| 本地播放列表为空 | LOCAL_PLAYLIST.length === 0 | showError("本地播放列表为空") | 3 秒提示(硬编码中文,未走 i18n) |
| 歌曲源加载失败 | audio 的 error 事件 | 显示错误 + 错误预算内延迟跳下一首(SKIP_ERROR_DELAY) | 3 秒提示后自动切歌 |
| 全歌单皆失败 | playbackErrorCount >= maxAttempts | willAutoPlay = false 停机 | 停止自动尝试 |
| 自动播放被拦截 | play() reject 且 name === "NotAllowedError" | 标记 autoplayFailed,等待首次交互重试 | 首次点击/按键后开始播放 |
| 非法时长值 | duration > 1 过滤 | 不更新 state.duration | 进度条保持配置/元数据时长 |
并发与时序保护清单
- 初始化幂等:
isInitialized标记防多次new Audio() - 跳歌定时器单飞锁:
errorRetryTimer !== null时提前 return,避免并发定时器 selectSongURL 判重:同曲不重载、不重置进度releaseAudioSource彻底断源:pause+removeAttribute("src")+load(),防止外部 CDN 连接泄漏pendingSeekTime暂存 seek:元数据未就绪时暂存,loadeddata后钳制执行once: true交互监听:手势监听只消费一次,不残留- 定时器到期二次检查
willAutoPlay:用户在等待期暂停则放弃跳歌 - 快照隔离:
createSnapshot()深拷贝核心嵌套对象,UI 无法污染内部状态
Performance & Operational Notes
preload = "none":音频资源完全懒加载,首次ensureAudioSource()时才开始网络请求,对首屏 LCP/FCP 零影响:r = Date.now()缓存破坏:每次页面加载都请求最新歌单,代价是无法利用 HTTP 缓存——对歌单这种小体积高时效数据是合理取舍timeupdate高频广播:每次timeupdate都执行playlist.map(...)深拷贝快照。歌单大时(数百首)这是潜在热点;当前以正确性(隔离)优先于性能load()同步触发重置:audio.load()会中断当前媒体管线并重置,是切歌路径的必要开销- 运维提示:更换 Meting API 只需改
meting_api配置;自建 Meting 实例(如 meting-api Docker 部署)时可填自托管地址以摆脱第三方依赖与可用性风险
Extension Points
- 新增音乐平台:
server字段直接透传给 Meting API 模板,无需改代码——仅受所选 Meting API 实例支持的平台集合限制 - 新增 UI 入口形态:
floatingEntryMode联合类型"default" | "fab"与组件目录结构对应(MusicPlayer.sveltevsFabMusicPanel.svelte+MusicFabButton.svelte);新增形态时扩展联合类型并添加对应组件即可,Store 层无需改动 - Store 的 UI 框架无关性:
MusicPlayerStore不依赖 Svelte 内部 API(纯 TS 类 +Set<listener>),理论上任何能调用subscribe()的渲染层都能接入 - 国际化:错误文案已走
i18n(Key.musicPlayerErrorSong)/i18n(Key.musicPlayerErrorPlaylist)/i18n(Key.unknownSong)/i18n(Key.unknownArtist);loadLocalPlaylist中的硬编码中文提示是遗留待 i18n 化的缺口
Tests
仓库提供 tests/music-player-loading.test.mjs(播放器加载相关测试)。测试文件用于验证播放器初始化/加载路径的行为约定;具体断言内容以该文件为准(本文未在源码预算内展开其内容)。
Related Links
- 配置契约:config.ts(
MusicPlayerConfig接口) - 配置实现:musicConfig.ts
- 配置导出枢纽:index.ts
- 播放引擎:musicPlayerStore.ts
- 悬浮播放器面板:MusicPlayer.svelte
- FAB 集成面板:FabMusicPanel.svelte
- FAB 入口按钮:MusicFabButton.svelte
- 侧栏挂载点:MusicSidebarWidget.astro
- 侧栏客户端:SidebarMusicClient.svelte
- 侧栏 UI Hook:useSidebarMusicUI.ts
- 测试:music-player-loading.test.mjs