Files
Thing/MUSIC_MODULE_REVIEW.md
2026-09-12 15:47:16 +08:00

47 KiB
Raw Permalink Blame History

音乐模块全链路审查报告

审查范围:数据请求(搜索/歌单解析/下载/解析真实链接)、播放控制(试听、播放器内核、队列、歌词、缓存)、状态管理(musicStore / feiniuStore)、UI 交互与视觉。 覆盖文件(前端 11 个 + Rust 4 个): src/stores/musicStore.tssrc/stores/feiniuStore.tssrc/modules/music/MusicModule.vuecomponents/PlayerBar.vuecomponents/PlayerFull.vuecomponents/MyMusicLibrary.vuecomponents/TrackItem.vuecomponents/settings/ConnectionsSettings.vuecomponents/settings/CacheSettings.vueSourcePicker.vuesources.tsindex.tssrc/App.vuesrc/components/layout/ModuleContainer.vuesrc-tauri/src/music/{mod,commands}.rssrc-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 / PlayerFullMusicModule.vue 提升到 App.vue 全局层。当前它们挂载在音乐模块内部,而模块容器是 :key="activeModule" 重挂载(App.vue:36-41)——切到任何其他模块,播放条和"正在播放"大屏整体卸载,但音频仍在后台播放,用户完全失去控制入口。这是当前体验上最严重的问题。


1. P0 功能性失效(必须修)

1.1 飞牛曲库搜索框完全无效

现象:输入关键词、回车,曲库列表纹丝不动。

根因MyMusicLibrary.vue:21 定义了本地 keywordInputonSearchInput:24-29)只做了防抖后调用 store.loadTracks(1),而 loadTracks 读的是 store 里的 keywordfeiniuStore.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(() => {})   // ← 无 srcplay() 直接 reject,被静默吞掉
  else a.pause()
}

ensureAudio() 新建的 Audio() 没有 srcplay()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()

二者没有互斥,playingKeystore.playing 彼此独立。附带问题:试听音频在切换 tab / 卸载模块时不会停止(模板 audio 被移除,引用置空,媒体仍可能继续播到自然结束)。

修复(推荐):废弃模块内试听 audio,试听直接走同一内核——playQueue([{...}], 0)source 用 'preview',播完即止。若短期不想动内核,至少在 togglePlay 之前 feiniu.clearPlayback(),并在 onBeforeUnmountaudioEl.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 初始恒为 falseonMounted 只读了 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 onProgressstore.duration 换算秒数;若流没有返回 Content-LengthloadedmetadatadurationNaNa.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。飞牛流代理是真流式透传 Rangeproxy.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 同时挂载(大屏打开时)→ 双份重渲染
  • PlayerFullcurrentLine()feiniuStore.ts:710-717)是线性扫描,每次重算 O(歌词行数)。改:缓存上次命中的索引,只在 position > 下一行时间idx++(歌词已排序,单调推进),或二分查找。
  • PlayerFull.vue:33-39watchEffect 每次行切换都 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/taskAggTotalMusicModule.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: 50page/total 暴露了却没有 UI。曲库 > 50 首时后面的歌完全访问不到playAll 也只能播前 50 首)。应加无限滚动或分页。
  • 本地扫描无上限scanLocal 一次性返回全部条目,超大音乐目录下会长时间占住主线程做映射与渲染。
  • 缓存模式首播延迟feiniu_cache_fetchcommands.rs:421-429cache_fetch)是"下载完整文件后返回路径",即 cache 模式下首次播放要等整首下完;而 loadingPlay 这个状态在 UI 上从未被使用PlayerBar/PlayerFull 都没读),用户看到的是"点了没反应"。要么改用边下边播的双源策略,要么至少把 loadingPlay 接上 spinner。

4. 交互体验问题(按操作路径)

播放控制

  1. 播放/暂停按钮 disabled 时无视觉降级(PlayerBar.vue:76-84),看起来可点却没反应。
  2. 音量图标(PlayerBar.vue:117)不可点击,没有"一键静音";音量条只有 80px,长按/滚轮调节都没有。
  3. 切歌没有 crossfade,也没有间奏/淡出,切歌瞬间是硬切。
  4. 播放模式按钮图标随状态变(Repeat/Repeat1/Shuffle),初看像三个不同功能,且没有文字提示(只有 Tooltip)。
  5. 队列入口用 ListMusic 图标 + 数字角标,但点击是平铺开关store.queueVisible = !store.queueVisible),而队列实际渲染在 PlayerFull 的右栏里——在大屏没打开时点这个图标看不到任何反馈(因为它只切了右栏的 tab,而弹窗没开)。
  6. 上一首/下一首没有"按住快进/快退"(老播放器的常见预期)。

进度条 7. 没有 hover 时间气泡、没有缓冲进度层、没有"拖动时显示预览时间"。当前拖动时只能看两侧的小字。 8. 轨道 6px + 16px 白色圆点滑块,Slider.vue:40 硬编码 bg-white,在浅色主题下白滑块白描边几乎隐形。

