给 blog-v3 添加一个侧边栏音乐播放器模块

记录在 Nuxt 4 + Nuxt Content 的 blog-v3 / Clarity 主题中新增右侧边栏音乐播放器的完整过程,包括音乐配置、服务端 API、播放器组件、侧边栏挂载、样式适配和上线验证。

写在前面

这篇文章记录一下我给 blog-v3 添加「侧边栏音乐播放器」的过程。

最终效果是:在首页右侧边栏新增一个「音乐」模块,可以从服务端接口读取歌单,然后在侧边栏里完成播放、暂停、上一首、下一首、进度拖动、音量调节和歌单切换。

它不是那种全局悬浮播放器,也不是通过 Teleport 挂到 body 下面的弹窗,而是一个真正嵌在 blog-v3 右侧边栏 widget 系统里的组件。

最终功能包括:

  • 右侧边栏显示音乐卡片;
  • /api/music 获取歌单数据;
  • 使用浏览器原生 <audio> 播放;
  • 显示封面、歌曲名、歌手;
  • 支持播放 / 暂停;
  • 支持上一首 / 下一首;
  • 支持播放进度条;
  • 支持音量滑块;
  • 支持展开 / 收起音乐列表;
  • 歌单列表在音乐模块内部展开,不使用浮层;
  • 接口失败时提供 fallback 歌单;
  • 适配 blog-v3 原有主题变量和组件风格。

实现思路

整个音乐模块分成四层:

text
blog.config.ts
    ↓
server/api/music.get.ts
    ↓
app/components/widget/MusicPlayer.vue
    ↓
app/composables/useWidgets.ts + 页面 setAside()

分别负责:

  1. blog.config.ts:配置音乐 API、歌单 ID、来源平台等。
  2. server/api/music.get.ts:服务端代理请求歌单,避免前端直接暴露或处理跨域。
  3. app/components/widget/MusicPlayer.vue:实现播放器 UI 和交互逻辑。
  4. app/composables/useWidgets.ts:把音乐播放器注册成 blog-v3 的侧边栏 widget。
  5. 页面中通过 layoutStore.setAside(['music-player']) 挂载到右侧边栏。

为什么不直接在前端请求第三方音乐 API

一开始很容易想到直接在播放器组件里请求外部音乐接口,比如:

ts
await $fetch('https://music.example.com/api?id=xxx')

但这样有几个问题:

  1. 可能遇到浏览器跨域限制。
  2. 请求头不好控制。
  3. 外部接口地址完全暴露在前端。
  4. 失败兜底逻辑会散落在组件里。
  5. 后续切换音乐源不方便。

所以更合适的做法是:在 Nuxt 服务端新增一个 /api/music,前端只请求自己站点的接口。

这样播放器只关心一件事:

ts
const { data: tracks } = await useFetch('/api/music')

外部 API、请求头、fallback、缓存策略全部放到服务端处理。

第一步:在 blog.config.ts 添加音乐配置

打开:

text
blog.config.ts

blogConfig 中新增 music 配置:

ts
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,只需要保持服务端返回的数据最终能被播放器识别即可。

播放器默认支持这些字段:

ts
{
	title?: string
	name?: string
	author?: string
	artist?: string
	url?: string
	pic?: string
	cover?: string
	lrc?: string
}

也就是说,歌曲名可以是 titlename,歌手可以是 authorartist,封面可以是 piccover

第二步:新增服务端音乐 API

创建文件:

text
server/api/music.get.ts

代码如下:

这里有几个关键点。

1. 使用服务端代理请求歌单

前端不直接请求外部接口,而是请求:

text
/api/music

服务端再去请求:

text
https://music.moodlog.cn/api?id=xxx&server=netease&type=playlist

这样前端代码更干净,也更容易处理异常。

2. 添加缓存头

ts
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 歌曲:

ts
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

ts
setResponseStatus(event, 200)
setHeader(event, 'X-Music-Fallback', '1')
return fallbackPlaylist

这样前端播放器不用处理太复杂的错误状态。

第三步:创建音乐播放器组件

创建文件:

text
app/components/widget/MusicPlayer.vue

组件整体分为三部分:

  1. 歌单数据获取和标准化;
  2. 播放控制逻辑;
  3. 模板和样式。

组件 script 部分

这里先定义了两个类型。

MusicTrack 对应接口原始数据,PlaylistTrack 是播放器内部使用的统一格式。这样后续模板只需要使用:

ts
currentTrack.name
currentTrack.artist
currentTrack.cover
currentTrack.url

不用到处判断 titlenameauthorartist

播放状态同步

继续在 <script setup> 中加入:

ts
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

ts
audio.volume = volumeModel.value / 100

播放 / 暂停

ts
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 包起来。

上一首 / 下一首

ts
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
}

这里使用取模实现循环播放。比如当前是最后一首,点击下一首会回到第一首。

歌单展开和选择歌曲

ts
function togglePlaylistPanel() {
	showPlaylistPanel.value = !showPlaylistPanel.value
}

function selectTrack(index: number) {
	currentIndex.value = index
}

