Repository Wiki
LyraVoid/Mizuki

音乐播放器(本地与 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。它同时承担四个角色:

  1. 播放列表提供者:根据 mode 配置,要么使用打包内置的 LOCAL_PLAYLIST 常量,要么在运行时向 Meting API 发起 fetch 拉取歌单 JSON
  2. 音频引擎封装:持有一个 preload = "none" 的 HTMLAudioElement,集中处理 play / pause / timeupdate / ended / error / loadeddata / loadstart 七类事件
  3. 状态广播中枢:维护 MusicPlayerState,通过 subscribe() 向 UI 组件推送深拷贝快照,避免组件直接改写内部状态
  4. 浏览器策略适配器:处理自动播放被浏览器 NotAllowedError 拦截后的"首次交互重试",以及单曲播放失败后的"自动跳下一首"

为什么这样设计

  • 不引入状态库:播放器状态(进度、加载、错误)更新频率极高(timeupdate 每秒触发多次),手写 Set<listener> + 快照广播是最轻量的方案,避免框架响应式系统的额外开销
  • preload = "none":播放器是页面加载后才可能使用的增强功能,禁止浏览器预加载音频,保证首屏性能不受音频资源拖累
  • 模式用配置而非代码切换:mode 是运行时读取的普通字段,且在 Store 内部对每个 Meting 参数都做了 ?? 兜底,即使配置文件被删空,播放器仍能以默认 Meting 参数工作(容错优先)
  • createSnapshot() 深拷贝 currentSong 与 playlist:防止 UI 拿到可变引用后意外污染 Store 内部状态,这是防御性不可变性的体现

Architecture

Loading diagram...

架构分层说明:

层组件职责
配置层musicPlayerConfig / MusicPlayerConfig声明式开关与模式选择,类型由 src/types/config.ts 约束
状态层MusicPlayerStore唯一的状态所有者与副作用执行者,UI 不直接触碰 HTMLAudioElement
数据常量层music-player/constants.tsLOCAL_PLAYLIST(本地歌单)、SKIP_ERROR_DELAY(跳歌延迟)、STORAGE_KEY_VOLUME(音量存储键)、DEFAULT_COVER_URL / DEFAULT_SONG(兜底值)
UI 层5 个 Svelte/Astro 组件三个入口形态:独立悬浮播放器(default)、FAB 集成(fab)、侧栏小部件
运行时HTMLAudioElement + localStorage浏览器原生音频能力与音量持久化
外部Meting APImeting 模式下的歌单数据源(第三方中转 API)

关键依赖方向是单向的:配置 → Store → 音频元素/网络/存储;UI 组件只订阅 Store。MusicPlayerStore 是唯一会调用 audio.play() / audio.pause() / audio.src = ... 的地方,这保证了并发操作(例如用户快速连点切歌)都有统一的序列化处理路径。

主实现:MusicPlayerStore 深度解析

内部状态与初始化门槛

MusicPlayerState 接口定义了全部 17 个状态字段,初始值由 createInitialState() 给出:

typescript
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() 是唯一的入口,有三重保护:

typescript
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

设计意图:

  1. typeof window === "undefined":Astro 的 .astro 组件可能在 SSR 阶段执行,此检查保证 new Audio() 永远只在浏览器执行
  2. isInitialized 幂等标记:多个 UI 组件(悬浮播放器、侧栏、FAB)都会各自调用 initialize(),幂等性避免重复创建 HTMLAudioElement 导致的多路音频
  3. enable 检查放在 new Audio() 之前:配置关闭时零副作用,连音频对象都不创建

发布-订阅模型:快照广播

typescript
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 状态变更:

typescript
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() 是两种模式的总开关,读取配置时对每个参数都做了 ?? 兜底:

typescript
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 模板替换与容错字段转换

typescript
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() 是对不可控第三方数据做全面防御的典型示例,每个字段都有类型守卫与兜底:

typescript
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 原始字段备选字段链最终兜底说明
nametitlei18n(Key.unknownSong)不同的 Meting 实现返回 name 或 title,两者都被接受
artistauthori18n(Key.unknownArtist)同上,双字段兼容
duration—0字符串数字自动 parseInt;> 10000 视为毫秒并除以 1000(不同 API 返回秒或毫秒,用阈值启发式区分)
id—0字符串/数字双形态解析
pic—DEFAULT_COVER_URL缺图时使用内置占位封面
url—""空串会导致 selectSong 直接 return(无源不可播)

这个"毫秒 vs 秒"的启发式(dur > 10000 则除以 1000)是一个务实的工程折衷:不可能要求所有第三方 Meting API 统一时长单位,于是用一个不可能出现在"秒"语义中的大数值作为分界线。

local 模式:内置播放列表

typescript
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

切歌到真正出声,经过三步管线:

typescript
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 作为歌曲身份标识,点击同一首歌时不重置进度、不重新加载音频,避免了"点当前播放项导致从头开始"的糟糕体验。

typescript
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。

自动播放被拦截后的恢复机制

typescript
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

配合初始化时注册的一次性交互监听:

typescript
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())的双阶段握手:

  1. 尝试阶段:初始化后若配置引导自动播放,直接调用 audio.play()。若浏览器拒绝,Promise reject 且 error.name === "NotAllowedError",Store 只标记 autoplayFailed = true,不弹错误(这不是错误,是预期内的浏览器行为)
  2. 恢复阶段:预先在 document 上挂了 click / keydown 的 { once: true } 监听。用户在页面上任意一次点击或按键(即获得"用户手势"资格)时,检查 autoplayFailed——若为真则重新请求播放。once: true 保证只消费一次手势,不残留监听

