写在前面
这篇文章记录一下我给 blog-v3 添加「侧边栏音乐播放器」的过程。
最终效果是:在首页右侧边栏新增一个「音乐」模块,可以从服务端接口读取歌单,然后在侧边栏里完成播放、暂停、上一首、下一首、进度拖动、音量调节和歌单切换。
它不是那种全局悬浮播放器,也不是通过 Teleport 挂到 body 下面的弹窗,而是一个真正嵌在 blog-v3 右侧边栏 widget 系统里的组件。
最终功能包括:
- 右侧边栏显示音乐卡片;
- 从
/api/music获取歌单数据; - 使用浏览器原生
<audio>播放; - 显示封面、歌曲名、歌手;
- 支持播放 / 暂停;
- 支持上一首 / 下一首;
- 支持播放进度条;
- 支持音量滑块;
- 支持展开 / 收起音乐列表;
- 歌单列表在音乐模块内部展开,不使用浮层;
- 接口失败时提供 fallback 歌单;
- 适配 blog-v3 原有主题变量和组件风格。
实现思路
整个音乐模块分成四层:
blog.config.ts
↓
server/api/music.get.ts
↓
app/components/widget/MusicPlayer.vue
↓
app/composables/useWidgets.ts + 页面 setAside()
分别负责:
blog.config.ts:配置音乐 API、歌单 ID、来源平台等。server/api/music.get.ts:服务端代理请求歌单,避免前端直接暴露或处理跨域。app/components/widget/MusicPlayer.vue:实现播放器 UI 和交互逻辑。app/composables/useWidgets.ts:把音乐播放器注册成 blog-v3 的侧边栏 widget。- 页面中通过
layoutStore.setAside(['music-player'])挂载到右侧边栏。
为什么不直接在前端请求第三方音乐 API
一开始很容易想到直接在播放器组件里请求外部音乐接口,比如:
await $fetch('https://music.example.com/api?id=xxx')
但这样有几个问题:
- 可能遇到浏览器跨域限制。
- 请求头不好控制。
- 外部接口地址完全暴露在前端。
- 失败兜底逻辑会散落在组件里。
- 后续切换音乐源不方便。
所以更合适的做法是:在 Nuxt 服务端新增一个 /api/music,前端只请求自己站点的接口。
这样播放器只关心一件事:
const { data: tracks } = await useFetch('/api/music')
外部 API、请求头、fallback、缓存策略全部放到服务端处理。
第一步:在 blog.config.ts 添加音乐配置
打开:
blog.config.ts
在 blogConfig 中新增 music 配置:
const blogConfig = {
// 其他配置...
/** 右侧边栏音乐播放器 */
music: {
api: 'https://music.moodlog.cn/api',
headers: {
'Referer': 'https://music.moodlog.cn/',
'User-Agent': 'Mozilla/5.0',
},
playlistId: 319670859,
server: 'netease',
type: 'playlist',
},
// 其他配置...
}
这里的字段含义是:
| 字段 | 说明 |
|---|---|
api | 音乐 API 地址 |
headers | 请求外部音乐 API 时携带的请求头 |
playlistId | 歌单 ID |
server | 音乐平台,例如 netease |
type | 请求类型,例如 playlist |
如果你使用的是其他音乐 API,只需要保持服务端返回的数据最终能被播放器识别即可。
播放器默认支持这些字段:
{
title?: string
name?: string
author?: string
artist?: string
url?: string
pic?: string
cover?: string
lrc?: string
}
也就是说,歌曲名可以是 title 或 name,歌手可以是 author 或 artist,封面可以是 pic 或 cover。
第二步:新增服务端音乐 API
创建文件:
server/api/music.get.ts
代码如下:
import blogConfig from '~~/blog.config'
type MusicTrack = {
author?: string
lrc?: string
pic?: string
title?: string
url?: string
}
const musicOrigin = 'https://music.moodlog.cn'
const fallbackPlaylist = [
{
author: 'MoodLog',
lrc: '',
pic: blogConfig.author.avatar,
title: blogConfig.title,
url: 'https://music.163.com/song/media/outer/url?id=1824045033.mp3',
},
]
function isValidPlaylist(playlist: unknown): playlist is MusicTrack[] {
return Array.isArray(playlist) && playlist.length > 0
}
function normalizeMusicUrl(value: unknown) {
if (typeof value !== 'string')
return value
return value.replace(/^http:\/\/music\.moodlog\.cn(?=\/)/, musicOrigin)
}
function normalizeTrack(track: MusicTrack): MusicTrack {
return {
...track,
lrc: normalizeMusicUrl(track.lrc) as string | undefined,
url: normalizeMusicUrl(track.url) as string | undefined,
}
}
function normalizePlaylist(playlist: MusicTrack[]) {
return playlist.map(normalizeTrack)
}
export default defineEventHandler(async (event) => {
const music = blogConfig.music
const query = new URLSearchParams({
id: String(music.playlistId),
server: music.server,
type: music.type,
})
setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=21600')
try {
const playlist = await $fetch(`${music.api}?${query.toString()}`, {
headers: music.headers,
})
if (!isValidPlaylist(playlist)) {
throw createError({
statusCode: 502,
statusMessage: 'Music playlist response is invalid',
})
}
return normalizePlaylist(playlist)
}
catch (error) {
console.warn('[music] upstream request failed, using fallback playlist', error)
setResponseStatus(event, 200)
setHeader(event, 'X-Music-Fallback', '1')
return fallbackPlaylist
}
})
这里有几个关键点。
1. 使用服务端代理请求歌单
前端不直接请求外部接口,而是请求:
/api/music
服务端再去请求:
https://music.moodlog.cn/api?id=xxx&server=netease&type=playlist
这样前端代码更干净,也更容易处理异常。
2. 添加缓存头
setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=21600')
这行的作用是让歌单接口可以被缓存一段时间,避免每次访问页面都去请求上游音乐 API。
简单理解:
max-age=300:浏览器缓存 5 分钟;s-maxage=300:CDN / Vercel 边缘缓存 5 分钟;stale-while-revalidate=21600:旧缓存可以继续短暂使用,同时后台刷新。
3. 外部接口失败时使用 fallback
如果音乐接口挂了,播放器不应该整个报错,所以这里准备了一首 fallback 歌曲:
const fallbackPlaylist = [
{
author: 'MoodLog',
lrc: '',
pic: blogConfig.author.avatar,
title: blogConfig.title,
url: 'https://music.163.com/song/media/outer/url?id=1824045033.mp3',
},
]
接口失败时仍然返回 200:
setResponseStatus(event, 200) setHeader(event, 'X-Music-Fallback', '1') return fallbackPlaylist
这样前端播放器不用处理太复杂的错误状态。
第三步:创建音乐播放器组件
创建文件:
app/components/widget/MusicPlayer.vue
组件整体分为三部分:
- 歌单数据获取和标准化;
- 播放控制逻辑;
- 模板和样式。
组件 script 部分
<script setup lang="ts">
interface MusicTrack {
title?: string
name?: string
author?: string
artist?: string
url?: string
pic?: string
cover?: string
lrc?: string
}
interface PlaylistTrack {
name: string
artist: string
url: string
cover?: string
lrc?: string
}
const audioEl = useTemplateRef<HTMLAudioElement>('audioEl')
const currentIndex = ref(0)
const isPlaying = ref(false)
const duration = ref(0)
const currentTime = ref(0)
const progressModel = ref(0)
const volumeModel = ref(70)
const isSeeking = ref(false)
const playError = ref('')
const showPlaylistPanel = ref(false)
const { data: tracks, error } = await useFetch<MusicTrack[]>('/api/music', {
default: () => [],
})
const playlist = computed<PlaylistTrack[]>(() => tracks.value
.map(track => ({
name: track.name ?? track.title ?? '未知歌曲',
artist: track.artist ?? track.author ?? '未知歌手',
url: track.url ?? '',
cover: track.cover ?? track.pic,
lrc: track.lrc,
}))
.filter(track => Boolean(track.url)))
const currentTrack = computed(() => playlist.value[currentIndex.value])
const progressPercent = computed(() => duration.value > 0 ? Math.min(100, (currentTime.value / duration.value) * 100) : 0)
const elapsedLabel = computed(() => formatAudioTime(currentTime.value))
const durationLabel = computed(() => formatAudioTime(duration.value))
function formatAudioTime(value: number) {
if (!Number.isFinite(value) || value <= 0)
return '00:00'
const minutes = Math.floor(value / 60)
const seconds = Math.floor(value % 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
</script>
这里先定义了两个类型。
MusicTrack 对应接口原始数据,PlaylistTrack 是播放器内部使用的统一格式。这样后续模板只需要使用:
currentTrack.name currentTrack.artist currentTrack.cover currentTrack.url
不用到处判断 title、name、author、artist。
播放状态同步
继续在 <script setup> 中加入:
function syncAudioState() {
const audio = audioEl.value
if (!audio)
return
currentTime.value = audio.currentTime || 0
duration.value = Number.isFinite(audio.duration) ? audio.duration : 0
isPlaying.value = !audio.paused && !audio.ended
if (!isSeeking.value)
progressModel.value = progressPercent.value
}
function syncVolume() {
const audio = audioEl.value
if (!audio)
return
audio.volume = volumeModel.value / 100
}
syncAudioState() 用来同步原生 <audio> 的状态:当前播放时间、总时长、是否正在播放、当前进度条百分比。
syncVolume() 则把 0 - 100 的滑块值转换成 <audio> 需要的 0 - 1:
audio.volume = volumeModel.value / 100
播放 / 暂停
async function togglePlay() {
const audio = audioEl.value
if (!audio || !currentTrack.value)
return
playError.value = ''
if (!audio.paused) {
audio.pause()
isPlaying.value = false
return
}
try {
await audio.play()
isPlaying.value = true
}
catch (error) {
console.warn('[music] native audio playback failed', error)
playError.value = '播放失败,请稍后再试'
isPlaying.value = false
}
}
现代浏览器对自动播放有限制,所以 audio.play() 可能失败。因此一定要用 try...catch 包起来。
上一首 / 下一首
function playNext() {
if (!playlist.value.length)
return
currentIndex.value = (currentIndex.value + 1) % playlist.value.length
}
function playPrevious() {
if (!playlist.value.length)
return
currentIndex.value = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length
}
这里使用取模实现循环播放。比如当前是最后一首,点击下一首会回到第一首。
歌单展开和选择歌曲
function togglePlaylistPanel() {
showPlaylistPanel.value = !showPlaylistPanel.value
}
function selectTrack(index: number) {
currentIndex.value = index
}
这里没有用弹窗,也没有用全局浮层。歌单只是音乐模块内部的一个 <section>,通过 showPlaylistPanel 控制显示隐藏。
拖动播放进度
function seek() {
const audio = audioEl.value
if (!audio || !duration.value)
return
audio.currentTime = (progressModel.value / 100) * duration.value
syncAudioState()
}
function startSeek() {
isSeeking.value = true
}
function endSeek() {
isSeeking.value = false
seek()
}
这里有一个细节:拖动进度条时,timeupdate 事件仍然可能不断更新进度条,导致用户拖动体验不好。
所以加了一个状态:
const isSeeking = ref(false)
同步状态时判断:
if (!isSeeking.value) progressModel.value = progressPercent.value
切歌时重置 audio
watch(currentTrack, async () => {
playError.value = ''
currentTime.value = 0
duration.value = 0
progressModel.value = 0
await nextTick()
const audio = audioEl.value
if (!audio)
return
audio.load()
if (isPlaying.value)
await togglePlay()
})
watch(playlist, (list) => {
if (currentIndex.value >= list.length)
currentIndex.value = 0
})
onBeforeUnmount(() => {
audioEl.value?.pause()
})
切换歌曲时需要清空错误提示、重置时间和进度条,并调用 audio.load() 重新加载音频资源。组件卸载时暂停播放,避免页面切换后还有音频残留。
第四步:编写播放器模板
模板的核心是把播放器放进 BlogWidget:
<template>
<BlogWidget card title="音乐">
<ClientOnly>
<div v-if="currentTrack" class="music-player">
<audio
ref="audioEl"
:src="currentTrack.url"
preload="metadata"
@timeupdate="syncAudioState"
@loadedmetadata="syncAudioState"
@play="syncAudioState"
@pause="syncAudioState"
@ended="playNext"
/>
<div class="music-main">
<img
v-if="currentTrack.cover"
class="music-cover"
:src="currentTrack.cover"
:alt="currentTrack.name"
loading="lazy"
>
<div v-else class="music-cover music-cover-fallback">
<Icon name="tabler:music" />
</div>
<div class="music-info">
<strong class="music-title" :title="currentTrack.name">{{ currentTrack.name }}</strong>
<span class="music-artist" :title="currentTrack.artist">{{ currentTrack.artist }}</span>
</div>
</div>
<div class="music-controls" aria-label="音乐播放器控制">
<ZButton class="music-control" icon="tabler:player-skip-back-filled" aria-label="上一首" title="上一首" @click="playPrevious" />
<ZButton class="music-control music-control-main" :icon="isPlaying ? 'tabler:player-pause-filled' : 'tabler:player-play-filled'" primary :aria-label="isPlaying ? '暂停' : '播放'" :title="isPlaying ? '暂停' : '播放'" @click="togglePlay" />
<ZButton class="music-control" icon="tabler:player-skip-forward-filled" aria-label="下一首" title="下一首" @click="playNext" />
<ZButton class="music-control" icon="tabler:list" :aria-label="showPlaylistPanel ? '收起音乐列表' : '展开音乐列表'" :title="showPlaylistPanel ? '收起音乐列表' : '展开音乐列表'" @click="togglePlaylistPanel" />
</div>
<div class="music-progress">
<span>{{ elapsedLabel }}</span>
<ZSlider
v-model="progressModel"
aria-label="播放进度"
:min="0"
:max="100"
step="0.1"
@pointerdown="startSeek"
@pointerup="endSeek"
@change="seek"
/>
<span>{{ durationLabel }}</span>
</div>
<div class="music-volume">
<Icon name="tabler:volume" />
<ZSlider
v-model="volumeModel"
aria-label="音量"
:min="0"
:max="100"
step="1"
@update:model-value="syncVolume"
@change="syncVolume"
/>
<span>{{ volumeModel }}%</span>
</div>
<section v-if="showPlaylistPanel" class="music-list-panel" aria-label="音乐列表">
<header class="music-list-header">
<strong>音乐列表</strong>
<ZButton class="music-list-close" icon="tabler:x" text="关闭" aria-label="关闭音乐列表" @click="togglePlaylistPanel" />
</header>
<ul class="music-list" aria-label="可播放歌曲">
<li v-for="(track, index) in playlist" :key="`${track.url}-${index}`">
<button
class="music-list-item"
:class="{ active: index === currentIndex }"
type="button"
:aria-current="index === currentIndex ? 'true' : undefined"
@click="selectTrack(index)"
>
<img v-if="track.cover" class="music-list-cover" :src="track.cover" :alt="track.name" loading="lazy">
<span v-else class="music-list-cover music-list-cover-fallback">
<Icon name="tabler:music" />
</span>
<span class="music-list-meta">
<strong>{{ track.name }}</strong>
<small>{{ track.artist }}</small>
</span>
<Icon v-if="index === currentIndex" class="music-list-playing" name="tabler:volume" />
</button>
</li>
</ul>
</section>
<p v-if="playError" class="music-tip error">
{{ playError }}
</p>
</div>
<p v-else-if="error" class="music-tip error">
歌单加载失败
</p>
<p v-else class="music-tip">
暂无歌曲
</p>
<template #fallback>
<p class="music-tip">
播放器加载中...
</p>
</template>
</ClientOnly>
</BlogWidget>
</template>
这里用了 blog-v3 原有的组件:
BlogWidget:侧边栏模块容器;ZButton:主题按钮;ZSlider:主题滑块;Icon:图标组件;ClientOnly:避免 SSR 阶段访问浏览器音频能力。
为什么要用 ClientOnly
播放器依赖浏览器的 <audio>,并且有播放状态、音频时长、当前时间等只在客户端可用的内容。
所以外层包一层:
<ClientOnly> <!-- 播放器 --> </ClientOnly>
可以避免服务端渲染和客户端 hydration 时出现不一致。
第五步:添加播放器样式
样式尽量复用 blog-v3 的主题变量:
var(--c-text)var(--c-text-2)var(--c-border)var(--c-bg-soft)var(--ld-bg-card)var(--box-shadow-1)var(--box-shadow-2)var(--c-brand)
这样播放器在亮色 / 暗色主题下都能保持一致。
核心布局如下:
.music-player {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.music-main {
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
}
.music-cover {
flex: 0 0 auto;
width: 3.4rem;
height: 3.4rem;
border: 1px solid var(--c-bg-soft);
border-radius: 0.5rem;
box-shadow: var(--box-shadow-1);
object-fit: cover;
}
.music-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
}
.music-progress,
.music-volume {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
color: var(--c-text-2);
}
.music-list-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: 0.85rem;
box-shadow: var(--box-shadow-2);
background: var(--ld-bg-card);
color: var(--c-text);
}
最重要的是:音乐列表使用普通文档流展示在 .music-player 内部,而不是 position: fixed 或 Teleport 到页面其他地方。
<section v-if="showPlaylistPanel" class="music-list-panel" aria-label="音乐列表"> <!-- 歌单内容 --> </section>
这样可以保证音乐列表始终属于音乐模块。
第六步:注册侧边栏 widget
blog-v3 的右侧边栏不是直接写死组件,而是通过 useWidgets 根据 widget 名称动态渲染。
打开:
app/composables/useWidgets.ts
引入音乐播放器组件:
import {
ContentRenderer,
LazyBlogWidget,
LazyWidgetBlogLog,
LazyWidgetBlogStats,
LazyWidgetBlogTech,
LazyWidgetCommGroup,
LazyWidgetEmpty,
LazyWidgetMusicPlayer,
LazyWidgetRecentComments,
LazyWidgetToc,
} from '#components'
然后在 rawWidgets 中注册:
const rawWidgets = {
LazyWidgetBlogLog,
LazyWidgetBlogStats,
LazyWidgetBlogTech,
LazyWidgetCommGroup,
LazyWidgetEmpty,
LazyWidgetMusicPlayer,
LazyWidgetRecentComments,
LazyWidgetToc,
}
注册之后,组件文件:
app/components/widget/MusicPlayer.vue
就会对应 widget 名称:
music-player
这是因为 useWidgets.ts 里会把 music-player 转成:
LazyWidgetMusicPlayer
核心逻辑是:
rawWidgets[`LazyWidget${pascalCase(widgetName)}` as RawWidgetName]
所以命名要对应:
| 文件名 | 组件名 | widget 名称 |
|---|---|---|
MusicPlayer.vue | LazyWidgetMusicPlayer | music-player |
第七步:在首页挂载音乐模块
打开首页:
app/pages/index.vue
找到:
const layoutStore = useLayoutStore()
设置右侧边栏模块:
const layoutStore = useLayoutStore() layoutStore.setAside(['music-player', 'blog-stats', 'blog-tech', 'recent-comments'])
这样首页右侧边栏会按顺序显示:
- 音乐播放器;
- 博客统计;
- 技术信息;
- 最新评论。
如果你只想显示音乐播放器,也可以写成:
layoutStore.setAside(['music-player'])
如果想在文章页也显示,可以在文章页 frontmatter 中配置 aside。
例如:
--- title: 示例文章 aside: [music-player, toc] ---
blog-v3 的文章页会读取文章 meta:
layoutStore.setAside(post.value.meta?.aside as WidgetName[] | undefined)
所以单篇文章也可以独立控制侧边栏模块。
第八步:理解右侧边栏渲染流程
blog-v3 的右侧边栏组件是:
app/components/blog/BlogAside.vue
核心代码大概是:
<script setup lang="ts">
const layoutStore = useLayoutStore()
const { asideWidgets } = storeToRefs(layoutStore)
const { widgets } = useWidgets(asideWidgets)
</script>
<template>
<aside v-if="asideWidgets?.length" id="blog-aside" :class="{ show: layoutStore.state === 'aside' }">
<TransitionGroup name="float-in">
<component :is="widget.comp" v-for="widget in widgets" :key="widget.name" />
</TransitionGroup>
</aside>
</template>
也就是说,页面只需要告诉 layout store:
layoutStore.setAside(['music-player'])
BlogAside.vue 就会自动根据名称渲染对应组件。
这也是为什么我们不需要手动在 BlogAside.vue 中写:
<WidgetMusicPlayer />
只需要注册到 useWidgets.ts 即可。
第九步:本地运行和验证
开发时先启动项目:
pnpm dev
然后打开:
http://localhost:3000
检查几个点:
- 首页右侧边栏是否出现「音乐」模块。
- 是否能看到封面、歌曲名、歌手。
- 点击播放按钮是否能播放。
- 点击下一首 / 上一首是否能切歌。
- 拖动进度条是否能 seek。
- 拖动音量条是否能改变音量。
- 点击列表按钮后,音乐列表是否在模块内部展开。
- 歌单不是全局弹窗,也不是页面底部浮层。
- 移动端或窄屏下,右侧栏展开后音乐模块仍然可用。
也可以直接检查接口:
curl -I http://localhost:3000/api/music
再查看接口返回:
curl http://localhost:3000/api/music
正常情况下应该返回一个数组:
[
{
"title": "歌曲名",
"author": "歌手",
"pic": "封面地址",
"url": "音频地址",
"lrc": "歌词地址"
}
]
第十步:添加测试
音乐模块建议至少加三类测试:
- API 测试;
- widget 注册测试;
- 播放器渲染测试。
API 测试思路
可以测试 /api/music 的处理逻辑:
- 上游返回数组时,接口正常返回;
- 上游返回空数组时,使用 fallback;
- 上游请求失败时,使用 fallback;
http://music.moodlog.cn/...会被规范化成https://music.moodlog.cn/...。
widget 注册测试思路
重点确认 music-player 能被 useWidgets 找到。
也就是说,app/composables/useWidgets.ts 中应该包含:
LazyWidgetMusicPlayer
并且 rawWidgets 中注册了:
LazyWidgetMusicPlayer
播放器组件测试思路
可以检查这些内容是否存在:
aria-label="音乐播放器控制";aria-label="播放进度";aria-label="音量";aria-label="音乐列表";- 播放 / 暂停按钮;
- 上一首 / 下一首按钮;
- 音乐列表按钮。
第十一步:构建检查
提交前建议跑一遍:
pnpm exec eslint app/components/widget/MusicPlayer.vue tests/music-player-custom.test.ts tests/sidebar-widgets.test.ts tests/music-api.test.ts
pnpm exec stylelint app/components/widget/MusicPlayer.vue
pnpm exec tsx --test tests/music-player-custom.test.ts tests/sidebar-widgets.test.ts tests/music-api.test.ts
pnpm build
如果全部通过,再提交代码。
第十二步:部署到 Vercel
如果项目已经配置了 Vercel,可以直接部署:
pnpm run deploy:vercel
或者使用项目里的部署脚本:
bash scripts/deploy-vercel.sh --prod
部署完成后访问生产域名:
https://blog.moodlog.cn
同时检查音乐接口:
https://blog.moodlog.cn/api/music
如果接口正常,应该能看到歌单 JSON。
第十三步:线上验证
上线后不要只看部署成功,还要实际验证页面行为。
建议检查:
1. 首页是否正常访问
https://blog.moodlog.cn
页面标题、文章列表、右侧边栏都应该正常。
2. 音乐模块是否出现
右侧边栏中应该能看到「音乐」模块。
3. 音乐 API 是否返回数据
打开:
https://blog.moodlog.cn/api/music
正常应该返回数组。
例如可以在浏览器控制台检查:
JSON.parse(document.body.innerText).length
如果返回 200,说明接口返回了 200 首音乐。
4. 音量条是否在模块内
音量条应该出现在音乐卡片内部,而不是漂浮在页面其他位置。
5. 音乐列表是否在模块内展开
点击列表按钮后,音乐列表应该在「音乐」模块内部展开。
它不应该是:
- 页面底部浮层;
- 居中弹窗;
body下的 Teleport overlay;- 脱离右侧栏的独立面板。
6. 播放控制是否可用
依次测试:
- 播放;
- 暂停;
- 上一首;
- 下一首;
- 拖动进度;
- 调整音量;
- 切换歌曲。
一些踩坑记录
1. 不建议用全局悬浮播放器
一开始如果直接接入某些现成播放器,很可能会生成全局浮层,样式和 blog-v3 的侧边栏不统一。
而且这类播放器通常会把歌单、控制面板挂到 body 下,移动端适配也不好控制。
所以最后选择了原生 <audio> + 自己写 UI。
2. 歌单列表不要 Teleport
音乐列表如果用 Teleport,很容易脱离右侧栏布局。
在 blog-v3 里更合适的方式是:
<section v-if="showPlaylistPanel" class="music-list-panel"> <!-- 歌单内容 --> </section>
让它自然出现在 BlogWidget 内部。
3. 进度条拖动要避免被 timeupdate 打断
如果没有 isSeeking,用户拖动进度条时,audio 的 timeupdate 可能持续把滑块拉回当前播放位置。
所以需要:
const isSeeking = ref(false)
拖动开始:
function startSeek() {
isSeeking.value = true
}
拖动结束:
function endSeek() {
isSeeking.value = false
seek()
}
同步状态时判断:
if (!isSeeking.value) progressModel.value = progressPercent.value
4. 音乐接口要有 fallback
外部音乐接口并不一定稳定。
如果没有 fallback,接口失败时播放器就会变成空模块。
所以服务端接口里一定要准备 fallback:
catch (error) {
console.warn('[music] upstream request failed, using fallback playlist', error)
setResponseStatus(event, 200)
setHeader(event, 'X-Music-Fallback', '1')
return fallbackPlaylist
}
这样即使上游接口挂了,页面也不会完全坏掉。
5. 注意 HTTP 音频地址
有些音乐 API 返回的是:
http://music.moodlog.cn/xxx
在 HTTPS 站点中加载 HTTP 音频可能遇到 mixed content 问题。
所以服务端做了一次规范化:
function normalizeMusicUrl(value: unknown) {
if (typeof value !== 'string')
return value
return value.replace(/^http:\/\/music\.moodlog\.cn(?=\/)/, musicOrigin)
}
将它转换成:
https://music.moodlog.cn/xxx
总结
这次给 blog-v3 添加侧边栏音乐模块,核心并不是简单放一个播放器,而是让它真正融入主题现有架构:
- 配置放在
blog.config.ts; - 数据通过
server/api/music.get.ts统一代理; - UI 使用
BlogWidget、ZButton、ZSlider等主题组件; - 组件注册到
useWidgets.ts; - 页面通过
layoutStore.setAside()控制显示; - 歌单列表在模块内部展开;
- 接口失败时有 fallback;
- 样式跟随主题变量,亮暗色都能适配。
这样实现之后,音乐播放器就变成了 blog-v3 侧边栏系统中的一个普通 widget,后续无论是首页、文章页、归档页,还是自定义页面,都可以通过一个名字来控制它是否显示:
layoutStore.setAside(['music-player'])
或者在文章 frontmatter 中使用:
--- aside: [music-player, toc] ---
整体实现比较轻量,也比较符合 Nuxt 4 + Nuxt Content 项目的组织方式。
评论区
评论加载中...