这里没有用弹窗,也没有用全局浮层。歌单只是音乐模块内部的一个 <section>,通过 showPlaylistPanel 控制显示隐藏。

拖动播放进度

ts
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 事件仍然可能不断更新进度条,导致用户拖动体验不好。

所以加了一个状态:

ts
const isSeeking = ref(false)

同步状态时判断:

ts
if (!isSeeking.value)
	progressModel.value = progressPercent.value

切歌时重置 audio

ts
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

这里用了 blog-v3 原有的组件:

  • BlogWidget:侧边栏模块容器;
  • ZButton:主题按钮;
  • ZSlider:主题滑块;
  • Icon:图标组件;
  • ClientOnly:避免 SSR 阶段访问浏览器音频能力。

为什么要用 ClientOnly

播放器依赖浏览器的 <audio>,并且有播放状态、音频时长、当前时间等只在客户端可用的内容。

所以外层包一层:

vue
<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 内部,而不是 position: fixedTeleport 到页面其他地方。

vue
<section v-if="showPlaylistPanel" class="music-list-panel" aria-label="音乐列表">
	<!-- 歌单内容 -->
</section>

这样可以保证音乐列表始终属于音乐模块。

第六步:注册侧边栏 widget

blog-v3 的右侧边栏不是直接写死组件,而是通过 useWidgets 根据 widget 名称动态渲染。

打开:

text
app/composables/useWidgets.ts

引入音乐播放器组件:

ts
import {
	ContentRenderer,
	LazyBlogWidget,
	LazyWidgetBlogLog,
	LazyWidgetBlogStats,
	LazyWidgetBlogTech,
	LazyWidgetCommGroup,
	LazyWidgetEmpty,
	LazyWidgetMusicPlayer,
	LazyWidgetRecentComments,
	LazyWidgetToc,
} from '#components'

然后在 rawWidgets 中注册:

ts
const rawWidgets = {
	LazyWidgetBlogLog,
	LazyWidgetBlogStats,
	LazyWidgetBlogTech,
	LazyWidgetCommGroup,
	LazyWidgetEmpty,
	LazyWidgetMusicPlayer,
	LazyWidgetRecentComments,
	LazyWidgetToc,
}

注册之后,组件文件:

text
app/components/widget/MusicPlayer.vue

就会对应 widget 名称:

text
music-player

这是因为 useWidgets.ts 里会把 music-player 转成:

text
LazyWidgetMusicPlayer

核心逻辑是:

ts
rawWidgets[`LazyWidget${pascalCase(widgetName)}` as RawWidgetName]

所以命名要对应:

文件名组件名widget 名称
MusicPlayer.vueLazyWidgetMusicPlayermusic-player

第七步:在首页挂载音乐模块

打开首页:

text
app/pages/index.vue

找到:

ts
const layoutStore = useLayoutStore()

设置右侧边栏模块:

ts
const layoutStore = useLayoutStore()
layoutStore.setAside(['music-player', 'blog-stats', 'blog-tech', 'recent-comments'])

这样首页右侧边栏会按顺序显示:

  1. 音乐播放器;
  2. 博客统计;
  3. 技术信息;
  4. 最新评论。

如果你只想显示音乐播放器,也可以写成:

ts
layoutStore.setAside(['music-player'])

如果想在文章页也显示,可以在文章页 frontmatter 中配置 aside

例如:

md
---
title: 示例文章
aside: [music-player, toc]
---

blog-v3 的文章页会读取文章 meta:

ts
layoutStore.setAside(post.value.meta?.aside as WidgetName[] | undefined)

所以单篇文章也可以独立控制侧边栏模块。

第八步:理解右侧边栏渲染流程

blog-v3 的右侧边栏组件是:

text
app/components/blog/BlogAside.vue

核心代码大概是:

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:

ts
layoutStore.setAside(['music-player'])

BlogAside.vue 就会自动根据名称渲染对应组件。

这也是为什么我们不需要手动在 BlogAside.vue 中写:

vue
<WidgetMusicPlayer />

只需要注册到 useWidgets.ts 即可。

第九步:本地运行和验证

开发时先启动项目:

bash
pnpm dev

然后打开:

text
http://localhost:3000

检查几个点:

  1. 首页右侧边栏是否出现「音乐」模块。
  2. 是否能看到封面、歌曲名、歌手。
  3. 点击播放按钮是否能播放。
  4. 点击下一首 / 上一首是否能切歌。
  5. 拖动进度条是否能 seek。
  6. 拖动音量条是否能改变音量。
  7. 点击列表按钮后,音乐列表是否在模块内部展开。
  8. 歌单不是全局弹窗,也不是页面底部浮层。
  9. 移动端或窄屏下,右侧栏展开后音乐模块仍然可用。

也可以直接检查接口:

bash
curl -I http://localhost:3000/api/music

再查看接口返回:

bash
curl http://localhost:3000/api/music

正常情况下应该返回一个数组:

json
[
  {
    "title": "歌曲名",
    "author": "歌手",
    "pic": "封面地址",
    "url": "音频地址",
    "lrc": "歌词地址"
  }
]