只精确匹配 NotAllowedError:其他失败(如网络错误)走 audio 的 error 事件通道,不进入自动播放恢复逻辑,两类失败路径严格隔离。

播放错误自动跳歌:错误预算机制

typescript
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() 恢复播放时清零预算并取消未决定时器,给用户"全新的重试机会"而非继续消耗旧预算

循环模式与曲目结束处理

typescript
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

typescript
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 / 流式媒体返回的非法时长值。

typescript
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() 的输出即此结构:

Loading diagram...
typescript
1// src/types/config.ts 中的配置契约 2floatingEntryMode?: "default" | "fab"; // 悬浮入口模式:默认独立播放器或集成到 FAB 组 3mode: "meting" | "local"; // 音乐播放器模式 4meting_api: string; // Meting API 地址

Source: config.ts

使用示例

配置声明(local 模式,主题默认)

typescript
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 组件的标准接入方式:

typescript
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 内部路径)

typescript
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)引擎内置兜底说明
enablebooleantrue—总开关。false 时 initialize() 在创建 Audio 前直接返回,零副作用
showFloatingPlayerbooleantrue—是否渲染悬浮播放器 UI(控制 UI 层可见性)
floatingEntryMode"default" | "fab""fab"—悬浮入口形态:独立悬浮播放器 或 集成进通用 FAB 组
mode"local" | "meting""local""meting"播放列表来源模式(两级默认,见上文"核心流程")
meting_apistring"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 个占位符
idstring"14164869977""14164869977"歌单 ID(Meting 查询目标)
serverstring"netease""netease"音乐源平台:netease/tencent/kugou/xiami/baidu(以具体 API 支持为准)
typestring"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

错误提示的自动关闭

typescript
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 网络失败 / 非 2xxfetch reject 或 !res.okshowError(i18n(Key.musicPlayerErrorPlaylist)),isLoading = false3 秒错误提示(国际化文案)
Meting 返回字段缺失/类型异常convertMetingSong 逐字段守卫字段级兜底(未知歌名/未知歌手/默认封面/时长 0)仍能播放,显示占位信息
歌单为空(meting 返回 [])playlist.length > 0 判断不 selectSong,保持空列表无首曲预选
本地播放列表为空LOCAL_PLAYLIST.length === 0showError("本地播放列表为空")3 秒提示(硬编码中文,未走 i18n)
歌曲源加载失败audio 的 error 事件显示错误 + 错误预算内延迟跳下一首(SKIP_ERROR_DELAY)3 秒提示后自动切歌
全歌单皆失败playbackErrorCount >= maxAttemptswillAutoPlay = false 停机停止自动尝试
自动播放被拦截play() reject 且 name === "NotAllowedError"标记 autoplayFailed,等待首次交互重试首次点击/按键后开始播放
非法时长值duration > 1 过滤不更新 state.duration进度条保持配置/元数据时长

并发与时序保护清单

  1. 初始化幂等:isInitialized 标记防多次 new Audio()
  2. 跳歌定时器单飞锁:errorRetryTimer !== null 时提前 return,避免并发定时器
  3. selectSong URL 判重:同曲不重载、不重置进度
  4. releaseAudioSource 彻底断源:pause + removeAttribute("src") + load(),防止外部 CDN 连接泄漏
  5. pendingSeekTime 暂存 seek:元数据未就绪时暂存,loadeddata 后钳制执行
  6. once: true 交互监听:手势监听只消费一次,不残留
  7. 定时器到期二次检查 willAutoPlay:用户在等待期暂停则放弃跳歌
  8. 快照隔离: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.svelte vs FabMusicPanel.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(播放器加载相关测试)。测试文件用于验证播放器初始化/加载路径的行为约定;具体断言内容以该文件为准(本文未在源码预算内展开其内容)。

Sources

(2 files)