47 KiB
音乐模块全链路审查报告
审查范围:数据请求(搜索/歌单解析/下载/解析真实链接)、播放控制(试听、播放器内核、队列、歌词、缓存)、状态管理(musicStore / feiniuStore)、UI 交互与视觉。 覆盖文件(前端 11 个 + Rust 4 个):
src/stores/musicStore.ts、src/stores/feiniuStore.ts、src/modules/music/MusicModule.vue、components/PlayerBar.vue、components/PlayerFull.vue、components/MyMusicLibrary.vue、components/TrackItem.vue、components/settings/ConnectionsSettings.vue、components/settings/CacheSettings.vue、SourcePicker.vue、sources.ts、index.ts、src/App.vue、src/components/layout/ModuleContainer.vue、src-tauri/src/music/{mod,commands}.rs、src-tauri/src/music/feiniu/{mod,proxy}.rs
0. 结论摘要
整体架构是清晰的:搜索/下载走 Python 桥接(musicdl),播放走飞牛 NAS 本地流代理(Rust axum,支持 Range 透传),两个 store 职责分离,流代理与缓存设计是对的。问题集中在三类:
| 类别 | 数量 | 代表问题 |
|---|---|---|
| P0 功能性失效 | 3 | 飞牛曲库搜索框完全无效;重启后播放键无响应;两个音频引擎可同时发声 |
| P1 逻辑/状态缺陷 | 12 | shuffle 自动切歌不随机;缓存开关状态不同步;队列被单曲播放重置;任务持久化写入 rawSearch 撑爆 localStorage |
| P2 性能瓶颈 | 9 | 拖动进度条即发 Range 请求;歌词/队列无虚拟滚动;全屏模糊背景;每行 2 个 Tooltip |
| P3 界面与交互缺陷 | 25 | 播放条未贴底且离开模块即消失;左右内边距不对称;三套切换控件;徽标 5 色抢戏;列表变卡片堆 |
最需要优先决策的一件事:把 PlayerBar / PlayerFull 从 MusicModule.vue 提升到 App.vue 全局层。当前它们挂载在音乐模块内部,而模块容器是 :key="activeModule" 重挂载(App.vue:36-41)——切到任何其他模块,播放条和"正在播放"大屏整体卸载,但音频仍在后台播放,用户完全失去控制入口。这是当前体验上最严重的问题。
1. P0 功能性失效(必须修)
1.1 飞牛曲库搜索框完全无效
现象:输入关键词、回车,曲库列表纹丝不动。
根因:MyMusicLibrary.vue:21 定义了本地 keywordInput,onSearchInput(:24-29)只做了防抖后调用 store.loadTracks(1),而 loadTracks 读的是 store 里的 keyword(feiniuStore.ts:381)。检查全仓:store.keyword 从未被任何 UI 赋值。
// feiniuStore.ts:375-382
const data = await invoke('feiniu_list_tracks', {
page: p, size: 50,
keyword: keyword.value.trim() || null // ← keyword 永远是 ''
})
修复:把输入直接接到 store 字段,去掉中间层。
<!-- MyMusicLibrary.vue -->
<Input v-model="store.keyword" placeholder="搜索飞牛曲库(歌名 / 歌手)"
@input="onSearchInput" @keydown.enter="onSearchInput" />
// onSearchInput 里补一句:清空关键词时立即回全量
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => store.loadTracks(1).catch(e => toast.error(String(e))), 400)
}
1.2 应用重启后,播放键点了没反应
现象:重启应用,底部播放条显示着上次的歌,点播放键没任何反应,也没有提示。
根因:restoreQueue()(feiniuStore.ts:644-655)只恢复了 queue / queueIndex,没有给 audio 设置 src。此时点击播放走 toggle()(:564-568):
function toggle() {
const a = ensureAudio()
if (a.paused) a.play().catch(() => {}) // ← 无 src,play() 直接 reject,被静默吞掉
else a.pause()
}
ensureAudio() 新建的 Audio() 没有 src,play() 抛 NotSupportedError,被空 catch 吞掉。点「上一首/下一首」反而能用(走 playCurrent() 会设 src),所以表现为"只有播放键坏了"。
修复:toggle() 检测无 src 时先走一次 playCurrent();同时给失败加可见提示。
function toggle() {
const a = ensureAudio()
if (!a.src) { // 恢复队列后首次播放
if (current.value) playCurrent()
return
}
if (a.paused) a.play().catch(err => { playing.value = false; logger.error(`播放失败: ${err}`) })
else a.pause()
}
另外建议 startPlayback 的 catch 里落一条 toast.error('播放失败:' + err.message),当前 :558-561 是先 catch 再静默,用户拿不到任何信息。
1.3 两个独立音频引擎,可同时发声
现象:在「发现音乐」点某首歌试听,再回到播放器播另一首 → 两首歌同时响;或者试听中点播放器播放,两路声音叠加。
根因:存在两个互不知晓的 <audio>:
MusicModule.vue:1413—— 试听用的模板<audio ref="audioEl">(togglePlay:372-406 驱动)feiniuStore.ts:109-126—— 播放器内核new Audio()
二者没有互斥,playingKey 与 store.playing 彼此独立。附带问题:试听音频在切换 tab / 卸载模块时不会停止(模板 audio 被移除,引用置空,媒体仍可能继续播到自然结束)。
修复(推荐):废弃模块内试听 audio,试听直接走同一内核——playQueue([{...}], 0),source 用 'preview',播完即止。若短期不想动内核,至少在 togglePlay 之前 feiniu.clearPlayback(),并在 onBeforeUnmount 里 audioEl.value?.pause()。
2. P1 逻辑与状态缺陷
| # | 问题 | 位置 | 说明与改法 |
|---|---|---|---|
| 2.1 | shuffle 自动切歌不随机 | feiniuStore.ts:570-586 |
next(manual) 的随机分支带 manual 条件,onEnded → next(false) 在 shuffle 下走的是顺序 ++。用户开了随机,一首播完却是下一首。改:把随机逻辑与 manual 解耦,shuffle 时无论手动/自动都随机(并记录已播集合避免短期重复)。 |
| 2.2 | prev 在 shuffle 下也是顺序 | feiniuStore.ts:598-607 |
同上;随机模式应回退到历史栈。 |
| 2.3 | 切缓存模式/清缓存会中断播放 | feiniuStore.ts:266-280 |
setCacheMode('stream') 和 clearCache() 都调 clearPlayback();而 CacheSettings.setEnabled(false) 又会触发 setCacheMode('stream')。改设置就把歌停了,属于误伤。应只在必要(如清掉正在播的那首缓存)时停止,或改为"下一首生效"。 |
| 2.4 | 缓存开关与真实模式不同步 | CacheSettings.vue:13-23,32-35 |
cacheEnabled 初始恒为 false,onMounted 只读了 cacheMode 没回填开关;若历史已是 cache 模式,打开设置页看到的是"关",但实际在缓存。另外 cacheMax = ref(5) 是死代码(从未使用,后端对应字段是 feiniu_cache_max_gb),cacheStatus 在 onMounted 也没刷新。 |
| 2.5 | 单曲播放会重置整个队列 | feiniuStore.ts:500-511 |
playItem 未命中队列时 queue.value = [{...item}]——用户从 2000 首的曲库点播放,队列被替换成 1 首,播完就停。播放器语义应是"以所在列表为上下文播放"。改:playItem(item, contextList?),命中则跳转,未命中则以 contextList 建队列。 |
| 2.6 | 歌单内的曲目无法暂停 / 无法整单播放 | MyMusicLibrary.vue:228-233 |
歌单里的 TrackItem 既没有 @dblclick,播放按钮又被 TrackItem.vue:71 的 :disabled="store.playing && store.current?.guid === item.guid" 禁用——正在播这首时按钮变灰,没有任何方式暂停。且点击播放会走 2.5 的"单曲替换队列"。 |
| 2.7 | init() 被重复调用,会覆盖内存中的队列 |
feiniuStore.ts:129-157 |
MyMusicLibrary.onMounted(:64-69)与 CacheSettings.onMounted(:16-20)都调 store.init();init 内含 restoreQueue(),会在用户切到设置页时用 localStorage 的旧值覆盖当前队列/索引。应加 inited 标志位做幂等,或把 restoreQueue 只放在应用启动路径。 |
| 2.8 | 任务持久化把 rawSearch 一起写进 localStorage |
musicStore.ts:79-93,228-234,439 |
songsData: songs 保留了每首歌的 rawSearch(上游 API 原始响应)。一个 500 首歌单任务 → 序列化后极易超过 5MB 配额,persistTasks 的 catch 会静默失败,历史记录随机丢失。改:持久化前剥离 rawSearch / defaultDownloadHeaders,只留重下所需字段。 |
| 2.9 | pendingUploadOnDone 泄漏,造成误上传 |
MusicModule.vue:60,74-82,483-500 |
该标志在下发下载请求时置 true,只有"检测到有任务 done"才清 false。若下载报错/被取消、或用户在选择音质弹窗点了取消,标志会残留,之后任意一个无关任务完成都会触发一次上传。autoUpload 打开时则是每个任务完成都重复全量上传一次。建议改为按 taskId 记录"待上传任务集合",完成时精确消费。 |
| 2.10 | Rust 引擎下载后跳到空的任务页 | MusicModule.vue:460 |
doDownload 无条件 activeTab.value = 'tasks',但 downloadEngine === 'rust' 时任务被交给「下载器」模块(musicStore.ts:391-430 不创建本模块任务),本模块任务列表是空的,用户看到"暂无下载任务"。且此时 downloadTarget === 'feiniu' 的自动上传链路也永远不会触发。应在 rust 引擎下改为提示"已交给下载器模块"并跳转到对应模块。 |
| 2.11 | duration 未知时拖动进度条无效 |
feiniuStore.ts:609-613(配合 PlayerBar.vue:18-20) |
onProgress 用 store.duration 换算秒数;若流没有返回 Content-Length,loadedmetadata 后 duration 为 NaN,a.currentTime = NaN 被忽略,进度条要么不动要么跳回 0。应在 seek 内做 `if (!isFinite(a.duration) |
| 2.12 | nowPlayingOpen 状态跨模块残留 |
feiniuStore.ts:105 + PlayerFull.vue:43 |
大屏开着时切走模块,Dialog 随模块卸载消失,但 nowPlayingOpen 仍是 true;回到音乐模块会突兀地自动弹出。建议改为由组件本地 ref 承载,或在 onMounted 时重置。 |
其它可在后续清理的小项:doneCount 在重复 done 事件下会虚增(musicStore.ts:320);redownloadTask 用的是当前默认音质而非原任务音质(musicStore.ts:523);音质弹窗取消后 selectedKeys 已被清空(MusicModule.vue:505-509);installRuntime 的进度监听清理逻辑正确,但 installError 与最后一条进度事件之间缺少"失败即清进度"的处理(注释已提到,代码未做)。
3. 性能瓶颈与优化点
3.1 拖动进度条 = 连续发起 Range 请求(最该改的一项)
PlayerBar.vue:104-110 / PlayerFull.vue:75-80 把 Slider 的 update:model-value 直接接到 store.seek(),而 seek 立刻写 a.currentTime。飞牛流代理是真流式透传 Range(proxy.rs:131-133),所以拖动一次会触发几十次"中断旧请求 → 发新 Range 请求":
- 占用带宽、NAS 侧反复建连
- 拖动过程中出现爆音/卡顿
- 拖到底后又从新位置重新缓冲
推荐:拖动期间只更新本地"预览位置",pointerup / change 时提交 seek。
// 父组件维护 dragging + previewValue
const dragging = ref(false)
const preview = ref(0)
function onProgressInput(v?: number[]) { dragging.value = true; preview.value = v?.[0] ?? 0 }
function onProgressCommit(v?: number[]) {
dragging.value = false
store.seek(((v?.[0] ?? 0) / 100) * store.duration)
}
<Slider :model-value="[dragging ? preview : store.progress]" @update:model-value="onProgressInput" @value-commit="onProgressCommit" />
另外 :step="0.5"(200 档)对 4 分钟歌 ≈ 1.2s 粒度尚可,但对播客/长音频(1 小时以上)单档 ≈ 18s,建议改为按 duration 动态计算 step(如 100 / duration)。
3.2 进度时间轴以 4Hz 驱动整棵组件树
timeupdate 约每 250ms 一次写 position.value,凡是读它的组件都会重渲染。当前影响面:
PlayerBar+PlayerFull同时挂载(大屏打开时)→ 双份重渲染PlayerFull的currentLine()(feiniuStore.ts:710-717)是线性扫描,每次重算 O(歌词行数)。改:缓存上次命中的索引,只在position > 下一行时间时idx++(歌词已排序,单调推进),或二分查找。PlayerFull.vue:33-39的watchEffect每次行切换都querySelectorAll('[data-line]')全量查询后再scrollIntoView({behavior:'smooth'})。改:用ref数组或在 v-for 里收集 DOM,仅对目标行滚动;并给滚动加prefers-reduced-motion降级。
let lastLineIdx = -1
function currentLine(): number {
const lines = lyricLines.value
if (lastLineIdx >= 0 && lastLineIdx < lines.length
&& (lastLineIdx + 1 >= lines.length || position.value < lines[lastLineIdx + 1].t)) {
while (lastLineIdx > 0 && position.value < lines[lastLineIdx].t) lastLineIdx--
while (lastLineIdx + 1 < lines.length && lines[lastLineIdx + 1].t <= position.value) lastLineIdx++
return lastLineIdx
}
lastLineIdx = -1
for (let k = 0; k < lines.length; k++) if (lines[k].t <= position.value) lastLineIdx = k; else break
return lastLineIdx
}
同时建议歌词/队列上虚拟滚动:LRC 常有 80~200 行,长播放列表可上千行,当前是全量 DOM。
3.3 大屏背景的整图高斯模糊
PlayerFull.vue:50-52:<img class="h-full w-full scale-110 object-cover blur-2xl opacity-30"> 铺满整个弹窗(min-h-[70vh],实际常超 900×700)。一张全尺寸图叠加 blur-2xl + scale-110,在打开期间持续占用合成/光栅资源,低端机上会明显掉帧(拖进度条时最明显)。
推荐:改为一次性低分辨率方案——用同图 size=96 缩略图 + CSS 渐变遮罩,或直接用取色(后端返回主色)生成柔和纯色/线性渐变背景。收益远大于视觉损失。
3.4 搜索结果行每行 2 个 Tooltip 组件
MusicModule.vue:928-965:每首歌行包了 2 个 Tooltip(reka-ui 组件 + 上下文)。首批 80 行 = 160 个 Tooltip 实例,滚动加载到 500 行 = 1000 个。挂载/更新成本高,且 Tooltip 会随列表 diff 频繁重建。
推荐:列表行用原生 title 属性承载提示(无需组件);只对"需要富文本提示"的 +N 音质档位保留一个 Tooltip。
3.5 其它
- 下载任务聚合计算:
taskAggDone/taskAggTotal(MusicModule.vue:547-548)每次渲染都遍历全部songs。一个 500 首歌的任务,每来一次进度事件就重算 500×2。改:在handleDownloadEvent里维护task.doneBytes/totalBytes增量字段。 displayItems/groupKeys/groupAllSelected:模板中对每个源分组头调用groupAllSelected,内部filteredResults.filter(...)是 O(n);结果集大时是 O(n × 分组数)。改:用一次computed预生成Map<source, keys[]>。- 飞牛曲库无分页:
loadTracks固定size: 50,page/total暴露了却没有 UI。曲库 > 50 首时后面的歌完全访问不到(playAll也只能播前 50 首)。应加无限滚动或分页。 - 本地扫描无上限:
scanLocal一次性返回全部条目,超大音乐目录下会长时间占住主线程做映射与渲染。 - 缓存模式首播延迟:
feiniu_cache_fetch(commands.rs:421-429→cache_fetch)是"下载完整文件后返回路径",即cache模式下首次播放要等整首下完;而loadingPlay这个状态在 UI 上从未被使用(PlayerBar/PlayerFull都没读),用户看到的是"点了没反应"。要么改用边下边播的双源策略,要么至少把loadingPlay接上 spinner。
4. 交互体验问题(按操作路径)
播放控制
- 播放/暂停按钮
disabled时无视觉降级(PlayerBar.vue:76-84),看起来可点却没反应。 - 音量图标(
PlayerBar.vue:117)不可点击,没有"一键静音";音量条只有 80px,长按/滚轮调节都没有。 - 切歌没有 crossfade,也没有间奏/淡出,切歌瞬间是硬切。
- 播放模式按钮图标随状态变(Repeat/Repeat1/Shuffle),初看像三个不同功能,且没有文字提示(只有 Tooltip)。
- 队列入口用
ListMusic图标 + 数字角标,但点击是平铺开关(store.queueVisible = !store.queueVisible),而队列实际渲染在PlayerFull的右栏里——在大屏没打开时点这个图标看不到任何反馈(因为它只切了右栏的 tab,而弹窗没开)。 - 上一首/下一首没有"按住快进/快退"(老播放器的常见预期)。
进度条
7. 没有 hover 时间气泡、没有缓冲进度层、没有"拖动时显示预览时间"。当前拖动时只能看两侧的小字。
8. 轨道 6px + 16px 白色圆点滑块,Slider.vue:40 硬编码 bg-white,在浅色主题下白滑块白描边几乎隐形。
列表切换
9. 发现音乐页切到「我的音乐」再切回,搜索关键词和结果全部丢失(模块内 tab 用 Tabs 非 keep-alive,flatResults 被重算但 store 里 results 还在——实际是因为 MusicModule 未卸载所以结果还在,但 visibleCount 会被 watch(filteredResults) 重置,滚动位置丢失)。
10. 列表里双击行不会播放(只有点小图标才播),不符合音乐软件的肌肉记忆。
11. 键盘:无空格播放/暂停、无 ↑↓ 选行、无 Enter 播放;所有列表行都不可聚焦。
12. 多选操作只有"下载所选",缺"加入歌单""加入队列""下一首播放"。
5. 界面缺陷清单(具体位置 + 改法)
5.1 结构与布局
| # | 缺陷 | 位置 | 改法 |
|---|---|---|---|
| L1 | 播放条没贴底:被模块根 p-6 包住,border-t 只在左右各缩进 24px 的宽度上出现,视觉上像一张"飘着的卡片"而非底部坞站 |
MusicModule.vue:688,1416 |
把 PlayerBar 提升到 App.vue,作为 flex-col 壳层的最后一行(全宽、border-t、h-[72px]);音乐模块内容区 pb-[72px] 让位 |
| L2 | 左右内边距不对称:模块左 24px + px-1(4px) = 28px;右 24px + pr-3(12px) + px-1 = 40px,右侧被滚动条再吃一层,卡片右边距明显窄于左边 |
MusicModule.vue:688,706-707,987-988 |
统一成 px-6,滚动区改成 [&>[data-slot=scroll-area-viewport]]:pr-6 或让 ScrollArea 用 scrollbar-gutter: stable,避免内边距被滚动条挤压 |
| L3 | 三套切换控件:模块级 TabsList(半透明覆盖)、MyMusicLibrary 自绘分段按钮(:89-114)、PlayerFull 的裸文字切换(:113-129)——三种视觉语言表达同一件事 |
三处 | 统一为一种 segmented 控件(建议用现有 Tabs/TabsList,或统一样式的 ButtonGroup),并用同一套"选中=主色底/浅底 + 中粗字"的规则 |
| L4 | Empty 组件误用:直接塞裸 children,继承 gap-6(24px) 与 md:p-12(48px),图标与文案间距过大且垂直被撑高;border-dashed 没有宽度声明,虚线框实际不显示 |
MusicModule.vue:801-809, 977-980、MyMusicLibrary.vue:76-83,135-140 |
用组件既定的子结构:EmptyMedia / EmptyTitle / EmptyDescription;或给容器加 border + border-dashed、gap-3、p-8 |
| L5 | 横向 Tab 与子 Tab 层级混乱:顶层 4 个 tab 里,「我的音乐」内部又横排 3 个子 tab,「设置」内部又竖排 5 张卡;导航成本高 | MusicModule.vue:691-696、MyMusicLibrary.vue:88-114 |
见 §6 的"左侧来源树 + 右侧主区"结构,把两级 tab 压成一级 |
| L6 | 首屏被低价值信息占据:「下载到 本地/飞牛」占一整个卡片行(MusicModule.vue:709-726),说明文字是 12px 灰字却放在最显眼位置;真正的搜索框被挤到第二行 |
MusicModule.vue:708-794 |
把"下载目标"收进搜索工具栏右侧的 Select(与源选择器同排),或放进设置页;首行只留 搜索输入 + 源选择 + 搜索按钮 |
| L7 | 歌单详情页底部孤零零一个"移除最后一首"按钮 | MyMusicLibrary.vue:234-243 |
语义错误,删掉。改为:行 hover 显示「⋯」菜单(从歌单移除 / 上移 / 下移),或支持多选后批量移除 |
| L8 | 歌单左栏无滚动:grid-cols-[220px_1fr],左栏是普通 div,歌单多了会撑高整页 |
MyMusicLibrary.vue:189-205 |
左栏用 ScrollArea 并固定高度(h-[calc(100vh-260px)]),或整体改三栏布局 |
| L9 | 设置页信息密度过低 + 语义重复:「飞牛音乐连接」管连接,「上传到飞牛曲库」又要求用户回头去前一张卡点云朵登录;5 张卡片纵向拉得很长 | MusicModule.vue:1123-1371 |
合并为「连接与存储」一张卡:连接列表 + NAS 文件服务登录 + 曲库目录 + 自动上传;「播放缓存」并入「播放」分组;「环境检查」做成折叠的"诊断"面板 |
5.2 视觉与色彩
| # | 缺陷 | 位置 | 改法 |
|---|---|---|---|
| V1 | 音质徽标 5 色抢戏:Hi-Res=琥珀、无损=紫、320K=天蓝、192K=橙、128K=锌灰,一行里 2 个高饱和徽标 + 1 个 +N,视觉噪音压过歌名 |
MusicModule.vue:336-355, 879-905 |
降为两档:无损/高解析用实心浅色徽标(一个色),其余用 outline 中性色;颜色不承担"档位"信息,档位靠文字。或改为歌名后的一个小图标 + Tooltip 详情 |
| V2 | 列表变成卡片堆:每行 border + rounded-lg + p-2.5 + space-y-2,80 行 = 80 张卡,行间 8px 空隙,扫描效率低 |
MusicModule.vue:845-859 |
改紧凑单行:行高 44~48px、无独立边框、border-b border-border/60 或干脆无分隔、hover bg-accent/60;表头一行(# / 标题 / 专辑 / 时长 / 操作) |
| V3 | 源分组头与大卡片行不匹配:分组头只是 12px 小字,紧贴一堆 56px 卡片 | MusicModule.vue:848-857 |
分组头改 sticky top-0 z-10 bg-background/95 backdrop-blur + 底部分隔线,字号 13px medium;或改用左侧来源 Tab 过滤,取消组内嵌头 |
| V4 | 当前播放项用"选中态"表达:border-primary bg-primary/10 在浅色主题下是"近黑边框 + 10% 黑底",像复选框选中而非"正在播放" |
TrackItem.vue:53-54 |
改为:左侧 3px 主色竖条 + 歌名主色 + 一个 12px 的跳动均衡器图标;hover 与 active 语义分离 |
| V5 | PlayerFull 硬编码白色边框:border-l border-white/10,浅色主题下完全不可见 |
PlayerFull.vue:112 |
改用 border-border |
| V6 | PlayerFull 背景被"洗白":整图 opacity-30 + 白→白渐变,浅色下糊成一片;同时是持续 GPU 开销 |
PlayerFull.vue:50-53 |
改为缩略图小尺寸模糊,或纯色/双色线性渐变(从封面取色);浅色主题下用极浅的封面色调,深色主题下用暗化封面色 |
| V7 | 滑块基本不可见:Slider.vue:40 硬编码 bg-white,16px 白色滑块 + 白色描边叠在浅底上;轨道仅 6px |
Slider.vue:36-41 |
滑块改 bg-background + 主色描边(或直接用 bg-primary);静息轨道 4px、hover/拖动时平滑增到 8px;加 transition-[height] |
| V8 | PlayerFull 歌词行区分度不足:非当前行 text-muted-foreground/60,当前行仅 font-medium text-foreground,没有位置指示 |
PlayerFull.vue:136-145 |
当前行放大到 17~18px + text-foreground + 左侧或下方主色指示;两侧行逐级降透明度(60/40/25)形成景深;点击区域扩大到整行并加 hover 背景 |
| V9 | PlayerFull 进度时间只在下方一行小字,且控制按钮组与进度条分属两个块,视觉不聚合 |
PlayerFull.vue:74-108 |
进度条与控制键合并为一块:[时间] ──●──── [时间] 上、[模式][上一首][播放][下一首][队列] 下的居中控制簇 |
| V10 | 「词」按钮用单个汉字当歌词开关 | PlayerBar.vue:125-132 |
换成 Lyrics 图标(FileText/MicVocal),与两侧图标风格一致 |
| V11 | 下载任务标题行过载:状态标签 + 歌名 + 歌手 + 音质标签 + 已下载/总量 + 最多 4 个图标按钮挤在一行;且 Card 被 gap-0 py-0 + CardContent py-2 覆盖,内边距与模块内其它卡片不一致 |
MusicModule.vue:995-1060 |
拆两行:第一行 [状态] 歌名 · 歌手 ……… [音质] [⋯菜单];第二行进度条 + 大小 + "打开目录/取消/重下"收进 ⋯ 菜单。Card 统一 p-0 + 内部 p-3 |
| V12 | 多曲目任务行进度条只有 56px(w-14),几乎读不出进度;错误信息被截断到 112px |
MusicModule.vue:1086-1097 |
进度条改占满剩余宽度(去掉固定宽度,放歌名下方一行);错误信息用 Tooltip 或点击展开 |
| V13 | TrackItem 悬停才出现的播放按钮占 40px 固定宽度,未悬停时标题左侧留出空白列,触屏/键盘用户无法触发 |
TrackItem.vue:68-75 |
改为:封面左下角 24px 半透明播放浮层(hover 显现),或整行 hover 时标题左侧淡入小图标;并支持双击整行播放 |
| V14 | 飞牛曲库行/本地曲库行/歌单行的"来源"标识不统一:本地用行内小标签「本地」,飞牛没有标识,歌单混排时无法区分 | TrackItem.vue:81 |
统一在行尾或封面角标显示来源(NAS 云图标 / 本机图标),并在歌单详情里按来源分组或加筛选 |
| V15 | 搜索历史下拉不适合键盘操作:@focusout 关闭 + @mousedown.prevent 的组合同样让 Tab 导航无法进入历史项 |
MusicModule.vue:738-760 |
改用 Combobox/Popover + Command 组件(仓库已有 components/ui/combobox),支持 ↑↓ 选择、Enter 确认、Esc 关闭 |
| V16 | +N 音质档位靠 Tooltip,触屏不可达;visibleTiers 固定展示 2 个,长歌名下把歌名挤窄 |
MusicModule.vue:888-904 |
行内只留 1 个"最高音质"徽标,全部档位收进右侧 ⋯ 菜单或点击弹出 popover |
| V17 | PlayerBar 缺少播放态反馈:loadingPlay 未接 UI;NAS 慢时点播放毫无反应 |
PlayerBar.vue:76-84 |
播放键上叠加 spinner(loadingPlay 时),或封面加旋转/脉冲 |
| V18 | PlayerBar 三段留白失衡:左侧信息 max-w-44,中间控制区 flex-1 里塞了模式/上下首/播放/队列 + 进度条,右侧音量+词+最大化;窄窗口下中间进度条被压到很短 |
PlayerBar.vue:34-141 |
采用标准三段:左 w-[240px] 固定、中 flex-1 min-w-0(控制 + 进度)、右 w-[200px](音量/词/大屏);窄容器下用 container query 渐进隐藏音量条与歌词按钮 |
| V19 | TabsList 同时带 grid w-full max-w-md 与组件默认 inline-flex w-fit,并靠 !p-0 !bg-transparent !shadow-none 强行覆盖 |
MusicModule.vue:691 |
不要用覆盖式改写;把"模块级分段导航"抽成一个 SegmentedNav 组件(grid grid-cols-N、bg-muted p-0.5、激活项 bg-background shadow-sm) |
| V20 | 封面圆角/阴影规格不统一:列表 40px rounded-md、播放条 44px rounded-md shadow、大屏 256px rounded-xl shadow-2xl |
多处 | 定规格:列表 6px 无阴影、播放条 6px 无阴影、大屏 12px + ring-1 ring-border/60 无重阴影 |
| V21 | 下载任务空态文案指向错误:「去"搜索"页选择歌曲下载」,但 tab 已改名为「发现音乐」 | MusicModule.vue:991 |
改为"去「发现音乐」页选择歌曲下载" |
| V22 | PlayerFull 未提供音量、队列收起、下一首信息;队列项没有播放态指示(只有 bg-primary/10) |
PlayerFull.vue:147-163 |
补:右上角音量滑条;队列项用序号↔均衡器切换表示当前项,hover 显示"下一首播放/移除" |
| V23 | ConnectionsSettings 图标按钮语义不明:Power 既表示"激活"又表示"登出",两个含义相同的图标相邻 |
ConnectionsSettings.vue:157-171 |
激活用 CircleCheck/RadioButton,登出用 LogOut,删除保留 Trash2;并为所有 icon-only 按钮补 Tooltip + aria-label |
| V24 | 歌单/曲库的「播放全部」用文字 ▶ 播放全部,与其他按钮的图标风格不统一 |
MyMusicLibrary.vue:115-117,214-216 |
统一为图标 + 文字:<Play class="size-4"/> 播放全部 |
| V25 | MusicModule 里两处搜索框文案不一致("输入歌名/歌手/专辑关键词" vs "粘贴歌单链接")且歌单解析行的 Input 靠 border-0 bg-transparent 融进容器,缺少"这是一行输入"的暗示 |
MusicModule.vue:730-782 |
歌单解析改为折叠的"粘贴歌单链接"次级入口(默认收起),避免与主搜索竞争注意力 |
6. 重设计方案
6.1 参考基准
- Spotify / Apple Music:左侧来源树 + 中间列表 + 底部全宽播放条 + 右侧可折叠"正在播放"。
- 网易云音乐 / QQ 音乐:紧凑表格行(
# / 标题 / 歌手 / 专辑 / 时长)、行内 hover 操作、双击播放、底部播放条 + 播放列表面板。 - foobar2000 / MusicBee:信息密度高、可排序表头、键盘优先。 本项目的定位是"NAS 曲库 + 本地曲库 + 多平台搜索下载"的工具型播放器,建议取 Spotify 的骨架 + 网易云的列表密度,避免做成消费级流媒体的强视觉风格。
6.2 目标信息架构(三级 tab → 一级导航)
音乐
├── 曲库
│ ├── 飞牛曲库 (NAS 曲目,分页/无限滚动 + 搜索 + 排序)
│ ├── 本地曲库 (扫描目录,按文件夹浏览)
│ └── 我的歌单 (歌单列表 → 歌单详情,可拖拽排序)
├── 发现音乐 (多平台搜索 + 歌单解析 + 下载)
├── 下载任务 (进行中 / 已完成 分组)
└── 设置 (连接与存储 / 播放 / 下载 / 诊断,用左侧锚点或分段)
把「我的音乐」内部的 3 个子 tab 提升为导航的一级子项(来源树),「设置」内部的 5 张卡片改为分组锚点。
6.3 布局结构(目标)
┌───────────────────────────────────────────────────────────────────────┐
│ TitleBar(含模块浮动切换器) │
├──────────┬──────────────┬─────────────────────────────────────────────┤
│ Sidebar │ 来源树 │ 内容主区 │
│ (模块) │ 240px │ ┌ 固定工具栏 ────────────────────────────┐ │
│ │ │ │ 搜索 来源筛 音质筛 排序 多选操作 │ │
│ │ 曲库 │ ├ 表头 (# 标题 歌手 专辑 时长 操作) ──────┤ │
│ │ · 飞牛曲库 │ │ 44px 紧凑行,hover 显示操作 │ │
│ │ · 本地曲库 │ │ 播放中:主色竖条 + 跳动的均衡器 │ │
│ │ · 我的歌单 │ │ ... │ │
│ │ 发现音乐 │ └────────────────────────────────────────┘ │
│ │ 下载任务 │ │
│ │ 设置 │ (可选右栏 320px:正在播放/歌词/队列) │
├──────────┴──────────────┴─────────────────────────────────────────────┤
│ MiniPlayer 72px(全宽坞站:左曲目 中控制+进度 右音量/歌词/大屏) │
└───────────────────────────────────────────────────────────────────────┘
要点:
- MiniPlayer 提升到
App.vue,全宽贴底、不参与任何模块内边距;音乐模块内容区pb-[72px]。这样在任何模块都能控制播放。 PlayerFull也提升到App.vue(全局 Dialog/Overlay),解决跨模块残留问题。- 去掉"模块级横向 Tab",音乐模块内部用来源树;
useModuleTabs仍可保留,用于把来源树的第一级注册给 TitleBar 浮动切换器(保持现有能力)。 - 右栏"正在播放"可折叠:窄窗口默认收起,宽窗口(≥1280px)默认展开,用
container query或useMediaQuery决定。
6.4 视觉规范(建议写入项目 Design Token)
| 项目 | 规范 |
|---|---|
| 主色使用 | 只用于:主操作按钮、播放中指示、"正在播放"高亮。不用于徽标分类、分组头 |
| 中性色阶 | 文本 foreground / 次文本 muted-foreground / 三级文本 muted-foreground/70;分隔线 border/60;hover 面 accent/60 |
| 圆角 | 行/输入 6px(radius-md)、卡片 10px(radius-lg)、大图 12px、头像/封面圆形仅用于单曲大图 |
| 阴影 | 列表与播放条不用阴影,靠 border 分层;只有 Dialog/Popover 用 shadow-lg |
| 行高 | 列表行 44px(紧凑)/ 48px(标准);表头 32px |
| 字号 | 歌名 14px/500,歌手·专辑 12px/400,时长时间数字 12px tabular-nums |
| 徽标 | 一类语义一个样式:状态用实心浅底,属性(音质/格式)用 outline 中性色 + 文字;不超过 2 个 |
| 动效 | 仅 opacity / transform,120~180ms ease-out;进度条高度过渡 150ms;尊重 prefers-reduced-motion |
| 明暗主题 | 禁止硬编码 bg-white / border-white/10 / text-white;一律走 token |
| 空状态 | 统一 EmptyMedia(32px 图标) → EmptyTitle(14px) → EmptyDescription(12px),gap-3、p-10、无边框 |
6.5 关键组件规格
MiniPlayer
高度 72px | 全宽 | border-t | bg-background/85 backdrop-blur
左 240px :封面 48px(6px 圆角) + 歌名 14px/500 截断 + 歌手 12px 截断 + ♥
中 flex-1:① [模式][上一首][播放 40px 圆形主色][下一首][队列] 居中
② [时间 00:00] ──进度条(hover 4→8px,含缓冲层)── [总时长] 居中 max-w-[560px]
右 200px :音量图标(可点静音) + 音量条 96px | 歌词 | 大屏 | (窄屏只留大屏)
播放中:封面右侧叠加 3 条均衡器动效;`loadingPlay` 时播放键显示 spinner
列表行(TrackItem 重写)
[#/均衡器 24px] [封面 40px] [标题 14px/500 + 歌手 12px] [专辑 12px 可隐藏] [时长 12px tabular] [⋯ hover 显示]
高度 44px | 无边框 | border-b border-border/50 | hover:bg-accent/60 | 当前项:左侧 3px 主色条 + 标题主色
双击整行播放;⋯ 菜单:下一首播放 / 加入队列 / 加入歌单 / 查看文件位置 / 下载
进度条交互
拖动中:只更新预览位置 + 显示时间气泡;轨道加粗;不写 currentTime
释放时:一次性 seek;若 duration 未知则禁用拖动并提示"该音频不支持跳转"
缓冲层:在轨道里用 bg-muted-foreground/25 画已缓冲区间(audio.buffered)
7. 落地路径(按性价比排序)
| 阶段 | 内容 | 影响面 | 预估 |
|---|---|---|---|
| S1 修 Bug(不做设计变更) | 1.1 搜索框绑定 store.keyword;1.2 toggle() 无 src 兜底;1.3 试听与播放器互斥(或合并内核);2.1/2.2 shuffle 自动切歌;2.4 缓存开关回填;2.7 init 幂等;2.8 持久化剥离 rawSearch |
仅改逻辑,风险低 | 半天 |
| S2 播放器骨架调整 | 3.1 拖动提交式 seek;3.2 歌词行索引缓存;3.3 大屏背景降级;4.1 播放键 loading 态;L1/V18 MiniPlayer 全宽贴底;V5/V7 主题 token 修正 | 播放条 + 大屏 | 1~2 天 |
| S3 列表与信息架构 | V2 紧凑列表;V1 徽标降噪;V3 分组头;V13/V4 TrackItem 重写;L6 首屏工具栏重构;2.5 上下文队列;2.6 歌单播放/暂停 | 发现音乐 + 曲库 | 3~4 天 |
| S4 结构性重构 | §6.2/6.3 左侧来源树 + 全局播放条;PlayerBar/PlayerFull 提升到 App.vue;右栏正在播放;设置页分组 | 模块外壳 | 1 周 |
| S5 体验补全 | 键盘快捷键、虚拟滚动、分页/无限滚动、拖拽排序歌单、音质档位 popover、缓冲层、桌面歌词 | — | 按需 |
建议先做 S1 + S2:S1 全是低风险的逻辑修复,S2 集中在两个组件文件里,能立刻解决"卡顿 + 没有控制感 + 拖动难用"的主观感受;S4 的结构调整等到前面稳定后再动,避免一次改动面过大。
8. 附:值得保留的设计
- 流代理走
127.0.0.1动态端口 +Cookie注入 + Range 透传(proxy.rs)——这是正确做法,seek 能真正生效,不要改成前端直连。 - 桥接进程按需拉起、禁用模块时停止(
music/index.ts:34-45)——避免了常驻 Python 进程。 tasksSignature轻量签名代替深度 watch(musicStore.ts:257-266)——思路正确(只是 payload 需要瘦身)。- 搜索结果懒解析(搜索阶段拿档位、播放/下载前才解析真实链接)+
rawSearch支持隔夜链接重解析(musicStore.ts:355-370,458)——设计合理。 - 发现音乐列表的增量渲染 + IntersectionObserver 哨兵(
MusicModule.vue:265-305)——方向正确,可平滑迁移为新列表的基础。
9. 重构落地记录(本次已实施)
9.1 新增文件
| 文件 | 作用 |
|---|---|
src/components/layout/TitleBarMusic.vue |
标题栏音乐栏:唱片图标 + 歌名,点击弹出方形播放控制窗 |
src/modules/music/components/PlaylistEditorDialog.vue |
歌单音乐编辑器:从全部音乐勾选加入 / 批量移出 |
src/components/layout/NowPlayingDialog.vue |
「正在播放」大屏(封面 + 控制 + 歌词 / 队列),由方形控制窗的「歌词 / 队列」入口打开 |
src/components/common/ScrubBar.vue |
通用拖拽进度条:拖动只预览、松手才 seek,含缓冲层与时间气泡 |
src/components/common/SegmentedNav.vue |
分段导航(现由 NowPlayingDialog 的「歌词 / 队列」切换使用) |
PlayerBar.vue / PlayerFull.vue 已删除;GlobalPlayerBar.vue 曾短暂作为全局坞站播放条存在,现已被标题栏音乐栏取代并删除。
播放器入口最终形态:标题栏搜索框左侧的音乐栏(TitleBarMusic)——未播放过时只显示唱片图标;有曲目后图标替换为封面并随播放旋转,右侧显示截断歌名。点击弹出约 300×340 的方形控制窗(封面 / 曲目信息 / 进度 / 循环·上下首·播放·队列 / 音量 / 歌词·队列入口),点击封面或「歌词 / 队列」进入大屏。
9.2 修复的缺陷
P0
- 飞牛曲库搜索框:直接
v-model="store.searchKeyword"并新增loadTracks(page, append)+loadMoreTracks(),搜索真正生效且支持分页续拉。 - 重启后播放键失效:
toggle()在audio无 src 时先按当前条目装载(试听或队列当前曲),失败原因经playError冒泡成 toast。 - 双音频引擎:删除模块内试听
<audio>,试听改走feiniu.playPreview(),与队列播放共用同一个 audio 实例,天然互斥。
P1
4. shuffle 自动切歌:next() 不再区分手动/自动,随机模式下都从「待播池」抽取;新增切歌历史栈,prev() 在随机模式下真正回退。播放模式持久化。
5. 缓存模式:setCacheMode / clearCache 不再调用 clearPlayback,改设置不中断播放。
6. 缓存开关状态:CacheSettings 的开关改为 cacheMode 的双向 computed,删除死代码 cacheMax,补 ensureCacheStatus 刷新。
7. init() 幂等化(inited 标志),不会再覆盖内存队列。
8. 任务持久化:超过 1.5MB 时自动剥离 rawSearch / defaultDownloadHeaders 后重试。
9. 上传触发:pendingUploadOnDone 布尔标志改为 uploadOnDoneTaskIds 任务集合 + uploadedTaskIds 去重表,每个任务最多触发一次;启动时的历史任务不触发重传。
10. Rust 引擎下载:startDownload 返回 { engine, skipped, taskId };Rust 引擎不再跳到空的任务页,改为提示到「下载器」查看;「下载到飞牛 + Rust 引擎」组合直接给出可读的错误提示并阻止。
11. 队列语义:playItem(item, context?) 支持以所在列表为播放上下文,解决「从大曲库点一首就只剩一首」;新增 playNext / addToQueue / removeFromQueue。
12. done 事件幂等,doneCount 不再虚增;redownloadTask 沿用原任务音质。
性能
13. 提交式 seek(ScrubBar),拖动不再触发几十次 Range 请求中断重建。
14. 歌词行索引由 O(n) 全量扫描改为「命中缓存微调 + 未命中二分」,并缓存上次索引。
15. 大屏背景从「整图 blur-2xl scale-110」改为主题色径向渐变,去掉持续 GPU 开销。
16. 列表行去掉 reka Tooltip(改用原生 title + 单个 Popover 展示音质档位),500 行不再产生 1000 个组件实例。
17. 歌词滚动用函数 ref 收集节点,不再每次 querySelectorAll。
18. 播放队列增量渲染(每批 200 行)。
19. 曲库分页 200/页 + 滚动续拉;任务明细进度条由固定 56px 改为占满剩余宽度。
交互与视觉
20. 播放入口改到标题栏:移除模块内播放条与全局坞站播放条,在自定义标题栏搜索框左侧新增音乐栏(唱片图标 + 歌名,未播放过时隐藏歌名);点击弹出方形控制窗;播放中图标替换为封面并旋转。
21. 音乐模块顶部 tabs 改回与其余模块完全一致的写法:<Tabs><div ref="tabsListRef"><TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none"> + TabsTrigger + TabsContent class="mt-4 min-h-0 flex-1 ... tab-animate",根节点恢复为 h-full p-6 overflow-hidden flex flex-col。
22. 三级 tab 压成「模块 tab → 曲库内左侧来源树」两级。左栏由 w-[210px] 收窄到 w-[168px],来源项的选中态对齐顶部 Tabs(rounded-lg bg-muted 容器 + bg-background shadow-sm 胶囊),歌单子树用 border-l 缩进 + 展开动画;右栏内容切换加 tab-animate(:key="view" 重挂载触发),不再生硬。
23. 首屏工具栏:搜索框回到首行,下载目标 / 来源筛选 / 音质筛选收到筛选条,歌单解析收进 Popover。
24. 列表改 44px 紧凑行:无独立边框、hover 高亮、双击整行播放、序号列悬停原位切换为播放按钮(不再出现 40px 空白列);播放中行用 music-eq 跳动均衡器 + 主色文字。
25. 音质徽标由 5 色降为单枚中性 outline 徽标 + Popover 详情。
26. Empty 全部改为组件既定结构(EmptyMedia / EmptyTitle / EmptyDescription)。
27. 歌单:删除语义奇怪的「移除最后一首」,改为行内「⋯」菜单的支持上移 / 下移 / 移除;新增重命名;左侧歌单列表可独立滚动。
28. 歌单页新增「编辑音乐」(components/PlaylistEditorDialog.vue):双 Tab 编辑器——「全部音乐」按来源(全部 / 飞牛 / 本地)勾选加入,已在歌单的曲目置灰标记「已加入」不可重复勾选;「已在歌单」批量勾选移出。含搜索、全选当前列表、增量渲染(每批 120)、飞牛曲库续拉、本地未扫描时的内联扫描入口。
29. 源分组头去掉 sticky 与白色底色(原 bg-background/95 backdrop-blur 会在浅色背景上形成白色横条)。
30. 发现音乐每行新增「下载到飞牛」(试听 / 下载右侧):走新增的 Rust 命令 feiniu_upload_from_url,由后端边拉音源边传给 NAS,本地不落地文件;懒解析歌曲会先按默认音质解析真实链接,并合并 defaultDownloadHeaders + defaultDownloadCookies(过滤非 ASCII 头,避免 reqwest panic)。
31. 设置页从「5 张卡片」改为「连接 / 存储与上传 / 播放 / 搜索与下载 / 环境与诊断(折叠)」分组,并把「NAS 文件服务登录」并入存储分组。
32. 主题一致性:删除 border-white/10、bg-white 等硬编码;滑块、均衡器、进度条全部走 --primary / --muted / --background token。
33. 连接管理图标语义修正(激活用 CircleCheck、登出用 LogOut),全部 icon-only 按钮补齐 title + aria-label。
34. 搜索历史下拉改为 focusin/focusout 容器判定,Tab 键可以进入历史项。
9.2.1 飞牛上传链路:fnOS 文件服务 → WebDAV(最终形态)
演进:multipart over WS 的 fnOS 文件服务存在会话易失、握手/登录协议联调困难等问题(三轮修复: WS 读写分离与 Origin → 加密登录体补
req/reqid→ 登录结果双通道等待),最终整体替换为 WebDAV。
| 结论 | 说明 |
|---|---|
| 协议选型 | fnOS 提供的 SMB / WebDAV / FTP / NFS 中选 WebDAV:纯 HTTP 语义、reqwest 即可实现、Basic 认证随请求携带无会话(重启免登录);SMB 的 Rust 客户端生态差、FTP 明文且被动模式易出问题、NFS 无 Windows 客户端方案 |
| 后端实现 | src-tauri/src/music/feiniu/webdav.rs(fnos.rs 已删除):test(PROPFIND Depth 0)/ upload_file(本地文件 128KB 分块流式 PUT)/ delete;上传前逐级 MKCOL 补目录 |
| 「下载到飞牛」机制 | 与自动上传同链路:正常走 musicdl 下载(引擎侧解析/代理/音质)→ 任务完成后扫描下载目录(下界=任务创建时刻)→ WebDAV 上传 → 删除本地音频及同名歌词/封面(feiniu_delete_local,扩展名白名单)→ 移除任务记录。失败时保留本地文件与任务记录供排查。早期的「URL 拉流直传」(webdav_upload_from_url)因可靠性不足已移除 |
| 前端配置 | feiniuStore.webdav = { url, username, password, dir } 持久化 localStorage(thing.music.feiniu.webdav);webdavReady 四项齐备才放行上传;设置页「存储与上传」分组内配置 + 「测试连接」 |
| fnOS 侧设置 | 系统设置 → 文件服务 → WebDAV 开启(HTTP 5005 / HTTPS 5006);目标文件夹在「可见文件夹范围」内;团队文件夹需勾选「允许通过文件共享协议挂载」;账号需读写权限(已写成设置页折叠提示) |
| 路径语义 | 目标目录是 WebDAV 根下的相对路径(不是 NAS 内部绝对路径),URL 逐段百分号编码 |
| 错误可读化 | 401 认证失败 / 403 无权限 / 404 目录不存在或不在可见范围 / 409 父目录不存在,均带上下文 |
9.3 校验
vue-tsc --noEmit → exit 0(0 error)
vite build → exit 0(11.5s)
cargo check → exit 0
9.4 仍未做的部分(建议后续)
- 键盘全局快捷键(空格播放/暂停、↑↓ 选行)——需接入项目既有快捷键体系,避免与输入框冲突。
- 列表虚拟滚动(当前为增量渲染,千级以上仍有优化空间)。
- 歌单拖拽排序(
vue-draggable-plus已在依赖中,可直接接入)。 - 本地文件的时长 / 内嵌封面解析(当前时长列退化为文件大小)。
- 缓存模式改为「边下边播」的双源策略(当前
cache模式首播仍需等整首下完)。