第十步:添加测试

音乐模块建议至少加三类测试:

  1. API 测试;
  2. widget 注册测试;
  3. 播放器渲染测试。

API 测试思路

可以测试 /api/music 的处理逻辑:

  • 上游返回数组时,接口正常返回;
  • 上游返回空数组时,使用 fallback;
  • 上游请求失败时,使用 fallback;
  • http://music.moodlog.cn/... 会被规范化成 https://music.moodlog.cn/...

widget 注册测试思路

重点确认 music-player 能被 useWidgets 找到。

也就是说,app/composables/useWidgets.ts 中应该包含:

ts
LazyWidgetMusicPlayer

并且 rawWidgets 中注册了:

ts
LazyWidgetMusicPlayer

播放器组件测试思路

可以检查这些内容是否存在:

  • aria-label="音乐播放器控制"
  • aria-label="播放进度"
  • aria-label="音量"
  • aria-label="音乐列表"
  • 播放 / 暂停按钮;
  • 上一首 / 下一首按钮;
  • 音乐列表按钮。

第十一步:构建检查

提交前建议跑一遍:

bash
pnpm exec eslint app/components/widget/MusicPlayer.vue tests/music-player-custom.test.ts tests/sidebar-widgets.test.ts tests/music-api.test.ts
bash
pnpm exec stylelint app/components/widget/MusicPlayer.vue
bash
pnpm exec tsx --test tests/music-player-custom.test.ts tests/sidebar-widgets.test.ts tests/music-api.test.ts
bash
pnpm build

如果全部通过,再提交代码。

第十二步:部署到 Vercel

如果项目已经配置了 Vercel,可以直接部署:

bash
pnpm run deploy:vercel

或者使用项目里的部署脚本:

bash
bash scripts/deploy-vercel.sh --prod

部署完成后访问生产域名:

text
https://blog.moodlog.cn

同时检查音乐接口:

text
https://blog.moodlog.cn/api/music

如果接口正常,应该能看到歌单 JSON。

第十三步:线上验证

上线后不要只看部署成功,还要实际验证页面行为。

建议检查:

1. 首页是否正常访问

text
https://blog.moodlog.cn

页面标题、文章列表、右侧边栏都应该正常。

2. 音乐模块是否出现

右侧边栏中应该能看到「音乐」模块。

3. 音乐 API 是否返回数据

打开:

text
https://blog.moodlog.cn/api/music

正常应该返回数组。

例如可以在浏览器控制台检查:

js
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 里更合适的方式是:

vue
<section v-if="showPlaylistPanel" class="music-list-panel">
	<!-- 歌单内容 -->
</section>

让它自然出现在 BlogWidget 内部。

3. 进度条拖动要避免被 timeupdate 打断

如果没有 isSeeking,用户拖动进度条时,audiotimeupdate 可能持续把滑块拉回当前播放位置。

所以需要:

ts
const isSeeking = ref(false)

拖动开始:

ts
function startSeek() {
	isSeeking.value = true
}

拖动结束:

ts
function endSeek() {
	isSeeking.value = false
	seek()
}

同步状态时判断:

ts
if (!isSeeking.value)
	progressModel.value = progressPercent.value

4. 音乐接口要有 fallback

外部音乐接口并不一定稳定。

如果没有 fallback,接口失败时播放器就会变成空模块。

所以服务端接口里一定要准备 fallback:

ts
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 返回的是:

text
http://music.moodlog.cn/xxx

在 HTTPS 站点中加载 HTTP 音频可能遇到 mixed content 问题。

所以服务端做了一次规范化:

ts
function normalizeMusicUrl(value: unknown) {
	if (typeof value !== 'string')
		return value

	return value.replace(/^http:\/\/music\.moodlog\.cn(?=\/)/, musicOrigin)
}

将它转换成:

text
https://music.moodlog.cn/xxx

总结

这次给 blog-v3 添加侧边栏音乐模块,核心并不是简单放一个播放器,而是让它真正融入主题现有架构:

  • 配置放在 blog.config.ts
  • 数据通过 server/api/music.get.ts 统一代理;
  • UI 使用 BlogWidgetZButtonZSlider 等主题组件;
  • 组件注册到 useWidgets.ts
  • 页面通过 layoutStore.setAside() 控制显示;
  • 歌单列表在模块内部展开;
  • 接口失败时有 fallback;
  • 样式跟随主题变量,亮暗色都能适配。

这样实现之后,音乐播放器就变成了 blog-v3 侧边栏系统中的一个普通 widget,后续无论是首页、文章页、归档页,还是自定义页面,都可以通过一个名字来控制它是否显示:

ts
layoutStore.setAside(['music-player'])

或者在文章 frontmatter 中使用:

md
---
aside: [music-player, toc]
---

整体实现比较轻量,也比较符合 Nuxt 4 + Nuxt Content 项目的组织方式。

新故事即将发生
给 blog-v3 新建一个博友圈页面:聚合友链最新文章

评论区

评论加载中...