列表切换 9. 发现音乐页切到「我的音乐」再切回,搜索关键词和结果全部丢失(模块内 tab 用 Tabs 非 keep-aliveflatResults 被重算但 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-th-[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-980MyMusicLibrary.vue:76-83,135-140 用组件既定的子结构:EmptyMedia / EmptyTitle / EmptyDescription;或给容器加 border + border-dashedgap-3p-8
L5 横向 Tab 与子 Tab 层级混乱:顶层 4 个 tab 里,「我的音乐」内部又横排 3 个子 tab,「设置」内部又竖排 5 张卡;导航成本高 MusicModule.vue:691-696MyMusicLibrary.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-280 行 = 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 个图标按钮挤在一行;且 Cardgap-0 py-0 + CardContent py-2 覆盖,内边距与模块内其它卡片不一致 MusicModule.vue:995-1060 拆两行:第一行 [状态] 歌名 · 歌手 ……… [音质] [⋯菜单];第二行进度条 + 大小 + "打开目录/取消/重下"收进 菜单。Card 统一 p-0 + 内部 p-3
V12 多曲目任务行进度条只有 56pxw-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 播放键上叠加 spinnerloadingPlay 时),或封面加旋转/脉冲
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-Nbg-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(全宽坞站:左曲目 中控制+进度 右音量/歌词/大屏)           │
└───────────────────────────────────────────────────────────────────────┘

要点:

  1. MiniPlayer 提升到 App.vue,全宽贴底、不参与任何模块内边距;音乐模块内容区 pb-[72px]。这样在任何模块都能控制播放。
  2. PlayerFull 也提升到 App.vue(全局 Dialog/Overlay),解决跨模块残留问题。
  3. 去掉"模块级横向 Tab",音乐模块内部用来源树;useModuleTabs 仍可保留,用于把来源树的第一级注册给 TitleBar 浮动切换器(保持现有能力)。
  4. 右栏"正在播放"可折叠:窄窗口默认收起,宽窗口(≥1280px)默认展开,用 container queryuseMediaQuery 决定。

6.4 视觉规范(建议写入项目 Design Token

项目 规范
主色使用 只用于:主操作按钮、播放中指示、"正在播放"高亮。不用于徽标分类、分组头
中性色阶 文本 foreground / 次文本 muted-foreground / 三级文本 muted-foreground/70;分隔线 border/60hover 面 accent/60
圆角 行/输入 6pxradius-md)、卡片 10pxradius-lg)、大图 12px、头像/封面圆形仅用于单曲大图
阴影 列表与播放条不用阴影,靠 border 分层;只有 Dialog/Popover 用 shadow-lg
行高 列表行 44px(紧凑)/ 48px(标准);表头 32px
字号 歌名 14px/500,歌手·专辑 12px/400,时长时间数字 12px tabular-nums
徽标 一类语义一个样式:状态用实心浅底,属性(音质/格式)用 outline 中性色 + 文字;不超过 2 个
动效 opacity / transform120~180ms ease-out;进度条高度过渡 150ms;尊重 prefers-reduced-motion
明暗主题 禁止硬编码 bg-white / border-white/10 / text-white;一律走 token
空状态 统一 EmptyMedia(32px 图标) → EmptyTitle(14px) → EmptyDescription(12px)gap-3p-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.keyword1.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 轻量签名代替深度 watchmusicStore.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

  1. 飞牛曲库搜索框:直接 v-model="store.searchKeyword" 并新增 loadTracks(page, append) + loadMoreTracks(),搜索真正生效且支持分页续拉。
  2. 重启后播放键失效:toggle()audio 无 src 时先按当前条目装载(试听或队列当前曲),失败原因经 playError 冒泡成 toast。
  3. 双音频引擎:删除模块内试听 <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. 提交式 seekScrubBar),拖动不再触发几十次 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],来源项的选中态对齐顶部 Tabsrounded-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/10bg-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.rsfnos.rs 已删除):testPROPFIND Depth 0/ upload_file(本地文件 128KB 分块流式 PUT)/ delete;上传前逐级 MKCOL 补目录
「下载到飞牛」机制 与自动上传同链路:正常走 musicdl 下载(引擎侧解析/代理/音质)→ 任务完成后扫描下载目录(下界=任务创建时刻)→ WebDAV 上传 → 删除本地音频及同名歌词/封面(feiniu_delete_local,扩展名白名单)→ 移除任务记录。失败时保留本地文件与任务记录供排查。早期的「URL 拉流直传」(webdav_upload_from_url)因可靠性不足已移除
前端配置 feiniuStore.webdav = { url, username, password, dir } 持久化 localStoragething.music.feiniu.webdav);webdavReady 四项齐备才放行上传;设置页「存储与上传」分组内配置 + 「测试连接」
fnOS 侧设置 系统设置 → 文件服务 → WebDAV 开启(HTTP 5005 / HTTPS 5006);目标文件夹在「可见文件夹范围」内;团队文件夹需勾选「允许通过文件共享协议挂载」;账号需读写权限(已写成设置页折叠提示)
路径语义 目标目录是 WebDAV 根下的相对路径(不是 NAS 内部绝对路径),URL 逐段百分号编码
错误可读化 401 认证失败 / 403 无权限 / 404 目录不存在或不在可见范围 / 409 父目录不存在,均带上下文

9.3 校验

vue-tsc --noEmit   → exit 00 error
vite build         → exit 011.5s
cargo check        → exit 0

9.4 仍未做的部分(建议后续)

  • 键盘全局快捷键(空格播放/暂停、↑↓ 选行)——需接入项目既有快捷键体系,避免与输入框冲突。
  • 列表虚拟滚动(当前为增量渲染,千级以上仍有优化空间)。
  • 歌单拖拽排序(vue-draggable-plus 已在依赖中,可直接接入)。
  • 本地文件的时长 / 内嵌封面解析(当前时长列退化为文件大小)。
  • 缓存模式改为「边下边播」的双源策略(当前 cache 模式首播仍需等整首下完)。