Compare commits

..

55 Commits

Author SHA1 Message Date
alger 187ce573a4 chore(eslint): 忽略嵌套的 dist 构建产物目录
ignores 由 dist/** 改为 **/dist/**,避免 src/renderer/dist 被误扫导致 lint 报 200+ 错误并短路后续 i18n 检查
2026-07-05 19:54:08 +08:00
alger 5d3596de29 fix(ui): 修复内存泄漏、搜索竞态、播客时长及颜色/指令边界问题
- MusicFullMobile: 卸载清理睡眠定时器 interval;getTextColors 补上调用括号
- loading 指令: 改为每元素独立实例、卸载真正销毁、class 用 classList,避免多个 v-loading 争用单例
- lyric/index: 歌词窗口 receive-lyric IPC 监听卸载时解绑
- SearchBar/SearchResult/list: 三处异步搜索加竞态守卫,丢弃过期返回避免旧结果覆盖新结果
- linearColor: 0 尺寸图片 NaN 兜底、渐变色标越界兜底
- utils/secondToMinute: 时长 >=1 小时进位为 h:mm:ss(修复播客时长显示 78:34)
2026-07-05 19:53:50 +08:00
alger 9153d85992 fix(api): 修复失败缓存清不掉、自定义音源解析及请求逻辑问题
- musicParser: clearMusicCache 改清内存失败缓存(覆盖含 lxMusic 全部策略);unblock 解析加 15s 整体超时兜底,超时降级不触发重复重试
- parseFromCustomApi: GET 请求按 apiUrl 是否已带查询串选择 ?/& 分隔符;br 由字符串码率映射,修复恒为 NaN
- request: 301 登录态失效直接拒绝(原 retryCount=3 与日志矛盾),并对 config.params 加可选链防护
2026-07-05 19:53:29 +08:00
alger 2073caf383 fix(main): 修复下载过期无法恢复、配置写放大、主进程 i18n 崩溃
- downloadManager: axios 放行 403/410,让直链过期能触发重新解析(修复重启恢复队列时下载失效)
- cache: getCacheConfig 纯读不再落盘 config.json,消除播放/下载热路径的写放大与文件争用
- i18n/main: 未知/非法 locale 回退默认语言并加空值防护,避免托盘/登录窗构建时崩溃
- lxMusicHttp: 超时定时器在 fetch 出错路径也清理
- loginWindow: 标题改为打开窗口时求值,跟随当前语言
2026-07-05 19:53:12 +08:00
alger f17bc62310 fix(player): 修复快速切歌卡死、随机模式洗牌、定时睡眠重启失效
- audioService: 切歌时清除上一首遗留的 canplay/error 监听器,避免过期回调 stop 掉正在播放的新歌导致静音卡死
- playlist: setPlayList 新增 preserveOrder,就地编辑(下一首播放/移除单曲)时随机模式下不再重新洗牌,并同步 originalPlayList
- sleepTimer: store 初始化时重建定时器 interval,修复重启后定时关闭到点不停
- preloadService: 清理 3s 验证超时定时器、validatedUrls 加容量上限、预加载调用处补 catch
2026-07-05 19:52:49 +08:00
alger 3dd85a7624 refactor(parser): 移除后备接口 2026-07-05 17:51:16 +08:00
alger b6e0fe0546 fix(lx): 修复落雪音源脚本导入失败及 API 端点死链导致的连环跳歌 2026-07-05 17:51:04 +08:00
alger 960afd04be fix(player): 修复播放失败死循环,失败静默重试一次后直接跳下一首
日志实证的故障链:试听歌曲走备用解析 → 命中缓存的失效 URL → Format
error → url_expired → 原样重放 → 再次命中同一坏缓存(30 分钟 TTL 内
永远命中)→ 死循环,最终静默停止且不切歌。

修复:
- url_expired 恢复前清除该歌曲的解析 URL 缓存(clearMusicCache 此前
  只有手动重新解析按钮调用),重试真正拿到新 URL
- 恢复为静默重试且仅 1 次(无任何用户提示),仍失败直接切下一首
  (单曲列表停止/FM 拉新歌),playTrack 成功时重置计数
- 恢复前校验当前歌曲未变更,避免与 nextPlay 的失败跳歌并发争抢
- nextPlay 播放失败不再原地重试,直接静默跳过下一首
- 修复连续失败上限从未生效的存量 bug:跳歌链递归重置了
  consecutiveFailCount,全列表失效时会无限跳歌;现失败链不重置计数,
  连续 5 次失败正确停止
- audioService 对 MEDIA_ERR_SRC_NOT_SUPPORTED(4) 不再用同一 URL 重试
  (源本身无效,重试纯耗时),网络类错误保留原重试

测试:16 项全过——恢复状态机 8 场景、错误分类 2 项、真实 HTTP 服务
集成 2 项(坏缓存死循环复现 + 清缓存换新 URL 恢复)、跳歌链 4 场景
(直接跳过/全失败恰好 5 次后停止/旧计数重置 bug 复现/主动切歌重置)。
2026-07-05 16:48:46 +08:00
alger 968116ce18 fix(player): 切歌时封面/歌词/背景色与音频同步就绪
修复 #712 重构引入的体验回退:全屏页点下一首会'先响歌、后换脸'——
歌词/背景色在播放成功后才开始加载。

- playTrack 的元数据加载(歌词+背景色)提前到与取 URL/加载音频并行:
  不阻塞出声,视觉信息通常在起播前就绪;playMusic 被 getSongDetail
  替换后重新应用已到达的元数据,避免丢失;generation 拦截过期请求
- 预加载下一首时顺带预热背景色(歌词已有预热),切歌时元数据全部
  缓存命中、立即应用

测试:时序状态机模拟 3 场景全过(元数据先到不丢失/后到补上/快速
切歌不污染),typecheck 与构建通过。
2026-07-05 16:29:58 +08:00
alger 3742ed5062 fix(lyric): 点击歌词跳转不再跳到歌曲末尾触发切歌
根因:MusicHook.parseLyricsString 的 lrcTimeArray 直接使用 yrcParser 的
毫秒时间戳,而 seek/nowTime 全链路以秒为单位。合并播放重构(#712)后
'先播放、歌词后到'使 ensureLyricsLoaded 的 API 兜底分支成为在线歌曲
常规路径,点击歌词 seek(毫秒值) 被钳到音频末尾直接触发 ended 切歌,
歌词高亮同步也一并失效。

修复:
- parseLyricsString 时间数组统一换算为秒,与 usePlayerHooks.parseLyrics 一致
- 完整歌词对象(yrc 逐字/翻译)异步送达时强制重新解析,替换先行的兜底纯 lrc

测试:以真实 LRC/YRC 样本驱动真实 yrcParser 验证 7 项断言全过,
含修复前行为回归复现(seek(28910) 钳到末尾触发切歌)与修复后
全行点击不触发切歌、两解析路径单位一致性。
2026-07-05 16:05:03 +08:00
alger 8f1248c959 fix(window): 修复迷你模式相关的两处状态污染
- 展开播放列表后的 340x400 迷你窗口被误判为普通窗口并持久化到
  windowState,导致下次启动主窗口尺寸异常
- 迷你/主界面切换时复位 musicFull,避免残留 true 使返回后第一次点击
  歌曲信息被取反关闭、全屏播放页'打不开'

Closes #242
2026-07-05 15:16:56 +08:00
alger 7af71074d2 fix(mini): 迷你模式右键'添加到歌单'恢复主窗口后接力打开歌单抽屉
Mini 窗口宽 340px 容不下 420px 歌单抽屉,此前 provide 的是空实现点击
无反应。现记录待添加歌曲 → window.api.restore() 恢复主窗口 →
AppLayout 挂载后自动打开歌单抽屉完成添加。

Closes #504
2026-07-05 15:16:50 +08:00
alger 9618cb9521 fix(lyric): 桌面歌词三处 UI 问题(主题色重置/延迟控件遮挡/锁图标淡出)
- 主题色面板新增'恢复默认'按钮,接入已有 resetThemeColor(此前设置自定义
  主题色后无任何入口可关闭,#591)
- 全屏歌词页的歌词延迟调整控件 bottom 从 16px 提至 96px,越过钉底的
  PlayBar(80px, z-index 9999),不再被遮挡无法点击(#592)
- 锁定态锁图标增加空闲淡出:无鼠标活动 2.5s 后隐藏并恢复点击穿透,
  mousemove(forward:true 转发)即重新唤出,移出窗口立即隐藏(#606)

Closes #591
Closes #592
Closes #606
2026-07-05 15:14:36 +08:00
alger b50f69a65b fix(playlist): 歌单增删歌曲后刷新用户歌单列表
添加/删除成功后调用 initializePlaylist 重新拉取,用户页与详情页的
歌曲总数(trackCount)会话内即时更新;重启后仍滞后属网易服务端缓存,
客户端无法根治。

Closes #508
2026-07-05 15:08:45 +08:00
alger 2d773ae946 feat(pwa): 补齐 web 端 PWA 可安装条件
- 新增 192/512 图标与 maskable 变体(源自 icon.png,安全区 80%)
- manifest.json 图标声明修正(原先仅声明一个实际不存在的 256 尺寸)
- 注册最小 Service Worker(空 fetch 处理器,不做缓存拦截),仅 Web 生产环境生效

Closes #640
Ref #382
2026-07-05 15:06:19 +08:00
alger 4554246c69 fix(web): 修复 web 端构建失败(顶层 await 超出默认 target)
f652932 起 MusicHook 使用顶层 await,vite 默认 target(es2020/chrome87)
无法转译,npx vite build 直接报错——web 版因此无法产出新构建(#641 的
'网页版还是 5.0.0'与此相关)。build.target 提升至 es2022。

Ref #641
2026-07-05 15:06:13 +08:00
alger 0f88a9dc14 feat(songitem): 外语歌曲显示中文译名/别名
网易 API 的 tns/alia 字段已随 formatSong 展开保留在歌曲对象上,
SongResult 补类型声明,5 个歌曲条目变体在歌名后以灰色小字显示译名。

Closes #629
2026-07-05 15:01:58 +08:00
alger 9979ec8237 fix(build): 重生成 Windows 图标补齐 64/96/128 中间尺寸层
原 icon.ico 仅含 16/24/32/48/256 五层,高 DPI(如 4K 200% 缩放)任务栏
需要约 64px 图标时只能从 48 放大导致模糊。现补齐 16-128 全梯度 BMP 层
+ 256 PNG 层,NSIS 安装器图标一并受益。

Closes #376
2026-07-05 14:58:54 +08:00
alger 408397304c fix(main): 拒绝麦克风/摄像头采集权限请求,防止系统授权弹窗
应用无任何录音功能;#639 已移除触发弹窗的 getUserMedia 调用(03b52cd),
本次在主进程权限层加防御:media/audioCapture 请求一律拒绝,防止未来依赖库
静默申请麦克风。输出设备枚举与 setSinkId 切换不受影响。

Ref #147 #246 #440 #639
2026-07-05 14:55:44 +08:00
alger fda79f2f8d fix(player): 系统媒体控件(SMTC)封面尺寸上限提升至 1024
artwork 增加 1024x1024 档位并改用 getImgUrl 生成 URL,正确处理
data:/local:// 封面与已带参数的图片地址,提升 AMLL 等 SMTC 监听端的封面清晰度。

Closes #595
2026-07-05 14:51:57 +08:00
alger 7c953406e0 fix(download): 单独下载歌词的文件名跟随文件名格式设置
downloadLyric 改用 set.downloadNameFormat 模板拼接文件名,与歌曲下载行为
一致(此前硬编码'歌曲名 - 歌手');多歌手连接符与歌曲下载统一为'、'。

Closes #655
2026-07-05 14:51:51 +08:00
alger a1b1006af0 fix(i18n): 洛雪音源名称错别字修正(落雪→洛雪)
LX Music 官方中文名为'洛雪音乐助手',设置页三种语言的用户可见文案统一修正。

Closes #565
2026-07-05 14:47:43 +08:00
alger 33149c6a74 feat(local-music): 支持从本地列表移除单曲并防止扫描失败误删(#713)
- 右键菜单新增'从本地列表移除'(本地歌曲自动切换文案,仅移除条目不删文件,5 语言文案)
- localMusic store 新增 removeEntry action
- 扫描失败的文件夹不再参与'已删除清理',避免移动盘/网络盘暂时不可用时整夹歌曲被误删
- 注:'刷新不清理已删除歌曲'主症状已由 c28368f 修复,本次补齐评论区诉求与防护

Closes #713
2026-07-05 14:35:00 +08:00
alger dee717a575 fix(parser): gdmusic 音源增加搜索结果校验,避免解析出货不对版的歌曲
此前 gdmusic 无条件取搜索第一条结果,版权下架歌曲(如周杰伦)会解析出
翻唱/同名不同曲并直接播放(#704)。现改为取前 5 条候选做归一化匹配:
- 歌名必须匹配(忽略大小写/标点/括号内 Live 等备注)
- 候选带歌手信息时歌手也必须匹配,宁可解析失败也不返回错歌
- 版权导致的全音源无资源属外部问题,代码层保证不再放错歌

Ref #704
2026-07-05 14:29:40 +08:00
alger 1e50334ac7 fix(build): 修复 Ubuntu/Linux 安装后应用图标显示为齿轮
根因:linux.icon 指向单张 1084x1084 PNG,electron-builder 将其原样装入
hicolor/1084x1084/(非法的主题尺寸目录),GNOME 图标主题索引无法命中,
启动器/任务栏回退为齿轮默认图标。

新增 build/icons 标准尺寸集(16-1024),deb/rpm/AppImage 均装入合法的
hicolor 尺寸目录。已实际构建 deb 对比验证新旧产物。

Closes #701
2026-07-05 14:26:55 +08:00
alger a3840e2bae fix(songitem): 添加到歌单权限判断改为响应式并提示不可用原因
- 权限判断从 localStorage 快照改为 userStore 响应式状态,登录/登出后菜单状态即时刷新
- 菜单项不再灰色禁用,未登录或 UID 登录时点击给出明确提示(新增 5 语言文案)

Closes #706
2026-07-05 14:09:57 +08:00
alger f4346f4c79 fix(player): 私人 FM 模式下点击上一曲不再重放当前歌曲
_prevPlay 补充 FM 分支:FM 不支持回到上一首,与下一曲行为一致直接拉取新的 FM 歌曲。
此前 FM 列表只有一首歌,上一曲计算出的索引仍是当前曲目导致原地重放。

Closes #682
2026-07-05 14:09:51 +08:00
alger 9f5473e14d fix(main): 修复 config.json 并发读写导致的 EBUSY 主进程崩溃弹窗
- 主进程 6 个独立 Store 实例合并为共享单例(window-size/lyric/server/deviceInfo/window 复用 config.ts 实例)
- initializeConfig 幂等化,修复 set-store-value IPC 监听重复注册导致的双倍写入
- 窗口/歌词窗 move、resize 保存增加 500ms 防抖,拖动时不再每秒几十次写盘,close 时冲刷落盘
- store 读写 IPC handler 与窗口状态读写加 try-catch,撞锁丢弃单次写入而非崩溃
- 全局 uncaughtException 兜底:文件锁类错误(EBUSY/EPERM 等)仅记日志,其余异常保留报错弹窗

Closes #714
2026-07-05 14:06:01 +08:00
alger f39100e9eb fix(player): 合并外部贡献后的集成修复
- PlayBar 下载按钮补 isElectron 守卫,Web 端不再显示无效按钮(#708 评审意见)
- preloadNextSongs 恢复 800ms 短去抖,避免快速切歌时音源请求风暴(#712 评审意见)
- 清理合并后未使用的导入
2026-07-05 14:00:07 +08:00
alger 1d36734f79 Merge pull request #712 from daili115/trae/agent-m1V9o5
feat: 优化音乐播放性能,实现异步加载和即时预加载
2026-07-05 13:55:34 +08:00
alger 91a3259e27 Merge pull request #708 from jiang-LJ/main
feat(player): 在播放栏添加下载当前歌曲按钮
2026-07-05 13:55:34 +08:00
alger ecb85f0146 Merge pull request #676 from chengww5217/refactor/persisted-storage
refactor(persist): localStorage 配额防护与历史持久化重写
2026-07-05 13:55:20 +08:00
alger 6bea735aef Merge pull request #675 from chengww5217/fix/local-music-playback
fix(local-music): 修复本地音乐播放失败、进度异常与切歌失效
2026-07-05 13:55:20 +08:00
alger d9b102879f Merge pull request #677 from chengww5217/chore/fix-existing-lint
ci: 修复 PR Code Quality job 失败(存量 lint + typecheck 拿不到 unplugin d.ts)
2026-07-05 13:55:09 +08:00
daili115 1198be4f11 feat: 修复软件切歌慢问题
Co-authored-by: daili115 <daili115@users.noreply.github.com>
2026-06-25 07:41:15 +00:00
jiang-LJ 7792eeac2e feat(player): 在播放栏添加下载当前歌曲按钮 2026-06-17 21:57:01 +08:00
chengww 1a8b5f4977 ci: 加 setup-bun 让 lint:i18n 步骤能跑
package.json 里 lint:i18n = "bun scripts/check_i18n.ts",但 pr-check.yml
只装了 Node 没装 bun,I18n check 步骤报 sh: bun: not found。

之前这个错误被前两步(lint / typecheck)的失败遮住,前两步修了之后才暴露。
用官方 oven-sh/setup-bun 一步搞定,放在 setup-node 之后、install 之前。
2026-05-18 00:07:05 +08:00
chengww 95694057ec ci: typecheck 前先跑 build 让 unplugin 生成自动 import d.ts
tsconfig.web.json 的 compilerOptions.types 显式 require
src/renderer/{auto-imports,components}.d.ts,这两个文件由
unplugin-auto-import / unplugin-vue-components 在 vite 启动时生成,
且被 .gitignore 排除(58922dc 维护者主动设置)。

CI 直接跑 typecheck 拿不到 d.ts 会报 TS2688,导致所有 PR 都过不了
Code Quality job。在 Type check 步骤前插入一次 npm run build 触发
unplugin 生成 d.ts,同时顺带验证 build 链路(一次到位,不增加额外
专用脚本,CI 多 30-60s 但能拦下 build 阶段的回归)。

本地验证:删除两个 d.ts 后跑 npm run build 重新生成,typecheck 通过。
2026-05-18 00:01:19 +08:00
chengww 938c497ca3 chore: 修复 main 上的存量 lint 错误
pr-check.yml 只在 PR 触发,main push 不跑 lint,导致存量错误一直未被发现,
PR #675 / #676 都被 Code Quality job 拦下。

- 9 处 prettier/prettier 格式错误:StickyTabPage.vue / RadioCard.vue /
  list/index.vue / mv/index.vue / podcast/index.vue 的属性换行与 import
  排序,全部 eslint --fix 自动修复
- 1 处 no-undef ScrollToOptions(StickyTabPage.vue:79):在 eslint.config.mjs
  的 .ts 与 .vue 配置块同时补 ScrollToOptions 到 globals,与既有的
  ScrollBehavior 同级;TypeScript DOM lib 类型不在 globals.browser 里,
  需手动声明
2026-05-17 23:54:28 +08:00
chengww a078e37e2c fix(build): renderer 单 chunk 打包,规避 chunk 间循环依赖 TDZ 白屏
store/index.ts 用 `export *` 聚合所有 store + 抽出的共享工具(如
debouncedStorage)被多 store 引用,Vite 6 默认拆分会让 utils 合并到
entry chunk,再与 store chunk 形成 index ↔ store 循环;当循环 chunk
顶层同步访问对方导出的 const(如 `persist: { storage: debouncedLocalStorage }`),
生产构建会触发 TDZ 报错 `Cannot access 'debouncedLocalStorage' before
initialization`,表现为打包后白屏(dev 不分包不暴露)。

Electron 桌面端无 CDN/首屏体积顾虑,单 chunk 是最简洁的兜底。
2026-05-17 23:38:33 +08:00
chengww 405b144e66 refactor(playHistory): v1 旧 localStorage key 清理与调用方对齐
老用户升级后清掉 v1 时代独立 key 释放配额,调用方切到 store API。

- player.ts initializePlayState 调用从 migrateFromLocalStorage 切到
  cleanupLegacyPlayHistoryStorage:不再做数据迁移(历史是低关键性
  衍生数据,老用户重新听几次即可),仅清掉旧 musicHistory/podcastHistory/
  playlistHistory/albumHistory/podcastRadioHistory/playHistory-migrated/
  playMode 这 7 个旧 key,避免和新 play-history-store key 双倍占用
- SystemTab.vue 清缓存的 case 'history' 改走 usePlayHistoryStore().
  clearMusicHistory(),与 v1 时代行为对齐(只清音乐历史,不动 podcast/
  playlist/album/podcastRadio);移除 case 'settings' 里的
  removeItem('playMode')——已并入 player-core-store
2026-05-17 23:08:52 +08:00
chengww 761884f23a refactor(playHistory): 持久化重写,统一防抖落盘与序列化兜底
把 playHistory 接入 utils/debouncedStorage 与 utils/persistedSong,
配合 add* 方法重构与 clearAll 同步落盘,闭合 localStorage 配额防护。

- musicHistory 类型从 SongResult 收敛到 MusicHistoryItem(精简子集),
  导出 MinifiedDjProgram、stripBase64Covers,给 podcast/playlist/album/
  podcastRadio 历史也做顶层 picUrl/coverImgUrl/coverUrl 的 base64 兜底
- serializePlayHistoryState 提取为模块级函数,给 persistedstate.serializer
  与 clearAll 同步落盘共用,避免格式漂移;isPodcast/program 字段必须
  保留——playbackController.playTrack 用 isPodcast 决定写哪条历史
- 5 个 add* 全部重写成单步 ref 重赋值,避免 splice/pop/unshift 多次
  触发 watch 与持久化;命中已有条目时累加 count + 刷新 lastPlayTime,
  picUrl/al 用新数据覆盖(封面可能换了短引用)
- clearAll 增加 flushDebouncedStorage + 同步 setItem 空状态,防止
  kill -9 落在 2s 防抖窗口里导致旧历史残留
- heatmap/index.vue 类型切到 MusicHistoryItem,移除 music.artists
  兜底(minifySong 已合并 ar/artists,只剩 ar)
2026-05-17 23:08:22 +08:00
chengww 537e280fdd refactor(persist): 抽公共防抖 storage 与 minifySong 工具,playlist/playerCore 接入
把 9caaec3 在 playlist 内联的 minifySong + debouncedLocalStorage 抽到
utils 共用,playerCore 同步接入,为后续推广到 playHistory 做准备。

- 新增 utils/debouncedStorage.ts:高频状态变更(volume 拖动、isPlay
  切换)2s 防抖落盘,beforeunload 钩子兜底刷盘;多 store 共用同一
  pendingWrites map,flush 时一次写完所有 pending key
- 新增 utils/persistedSong.ts:minifySong 剥离 base64 picUrl、仅保留
  local:// 永不过期的 playMusicUrl;ar.length 守卫避免空数组吞掉
  s.artists 兜底;al.id 守卫避免序列化出无 id 空壳
- playlist.ts 删除内联实现改用 import,行为不变
- playerCore.ts 切到防抖落盘并 minify playMusic;id 守卫避免空
  playMusic 走 minify 后失去 Object.keys().length===0 判空能力,
  下次启动会误恢复一首无 id 的空歌

Trade-off:极端非正常退出(kill -9 / 断电)下最近 2s 的 volume /
isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
2026-05-17 23:07:58 +08:00
chengww 15258f28fd fix(local-music): 封面落盘 + URL 编码统一,修复持久化配额与编码边界
- 新增 src/shared/localUrl.ts 共用 local:// 编码:按路径段 encodeURIComponent,
  避免整体编码把 / 转成 %2F 引发 Chromium 解析边界差异,同时正确处理
  空格/中文/# 等特殊字符(封面落到含空格目录时 Image loader 会加载失败)
- 封面从内嵌 base64 Data URL 改为 userData/AudioCovers/<sha256>.<ext> 落盘,
  MAX_COVER_BYTES 1MB→8MB;老条目(无 coverPath 字段)扫描时一次性自愈
- playlist minify 剥离 base64 picUrl 并仅持久化 local:// 永不过期的 playMusicUrl,
  防止单张 base64 封面撑爆 localStorage 5MB 配额导致整个 playList 写入失败;
  localStorage 写入加 try/catch 兜底,避免配额超限时直接抛异常
2026-05-17 23:07:17 +08:00
chengww fa818a020f fix(player): 双击歌曲时同步设置播放列表上下文
BaseSongItem 双击和右键菜单"播放"只调了 setPlay,未 emit 'play',
导致父组件 setPlayList 不会执行,上下一首切换失效。抽出统一入口
先 emit 再触发播放,与点击播放按钮行为对齐。
2026-05-17 23:07:01 +08:00
chengww c15a10c098 fix(local-music): 主进程实现 Range/206 让本地音乐 seek/快进恢复
切到 protocol.handle 后,audio 元素发的 Range 请求会带 HTTP 头到达
主进程;net.fetch(file://) 既不识别 Range 也不带 Accept-Ranges,导致
浏览器认为 local:// 不支持随机访问,seek 时只能从头加载,表现为
快进失效、退出后恢复播放从 0 开始。

新增 buildLocalFileResponse:
- 无 Range:200 + Accept-Ranges: bytes,让 audio 知道支持随机访问
- 有 Range:206 + Content-Range,用 fs.createReadStream 切片返回
- 非法 Range:416 + Content-Range: bytes */total

顺手把 fs.existsSync 换成 stat,一次拿文件大小且过滤掉目录。
2026-05-17 23:07:01 +08:00
chengww 51f1aaba55 chore: 忽略 *.tsbuildinfo 编译缓存 2026-05-17 23:07:01 +08:00
chengww 0c0189bcef fix(local-music): 注册 local 协议为特权 scheme 修复本地音乐 CORS 报错
- main/index.ts 在 app.whenReady 之前调用 registerSchemesAsPrivileged,
  把 local 标记为 standard/secure/supportFetchAPI/stream/corsEnabled/bypassCSP,
  让 http 页面(dev server / 生产)可跨协议加载本地音频
- fileManager.ts 用 Electron 25 起推荐的 protocol.handle 替代已弃用的
  registerFileProtocol,内部转发到 file:// 自动支持 Range / 流式播放
- 修复 macOS/Linux 上去掉前导斜杠后丢失绝对路径前缀的问题
2026-05-17 23:07:01 +08:00
alger ee98eb0266 fix(player): 私人 FM 模式下点击下一首按钮可正常切歌
FM 播放列表只保留 1 首,原 _nextPlay 走到"顺序模式 + 最后一首"
分支只弹"列表已播完"提示,仅 audioService end 事件中拉取下一首
FM 的逻辑生效,导致用户手动点击下一首无效(issue #666)。

抽出 _nextFmPlay,_nextPlay 入口检测 isFmPlaying 直接路由到 FM
分支;MusicHook end 事件去掉重复的 FM 处理,统一走 nextPlayOnEnd。
2026-05-10 13:00:55 +08:00
alger d722728ee0 chore(scripts): 移动 fix-sandbox.js 到 scripts 目录
将根目录的运维脚本统一收纳到 scripts/,并把脚本内的相对路径
改为基于 __dirname,避免在仓库子目录执行时找不到 node_modules。
2026-05-10 12:34:53 +08:00
alger 5ba9e6591a refactor(lyric): 抽取全屏背景/文字颜色逻辑为 useLyricBackground composable
MusicFull.vue 与 MusicFullMobile.vue 各自持有的 setTextColors /
currentBackground / animationFrame / isDark 合并到共享 composable,
消除两份几乎一致的包装逻辑。Mobile 的 --bg-color 差异通过 writeBgColor
option 注入,行为等价。
2026-05-10 12:34:53 +08:00
Alger 7e95ab69be Merge pull request #654 from chengww5217/fix/unmute-restore-volume
fix(player): 静音保留原音量,解除后可恢复
2026-05-10 12:23:34 +08:00
Alger 7c6448733d Merge pull request #653 from chengww5217/fix/download-config
fix(download): 下载设置抽屉打开时路径显示为空
2026-05-10 12:23:31 +08:00
chengww 2b1024ca24 fix(player): 静音保留原音量,解除后可恢复
- playerCore 新增持久化 isMuted 状态及 setMuted/toggleMute,静音时音频输出置 0 但 volume 保持不变
- 音量 > 0 时自动解除静音
- useVolumeControl 移除原 0↔30 切换;滑块/百分比展示真实音量,图标反映静音态
- 三个播放栏的音量滑块在静音时 disabled;PlayBar 百分比文字同步置灰(仅文字颜色)
2026-04-26 21:47:11 +08:00
chengww a62f525840 fix(download): 下载设置抽屉打开时路径显示为空
get-store-value 主进程用 ipcMain.on 同步返回,renderer 却用 invoke 读取会直接
reject,导致 initDownloadSettings 中断、已保存的下载路径等配置回读失败。改为
sendSync,与项目其它调用点保持一致。

contentLength 类型错误非本次提交引入,因无法通过 precommit 检查,故一并修复。
2026-04-26 21:05:59 +08:00
119 changed files with 2317 additions and 917 deletions
+1 -3
View File
@@ -1,4 +1,2 @@
# 你的接口地址 (必填)
VITE_API = http://127.0.0.1:30488
# 音乐破解接口地址 web端
VITE_API_MUSIC = ***
VITE_API = http://127.0.0.1:30488
+12
View File
@@ -58,12 +58,24 @@ jobs:
with:
node-version: 24
# lint:i18n 脚本用 bun 跑(package.json: "bun scripts/check_i18n.ts"),
# CI 默认环境没有 bun 会报 sh: bun: not found
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm install
- name: Lint
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
# tsconfig.web.json 显式 require src/renderer/{auto-imports,components}.d.ts
# 这两个文件由 unplugin-auto-import / unplugin-vue-components 在 vite 启动时
# 生成,且被 .gitignore 排除(58922dc 维护者主动设置)。CI 直接跑 typecheck
# 拿不到 d.ts 会报 TS2688,先跑一次 build 触发 unplugin 生成
- name: Build (generates auto-import / components d.ts)
run: npm run build
- name: Type check
run: npm run typecheck
+3
View File
@@ -46,5 +46,8 @@ AGENTS.md
.auto-imports.d.ts
.components.d.ts
# TypeScript 增量编译缓存
*.tsbuildinfo
src/renderer/auto-imports.d.ts
src/renderer/components.d.ts
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+11
View File
@@ -35,6 +35,17 @@ export default defineConfig({
})
],
publicDir: resolve('resources'),
build: {
rollupOptions: {
output: {
// 全部代码打到 entry chunk,避免 Vite 默认按共享依赖拆分时
// 与 store/index.ts 的 `export *` 形成 chunk 间循环引用,
// 触发生产构建里的 TDZ(dev 不分包不会暴露此问题)。
// Electron 桌面端本地加载,无 CDN/首屏体积顾虑,单 chunk 合算。
manualChunks: () => 'index'
}
}
},
server: {
host: '0.0.0.0',
port: 2389
+5 -3
View File
@@ -11,7 +11,7 @@ import globals from 'globals';
export default [
// 忽略文件配置
{
ignores: ['node_modules/**', 'dist/**', 'out/**', '.gitignore']
ignores: ['node_modules/**', '**/dist/**', 'out/**', '.gitignore']
},
// 基础 JavaScript 配置
@@ -55,7 +55,8 @@ export default [
defineEmits: 'readonly',
// TypeScript 全局类型
NodeJS: 'readonly',
ScrollBehavior: 'readonly'
ScrollBehavior: 'readonly',
ScrollToOptions: 'readonly'
}
},
plugins: {
@@ -148,7 +149,8 @@ export default [
useMessage: 'readonly',
// TypeScript 全局类型
NodeJS: 'readonly',
ScrollBehavior: 'readonly'
ScrollBehavior: 'readonly',
ScrollToOptions: 'readonly'
}
},
plugins: {
+2 -2
View File
@@ -18,7 +18,7 @@
"dev:web": "vite dev",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"fix-sandbox": "node fix-sandbox.js",
"fix-sandbox": "node scripts/fix-sandbox.js",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win --publish never",
"build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml",
@@ -180,7 +180,7 @@
"requestedExecutionLevel": "asInvoker"
},
"linux": {
"icon": "resources/icon.png",
"icon": "build/icons",
"target": [
{
"target": "AppImage",
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 172 KiB

+13 -2
View File
@@ -8,9 +8,20 @@
"theme_color": "#000000",
"icons": [
{
"src": "./icon.png",
"src": "/icon-192.png",
"type": "image/png",
"sizes": "256x256"
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-512-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
// 最小 Service Worker:仅满足 PWA 可安装性要求(需存在 fetch 处理器)。
// 不做任何缓存拦截,所有请求保持浏览器默认网络行为。
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', () => {
// 特意留空:不拦截,默认走网络
});
+2 -2
View File
@@ -3,14 +3,14 @@
* chrome-sandbox 需要 root 拥有且权限为 4755
*
* 注意此脚本需要 sudo 权限仅在 CI 环境或手动执行时使用
* 用法sudo node fix-sandbox.js
* 用法sudo npm run fix-sandbox
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
if (process.platform === 'linux') {
const sandboxPath = path.resolve('./node_modules/electron/dist/chrome-sandbox');
const sandboxPath = path.resolve(__dirname, '../node_modules/electron/dist/chrome-sandbox');
if (fs.existsSync(sandboxPath)) {
execSync(`sudo chown root:root ${sandboxPath}`);
execSync(`sudo chmod 4755 ${sandboxPath}`);
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: 'No local music found. Please select a folder to scan.',
fileNotFound: 'File not found or has been moved',
rescan: 'Rescan',
songCount: '{count} songs'
songCount: '{count} songs',
removeFromLibrary: 'Remove from Library',
removedFromLibrary: 'Removed from library (file not deleted)'
};
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: 'Equalizer',
playList: 'Play List',
reparse: 'Reparse',
download: 'Download',
miniPlayBar: 'Mini Play Bar',
playMode: {
sequence: 'Sequence',
+1
View File
@@ -400,6 +400,7 @@ export default {
themeColor: {
title: 'Lyric Theme Color',
presetColors: 'Preset Colors',
reset: 'Reset to Default',
customColor: 'Custom Color',
preview: 'Preview',
previewText: 'Lyric Effect',
+2
View File
@@ -13,6 +13,8 @@ export default {
},
message: {
downloading: 'Downloading, please wait...',
addToPlaylistNeedLogin:
'Please log in with Cookie or QR code to add songs to a playlist (not available for UID login)',
downloadFailed: 'Download failed',
downloadQueued: 'Added to download queue',
addedToNextPlay: 'Added to play next',
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン',
songCount: '{count} 曲'
songCount: '{count} 曲',
removeFromLibrary: 'ライブラリから削除',
removedFromLibrary: 'ライブラリから削除しました(ファイルは削除されません)'
};
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: 'イコライザー',
playList: 'プレイリスト',
reparse: '再解析',
download: 'ダウンロード',
playMode: {
sequence: '順次再生',
loop: 'ループ再生',
+8 -7
View File
@@ -128,26 +128,26 @@ export default {
lxMusic: {
tabs: {
sources: '音源選択',
lxMusic: '雪音源',
lxMusic: '雪音源',
customApi: 'カスタムAPI'
},
scripts: {
title: 'インポート済みのスクリプト',
importLocal: 'ローカルインポート',
importOnline: 'オンラインインポート',
urlPlaceholder: '雪音源スクリプトのURLを入力',
urlPlaceholder: '雪音源スクリプトのURLを入力',
importBtn: 'インポート',
empty: 'インポート済みの雪音源はありません',
notConfigured: '未設定(雪音源タブで設定してください)',
empty: 'インポート済みの雪音源はありません',
notConfigured: '未設定(雪音源タブで設定してください)',
importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します',
noScriptWarning: '先に雪音源スクリプトをインポートしてください',
noSelectionWarning: '先に雪音源を選択してください',
noScriptWarning: '先に雪音源スクリプトをインポートしてください',
noSelectionWarning: '先に雪音源を選択してください',
notFound: '音源が存在しません',
switched: '音源を切り替えました: {name}',
deleted: '音源を削除しました: {name}',
enterUrl: 'スクリプトURLを入力してください',
invalidUrl: '無効なURL形式',
invalidScript: '無効な雪音源スクリプトです(globalThis.lxが見つかりません)',
invalidScript: '無効な雪音源スクリプトです(globalThis.lxが見つかりません)',
nameRequired: '名前を空にすることはできません',
renameSuccess: '名前を変更しました'
}
@@ -399,6 +399,7 @@ export default {
themeColor: {
title: '歌詞テーマカラー',
presetColors: 'プリセットカラー',
reset: 'デフォルトに戻す',
customColor: 'カスタムカラー',
preview: 'プレビュー効果',
previewText: '歌詞効果',
+35 -33
View File
@@ -1,33 +1,35 @@
export default {
menu: {
play: '再生',
playNext: '次に再生',
download: '楽曲をダウンロード',
downloadLyric: '歌詞をダウンロード',
addToPlaylist: 'プレイリストに追加',
favorite: 'いいね',
unfavorite: 'いいね解除',
removeFromPlaylist: 'プレイリストから削除',
dislike: '嫌い',
undislike: '嫌い解除'
},
message: {
downloading: 'ダウンロード中です。しばらくお待ちください...',
downloadFailed: 'ダウンロードに失敗しました',
downloadQueued: 'ダウンロードキューに追加しました',
addedToNextPlay: '次の再生に追加しました',
getUrlFailed:
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
noLyric: 'この楽曲には歌詞がありません',
lyricDownloaded: '歌詞のダウンロードが完了しました',
lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
},
dialog: {
dislike: {
title: 'お知らせ!',
content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。',
positiveText: '嫌い',
negativeText: 'キャンセル'
}
}
};
export default {
menu: {
play: '再生',
playNext: '次に再生',
download: '楽曲をダウンロード',
downloadLyric: '歌詞をダウンロード',
addToPlaylist: 'プレイリストに追加',
favorite: 'いいね',
unfavorite: 'いいね解除',
removeFromPlaylist: 'プレイリストから削除',
dislike: '嫌い',
undislike: '嫌い解除'
},
message: {
downloading: 'ダウンロード中です。しばらくお待ちください...',
addToPlaylistNeedLogin:
'プレイリストに追加するには Cookie または QR コードでログインしてください(UID ログインでは利用できません)',
downloadFailed: 'ダウンロードに失敗しました',
downloadQueued: 'ダウンロードキューに追加しました',
addedToNextPlay: '次の再生に追加しました',
getUrlFailed:
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
noLyric: 'この楽曲には歌詞がありません',
lyricDownloaded: '歌詞のダウンロードが完了しました',
lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
},
dialog: {
dislike: {
title: 'お知らせ!',
content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。',
positiveText: '嫌い',
negativeText: 'キャンセル'
}
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔',
songCount: '{count}곡'
songCount: '{count}곡',
removeFromLibrary: '라이브러리에서 제거',
removedFromLibrary: '라이브러리에서 제거했습니다 (파일은 삭제되지 않음)'
};
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: '이퀄라이저',
playList: '재생 목록',
reparse: '재분석',
download: '다운로드',
playMode: {
sequence: '순차 재생',
loop: '반복 재생',
+1
View File
@@ -400,6 +400,7 @@ export default {
themeColor: {
title: '가사 테마 색상',
presetColors: '미리 설정된 색상',
reset: '기본값으로 복원',
customColor: '사용자 정의 색상',
preview: '미리보기 효과',
previewText: '가사 효과',
+34 -32
View File
@@ -1,32 +1,34 @@
export default {
menu: {
play: '재생',
playNext: '다음에 재생',
download: '곡 다운로드',
downloadLyric: '가사 다운로드',
addToPlaylist: '플레이리스트에 추가',
favorite: '좋아요',
unfavorite: '좋아요 취소',
removeFromPlaylist: '플레이리스트에서 삭제',
dislike: '싫어요',
undislike: '싫어요 취소'
},
message: {
downloading: '다운로드 중입니다. 잠시 기다려주세요...',
downloadFailed: '다운로드 실패',
downloadQueued: '다운로드 대기열에 추가됨',
addedToNextPlay: '다음 재생에 추가됨',
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
noLyric: '이 곡에는 가사가 없습니다',
lyricDownloaded: '가사 다운로드 완료',
lyricDownloadFailed: '가사 다운로드 실패'
},
dialog: {
dislike: {
title: '알림!',
content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.',
positiveText: '싫어요',
negativeText: '취소'
}
}
};
export default {
menu: {
play: '재생',
playNext: '다음에 재생',
download: '곡 다운로드',
downloadLyric: '가사 다운로드',
addToPlaylist: '플레이리스트에 추가',
favorite: '좋아요',
unfavorite: '좋아요 취소',
removeFromPlaylist: '플레이리스트에서 삭제',
dislike: '싫어요',
undislike: '싫어요 취소'
},
message: {
downloading: '다운로드 중입니다. 잠시 기다려주세요...',
addToPlaylistNeedLogin:
'플레이리스트에 추가하려면 Cookie 또는 QR 코드로 로그인하세요 (UID 로그인은 사용 불가)',
downloadFailed: '다운로드 실패',
downloadQueued: '다운로드 대기열에 추가됨',
addedToNextPlay: '다음 재생에 추가됨',
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
noLyric: '이 곡에는 가사가 없습니다',
lyricDownloaded: '가사 다운로드 완료',
lyricDownloadFailed: '가사 다운로드 실패'
},
dialog: {
dislike: {
title: '알림!',
content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.',
positiveText: '싫어요',
negativeText: '취소'
}
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '从本地列表移除',
removedFromLibrary: '已从本地列表移除(不删除文件)'
};
+1
View File
@@ -58,6 +58,7 @@ export default {
eq: '均衡器',
playList: '播放列表',
reparse: '重新解析',
download: '下载',
playMode: {
sequence: '顺序播放',
loop: '循环播放',
+8 -7
View File
@@ -128,26 +128,26 @@ export default {
lxMusic: {
tabs: {
sources: '音源选择',
lxMusic: '雪音源',
lxMusic: '雪音源',
customApi: '自定义API'
},
scripts: {
title: '已导入的音源脚本',
importLocal: '本地导入',
importOnline: '在线导入',
urlPlaceholder: '输入雪音源脚本 URL',
urlPlaceholder: '输入雪音源脚本 URL',
importBtn: '导入',
empty: '暂无已导入的雪音源',
notConfigured: '未配置 (请去雪音源Tab配置)',
empty: '暂无已导入的雪音源',
notConfigured: '未配置 (请去雪音源Tab配置)',
importHint: '导入兼容的自定义 API 插件以扩展音源',
noScriptWarning: '请先导入雪音源脚本',
noSelectionWarning: '请先选择一个雪音源',
noScriptWarning: '请先导入雪音源脚本',
noSelectionWarning: '请先选择一个雪音源',
notFound: '音源不存在',
switched: '已切换到音源: {name}',
deleted: '已删除音源: {name}',
enterUrl: '请输入脚本 URL',
invalidUrl: '无效的 URL 格式',
invalidScript: '无效的雪音源脚本,未找到 globalThis.lx 相关代码',
invalidScript: '无效的雪音源脚本,未找到 globalThis.lx 相关代码',
nameRequired: '名称不能为空',
renameSuccess: '重命名成功'
}
@@ -396,6 +396,7 @@ export default {
themeColor: {
title: '歌词主题色',
presetColors: '预设颜色',
reset: '恢复默认',
customColor: '自定义颜色',
preview: '预览效果',
previewText: '歌词效果',
+1
View File
@@ -13,6 +13,7 @@ export default {
},
message: {
downloading: '正在下载中,请稍候...',
addToPlaylistNeedLogin: '请使用 Cookie 或扫码登录后再添加到歌单(UID 登录无法使用此功能)',
downloadFailed: '下载失败',
downloadQueued: '已加入下载队列',
addedToNextPlay: '已添加到下一首播放',
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '從本機清單移除',
removedFromLibrary: '已從本機清單移除(不刪除檔案)'
};
+1
View File
@@ -58,6 +58,7 @@ export default {
eq: '等化器',
playList: '播放清單',
reparse: '重新解析',
download: '下載',
playMode: {
sequence: '順序播放',
loop: '循環播放',
+8 -7
View File
@@ -124,26 +124,26 @@ export default {
lxMusic: {
tabs: {
sources: '音源選擇',
lxMusic: '雪音源',
lxMusic: '雪音源',
customApi: '自訂API'
},
scripts: {
title: '已匯入的音源腳本',
importLocal: '本機匯入',
importOnline: '線上匯入',
urlPlaceholder: '輸入雪音源腳本 URL',
urlPlaceholder: '輸入雪音源腳本 URL',
importBtn: '匯入',
empty: '暫無已匯入的雪音源',
notConfigured: '未設定 (請至雪音源分頁設定)',
empty: '暫無已匯入的雪音源',
notConfigured: '未設定 (請至雪音源分頁設定)',
importHint: '匯入相容的自訂 API 外掛以擴充音源',
noScriptWarning: '請先匯入雪音源腳本',
noSelectionWarning: '請先選擇一個雪音源',
noScriptWarning: '請先匯入雪音源腳本',
noSelectionWarning: '請先選擇一個雪音源',
notFound: '音源不存在',
switched: '已切換到音源: {name}',
deleted: '已刪除音源: {name}',
enterUrl: '請輸入腳本 URL',
invalidUrl: '無效的 URL 格式',
invalidScript: '無效的雪音源腳本,未找到 globalThis.lx 相關程式碼',
invalidScript: '無效的雪音源腳本,未找到 globalThis.lx 相關程式碼',
nameRequired: '名稱不能為空',
renameSuccess: '重新命名成功'
}
@@ -387,6 +387,7 @@ export default {
themeColor: {
title: '歌詞主題色',
presetColors: '預設顏色',
reset: '恢復預設',
customColor: '自訂顏色',
preview: '預覽效果',
previewText: '歌詞效果',
+1
View File
@@ -13,6 +13,7 @@ export default {
},
message: {
downloading: '正在下載中,請稍候...',
addToPlaylistNeedLogin: '請使用 Cookie 或掃碼登入後再新增至播放清單(UID 登入無法使用此功能)',
downloadFailed: '下載失敗',
downloadQueued: '已加入下載佇列',
addedToNextPlay: '已新增至下一首播放',
+6 -2
View File
@@ -18,9 +18,13 @@ const mainI18n = {
},
t(key: string) {
const keys = key.split('.');
let current: any = messages[this.currentLocale];
// 未知/非法 locale 时回退默认语言,避免 messages[locale] 为 undefined 导致崩溃
let current: any = messages[this.currentLocale] ?? messages[DEFAULT_LANGUAGE as Language];
if (current == null) {
return key;
}
for (const k of keys) {
if (current[k] === undefined) {
if (current == null || current[k] === undefined) {
// 如果找不到翻译,返回键名
return key;
}
+42 -3
View File
@@ -1,7 +1,43 @@
import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, ipcMain, nativeImage, session } from 'electron';
import { app, dialog, ipcMain, nativeImage, protocol, session } from 'electron';
import { join } from 'path';
// 全局兜底(#714):Windows 上 config.json 等文件可能被杀毒/云同步软件短暂锁定,
// electron-store 读写撞锁会抛 EBUSY 等未捕获异常,Electron 默认弹出致命错误框。
// 对带 path 的文件系统锁类错误仅记录日志;其余异常保留报错弹窗以免掩盖真 bug。
const FILE_LOCK_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN', 'EMFILE', 'ENFILE']);
process.on('uncaughtException', (error: NodeJS.ErrnoException) => {
if (error?.code && FILE_LOCK_ERROR_CODES.has(error.code) && typeof error.path === 'string') {
console.error('[main] 文件被占用/锁定,已忽略本次读写:', error.message);
return;
}
console.error('[main] 未捕获异常:', error);
dialog.showErrorBox(
'A JavaScript error occurred in the main process',
error?.stack || String(error)
);
});
process.on('unhandledRejection', (reason) => {
console.error('[main] 未处理的 Promise 拒绝:', reason);
});
// 必须在 app.whenReady() 之前注册自定义协议为特权协议,
// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成
// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME
protocol.registerSchemesAsPrivileged([
{
scheme: 'local',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
bypassCSP: true,
corsEnabled: true
}
}
]);
import type { Language } from '../i18n/main';
import i18n from '../i18n/main';
import { loadLyricWindow } from './lyric';
@@ -142,15 +178,18 @@ if (!isSingleInstance) {
// 初始化窗口大小管理器
initWindowSizeManager();
// 设置媒体设备权限 - 允许枚举音频输出设备
// 媒体设备权限:应用没有任何录音功能,麦克风/摄像头采集一律拒绝,
// 防止依赖库静默调用 getUserMedia 触发系统麦克风授权弹窗(#147/#246/#440/#639 防御性加固)。
// 输出设备切换走 speaker-selection / enumerateDevices,不受影响
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === ('media' as any) || permission === ('audioCapture' as any)) {
callback(true);
callback(false);
return;
}
callback(true);
});
// 保持放行:enumerateDevices 依赖它返回真实设备名(不访问麦克风硬件、不触发系统授权)
session.defaultSession.setPermissionCheckHandler(() => {
return true;
});
+24 -6
View File
@@ -1,10 +1,28 @@
import { BrowserWindow, IpcMain, screen } from 'electron';
import Store from 'electron-store';
import path, { join } from 'path';
const store = new Store();
import { getSharedStore } from './modules/config';
const store = getSharedStore();
let lyricWindow: BrowserWindow | null = null;
// 歌词窗口 bounds 防抖保存:拖动/缩放时高频触发,
// 直接写盘会加剧 config.json 文件争用(#714 EBUSY
let lyricBoundsSaveTimer: ReturnType<typeof setTimeout> | null = null;
const saveLyricWindowBounds = (bounds: Record<string, number>) => {
if (lyricBoundsSaveTimer) {
clearTimeout(lyricBoundsSaveTimer);
}
lyricBoundsSaveTimer = setTimeout(() => {
lyricBoundsSaveTimer = null;
try {
store.set('lyricWindowBounds', bounds);
} catch (error) {
console.error('保存歌词窗口位置失败:', error);
}
}, 500);
};
// 跟踪拖动状态
let isDragging = false;
@@ -196,8 +214,8 @@ const createWin = () => {
const [width, height] = lyricWindow.getSize();
const [x, y] = lyricWindow.getPosition();
// 保存窗口位置和大小
store.set('lyricWindowBounds', { x, y, width, height });
// 保存窗口位置和大小(防抖)
saveLyricWindowBounds({ x, y, width, height });
}
});
@@ -357,7 +375,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
false
);
// 更新存储的位置
// 更新存储的位置(防抖,拖动结束后统一落盘)
const windowBounds = {
x: newX,
y: newY,
@@ -365,7 +383,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
height: windowHeight,
displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理
};
store.set('lyricWindowBounds', windowBounds);
saveLyricWindowBounds(windowBounds);
} catch (error) {
console.error('Error during window drag:', error);
// 出错时尝试使用更简单的方法
+5 -3
View File
@@ -6,6 +6,7 @@ import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import { filePathToLocalUrl } from '../../shared/localUrl';
import { getStore } from './config';
type CacheCleanupPolicy = 'lru' | 'fifo';
@@ -412,7 +413,9 @@ class DiskCacheManager {
cleanupPolicy
};
this.saveConfig(normalizedConfig);
// 注意:getCacheConfig 是纯读取,处于播放/下载/歌词等多个热路径。
// 此处不再落盘(electron-store.set 每次整文件写 config.json,会造成写放大与文件争用),
// 持久化交由 updateCacheConfig / setCacheDirectory 等真正的写操作完成。
return normalizedConfig;
}
@@ -535,8 +538,7 @@ class DiskCacheManager {
}
private toLocalUrl(filePath: string): string {
const normalized = path.normalize(filePath).replace(/\\/g, '/');
return `local:///${encodeURIComponent(normalized)}`;
return filePathToLocalUrl(path.normalize(filePath));
}
private isRemoteAudioUrl(url: string): boolean {
+54 -24
View File
@@ -38,41 +38,63 @@ interface StoreType {
shortcuts: ShortcutsConfig;
}
let store: Store<StoreType>;
// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。
// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁
// 叠加触发 EBUSY 未捕获异常(#714)
const store = new Store<StoreType>({
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
let initialized = false;
/**
* 初始化配置管理
* 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听)
*/
export function initializeConfig() {
store = new Store<StoreType>({
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
if (initialized) {
return store;
}
initialized = true;
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
store.get('set.diskCacheDir') ||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
store.set('set.diskCacheMaxSizeMB', 4096);
}
if (!store.get('set.diskCacheCleanupPolicy')) {
store.set('set.diskCacheCleanupPolicy', 'lru');
}
if (store.get('set.enableDiskCache') === undefined) {
store.set('set.enableDiskCache', true);
try {
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
store.get('set.diskCacheDir') ||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
store.set('set.diskCacheMaxSizeMB', 4096);
}
if (!store.get('set.diskCacheCleanupPolicy')) {
store.set('set.diskCacheCleanupPolicy', 'lru');
}
if (store.get('set.enableDiskCache') === undefined) {
store.set('set.enableDiskCache', true);
}
} catch (error) {
console.error('[config] 初始化默认配置失败:', error);
}
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {
store.set(key, value);
try {
store.set(key, value);
} catch (error) {
// config.json 可能被杀毒/云同步短暂锁定,丢一次写入无害,避免主进程崩溃
console.error(`[config] 写入配置失败 key=${key}:`, error);
}
});
ipcMain.on('get-store-value', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
ipcMain.on('get-store-value', (event, key) => {
try {
const value = store.get(key);
event.returnValue = value || '';
} catch (error) {
console.error(`[config] 读取配置失败 key=${key}:`, error);
event.returnValue = '';
}
});
// GPU加速设置更新处理
@@ -98,3 +120,11 @@ export function initializeConfig() {
export function getStore() {
return store;
}
/**
* 供主进程其他模块共享同一个 config.json Store 实例
* (不要再 new Store(),多实例并发读写是 #714 EBUSY 崩溃的诱因之一)
*/
export function getSharedStore(): Store<Record<string, unknown>> {
return store as unknown as Store<Record<string, unknown>>;
}
+3 -2
View File
@@ -1,9 +1,10 @@
import { app } from 'electron';
import Store from 'electron-store';
import { machineIdSync } from 'node-machine-id';
import os from 'os';
const store = new Store();
import { getSharedStore } from './config';
const store = getSharedStore();
/**
* 获取设备唯一标识符
+8 -2
View File
@@ -484,13 +484,17 @@ class DownloadManager {
}
// Start download
// 注意:axios 默认只接受 2xx403/410 会直接抛错进入 catch,导致下方"直链过期重新解析"
// 分支永远走不到。这里放行 403/410,让过期直链能触发重新解析(尤其是重启后恢复队列时)。
const response = await axios({
url: task.url,
method: 'GET',
responseType: 'stream',
timeout: 30000,
signal: controller.signal,
headers
headers,
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 403 || status === 410
});
// Handle response status
@@ -498,6 +502,8 @@ class DownloadManager {
if (status === 403 || status === 410) {
// URL expired, request re-resolution from renderer
// 排空未消费的错误响应流,避免连接悬挂
response.data?.destroy?.();
this.sendToRenderer('download:request-url', {
taskId: task.taskId,
songInfo: task.songInfo
@@ -523,7 +529,7 @@ class DownloadManager {
} else {
// Full response (200) - start from beginning
task.loaded = 0;
const contentLength = response.headers['content-length'];
const contentLength = response.headers['content-length'] as string;
task.total = contentLength ? parseInt(contentLength, 10) : 0;
}
+59 -15
View File
@@ -2,6 +2,7 @@ import { app, dialog, ipcMain, protocol, shell } from 'electron';
import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
import { getStore } from './config';
@@ -23,36 +24,79 @@ function sanitizeFilename(filename: string): string {
.trim();
}
// Electron net.fetch(file://) 在当前版本不会回 206audio seek 需要主进程自己处理 Range
function buildLocalFileResponse(
filePath: string,
total: number,
rangeHeader: string | null
): Response {
const range416 = () =>
new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
let start = 0;
let end = total - 1;
let partial = false;
if (rangeHeader) {
const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!m || (!m[1] && !m[2])) return range416();
if (m[1]) {
start = parseInt(m[1], 10);
if (m[2]) end = Math.min(parseInt(m[2], 10), end);
} else {
start = Math.max(0, total - parseInt(m[2], 10));
}
if (start > end || start >= total) return range416();
partial = true;
}
return new Response(
Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream,
{
status: partial ? 206 : 200,
headers: {
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes',
...(partial && { 'Content-Range': `bytes ${start}-${end}/${total}` })
}
}
);
}
/**
* 初始化文件管理相关的IPC监听
*/
export function initializeFileManager() {
// 注册本地文件协议
protocol.registerFileProtocol('local', (request, callback) => {
// Electron 25+ 起 registerFileProtocol 已弃用,改用 protocol.handle,并配合 main/index.ts
// 中的 registerSchemesAsPrivileged,让 audio 元素能从 http(s) 页面跨协议加载本地文件
protocol.handle('local', async (request) => {
try {
const url = request.url;
// local://C:/Users/xxx.mp3
let filePath = decodeURIComponent(url.replace('local:///', ''));
// local:///<absolute-path>
let filePath = decodeURIComponent(request.url.replace(/^local:\/\/\/?/, ''));
// 兼容 local:///C:/Users/xxx.mp3 这种情况
// Windows: 协议解析后可能是 /C:/...,去掉前导斜杠
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
// 还原为系统路径格式
filePath = path.normalize(filePath);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error('File not found:', filePath);
callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND
return;
// macOS/Linux 上去掉前导斜杠后会丢失绝对路径标识,这里补回
if (process.platform !== 'win32' && !filePath.startsWith('/')) {
filePath = '/' + filePath;
}
callback({ path: filePath });
filePath = path.normalize(filePath);
const stat = await fs.promises.stat(filePath).catch(() => null);
if (!stat?.isFile()) {
console.error('File not found:', filePath);
return new Response(null, { status: 404 });
}
return buildLocalFileResponse(filePath, stat.size, request.headers.get('range'));
} catch (error) {
console.error('Error handling local protocol:', error);
callback({ error: -2 }); // net::FAILED
return new Response(null, { status: 500 });
}
});
+51 -13
View File
@@ -1,7 +1,8 @@
// 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import { ipcMain } from 'electron';
import * as crypto from 'crypto';
import { app, ipcMain } from 'electron';
import * as fs from 'fs';
import * as mm from 'music-metadata';
import * as os from 'os';
@@ -10,7 +11,30 @@ import * as path from 'path';
/** 支持的音频文件格式 */
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length));
const MAX_COVER_BYTES = 1024 * 1024;
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** 封面缓存目录:userData/AudioCovers/<hash>.<ext> */
const COVER_DIR_NAME = 'AudioCovers';
let cachedCoverDir: string | null = null;
function getCoverDir(): string {
if (cachedCoverDir) return cachedCoverDir;
const dir = path.join(app.getPath('userData'), COVER_DIR_NAME);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (error) {
console.error('创建封面目录失败:', error);
}
cachedCoverDir = dir;
return dir;
}
/** 从 mime 类型推断文件扩展名 */
function extFromMime(mime: string | undefined): string {
const sub = mime?.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
if (!sub) return 'bin';
return sub === 'jpeg' ? 'jpg' : sub;
}
/**
* 主进程返回的原始音乐元数据
@@ -27,8 +51,8 @@ type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
@@ -66,23 +90,37 @@ function extractTitleFromFilename(filePath: string): string {
}
/**
* 将封面图片数据转换为 base64 Data URL
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
* @param picture music-metadata 解析出的封面图片对象
* @returns base64 Data URL 字符串,转换失败返回 null
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名
* @returns 封面文件绝对路径,无封面或写入失败返回 null
*/
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
async function extractCoverToFile(
picture: mm.IPicture | undefined,
sourceFilePath: string
): Promise<string | null> {
if (!picture) {
return null;
}
try {
if (picture.data.length > MAX_COVER_BYTES) {
console.warn(
`封面超过大小上限被跳过: ${sourceFilePath} (${picture.data.length} bytes > ${MAX_COVER_BYTES})`
);
return null;
}
const mime = picture.format ?? 'image/jpeg';
const base64 = Buffer.from(picture.data).toString('base64');
return `data:${mime};base64,${base64}`;
const ext = extFromMime(picture.format);
const hash = crypto.createHash('sha256').update(sourceFilePath).digest('hex');
const coverFile = path.join(getCoverDir(), `${hash}.${ext}`);
// 直接覆盖写:本函数只在文件 mtime 变更时被调用(见 scanFolders 的 parseTargets),
// 频率本就受守门;按 size 跳过会在"用户替换内嵌封面、新旧字节数恰好相等"时留旧图,
// 单张封面几十~几百 KB,覆盖代价可忽略。
await fs.promises.writeFile(coverFile, Buffer.from(picture.data));
return coverFile;
} catch (error) {
console.error('封面提取失败:', error);
console.error('封面落盘失败:', error);
return null;
}
}
@@ -234,7 +272,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
coverPath: null,
lyrics: null,
fileSize,
modifiedTime
@@ -250,7 +288,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: common.artist || fallback.artist,
album: common.album || fallback.album,
duration: format.duration ? Math.round(format.duration * 1000) : 0,
cover: extractCoverAsDataUrl(common.picture?.[0]),
coverPath: await extractCoverToFile(common.picture?.[0], filePath),
lyrics: extractLyrics(common.lyrics),
fileSize,
modifiedTime
+2 -2
View File
@@ -6,7 +6,6 @@ import i18n from '../../i18n/main';
let loginWindow: BrowserWindow | null = null;
const loginUrl = 'https://music.163.com/#/login/';
const loginTitle = i18n.global.t('login.qrTitle');
/**
* 打开登录窗口获取Cookie
@@ -29,7 +28,8 @@ const openLoginWindow = async (mainWin: BrowserWindow) => {
loginWindow = new BrowserWindow({
parent: mainWin,
title: loginTitle,
// 在打开窗口时求值,确保跟随当前语言(模块加载时 i18n locale 可能尚未设置)
title: i18n.global.t('login.qrTitle'),
width: 1280,
height: 800,
center: true,
+8 -2
View File
@@ -42,6 +42,8 @@ export const initLxMusicHttp = () => {
// 保存取消控制器
abortControllers.set(requestId, controller);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
@@ -77,13 +79,14 @@ export const initLxMusicHttp = () => {
// 设置超时
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => {
timeoutId = setTimeout(() => {
console.warn(`[LxMusicHttp] 请求超时: ${url}`);
controller.abort();
}, timeout);
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
timeoutId = null;
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
@@ -122,7 +125,10 @@ export const initLxMusicHttp = () => {
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
throw error;
} finally {
// 清理取消控制器
// 清理超时定时器(fetch 出错时前面来不及 clear)与取消控制器
if (timeoutId) {
clearTimeout(timeoutId);
}
abortControllers.delete(requestId);
}
}
+54 -13
View File
@@ -1,7 +1,9 @@
import { app, BrowserWindow, ipcMain, screen } from 'electron';
import Store from 'electron-store';
import type Store from 'electron-store';
const store = new Store();
import { getSharedStore } from './config';
const store = getSharedStore();
// 默认窗口尺寸
export const DEFAULT_MAIN_WIDTH = 1200;
@@ -38,16 +40,40 @@ export interface WindowState {
* 负责保存、恢复和维护窗口大小状态
*/
class WindowSizeManager {
private store: Store;
private store: Store<Record<string, unknown>>;
private mainWindow: BrowserWindow | null = null;
private savedState: WindowState | null = null;
private isInitialized: boolean = false;
private saveStateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.store = store;
// 初始化时不做与screen相关的操作,等app ready后再初始化
}
/**
* 防抖保存窗口状态:move/resize 在拖动时每秒触发几十次,
* 直接写 config.json 是 #714 EBUSY 争用的最大来源
*/
private scheduleSaveWindowState(win: BrowserWindow): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
}
this.saveStateDebounceTimer = setTimeout(() => {
this.saveStateDebounceTimer = null;
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
}
}, 500);
}
private flushScheduledSave(): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
this.saveStateDebounceTimer = null;
}
}
/**
* 初始化窗口大小管理器
* 必须在app ready后调用
@@ -117,17 +143,17 @@ class WindowSizeManager {
* 设置事件监听器
*/
private setupEventListeners(win: BrowserWindow): void {
// 监听窗口大小调整事件
// 监听窗口大小调整事件(防抖,拖动结束后统一落盘)
win.on('resize', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
this.scheduleSaveWindowState(win);
}
});
// 监听窗口移动事件
// 监听窗口移动事件(防抖,拖动结束后统一落盘)
win.on('move', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
this.scheduleSaveWindowState(win);
}
});
@@ -145,8 +171,9 @@ class WindowSizeManager {
}
});
// 监听窗口关闭事件,确保保存最终状态
// 监听窗口关闭事件,确保保存最终状态(取消挂起的防抖,立即落盘)
win.on('close', () => {
this.flushScheduledSave();
if (!win.isDestroyed()) {
this.saveWindowState(win);
}
@@ -354,8 +381,12 @@ class WindowSizeManager {
}
// 检查是否是mini模式窗口(根据窗口大小判断)
// 注意展开播放列表后的 mini 窗口是 340x400,也不能当普通窗口持久化,
// 否则污染 windowState 导致下次启动主窗口尺寸异常(#242)
const [currentWidth, currentHeight] = win.getSize();
const isMiniMode = currentWidth === DEFAULT_MINI_WIDTH && currentHeight === DEFAULT_MINI_HEIGHT;
const isMiniMode =
currentWidth === DEFAULT_MINI_WIDTH &&
(currentHeight === DEFAULT_MINI_HEIGHT || currentHeight === DEFAULT_MINI_EXPANDED_HEIGHT);
const isMaximized = win.isMaximized();
let state: WindowState;
@@ -409,9 +440,13 @@ class WindowSizeManager {
return state;
}
// 保存状态到存储
this.store.set(WINDOW_STATE_KEY, state);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
// 保存状态到存储config.json 可能被外部程序短暂锁定,失败时丢弃本次写入即可)
try {
this.store.set(WINDOW_STATE_KEY, state);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
} catch (error) {
console.error('保存窗口状态失败:', error);
}
// 更新内部状态
this.savedState = state;
@@ -424,7 +459,13 @@ class WindowSizeManager {
* 获取保存的窗口状态
*/
getWindowState(): WindowState | null {
const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
let state: WindowState | undefined;
try {
state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
} catch (error) {
console.error('读取窗口状态失败:', error);
return this.savedState;
}
if (!state) {
console.log('未找到保存的窗口状态,将使用默认值');
+2 -2
View File
@@ -9,9 +9,9 @@ import {
session,
shell
} from 'electron';
import Store from 'electron-store';
import { join } from 'path';
import { getSharedStore } from './config';
import {
applyContentZoom,
applyInitialState,
@@ -27,7 +27,7 @@ import {
WindowState
} from './window-size';
const store = new Store();
const store = getSharedStore();
// 保存主窗口引用,以便在 activate 事件中使用
let mainWindowInstance: BrowserWindow | null = null;
+2 -2
View File
@@ -1,9 +1,9 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getSharedStore } from './modules/config';
import { type Platform, unblockMusic } from './unblockMusic';
// 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件
@@ -12,7 +12,7 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}
const store = new Store();
const store = getSharedStore();
// 设置音乐解析的处理程序
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
+4
View File
@@ -100,6 +100,10 @@ if (isElectron) {
window.api.onLanguageChanged(handleSetLanguage);
window.electron.ipcRenderer.on('mini-mode', (_, value) => {
settingsStore.setMiniMode(value);
// 切换迷你/主界面时复位全屏播放页状态:
// musicFull 残留为 true 会让返回主界面后第一次点击歌曲信息被"取反"关闭,
// 表现为全屏页打不开(#242
playerStore.setMusicFull(false);
if (value) {
// 存储当前路由
localStorage.setItem('currentRoute', router.currentRoute.value.path);
+108 -13
View File
@@ -56,17 +56,18 @@ export const parseFromGDMusic = async (
}
const songName = data.name || '';
let artistNames = '';
let artistList: string[] = [];
// 处理不同的艺术家字段结构
if (data.artists && Array.isArray(data.artists)) {
artistNames = data.artists.map((artist) => artist.name).join(' ');
artistList = data.artists.map((artist) => artist?.name).filter(Boolean);
} else if (data.ar && Array.isArray(data.ar)) {
artistNames = data.ar.map((artist) => artist.name).join(' ');
} else if (data.artist) {
artistNames = typeof data.artist === 'string' ? data.artist : '';
artistList = data.ar.map((artist) => artist?.name).filter(Boolean);
} else if (data.artist && typeof data.artist === 'string') {
artistList = [data.artist];
}
const artistNames = artistList.join(' ');
const searchQuery = `${songName} ${artistNames}`.trim();
if (!searchQuery || searchQuery.length < 2) {
@@ -82,7 +83,12 @@ export const parseFromGDMusic = async (
// 依次尝试所有音源
for (const source of allSources) {
try {
const result = await searchAndGetUrl(source, searchQuery, quality);
const result = await searchAndGetUrl(
source,
searchQuery,
{ name: songName, artists: artistList },
quality
);
if (result) {
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
// 返回符合原API格式的数据
@@ -132,35 +138,124 @@ interface GDMusicUrlResult {
source: string;
}
type GDSearchItem = {
id: string | number;
name?: string;
artist?: unknown;
source?: string;
};
type ExpectedSong = {
name: string;
artists: string[];
};
const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
/**
* Live//
*/
const normalizeText = (text: string): string => {
const stripped = text
.toLowerCase()
.replace(/[(【[].*?[))】\]]/g, '')
.replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
// 整个歌名都在括号里时退化为仅去标点,避免归一化成空串
return stripped || text.toLowerCase().replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
};
const getCandidateArtistText = (artist: unknown): string => {
if (Array.isArray(artist)) {
return artist
.map((item) => (typeof item === 'string' ? item : (item as any)?.name || ''))
.join(' ');
}
return typeof artist === 'string' ? artist : '';
};
const isNameMatched = (expectedName: string, candidateName: string): boolean => {
const expected = normalizeText(expectedName);
const candidate = normalizeText(candidateName);
if (!expected || !candidate) return false;
return expected === candidate || candidate.includes(expected) || expected.includes(candidate);
};
/**
*
* /
* "货不对版"#704
*
*/
const pickBestCandidate = (
candidates: GDSearchItem[],
expected: ExpectedSong
): GDSearchItem | null => {
let best: GDSearchItem | null = null;
let bestScore = 0;
for (const item of candidates) {
if (!item || !item.id) continue;
if (!isNameMatched(expected.name, item.name || '')) continue;
const candidateArtist = normalizeText(getCandidateArtistText(item.artist));
let score: number;
if (expected.artists.length === 0) {
// 原曲无歌手信息,歌名匹配即可
score = 2;
} else if (!candidateArtist) {
// 候选缺少歌手信息:保留为低优先级候选
score = 1;
} else {
const artistMatched = expected.artists.some((name) => {
const normalized = normalizeText(name);
return (
!!normalized &&
(candidateArtist.includes(normalized) || normalized.includes(candidateArtist))
);
});
// 有歌手信息但对不上 → 拒绝,这正是"货不对版"的来源
if (!artistMatched) continue;
score = 3;
}
if (score > bestScore) {
best = item;
bestScore = score;
}
}
return best;
};
/**
* URL
* @param source
* @param searchQuery
* @param expected
* @param quality
* @returns URL结果
*/
async function searchAndGetUrl(
source: MusicSourceType,
searchQuery: string,
expected: ExpectedSong,
quality: string
): Promise<GDMusicUrlResult | null> {
// 1. 搜索歌曲
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
// 1. 搜索歌曲(取前5条做校验,而不是盲取第一条)
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=5&pages=1`;
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
const firstResult = searchResponse.data[0];
if (!firstResult || !firstResult.id) {
console.log(`GD音乐台 ${source} 搜索结果无效`);
const matchedResult = pickBestCandidate(searchResponse.data as GDSearchItem[], expected);
if (!matchedResult) {
console.log(`GD音乐台 ${source} 搜索结果与原曲不匹配,已拒绝(避免货不对版)`);
return null;
}
const trackId = firstResult.id;
const trackSource = firstResult.source || source;
const trackId = matchedResult.id;
const trackSource = matchedResult.source || source;
// 2. 获取歌曲URL
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;
+54 -50
View File
@@ -16,68 +16,72 @@ import { CacheManager } from './musicParser';
/**
* API URL URL
* API URL
*
* CORS null
* URL
* URL audio Format error
* unblockMusic
*/
const resolveAudioUrl = async (url: string): Promise<string> => {
try {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
const resolveAudioUrl = async (url: string): Promise<string | null> => {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
if (!isApiEndpoint) {
// 看起来像直接的音频 URL,直接返回
if (!isApiEndpoint) {
// 看起来像直接的音频 URL,直接返回
return url;
}
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
// 非 Electron 环境无法绕过 CORS 验证,保持乐观返回
if (typeof window.api?.lxMusicHttpRequest !== 'function') {
return url;
}
try {
const requestId = `lx_resolve_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const response = await window.api.lxMusicHttpRequest({
url,
options: {
method: 'GET',
// 端点若直接返回音频流,用 Range 避免整段下载;返回 JSON 时 8KB 也足够
headers: { Range: 'bytes=0-8191' },
timeout: 15000
},
requestId
});
const status = response?.statusCode ?? 0;
const contentType = String(response?.headers?.['content-type'] || '');
if (status < 200 || status >= 400) {
console.warn(`[LxMusicStrategy] API 端点返回 ${status},判定解析失败`);
return null;
}
// 端点直接返回音频流(或重定向到音频,主进程已自动跟随),
// audio 元素可以直接播放原始 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] API 端点为音频流,直接使用原始 URL');
return url;
}
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
// 尝试获取真实 URL
const response = await fetch(url, {
method: 'HEAD',
redirect: 'manual' // 不自动跟随重定向
});
// 检查是否是重定向
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('Location');
if (location) {
console.log('[LxMusicStrategy] API 返回重定向 URL:', location);
return location;
}
}
// 如果 HEAD 请求没有重定向,尝试 GET 请求
const getResponse = await fetch(url, {
redirect: 'follow'
});
// 检查 Content-Type
const contentType = getResponse.headers.get('Content-Type') || '';
// 如果是音频类型,返回最终 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url);
return getResponse.url;
}
// 如果是 JSON,尝试解析
if (contentType.includes('application/json') || contentType.includes('text/json')) {
const json = await getResponse.json();
console.log('[LxMusicStrategy] API 返回 JSON:', json);
// 尝试从 JSON 中提取 URL(常见字段)
const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src;
// JSON 响应:尝试提取常见字段中的音频 URL
const body = response?.body;
if (body && typeof body === 'object') {
const audioUrl = body.url || body.data?.url || body.audio_url || body.link || body.src;
if (audioUrl && typeof audioUrl === 'string') {
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
return audioUrl;
}
}
// 如果都不是,返回原始 URL(可能直接可用)
console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
return url;
// 2xx 但既不是音频也提取不到 URL(如 HTML 错误页),视为不可播放
console.warn('[LxMusicStrategy] API 端点响应无法解析为音频,判定解析失败');
return null;
} catch (error) {
console.error('[LxMusicStrategy] URL 解析失败:', error);
// 解析失败时返回原始 URL
return url;
console.error('[LxMusicStrategy] URL 解析请求失败:', error);
return null;
}
};
+42 -50
View File
@@ -5,7 +5,6 @@ import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import { useSettingsStore } from '@/store';
import type { SongResult } from '@/types/music';
import { isElectron } from '@/utils';
import requestMusic from '@/utils/request_music';
import type { ParsedMusicResult } from './gdmusic';
import { parseFromGDMusic } from './gdmusic';
@@ -161,21 +160,11 @@ export class CacheManager {
// 清除URL缓存
await deleteData('music_url_cache', id);
console.log(`清除歌曲 ${id} 的URL缓存`);
// 清除失败缓存 - 需要遍历所有策略
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
for (const strategy of strategies) {
const cacheKey = `${id}_${strategy}`;
try {
await deleteData('music_failed_cache', cacheKey);
} catch {
// 忽略删除不存在缓存的错误
}
}
console.log(`清除歌曲 ${id} 的失败缓存`);
} catch (error) {
console.error('清除缓存失败:', error);
console.error('清除URL缓存失败:', error);
}
// 清除内存失败缓存(覆盖所有策略,含 lxMusic)
CacheManager.clearFailedCache(id);
}
}
@@ -238,7 +227,19 @@ const getGDMusicAudio = async (id: number, data: SongResult): Promise<ParsedMusi
const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
const filteredSources = sources.filter((source) => source !== 'gdmusic');
console.log(`使用unblockMusic解析,音源:`, filteredSources);
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
// 整体超时兜底:unblock 全链路(IPC → match())本身无超时,底层请求挂起时会
// 导致渲染进程无限等待、起播/切歌长时间无响应。超时解析为 null 以走降级,
// 且不抛异常(避免触发 RetryHelper 的多次重试放大等待时间)。
const UNBLOCK_TIMEOUT = 15000;
return Promise.race([
window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources)),
new Promise((resolve) => {
setTimeout(() => {
console.warn(`unblockMusic 解析超时(${UNBLOCK_TIMEOUT}ms),放弃等待`);
resolve(null);
}, UNBLOCK_TIMEOUT);
})
]);
};
/**
@@ -486,6 +487,18 @@ const getMusicConfig = (id: number, settingsStore?: any) => {
return { musicSources, quality };
};
/**
*
* music_proxy 退
*/
const buildFailedResult = (message: string, code = 404): MusicParseResult => ({
data: {
code,
message,
data: undefined
}
});
/**
*
*/
@@ -500,10 +513,10 @@ export class MusicParser {
const startTime = performance.now();
try {
// 非Electron环境直接使用API请求
// 非Electron环境不支持本地解析
if (!isElectron) {
console.log('非Electron环境,使用API请求');
return await requestMusic.get<any>('/music', { params: { id } });
console.log('非Electron环境,不支持音乐解析');
return buildFailedResult('当前环境不支持音乐解析');
}
// 获取设置存储
@@ -511,8 +524,8 @@ export class MusicParser {
try {
settingsStore = useSettingsStore();
} catch (error) {
console.error('无法获取设置存储,使用后备方案:', error);
return await requestMusic.get<any>('/music', { params: { id } });
console.error('无法获取设置存储:', error);
return buildFailedResult('无法读取设置,音乐解析不可用');
}
// 获取音源配置
@@ -541,8 +554,8 @@ export class MusicParser {
}
if (musicSources.length === 0) {
console.warn('没有配置可用的音源,使用后备方案');
return await requestMusic.get<any>('/music', { params: { id } });
console.warn('没有配置可用的音源');
return buildFailedResult('没有配置可用的音源');
}
// 获取可用的解析策略
@@ -552,8 +565,8 @@ export class MusicParser {
);
if (availableStrategies.length === 0) {
console.warn('没有可用的解析策略,使用后备方案');
return await requestMusic.get<any>('/music', { params: { id } });
console.warn('没有可用的解析策略');
return buildFailedResult('没有可用的解析策略');
}
console.log(
@@ -583,34 +596,13 @@ export class MusicParser {
}
}
console.warn('所有解析策略都失败了,使用后备方案');
console.warn('所有解析策略都失败了');
} catch (error) {
console.error('MusicParser.parseMusic 执行异常,使用后备方案:', error);
console.error('MusicParser.parseMusic 执行异常:', error);
}
// 后备方案:使用API请求
try {
console.log('使用后备方案:API请求');
const result = await requestMusic.get<any>('/music', { params: { id } });
// 如果后备方案成功,也进行缓存
if (result?.data?.data?.url) {
console.log('后备方案成功,缓存结果');
await CacheManager.setCachedMusicUrl(id, result, []);
}
return result;
} catch (apiError) {
console.error('API请求也失败了:', apiError);
const endTime = performance.now();
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`);
return {
data: {
code: 500,
message: '所有解析方式都失败了',
data: undefined
}
};
}
const endTime = performance.now();
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`);
return buildFailedResult('所有解析方式都失败了', 500);
}
}
+13 -2
View File
@@ -72,7 +72,9 @@ export const parseFromCustomApi = async (
response = await axios.post(plugin.apiUrl, finalParams, { timeout });
} else {
// 默认为 GET
const finalUrl = `${plugin.apiUrl}?${new URLSearchParams(finalParams).toString()}`;
// apiUrl 本身可能已带查询串(如 xxx/api.php?type=url),需按情况选择 ? 或 &
const separator = plugin.apiUrl.includes('?') ? '&' : '?';
const finalUrl = `${plugin.apiUrl}${separator}${new URLSearchParams(finalParams).toString()}`;
console.log('自定义API:发送 GET 请求到:', finalUrl);
response = await axios.get(finalUrl, { timeout });
}
@@ -83,11 +85,20 @@ export const parseFromCustomApi = async (
if (musicUrl && typeof musicUrl === 'string') {
console.log('自定义API:成功获取URL');
// 5. 组装成应用所需的标准格式并返回
// quality 是 'standard'/'higher'/'exhigh'/'lossless'/'hires' 等字符串,
// 直接 parseInt 会得到 NaN,这里映射为对应码率(bps)
const QUALITY_BITRATE: Record<string, number> = {
standard: 128000,
higher: 192000,
exhigh: 320000,
lossless: 999000,
hires: 1900000
};
return {
data: {
data: {
url: musicUrl,
br: parseInt(quality) * 1000,
br: QUALITY_BITRATE[quality] ?? 320000,
size: 0,
md5: '',
platform: plugin.name.toLowerCase().replace(/\s/g, ''),
@@ -235,6 +235,8 @@ const handleAddToPlaylist = async (playlist: any) => {
if (res.status === 200) {
message.success(t('comp.playlistDrawer.addSuccess'));
emit('update:modelValue', false);
// /(trackCount)#508
store.initializePlaylist().catch(() => {});
} else {
throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed'));
}
@@ -4,7 +4,9 @@
<div class="w-full pb-32">
<!-- Page Header (scrolls away) -->
<div ref="headerRef" class="page-padding pt-6 pb-2">
<h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
<h1
class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white"
>
{{ title }}
</h1>
<p v-if="description" class="text-neutral-500 dark:text-neutral-400">
@@ -4,7 +4,7 @@
@contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick.stop="playMusicEvent(item)"
@dblclick.stop="handlePlay(item)"
>
<slot name="index"></slot>
<slot name="select" v-if="selectable"></slot>
@@ -22,7 +22,7 @@
:is-dislike="isDislike"
:can-remove="canRemove"
@update:show="showDropdown = $event"
@play="playMusicEvent(item)"
@play="handlePlay(item)"
@play-next="handlePlayNext"
@download="downloadMusic(item)"
@download-lyric="downloadLyric(item)"
@@ -83,6 +83,12 @@ const imageLoad = async (event: Event) => {
await handleImageLoad(target);
};
// ""
const handlePlay = (song: SongResult) => {
emits('play', song);
playMusicEvent(song);
};
//
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
@@ -41,6 +41,11 @@
:class="{ 'text-green-500': isPlaying }"
>
{{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis>
</div>
<div class="song-item-content-compact-artist">
@@ -33,6 +33,11 @@
:class="{ 'text-green-500': isPlaying }"
>
{{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500 font-normal"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis>
<n-ellipsis
class="artist-name text-xs md:text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"
@@ -43,6 +43,11 @@
:class="{ 'text-green-500': isPlaying }"
>
{{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
@@ -39,6 +39,11 @@
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis>
</div>
<div class="song-item-content-name">
@@ -15,13 +15,15 @@
<script lang="ts" setup>
import type { MenuOption } from 'naive-ui';
import { NDropdown, NEllipsis, NImage } from 'naive-ui';
import { createDiscreteApi, NDropdown, NEllipsis, NImage } from 'naive-ui';
import { computed, h, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store';
import type { SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils';
import { hasPermission } from '@/utils/auth';
const { message } = createDiscreteApi(['message']);
const { t } = useI18n();
@@ -50,6 +52,19 @@ const emits = defineEmits([
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const userStore = useUserStore();
// Cookie/
// userStore localStorage
// /#706
const hasRealAuth = computed(() => !!userStore.user && userStore.loginType !== 'uid');
// """"#713
const isLocalSong = computed(
() =>
typeof props.item.playMusicUrl === 'string' && props.item.playMusicUrl.startsWith('local://')
);
//
const renderSongPreview = () => {
return h(
@@ -123,8 +138,6 @@ const renderSongPreview = () => {
//
const dropdownOptions = computed<MenuOption[]>(() => {
const hasRealAuth = hasPermission(true);
const options: MenuOption[] = [
{
key: 'header',
@@ -160,10 +173,10 @@ const dropdownOptions = computed<MenuOption[]>(() => {
icon: () => h('i', { class: 'iconfont ri-file-text-line' })
},
{
// ""#706
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' }),
disabled: !hasRealAuth
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
@@ -191,7 +204,9 @@ const dropdownOptions = computed<MenuOption[]>(() => {
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
label: isLocalSong.value
? t('localMusic.removeFromLibrary')
: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
@@ -216,6 +231,10 @@ const handleSelect = (key: string | number) => {
emits('play-next');
break;
case 'addToPlaylist':
if (!hasRealAuth.value) {
message.warning(t('songItem.message.addToPlaylistNeedLogin'));
break;
}
openPlaylistDrawer?.(props.item.id);
break;
case 'favorite':
@@ -37,11 +37,13 @@
<template #content>
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis
class="text-ellipsis"
line-clamp="1"
:class="{ 'text-green-500': isPlaying }"
>{{ item.name }}</n-ellipsis
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }"
>{{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
></n-ellipsis
>
</div>
<div class="song-item-content-name">
@@ -48,7 +48,8 @@ const { t } = useI18n();
<style scoped lang="scss">
.lyric-correction {
@apply absolute right-0 bottom-4 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
/* bottom 需越过全屏态下钉底的 PlayBarh-20=80px, z-index:9999),否则被遮挡无法点击(#592) */
@apply absolute right-0 bottom-24 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
}
.lyric-correction-btn {
+4 -51
View File
@@ -202,19 +202,18 @@ import {
useLyricProgress
} from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { useLyricBackground } from '@/hooks/useLyricBackground';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, isMobile } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
import { getTextColors } from '@/utils/linearColor';
const { t } = useI18n();
// refs
const lrcSider = ref<any>(null);
const isMouse = ref(false);
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const { currentBackground, applyBackground } = useLyricBackground();
//
const customBackgroundStyle = computed(() => {
@@ -381,42 +380,6 @@ watch(
}
);
const setTextColors = (background: string) => {
if (!background) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
//
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
//
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
const targetBackground = computed(() => {
if (config.value.useCustomBackground && customBackgroundStyle.value) {
if (typeof customBackgroundStyle.value === 'string') {
@@ -434,7 +397,7 @@ watch(
targetBackground,
(newBg) => {
if (newBg) {
setTextColors(newBg);
applyBackground(newBg);
}
},
{ immediate: true }
@@ -523,13 +486,6 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
}
};
//
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist();
@@ -626,9 +582,6 @@ onMounted(() => {
//
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
if (lrcSider.value?.$el) {
lrcSider.value.$el.removeEventListener('scroll', handleScroll);
}
@@ -408,12 +408,13 @@ import {
useLyricProgress
} from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { useLyricBackground } from '@/hooks/useLyricBackground';
import { usePlayMode } from '@/hooks/usePlayMode';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, secondToMinute } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
import { getTextColors } from '@/utils/linearColor';
import { showBottomToast } from '@/utils/shortcutToast';
const { t } = useI18n();
@@ -876,10 +877,10 @@ const handleThumbTouchEnd = (e: TouchEvent) => {
isThumbDragging.value = false;
};
//
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
// composable
const { isDark, applyBackground } = useLyricBackground({
writeBgColor: () => playerStore.playMusic.primaryColor || undefined
});
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
//
@@ -937,49 +938,6 @@ const isVisible = computed({
set: (value) => emit('update:modelValue', value)
});
//
const setTextColors = (background: string) => {
if (!background) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
document.documentElement.style.setProperty('--bg-color', 'rgba(25, 25, 25, 1)');
return;
}
//
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
//
let bgColor = playerStore.playMusic.primaryColor || 'rgba(25, 25, 25, 1)';
document.documentElement.style.setProperty('--bg-color', bgColor);
//
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
const targetBackground = computed(() => {
if (config.value.theme !== 'default') {
return themeMusic[config.value.theme] || props.background;
@@ -992,21 +950,24 @@ watch(
targetBackground,
(newBg) => {
if (newBg) {
setTextColors(newBg);
applyBackground(newBg);
}
},
{ immediate: true }
);
//
//
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
if (autoScrollTimer.value) {
clearTimeout(autoScrollTimer.value);
}
// interval store
if (sleepTimerInterval) {
clearInterval(sleepTimerInterval);
sleepTimerInterval = null;
}
//
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
@@ -1113,7 +1074,7 @@ watch(isVisible, (newVal) => {
if (newVal) {
//
if (targetBackground.value) {
setTextColors(targetBackground.value);
applyBackground(targetBackground.value);
}
} else {
showFullLyrics.value = false;
@@ -1129,7 +1090,7 @@ const { getLrcStyle: originalLrcStyle } = useLyricProgress();
// getLrcStyle
const getLrcStyle = (index: number) => {
const colors = textColors.value || getTextColors;
const colors = textColors.value || getTextColors();
const originalStyle = originalLrcStyle(index);
if (index === nowIndex.value) {
@@ -7,8 +7,14 @@
>
<div class="panel-header">
<span class="panel-title">{{ t('settings.themeColor.title') }}</span>
<div class="close-button" @click="handleClose">
<i class="ri-close-line"></i>
<div class="header-actions">
<div class="reset-button" :title="t('settings.themeColor.reset')" @click="handleReset">
<i class="ri-arrow-go-back-line"></i>
<span>{{ t('settings.themeColor.reset') }}</span>
</div>
<div class="close-button" @click="handleClose">
<i class="ri-close-line"></i>
</div>
</div>
</div>
@@ -111,6 +117,7 @@ interface Props {
interface Emits {
(e: 'colorChange', _color: string): void;
(e: 'close'): void;
(e: 'reset'): void;
}
const props = withDefaults(defineProps<Props>(), {
@@ -160,6 +167,12 @@ const handleClose = () => {
emit('close');
};
// #591
const handleReset = () => {
showColorPicker.value = false;
emit('reset');
};
const handlePresetColorSelect = (color: LyricThemeColor) => {
const colorValue = getColorValue(color);
const optimizedColor = optimizeColorForTheme(colorValue, props.theme);
@@ -301,6 +314,35 @@ watch(
opacity: 0.9;
}
.header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.reset-button {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
cursor: pointer;
border-radius: 6px;
color: var(--text-color);
font-size: 11px;
opacity: 0.8;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
i {
font-size: 12px;
}
}
.close-button {
width: 24px;
height: 24px;
+12 -3
View File
@@ -69,6 +69,7 @@
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
:disabled="isMuted"
vertical
@wheel.prevent="handleVolumeWheel"
></n-slider>
@@ -145,7 +146,13 @@ const { navigateToArtist } = useArtist();
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// playerStore
const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
const {
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
//
const { isFavorite, toggleFavorite } = useFavorite();
@@ -175,9 +182,11 @@ const palyListRef = useTemplateRef('palyListRef') as any;
const isPlaylistOpen = ref(false);
// openPlaylistDrawer
// Mini 340px 420px
// AppLayout #504
provide('openPlaylistDrawer', (songId: number) => {
console.log('打开歌单抽屉', songId);
//
localStorage.setItem('pendingAddToPlaylistSongId', String(songId));
window.api.restore();
});
// /
+39 -3
View File
@@ -99,8 +99,16 @@
<i class="iconfont" :class="getVolumeIcon"></i>
</div>
<div class="volume-slider">
<div class="volume-percentage">{{ Math.round(volumeSlider) }}%</div>
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
<div class="volume-percentage" :class="{ 'volume-percentage-disabled': isMuted }">
{{ Math.round(volumeSlider) }}%
</div>
<n-slider
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
:disabled="isMuted"
vertical
></n-slider>
</div>
</div>
<n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999">
@@ -143,6 +151,16 @@
</template>
{{ t('player.playBar.reparse') }}
</n-tooltip>
<n-tooltip v-if="playMusic?.id && isElectron" trigger="hover" :z-index="9999999">
<template #trigger>
<i
class="iconfont ri-download-line"
:class="{ 'disabled-icon': isDownloading }"
@click="playMusic?.id && handleDownload()"
/>
</template>
{{ isDownloading ? t('songItem.message.downloading') : t('player.playBar.download') }}
</n-tooltip>
<!-- 高级控制菜单按钮整合了 EQ定时关闭播放速度 -->
<advanced-controls-popover />
@@ -181,6 +199,7 @@ import {
textColors
} from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { useDownload } from '@/hooks/useDownload';
import { useFavorite } from '@/hooks/useFavorite';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayMode } from '@/hooks/usePlayMode';
@@ -198,11 +217,24 @@ const { t } = useI18n();
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
//
const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
const {
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
//
const { isFavorite, toggleFavorite } = useFavorite();
//
const { downloadMusic, isDownloading } = useDownload();
const handleDownload = () => {
if (!playMusic.value || isDownloading.value) return;
downloadMusic(playMusic.value);
};
//
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
@@ -382,6 +414,10 @@ const openPlayListDrawer = () => {
@apply border border-gray-200 dark:border-gray-700;
@apply text-gray-800 dark:text-white;
white-space: nowrap;
&.volume-percentage-disabled {
@apply text-gray-400 dark:text-gray-500;
}
}
}
}
@@ -68,6 +68,7 @@
v-model:value="volumeSlider"
:step="1"
:tooltip="false"
:disabled="isMuted"
@wheel.prevent="handleVolumeWheel"
></n-slider>
</div>
@@ -107,7 +108,13 @@ const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackC
const { playMode, playModeIcon, togglePlayMode } = usePlayMode();
// playerStore
const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
const {
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
//
const isDragging = ref(false);
@@ -23,11 +23,7 @@ const goToDetail = () => {
</script>
<template>
<div
class="group cursor-pointer animate-item"
:style="{ animationDelay }"
@click="goToDetail"
>
<div class="group cursor-pointer animate-item" :style="{ animationDelay }" @click="goToDetail">
<!-- Cover -->
<div
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
+34 -27
View File
@@ -2,39 +2,46 @@ import { createVNode, render, VNode } from 'vue';
import Loading from './index.vue';
const vnode: VNode = createVNode(Loading) as VNode;
// 每个使用 v-loading 的元素独立持有一个 Loading 实例,
// 避免此前"模块级单例 vnode"导致的多个 v-loading 争用同一实例、
// spinner 只出现在最后挂载元素上的问题。
const instanceMap = new WeakMap<HTMLElement, VNode>();
export const vLoading = {
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
mounted: (el: HTMLElement) => {
render(vnode, el);
},
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
if (binding.value) {
vnode?.component?.exposed?.show();
} else {
vnode?.component?.exposed?.hide();
}
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用
unmounted: () => {
const setLoading = (el: HTMLElement, visible: boolean) => {
const vnode = instanceMap.get(el);
if (visible) {
vnode?.component?.exposed?.show();
} else {
vnode?.component?.exposed?.hide();
}
};
export const vLoading = {
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted: (el: HTMLElement, binding: any) => {
const vnode = createVNode(Loading);
render(vnode, el);
instanceMap.set(el, vnode);
setLoading(el, !!binding.value);
formatterClass(el, binding);
},
// 在绑定元素的父组件及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
setLoading(el, !!binding.value);
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用:真正卸载组件实例,释放资源
unmounted: (el: HTMLElement) => {
render(null, el);
instanceMap.delete(el);
}
};
function formatterClass(el: HTMLElement, binding: any) {
const classStr = el.getAttribute('class');
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
if (binding.value) {
if (tagetClass === -1) {
el.setAttribute('class', `${classStr} loading-parent`);
}
} else if (tagetClass > -1) {
const classArray: Array<string> = classStr?.split('') as string[];
classArray.splice(tagetClass - 1, tagetClass + 15);
el.setAttribute('class', classArray?.join(''));
el.classList.add('loading-parent');
} else {
el.classList.remove('loading-parent');
}
}
+18 -41
View File
@@ -140,7 +140,10 @@ const parseLyricsString = async (
duration: line.duration
});
lrcTimeArray.push(line.startTime);
// yrcParser 的 startTime 是毫秒;lrcTimeArray 全链路(nowTime 对比、
// setAudioTime seek)以秒为单位,必须换算,否则点击歌词会 seek 到
// 远超时长的位置被钳到末尾、直接触发切歌
lrcTimeArray.push(line.startTime / 1000);
}
return { lrcArray, lrcTimeArray, hasWordByWord };
} catch (error) {
@@ -259,12 +262,17 @@ const setupMusicWatchers = () => {
{ immediate: true }
);
// 同一首歌但 lyric 字段后到 (重启 + autoPlay 关闭场景)
// 同一首歌但 lyric 字段后到 (播放后异步加载元数据 / 重启 + autoPlay 关闭场景)
watch(
() => playMusic.value?.lyric,
() => {
if (lrcArray.value.length === 0 && playMusic.value?.id) {
ensureLyricsLoaded();
(newLyric) => {
if (!playMusic.value?.id) return;
// 完整歌词对象(含 yrc 逐字/翻译,时间单位为秒)后到时强制重新解析,
// 替换掉先行的 API 兜底纯 lrc 歌词
const isRichLyric =
!!newLyric && typeof newLyric === 'object' && (newLyric.lrcArray?.length ?? 0) > 0;
if (lrcArray.value.length === 0 || isRichLyric) {
ensureLyricsLoaded(isRichLyric);
}
}
);
@@ -535,43 +543,12 @@ const setupAudioListeners = () => {
if (getPlayerStore().playMode === 1) {
// 单曲循环模式
replayMusic();
} else if (getPlayerStore().isFmPlaying) {
// 私人FM模式:自动获取下一首
try {
const { getPersonalFM } = await import('@/api/home');
const res = await getPersonalFM();
const songs = res.data?.data;
if (Array.isArray(songs) && songs.length > 0) {
const song = songs[0];
const fmSong = {
id: song.id,
name: song.name,
picUrl: song.al?.picUrl || song.album?.picUrl,
ar: song.artists || song.ar,
al: song.al || song.album,
source: 'netease' as const,
song,
...song,
playLoading: false
} as any;
const { usePlaylistStore } = await import('@/store/modules/playlist');
const playlistStore = usePlaylistStore();
playlistStore.setPlayList([fmSong], false, false);
getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设
const { playTrack } = await import('@/services/playbackController');
await playTrack(fmSong, true);
} else {
getPlayerStore().setIsPlay(false);
}
} catch (error) {
console.error('FM自动播放下一首失败:', error);
getPlayerStore().setIsPlay(false);
}
} else {
// 顺序播放、列表循环、随机播放模式:歌曲自然结束
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
return;
}
// 其他模式(FM/顺序/列表循环/随机):交给 playlist store 路由
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
});
audioService.on('previoustrack', () => {
+12 -4
View File
@@ -147,10 +147,18 @@ export const useDownload = () => {
lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric);
}
const artistNames = (song.ar || song.song?.artists)
?.map((a: { name: string }) => a.name)
.join(',');
const filename = `${song.name} - ${artistNames}`;
// 与歌曲下载一致:使用设置中的文件名格式模板拼接歌词文件名(#655)
const nameFormat =
(ipcRenderer?.sendSync('get-store-value', 'set.downloadNameFormat') as string) ||
'{songName} - {artistName}';
const artistNames =
(song.ar || song.song?.artists)?.map((a: { name: string }) => a.name).join('、') ||
'未知艺术家';
const albumName = song.al?.name || '未知专辑';
const filename = nameFormat
.replace(/\{songName\}/g, song.name || '')
.replace(/\{artistName\}/g, artistNames)
.replace(/\{albumName\}/g, albumName);
const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent });
+76
View File
@@ -0,0 +1,76 @@
import { onBeforeUnmount, ref } from 'vue';
import { textColors } from '@/hooks/MusicHook';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
type UseLyricBackgroundOptions = {
/**
* --bg-color CSS
* - --bg-color
* - undefined DEFAULT_BG_COLOR
* DEFAULT_BG_COLOR
*/
writeBgColor?: () => string | undefined;
};
const DEFAULT_BG_COLOR = 'rgba(25, 25, 25, 1)';
export function useLyricBackground(options: UseLyricBackgroundOptions = {}) {
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const { writeBgColor } = options;
const root = document.documentElement;
const applyBackground = (background: string) => {
if (!background) {
textColors.value = getTextColors();
root.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
root.style.setProperty('--text-color-primary', textColors.value.primary);
root.style.setProperty('--text-color-active', textColors.value.active);
if (writeBgColor) {
root.style.setProperty('--bg-color', DEFAULT_BG_COLOR);
}
return;
}
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
root.style.setProperty('--hover-bg-color', getHoverBackgroundColor(isDark.value));
root.style.setProperty('--text-color-primary', textColors.value.primary);
root.style.setProperty('--text-color-active', textColors.value.active);
if (writeBgColor) {
const bg = writeBgColor();
root.style.setProperty('--bg-color', bg || DEFAULT_BG_COLOR);
}
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
return {
isDark,
currentBackground,
applyBackground
};
}
+4 -16
View File
@@ -6,8 +6,7 @@ import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { playbackRequestManager } from '@/services/playbackRequestManager';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { isElectron } from '@/utils';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
const { message } = createDiscreteApi(['message']);
@@ -372,11 +371,9 @@ export const useLyrics = () => {
};
/**
*
* - URL
*/
export const useSongDetail = () => {
const { getSongUrl } = useSongUrl();
const getSongDetail = async (playMusic: SongResult, requestId?: string) => {
// 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
@@ -405,19 +402,10 @@ export const useSongDetail = () => {
playMusic.createdAt = Date.now();
// 半小时后过期
playMusic.expiredAt = playMusic.createdAt + 1800000;
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
// 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`);
throw new Error('Request cancelled');
}
playMusic.playLoading = false;
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
// 返回歌曲信息,背景色和歌词将在播放后异步加载
return { ...playMusic, playMusicUrl } as SongResult;
} catch (error) {
if ((error as Error).message === 'Request cancelled') {
throw error;
+9 -9
View File
@@ -9,7 +9,10 @@ import { usePlayerStore } from '@/store/modules/player';
export function useVolumeControl() {
const playerStore = usePlayerStore();
/** 音量滑块值 (0-100) */
/** 是否静音 */
const isMuted = computed(() => playerStore.isMuted);
/** 音量滑块值 (0-100),静音时仍展示原始音量 */
const volumeSlider = computed({
get: () => playerStore.volume * 100,
set: (value: number) => {
@@ -19,21 +22,17 @@ export function useVolumeControl() {
/** 音量图标 class */
const volumeIcon = computed(() => {
if (playerStore.volume === 0) return 'ri-volume-mute-line';
if (playerStore.isMuted || playerStore.volume === 0) return 'ri-volume-mute-line';
if (playerStore.volume <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
/** 静音切换 (0 ↔ 30%) */
/** 切换静音(保留静音前的音量) */
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
playerStore.toggleMute();
};
/** 鼠标滚轮调整音量 ±5% */
/** 鼠标滚轮调整音量 ±5%;静音时向上滚轮会自动解除静音 */
const handleVolumeWheel = (e: WheelEvent) => {
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
@@ -41,6 +40,7 @@ export function useVolumeControl() {
};
return {
isMuted,
volumeSlider,
volumeIcon,
mute,
+9
View File
@@ -122,6 +122,15 @@ const isPhone = computed(() => settingsStore.isMobile);
onMounted(() => {
settingsStore.initializeSettings();
settingsStore.initializeTheme();
// Mini ""#504
const pendingSongId = localStorage.getItem('pendingAddToPlaylistSongId');
if (pendingSongId) {
localStorage.removeItem('pendingAddToPlaylistSongId');
nextTick(() => {
openPlaylistDrawer(Number(pendingSongId));
});
}
});
const showPlaylistDrawer = ref(false);
+9 -1
View File
@@ -382,14 +382,22 @@ const showSuggestions = ref(false);
const suggestionsLoading = ref(false);
const highlightedIndex = ref(-1);
//
let lastSuggestKeyword = '';
const debouncedSuggest = useDebounceFn(async (kw: string) => {
if (!kw.trim()) {
suggestions.value = [];
showSuggestions.value = false;
return;
}
lastSuggestKeyword = kw;
suggestionsLoading.value = true;
suggestions.value = await getSearchSuggestions(kw);
const result = await getSearchSuggestions(kw);
//
if (kw !== lastSuggestKeyword) {
return;
}
suggestions.value = result;
suggestionsLoading.value = false;
showSuggestions.value = suggestions.value.length > 0;
highlightedIndex.value = -1;
+10
View File
@@ -8,10 +8,20 @@ import { createApp } from 'vue';
import i18n from '@/../i18n/renderer';
import router from '@/router';
import pinia from '@/store';
import { isElectron } from '@/utils';
import App from './App.vue';
import directives from './directive';
// Web 端注册最小 Service Worker,使站点满足 PWA 可安装条件(#640/#382
if (!isElectron && import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.warn('[PWA] Service Worker 注册失败:', error);
});
});
}
const app = createApp(App);
Object.keys(directives).forEach((key: string) => {
+1 -1
View File
@@ -366,7 +366,7 @@ export class LxMusicSourceRunner {
this.initResolver = resolve;
this.initRejecter = reject;
this.initTimeoutId = window.setTimeout(() => {
this.rejectInitialization(new Error('脚本初始化超时'));
this.rejectInitialization(new Error('脚本初始化超时10 秒内未调用 lx.send(inited)'));
}, 10000);
});
+32 -4
View File
@@ -1,6 +1,6 @@
import type { AudioOutputDevice } from '@/types/audio';
import type { SongResult } from '@/types/music';
import { isElectron } from '@/utils';
import { getImgUrl, isElectron } from '@/utils';
class AudioService {
private audio: HTMLAudioElement;
@@ -19,6 +19,11 @@ class AudioService {
private operationLock = false;
private operationLockTimer: ReturnType<typeof setTimeout> | null = null;
// 当前一次加载(play)挂载在共享 audio 元素上的 canplay/error 监听器的清理函数。
// 快速切歌时,上一首尚未结算的监听器必须在新一轮加载或 stop() 时移除,
// 否则新歌 canplay 会同时触发旧歌的回调,导致"过期回调 stop 掉正在播放的新歌"卡死。
private pendingLoadCleanup: (() => void) | null = null;
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
private defaultEQSettings: { [key: string]: number } = {
@@ -145,8 +150,10 @@ class AudioService {
? track.ar.map((a) => a.name)
: track.song.artists?.map((a) => a.name);
const album = track.al ? track.al.name : track.song.album.name;
const artwork = ['96', '128', '192', '256', '384', '512'].map((size) => ({
src: `${track.picUrl}?param=${size}y${size}`,
// 上限提到 1024 提升 SMTC/AMLL 等系统媒体控件的封面清晰度(#595);
// 走 getImgUrl 以正确处理 data:/local:// 封面与已带参数的 URL
const artwork = ['96', '128', '192', '256', '384', '512', '1024'].map((size) => ({
src: getImgUrl(track.picUrl, `${size}y${size}`),
type: 'image/jpg',
sizes: `${size}x${size}`
}));
@@ -432,6 +439,12 @@ class AudioService {
return Promise.resolve(this.audio);
}
// 开始新一轮加载前,先移除上一轮尚未结算的加载监听器,避免污染新歌
if (this.pendingLoadCleanup) {
this.pendingLoadCleanup();
this.pendingLoadCleanup = null;
}
return new Promise<HTMLAudioElement>((resolve, reject) => {
let retryCount = 0;
const maxRetries = 1;
@@ -482,7 +495,11 @@ class AudioService {
console.error('Audio load error:', error?.code, error?.message);
this.emit('loaderror', { track, error });
if (retryCount < maxRetries) {
// MEDIA_ERR_SRC_NOT_SUPPORTED(4):源本身无效(URL 已失效/返回了
// 非音频内容),用同一 URL 重试毫无意义,直接走 url_expired 换新 URL
const isSrcNotSupported = error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED;
if (!isSrcNotSupported && retryCount < maxRetries) {
retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount);
@@ -496,8 +513,14 @@ class AudioService {
const cleanup = () => {
this.audio.removeEventListener('canplay', onCanPlay);
this.audio.removeEventListener('error', onError);
if (this.pendingLoadCleanup === cleanup) {
this.pendingLoadCleanup = null;
}
};
// 记录本轮清理函数,供下一次 play()/stop() 在结算前主动移除
this.pendingLoadCleanup = cleanup;
this.audio.addEventListener('canplay', onCanPlay, { once: true });
this.audio.addEventListener('error', onError, { once: true });
@@ -523,6 +546,11 @@ class AudioService {
public stop() {
this.forceResetOperationLock();
// 移除尚未结算的加载监听器,避免 stop 后旧的 canplay/error 仍触发过期回调
if (this.pendingLoadCleanup) {
this.pendingLoadCleanup();
this.pendingLoadCleanup = null;
}
try {
this.audio.pause();
this.audio.removeAttribute('src');
+124 -54
View File
@@ -114,7 +114,7 @@ const loadAndPlayAudio = async (song: SongResult, shouldPlay: boolean): Promise<
};
/**
* /
* /
*/
const triggerPreload = async (song: SongResult): Promise<void> => {
try {
@@ -125,9 +125,8 @@ const triggerPreload = async (song: SongResult): Promise<void> => {
(item: SongResult) => item.id === song.id && item.source === song.source
);
if (idx !== -1) {
setTimeout(() => {
playlistStore.preloadNextSongs(idx);
}, 3000);
// 立即触发预加载,不等待
playlistStore.preloadNextSongs(idx);
}
}
} catch (e) {
@@ -152,7 +151,7 @@ const updateDocumentTitle = (music: SongResult): void => {
// ==================== 导出函数 ====================
/**
*
*
*
* @param music
* @param shouldPlay true
@@ -193,32 +192,40 @@ export const playTrack = async (
playerCore.isPlay = shouldPlay;
playerCore.userPlayIntent = shouldPlay;
// 4. 加载元数据(歌词 + 背景色
try {
const { lyrics, backgroundColor, primaryColor } = await loadMetadata(music);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(加载元数据后),当前 gen=${generation}`);
return false;
}
music.lyric = lyrics;
music.backgroundColor = backgroundColor;
music.primaryColor = primaryColor;
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 加载元数据失败:', error);
// 元数据加载失败不阻塞播放,继续执行
}
// 5. 歌词已加载,现在设置 playMusic(触发 MusicHook 的歌词 watcher
// 4. 先设置基本歌曲信息(立即显示UI
music.playLoading = true;
playerCore.playMusic = music;
updateDocumentTitle(music);
// 保存原始歌曲数据
const originalMusic = { ...music };
// 4.5 立即并行加载元数据(歌词 + 背景色),与取 URL/加载音频同时进行:
// 不阻塞出声,但让全屏页的封面/歌词/背景色尽量在音频起播前就绪,
// 避免"先响歌、后换脸"的割裂感。预加载过的歌曲两者都是缓存命中,立即应用
let loadedMetadata: {
lyrics: SongResult['lyric'];
backgroundColor: string;
primaryColor: string;
} | null = null;
const applyLoadedMetadata = () => {
if (!loadedMetadata || gen !== generation) return;
playerCore.playMusic.lyric = loadedMetadata.lyrics;
playerCore.playMusic.backgroundColor = loadedMetadata.backgroundColor;
playerCore.playMusic.primaryColor = loadedMetadata.primaryColor;
// 触发 watcher 更新
playerCore.playMusic = { ...playerCore.playMusic };
};
loadMetadata(originalMusic)
.then((result) => {
loadedMetadata = result;
applyLoadedMetadata();
})
.catch((error) => {
// 元数据加载失败不阻塞播放
console.warn('[playbackController] 元数据加载失败:', error);
});
// 5. 添加到播放历史
try {
const playHistoryStore = await getPlayHistoryStore();
@@ -233,7 +240,7 @@ export const playTrack = async (
console.warn('[playbackController] 添加播放历史失败:', e);
}
// 6. 获取歌曲详情(解析 URL)
// 6. 获取歌曲详情(解析 URL)- 这是必须的,不能跳过
try {
const { getSongDetail } = useSongDetail();
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
@@ -244,10 +251,11 @@ export const playTrack = async (
return false;
}
updatedPlayMusic.lyric = music.lyric;
playerCore.playMusic = updatedPlayMusic;
playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
// playMusic 被替换为新对象,若元数据已先行到达则重新应用,避免歌词/背景色丢失
applyLoadedMetadata();
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 获取歌曲详情失败:', error);
@@ -259,10 +267,7 @@ export const playTrack = async (
return false;
}
// 7. 触发预加载下一首(异步,不阻塞
triggerPreload(playerCore.playMusic);
// 8. 加载并播放音频
// 7. 加载并播放音频(优先播放
try {
const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay);
@@ -274,18 +279,25 @@ export const playTrack = async (
}
if (success) {
// 9. 播放成功
// 8. 元数据已在 4.5 步与播放并行加载,此处无需再处理
// 9. 播放成功,重置 URL 过期恢复计数
resetUrlExpiredRetry();
playerCore.playMusic.playLoading = false;
playerCore.playMusic.isFirstPlay = false;
playbackRequestManager.completeRequest(requestId);
console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`);
// 10. 触发预加载下一首(立即触发,不等待)
triggerPreload(playerCore.playMusic);
return true;
} else {
playbackRequestManager.failRequest(requestId);
return false;
}
} catch (error) {
// 10. 播放失败
// 11. 播放失败
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`);
return false;
@@ -378,9 +390,41 @@ export const reparseCurrentSong = async (
}
};
/**
* URL
* /
*
*/
const MAX_URL_EXPIRED_RETRIES = 1;
let urlExpiredRetrySongId: string | number | null = null;
let urlExpiredRetryCount = 0;
/** playTrack 成功后重置恢复计数(导出给 playTrack 内部使用) */
const resetUrlExpiredRetry = (): void => {
urlExpiredRetrySongId = null;
urlExpiredRetryCount = 0;
};
/**
* URL
* URL HTML
* Format error URL
*/
const clearParsedUrlCache = async (songId: string | number): Promise<void> => {
// 本地歌曲 id 为 hex 字符串,无解析缓存可清
if (!/^\d+$/.test(String(songId))) return;
try {
const { CacheManager } = await import('@/api/musicParser');
await CacheManager.clearMusicCache(Number(songId));
} catch (error) {
console.warn('[playbackController] 清除URL缓存失败:', error);
}
};
/**
* URL
* audioService url_expired URL
* audioService url_expired URL
*
*/
export const setupUrlExpiredHandler = (): void => {
audioService.on('url_expired', async (expiredTrack: SongResult) => {
@@ -396,6 +440,42 @@ export const setupUrlExpiredHandler = (): void => {
return;
}
// 当前歌曲已变更(例如 nextPlay 的失败跳歌已接管),不再恢复旧歌,
// 避免两个恢复驱动并发争抢 generation
if (playerCore.playMusic?.id !== expiredTrack.id) {
console.log('[playbackController] 当前歌曲已变更,跳过URL过期处理');
return;
}
// 统计同一首歌的连续恢复次数
if (urlExpiredRetrySongId === expiredTrack.id) {
urlExpiredRetryCount++;
} else {
urlExpiredRetrySongId = expiredTrack.id;
urlExpiredRetryCount = 1;
}
// 先清掉可能已损坏的解析 URL 缓存,让重试真正拿到新 URL
await clearParsedUrlCache(expiredTrack.id);
// 超过重试上限:不再原地循环,静默切下一首
if (urlExpiredRetryCount > MAX_URL_EXPIRED_RETRIES) {
console.warn(`[playbackController] ${expiredTrack.name} 恢复重试失败,切换下一首`);
resetUrlExpiredRetry();
try {
const playlistStore = await getPlaylistStore();
if (playlistStore.playList.length > 1 || playerCore.isFmPlaying) {
playlistStore.nextPlay();
} else {
playerCore.setIsPlay(false);
}
} catch (error) {
console.error('[playbackController] 切换下一首失败:', error);
playerCore.setIsPlay(false);
}
return;
}
// 保存当前播放位置
const currentSound = audioService.getCurrentSound();
let seekPosition = 0;
@@ -419,31 +499,21 @@ export const setupUrlExpiredHandler = (): void => {
playMusicUrl: undefined
};
// 静默重试:成功恢复位置,失败交由下一次 url_expired 事件按上限切歌
const success = await playTrack(trackToPlay, true);
if (success) {
// 恢复播放位置
if (seekPosition > 0) {
// 延迟一小段时间确保音频已就绪
setTimeout(() => {
try {
audioService.seek(seekPosition);
} catch {
console.warn('[playbackController] 恢复播放位置失败');
}
}, 300);
}
message.success(i18n.global.t('player.autoResumed'));
} else {
// 检查歌曲是否仍然是当前歌曲
const currentPlayerCore = await getPlayerCoreStore();
if (currentPlayerCore.playMusic?.id === expiredTrack.id) {
message.error(i18n.global.t('player.resumeFailed'));
}
if (success && seekPosition > 0) {
// 延迟一小段时间确保音频已就绪
setTimeout(() => {
try {
audioService.seek(seekPosition);
} catch {
console.warn('[playbackController] 恢复播放位置失败');
}
}, 300);
}
} catch (error) {
console.error('[playbackController] 处理URL过期事件失败:', error);
message.error(i18n.global.t('player.resumeFailed'));
}
});
};
+19 -3
View File
@@ -10,6 +10,9 @@ class PreloadService {
private validatedUrls: Map<string | number, string> = new Map();
private loadingPromises: Map<string | number, Promise<string>> = new Map();
// 已验证 URL 缓存的最大条目数,超出后按插入顺序淘汰最旧项,避免长会话内无界增长
private static readonly MAX_VALIDATED_URLS = 100;
/**
* URL
* HEAD URL 访
@@ -44,6 +47,13 @@ class PreloadService {
try {
const validatedUrl = await loadPromise;
this.validatedUrls.set(song.id, validatedUrl);
// 超出容量上限时淘汰最旧插入的条目
if (this.validatedUrls.size > PreloadService.MAX_VALIDATED_URLS) {
const oldestKey = this.validatedUrls.keys().next().value;
if (oldestKey !== undefined) {
this.validatedUrls.delete(oldestKey);
}
}
return validatedUrl;
} finally {
this.loadingPromises.delete(song.id);
@@ -59,7 +69,13 @@ class PreloadService {
testAudio.crossOrigin = 'anonymous';
testAudio.preload = 'metadata';
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
testAudio.removeEventListener('loadedmetadata', onLoaded);
testAudio.removeEventListener('error', onError);
testAudio.src = '';
@@ -104,12 +120,12 @@ class PreloadService {
testAudio.src = url;
testAudio.load();
// 5秒超时
setTimeout(() => {
// 3秒超时(优化预加载速度)
timeoutId = setTimeout(() => {
cleanup();
// 超时不算失败,URL 可能是可用的只是服务器慢
resolve(url);
}, 5000);
}, 3000);
});
}
@@ -67,7 +67,6 @@ type HostWorkerMessage =
| HostLogMessage;
let requestHandler: ((data: any) => Promise<any>) | null = null;
let initialized = false;
let requestCounter = 0;
const pendingHttpCallbacks = new Map<
@@ -87,6 +86,90 @@ const postLog = (level: HostLogMessage['level'], ...args: any[]) => {
});
};
/**
* Node Buffer
* lx-music-desktop Node
* buffer.from/bufToString encoding base64/hex
* Buffer .toString(encoding)
*/
class LxBuffer extends Uint8Array {
toString(encoding = 'utf-8'): string {
return bytesToString(this, encoding);
}
}
const toLxBuffer = (bytes: Uint8Array): LxBuffer =>
new LxBuffer(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength);
const hexToBytes = (hex: string): Uint8Array => {
const clean = hex.length % 2 ? `0${hex}` : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) || 0;
}
return out;
};
const toBytes = (data: any, encoding = 'utf-8'): Uint8Array => {
if (typeof data === 'string') {
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Decode(data);
case 'hex':
return hexToBytes(data);
case 'binary':
case 'latin1': {
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
default:
return new TextEncoder().encode(data);
}
}
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (Array.isArray(data)) return Uint8Array.from(data);
return new Uint8Array(0);
};
const bytesToString = (data: any, encoding = 'utf-8'): string => {
const bytes = toBytes(data);
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Encode(bytes);
case 'hex': {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
case 'binary':
case 'latin1': {
let out = '';
for (const byte of bytes) out += String.fromCharCode(byte);
return out;
}
default:
return new TextDecoder(encoding).decode(bytes);
}
};
/**
* lx-music-desktop BrowserWindowwindow
* `window -> (process/require ? global : this)`
* module worker undefined访 `.console`
* "Cannot read properties of undefined (reading 'console')"
*/
const exposeNodeLikeGlobals = () => {
const g = globalThis as any;
if (!g.window) g.window = g;
if (!g.global) g.global = g;
};
const hardenGlobalScope = () => {
const blockedKeys: Array<keyof typeof globalThis> = [
'fetch',
@@ -130,7 +213,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
},
send: (eventName: string, data: any) => {
if (eventName === 'inited') {
initialized = true;
postToHost({
type: 'initialized',
data: data as LxInitedData
@@ -158,27 +240,37 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
},
utils: {
buffer: {
from: (data: any, _encoding?: string) => {
if (typeof data === 'string') {
return new TextEncoder().encode(data);
}
return new Uint8Array(data);
},
bufToString: (buffer: Uint8Array, encoding?: string) => {
return new TextDecoder(encoding || 'utf-8').decode(buffer);
}
from: (data: any, encoding?: string) => toLxBuffer(toBytes(data, encoding)),
bufToString: (buffer: any, encoding?: string) => bytesToString(buffer, encoding)
},
crypto: {
md5: lxCrypto.md5,
sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256,
randomBytes: lxCrypto.randomBytes,
aesEncrypt: lxCrypto.aesEncrypt,
aesDecrypt: lxCrypto.aesDecrypt,
rsaEncrypt: lxCrypto.rsaEncrypt,
rsaDecrypt: lxCrypto.rsaDecrypt,
// lx-music 中 randomBytes 返回 Node Buffer,脚本会对其调用 .toString(encoding)
randomBytes: (size: number) => {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return toLxBuffer(bytes);
},
aesEncrypt: (
buffer: string | Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesEncrypt(buffer, mode, key as any, iv as any)),
aesDecrypt: (
buffer: Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesDecrypt(buffer, mode, key as any, iv as any)),
rsaEncrypt: (buffer: string | Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaEncrypt(buffer, key)),
rsaDecrypt: (buffer: Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaDecrypt(buffer, key)),
base64Encode: lxCrypto.base64Encode,
base64Decode: lxCrypto.base64Decode
base64Decode: (str: string) => toLxBuffer(lxCrypto.base64Decode(str))
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
@@ -240,7 +332,6 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
const resetWorkerState = () => {
requestHandler = null;
initialized = false;
pendingHttpCallbacks.clear();
requestCounter = 0;
};
@@ -248,6 +339,7 @@ const resetWorkerState = () => {
const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
resetWorkerState();
hardenGlobalScope();
exposeNodeLikeGlobals();
(globalThis as any).lx = createLxApi(scriptInfo);
@@ -265,9 +357,8 @@ const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
try {
await import(/* @vite-ignore */ scriptUrl);
if (!initialized) {
throw new Error('脚本未调用 lx.send(EVENT_NAMES.inited, data)');
}
// 不在此处判定 initialized:脚本可能异步初始化(如先请求远端配置,
// 回调里才调用 lx.send(inited)),由 Runner 侧的初始化超时兜底
} finally {
URL.revokeObjectURL(scriptUrl);
}
+36 -2
View File
@@ -127,6 +127,9 @@ export const useLocalMusicStore = defineStore(
// 磁盘上实际存在的文件路径集合(扫描时收集)
const diskFilePaths = new Set<string>();
// 扫描失败的文件夹:其下的缓存条目不参与"已删除清理",
// 避免移动盘/网络盘暂时不可用时整个文件夹的歌曲被误删(#713)
const failedFolders: string[] = [];
// 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) {
@@ -138,6 +141,7 @@ export const useLocalMusicStore = defineStore(
if ((result as any).error) {
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
message.error(`扫描失败: ${(result as any).error}`);
failedFolders.push(folderPath);
continue;
}
@@ -150,10 +154,15 @@ export const useLocalMusicStore = defineStore(
}
// 2. 增量扫描:基于修改时间筛选需重新解析的文件
// 老条目(无 coverPath 字段)也视为需要重新解析,让数据自愈到统一格式
const parseTargets: string[] = [];
for (const file of files) {
const cached = cachedMap.get(file.path);
if (!cached || cached.modifiedTime !== file.modifiedTime) {
if (
!cached ||
cached.modifiedTime !== file.modifiedTime ||
!('coverPath' in cached)
) {
parseTargets.push(file.path);
}
}
@@ -173,12 +182,23 @@ export const useLocalMusicStore = defineStore(
} catch (error) {
console.error(`扫描文件夹出错: ${folderPath}`, error);
message.error(`扫描文件夹出错: ${folderPath}`);
failedFolders.push(folderPath);
}
}
/** 判断文件路径是否位于某个扫描失败的文件夹下 */
const isUnderFailedFolder = (filePath: string): boolean =>
failedFolders.some((folder) => {
if (!filePath.startsWith(folder)) return false;
if (folder.endsWith('/') || folder.endsWith('\\')) return true;
const next = filePath.charAt(folder.length);
return next === '/' || next === '\\';
});
// 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目
// (扫描失败的文件夹跳过清理,其文件未被枚举并不代表已删除)
for (const [filePath, entry] of cachedMap) {
if (!diskFilePaths.has(filePath)) {
if (!diskFilePaths.has(filePath) && !isUnderFailedFolder(filePath)) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
}
@@ -208,6 +228,19 @@ export const useLocalMusicStore = defineStore(
}
}
/**
* #713
* @param id IDgenerateId hex
*/
async function removeEntry(id: string): Promise<void> {
const localDB = await getDB();
await localDB.deleteData(LOCAL_MUSIC_STORE, id);
const index = musicList.value.findIndex((entry) => entry.id === id);
if (index !== -1) {
musicList.value.splice(index, 1);
}
}
/**
*
*/
@@ -266,6 +299,7 @@ export const useLocalMusicStore = defineStore(
removeFolder,
scanFolders,
loadFromCache,
removeEntry,
clearCache
};
},
+161 -104
View File
@@ -3,6 +3,37 @@ import { ref } from 'vue';
import type { SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast';
import { debouncedLocalStorage, flushDebouncedStorage } from '@/utils/debouncedStorage';
import type { MusicHistoryItem } from '@/utils/persistedSong';
import {
minifyHistoryEntry,
minifyHistoryList,
stripBase64CoversList
} from '@/utils/persistedSong';
export type { MusicHistoryItem };
// 一次性清理 v1 时代遗留的 localStorage key。
// 旧版本以 5 个独立 key 单独存历史,新版本合并到 PERSIST_KEY 由 persistedstate 管。
// 不做数据迁移:历史是低关键性衍生数据,老用户升级后看到空"最近播放",重新听几次即可。
// 仅清掉旧 key 释放配额,避免和新 key 双倍占用
const LEGACY_KEYS = [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory',
// v1 迁移方案的 flag,已随 e53a035 发布到用户机器上
'playHistory-migrated',
// v1 时代独立持久化的播放模式,现已并入 player-core-store
'playMode'
];
export const cleanupLegacyPlayHistoryStorage = (): void => {
if (localStorage.getItem('playHistory-cleaned-v1')) return;
LEGACY_KEYS.forEach((key) => localStorage.removeItem(key));
localStorage.setItem('playHistory-cleaned-v1', '1');
};
// ==================== 类型定义 ====================
@@ -54,6 +85,43 @@ export type PodcastRadioHistoryItem = {
// 历史记录最大条数
const MAX_HISTORY_SIZE = 500;
// persistedstate 的 storage key
const PERSIST_KEY = 'play-history-store';
// pick 出来的持久化状态,给 persistedstate.serializer 与 clearAll 同步落盘共用
type PersistedPlayHistoryState = {
musicHistory: MusicHistoryItem[];
podcastHistory: DjProgram[];
playlistHistory: PlaylistHistoryItem[];
albumHistory: AlbumHistoryItem[];
podcastRadioHistory: PodcastRadioHistoryItem[];
};
// 序列化层兜底:哪怕有代码绕过 addMusic 直接 push 完整 SongResult,也能在 serialize 时
// 再过一遍 minifyHistoryList,确保 localStorage 里的 musicHistory 不混入 lyric/song/base64 封面
//
// podcast/playlist/album/podcastRadio 等历史也走 stripBase64CoversList 兜底:
// 它们的图片字段(picUrl / coverImgUrl / coverUrl)若被注入 base64 Data URL
// 同样会撑爆 5MB 配额;保持四类历史的防御对称,避免某一处漏掉变成隐患
//
// 入参用 any 是为了同时兼容 persistedstate 的 StateTree 与 clearAll 手工构造的 PersistedPlayHistoryState
const serializePlayHistoryState = (state: any): string => {
const s = state as PersistedPlayHistoryState;
return JSON.stringify({
...state,
musicHistory: minifyHistoryList(
s.musicHistory as unknown as (SongResult & {
count?: number;
lastPlayTime?: number;
})[]
),
podcastHistory: stripBase64CoversList(s.podcastHistory),
playlistHistory: stripBase64CoversList(s.playlistHistory),
albumHistory: stripBase64CoversList(s.albumHistory),
podcastRadioHistory: stripBase64CoversList(s.podcastRadioHistory)
});
};
/**
* Store
* 使 Pinia
@@ -63,7 +131,9 @@ export const usePlayHistoryStore = defineStore(
'playHistory',
() => {
// ==================== 状态 ====================
const musicHistory = ref<SongResult[]>([]);
// musicHistory 用 MusicHistoryItem 而非完整 SongResultlyric/song/expiredAt 等
// 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts
const musicHistory = ref<MusicHistoryItem[]>([]);
const podcastHistory = ref<DjProgram[]>([]);
const playlistHistory = ref<PlaylistHistoryItem[]>([]);
const albumHistory = ref<AlbumHistoryItem[]>([]);
@@ -71,20 +141,36 @@ export const usePlayHistoryStore = defineStore(
// ==================== 音乐记录 ====================
// lastPlayTime 语义:每次播放都刷新为当前时间("最近一次播放"),不是"首次添加"。
// 与 playlistHistory/albumHistory 等其他历史保持一致;count 仍为累计播放次数
const addMusic = (music: SongResult): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
// 单步 ref 重赋值,避免 splice/pop + unshift 多次触发 watch 与持久化
let next: MusicHistoryItem[];
if (index !== -1) {
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
// 命中已有条目:累加计数 + 刷新时间戳,picUrl/al 等用新数据覆盖(封面可能换了短引用)
const existing = musicHistory.value[index];
const refreshed: MusicHistoryItem = {
...minifyHistoryEntry(music),
count: (existing.count || 0) + 1,
lastPlayTime: Date.now()
};
next = [
refreshed,
...musicHistory.value.slice(0, index),
...musicHistory.value.slice(index + 1)
];
} else {
musicHistory.value.unshift({ ...music, count: 1 });
}
if (musicHistory.value.length > MAX_HISTORY_SIZE) {
musicHistory.value.pop();
next = [
minifyHistoryEntry({ ...music, count: 1, lastPlayTime: Date.now() }),
...musicHistory.value
];
}
musicHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delMusic = (music: SongResult): void => {
// 删除入参允许 SongResult 或 MusicHistoryItem,仅按 id 匹配,类型放宽不影响逻辑
const delMusic = (music: { id: SongResult['id'] }): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value.splice(index, 1);
@@ -95,14 +181,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcast = (program: DjProgram): void => {
const index = podcastHistory.value.findIndex((item) => item.id === program.id);
// 单步 ref 重赋值,避免 splice/unshift/pop 多次触发 watch 与持久化(与 addMusic 一致)
let next: DjProgram[];
if (index !== -1) {
podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]);
next = [
podcastHistory.value[index],
...podcastHistory.value.slice(0, index),
...podcastHistory.value.slice(index + 1)
];
} else {
podcastHistory.value.unshift(program);
}
if (podcastHistory.value.length > MAX_HISTORY_SIZE) {
podcastHistory.value.pop();
next = [program, ...podcastHistory.value];
}
podcastHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcast = (program: DjProgram): void => {
@@ -117,16 +208,19 @@ export const usePlayHistoryStore = defineStore(
const addPlaylist = (playlist: PlaylistHistoryItem): void => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
const now = Date.now();
let next: PlaylistHistoryItem[];
if (index !== -1) {
playlistHistory.value[index].count = (playlistHistory.value[index].count || 0) + 1;
playlistHistory.value[index].lastPlayTime = now;
playlistHistory.value.unshift(playlistHistory.value.splice(index, 1)[0]);
const existing = playlistHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...playlistHistory.value.slice(0, index),
...playlistHistory.value.slice(index + 1)
];
} else {
playlistHistory.value.unshift({ ...playlist, count: 1, lastPlayTime: now });
}
if (playlistHistory.value.length > MAX_HISTORY_SIZE) {
playlistHistory.value.pop();
next = [{ ...playlist, count: 1, lastPlayTime: now }, ...playlistHistory.value];
}
playlistHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPlaylist = (playlist: PlaylistHistoryItem): void => {
@@ -141,16 +235,18 @@ export const usePlayHistoryStore = defineStore(
const addAlbum = (album: AlbumHistoryItem): void => {
const index = albumHistory.value.findIndex((item) => item.id === album.id);
const now = Date.now();
let next: AlbumHistoryItem[];
if (index !== -1) {
albumHistory.value[index].count = (albumHistory.value[index].count || 0) + 1;
albumHistory.value[index].lastPlayTime = now;
albumHistory.value.unshift(albumHistory.value.splice(index, 1)[0]);
const existing = albumHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...albumHistory.value.slice(0, index),
...albumHistory.value.slice(index + 1)
];
} else {
albumHistory.value.unshift({ ...album, count: 1, lastPlayTime: now });
}
if (albumHistory.value.length > MAX_HISTORY_SIZE) {
albumHistory.value.pop();
next = [{ ...album, count: 1, lastPlayTime: now }, ...albumHistory.value];
}
albumHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delAlbum = (album: AlbumHistoryItem): void => {
@@ -165,17 +261,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
const now = Date.now();
let next: PodcastRadioHistoryItem[];
if (index !== -1) {
const existing = podcastRadioHistory.value.splice(index, 1)[0];
existing.count = (existing.count || 0) + 1;
existing.lastPlayTime = now;
podcastRadioHistory.value.unshift(existing);
const existing = podcastRadioHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...podcastRadioHistory.value.slice(0, index),
...podcastRadioHistory.value.slice(index + 1)
];
} else {
podcastRadioHistory.value.unshift({ ...radio, count: 1, lastPlayTime: now });
}
if (podcastRadioHistory.value.length > MAX_HISTORY_SIZE) {
podcastRadioHistory.value.pop();
next = [{ ...radio, count: 1, lastPlayTime: now }, ...podcastRadioHistory.value];
}
podcastRadioHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
@@ -213,72 +311,25 @@ export const usePlayHistoryStore = defineStore(
clearPlaylistHistory();
clearAlbumHistory();
clearPodcastRadioHistory();
};
// ==================== 数据迁移 ====================
/**
* localStorage Pinia store
*
*/
const migrateFromLocalStorage = (): void => {
const migrated = localStorage.getItem('playHistory-migrated');
if (migrated) return;
// 同步落盘空状态:persistedstate 的 watch 异步触发,clearAll 同步代码返回时
// watch 还没把空状态送进 storage;再叠 2s 防抖窗口 → 立刻 kill -9 会让 PERSIST_KEY
// 留在旧历史。手动 setItem 让"clearAll 返回 = 落盘完成"成立。
// 前面 flushDebouncedStorage 是顺手清掉别的 store 排队的写入与防抖定时器,
// 避免后续 watch 异步把同一份空状态再写一次(无害但多余 I/O)
flushDebouncedStorage();
try {
// 迁移音乐记录
const oldMusic = localStorage.getItem('musicHistory');
if (oldMusic) {
const parsed = JSON.parse(oldMusic);
if (Array.isArray(parsed) && parsed.length > 0 && musicHistory.value.length === 0) {
musicHistory.value = parsed;
}
}
// 迁移播客记录
const oldPodcast = localStorage.getItem('podcastHistory');
if (oldPodcast) {
const parsed = JSON.parse(oldPodcast);
if (Array.isArray(parsed) && parsed.length > 0 && podcastHistory.value.length === 0) {
podcastHistory.value = parsed;
}
}
// 迁移歌单记录
const oldPlaylist = localStorage.getItem('playlistHistory');
if (oldPlaylist) {
const parsed = JSON.parse(oldPlaylist);
if (Array.isArray(parsed) && parsed.length > 0 && playlistHistory.value.length === 0) {
playlistHistory.value = parsed;
}
}
// 迁移专辑记录
const oldAlbum = localStorage.getItem('albumHistory');
if (oldAlbum) {
const parsed = JSON.parse(oldAlbum);
if (Array.isArray(parsed) && parsed.length > 0 && albumHistory.value.length === 0) {
albumHistory.value = parsed;
}
}
// 迁移播客电台记录
const oldRadio = localStorage.getItem('podcastRadioHistory');
if (oldRadio) {
const parsed = JSON.parse(oldRadio);
if (
Array.isArray(parsed) &&
parsed.length > 0 &&
podcastRadioHistory.value.length === 0
) {
podcastRadioHistory.value = parsed;
}
}
localStorage.setItem('playHistory-migrated', '1');
console.log('[PlayHistory] 数据迁移完成');
localStorage.setItem(
PERSIST_KEY,
serializePlayHistoryState({
musicHistory: [],
podcastHistory: [],
playlistHistory: [],
albumHistory: [],
podcastRadioHistory: []
})
);
} catch (error) {
console.error('[PlayHistory] 数据迁移失败:', error);
console.error('[PlayHistory] 清空落盘失败:', error);
}
};
@@ -316,21 +367,27 @@ export const usePlayHistoryStore = defineStore(
clearPodcastRadioHistory,
// 通用
clearAll,
migrateFromLocalStorage
clearAll
};
},
{
persist: {
key: 'play-history-store',
storage: localStorage,
key: PERSIST_KEY,
// 共用防抖 storageaddMusic 在播放切换时会触发 mutation,避免每次都 stringify
// 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList
storage: debouncedLocalStorage,
pick: [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory'
]
],
// 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移
serializer: {
serialize: serializePlayHistoryState,
deserialize: JSON.parse
}
}
}
);
+7 -4
View File
@@ -13,7 +13,7 @@ import { computed } from 'vue';
import { useFavoriteStore } from './favorite';
import { useIntelligenceModeStore } from './intelligenceMode';
import { usePlayerCoreStore } from './playerCore';
import { usePlayHistoryStore } from './playHistory';
import { cleanupLegacyPlayHistoryStorage } from './playHistory';
import { usePlaylistStore } from './playlist';
import { type SleepTimerInfo, SleepTimerType, useSleepTimerStore } from './sleepTimer';
@@ -41,6 +41,7 @@ export const usePlayerStore = defineStore('player', () => {
musicFull,
playbackRate,
volume,
isMuted,
userPlayIntent,
isFmPlaying
} = storeToRefs(playerCore);
@@ -71,9 +72,8 @@ export const usePlayerStore = defineStore('player', () => {
* localStorage
*/
const initializePlayState = async () => {
// 从旧的 localStorage 迁移播放记录到 Pinia store
const playHistoryStore = usePlayHistoryStore();
playHistoryStore.migrateFromLocalStorage();
// 一次性清理 v1 时代的旧 localStorage key,不做数据迁移(详见 playHistory.ts 注释)
cleanupLegacyPlayHistoryStorage();
const { initializePlayState: initPlayState } = await import('@/services/playbackController');
await initPlayState();
@@ -97,6 +97,7 @@ export const usePlayerStore = defineStore('player', () => {
musicFull,
playbackRate,
volume,
isMuted,
userPlayIntent,
isFmPlaying,
@@ -113,6 +114,8 @@ export const usePlayerStore = defineStore('player', () => {
getVolume: playerCore.getVolume,
increaseVolume: playerCore.increaseVolume,
decreaseVolume: playerCore.decreaseVolume,
setMuted: playerCore.setMuted,
toggleMute: playerCore.toggleMute,
handlePause: playerCore.handlePause,
// ========== 播放列表管理 (Playlist) ==========
+57 -3
View File
@@ -4,6 +4,8 @@ import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import type { AudioOutputDevice } from '@/types/audio';
import type { SongResult } from '@/types/music';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySong } from '@/utils/persistedSong';
/**
* Store
@@ -20,6 +22,7 @@ export const usePlayerCoreStore = defineStore(
const musicFull = ref(false);
const playbackRate = ref(1.0);
const volume = ref(1);
const isMuted = ref(false);
const userPlayIntent = ref(false); // 用户是否想要播放
const isFmPlaying = ref(false); // 是否正在播放私人FM
@@ -65,7 +68,27 @@ export const usePlayerCoreStore = defineStore(
const setVolume = (newVolume: number) => {
const normalizedVolume = Math.max(0, Math.min(1, newVolume));
volume.value = normalizedVolume;
audioService.setVolume(normalizedVolume);
// 用户调高音量时自动解除静音
if (isMuted.value && normalizedVolume > 0) {
isMuted.value = false;
}
audioService.setVolume(isMuted.value ? 0 : normalizedVolume);
};
/**
* volume
*/
const setMuted = (value: boolean) => {
if (isMuted.value === value) return;
isMuted.value = value;
audioService.setVolume(isMuted.value ? 0 : volume.value);
};
/**
*
*/
const toggleMute = () => {
setMuted(!isMuted.value);
};
/**
@@ -169,6 +192,7 @@ export const usePlayerCoreStore = defineStore(
musicFull,
playbackRate,
volume,
isMuted,
userPlayIntent,
isFmPlaying,
audioOutputDeviceId,
@@ -187,6 +211,8 @@ export const usePlayerCoreStore = defineStore(
getVolume,
increaseVolume,
decreaseVolume,
setMuted,
toggleMute,
handlePause,
refreshAudioDevices,
setAudioOutputDevice,
@@ -196,8 +222,36 @@ export const usePlayerCoreStore = defineStore(
{
persist: {
key: 'player-core-store',
storage: localStorage,
pick: ['playMusic', 'playMusicUrl', 'playbackRate', 'volume', 'isPlay', 'audioOutputDeviceId']
// 使用 debouncedLocalStoragevolume 拖动 / 静音切换会高频触发 mutation,
// 直接写 localStorage 会导致每次都 stringify + minify 整个 state,浪费 I/O。
// 防抖 2s 写一次足够,beforeunload 钩子兜底刷盘。
// Trade-off:极端非正常退出(kill -9 / 断电 / 主进程崩溃)下 beforeunload 不触发,
// 最近 2s 的 volume / isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
storage: debouncedLocalStorage,
pick: [
'playMusic',
'playMusicUrl',
'playbackRate',
'volume',
'isMuted',
'isPlay',
'audioOutputDeviceId'
],
// playMusic 持久化前过 minifySong:剥离 base64 封面、lyric、song 等大字段。
// 单首歌的 lyric 持久化后没有用(重启后会重新加载),但 picUrl 若是 base64 会
// 拖累整个 player-core-store 写入失败(5MB 配额)。playMusicUrl 在 store 层级单独
// 持久化,不受 playMusic 内部精简影响——本地音乐 local:// URL 仍保持可恢复。
// id 守卫:空 playMusic 走 minifySong 会得到 {picUrl:'', ar:[]}stripDataUrl
// 把 undefined 转成空串、ar 缺省返回空数组),下次启动 playbackController 的
// Object.keys().length === 0 判空就会失效,误恢复一首无 id 的空歌
serializer: {
serialize: (state: any) =>
JSON.stringify({
...state,
playMusic: state.playMusic?.id ? minifySong(state.playMusic as SongResult) : {}
}),
deserialize: JSON.parse
}
}
}
);
+156 -92
View File
@@ -1,5 +1,4 @@
import { useThrottleFn } from '@vueuse/core';
import { debounce } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { defineStore, storeToRefs } from 'pinia';
import { computed, ref, shallowRef, triggerRef } from 'vue';
@@ -9,6 +8,8 @@ import { useSongDetail } from '@/hooks/usePlayerHooks';
import { preloadService } from '@/services/preloadService';
import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySongList } from '@/utils/persistedSong';
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
import { useIntelligenceModeStore } from './intelligenceMode';
@@ -22,53 +23,6 @@ const getMessage = () => {
return _message;
};
/**
* SongResult
* lyric, song, playMusicUrl, backgroundColor, primaryColor
*/
const minifySong = (s: SongResult) => ({
id: s.id,
name: s.name,
picUrl: s.picUrl,
ar: s.ar?.map((a) => ({ id: a.id, name: a.name })),
al: s.al,
source: s.source,
dt: s.dt
});
const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? [];
/**
* localStorage
* pendingWrites beforeunload
*/
const pendingWrites = new Map<string, string>();
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
localStorage.setItem(key, value);
});
pendingWrites.clear();
};
const debouncedSetItem = debounce((key: string, value: string) => {
localStorage.setItem(key, value);
pendingWrites.delete(key);
}, 2000);
const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedSetItem(key, value);
}
};
// 正常关闭时刷新未写入的数据
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPendingWrites);
}
/**
* Store
* /
@@ -130,6 +84,21 @@ export const usePlaylistStore = defineStore(
}
}
// 预热下一首的背景色:切歌时 playTrack 的元数据加载可直接缓存命中,
// 全屏页封面/背景色与音频同步就绪,不再"先响歌、后换背景"
if (nextSong?.picUrl && !(nextSong.backgroundColor && nextSong.primaryColor)) {
try {
const { getImageLinearBackground } = await import('@/utils/linearColor');
const { backgroundColor, primaryColor } = await getImageLinearBackground(
getImgUrl(nextSong.picUrl, '30y30')
);
nextSong.backgroundColor = backgroundColor;
nextSong.primaryColor = primaryColor;
} catch (error) {
console.warn('预热背景色失败:', error);
}
}
detailedSongs.forEach((song, index) => {
if (song && startIndex + index < playList.value.length) {
playList.value[startIndex + index] = song;
@@ -141,7 +110,10 @@ export const usePlaylistStore = defineStore(
// 预加载下一首歌曲的音频和封面
if (nextSong) {
if (nextSong.playMusicUrl) {
preloadService.load(nextSong);
// 预加载失败(URL 验证不过)不应影响主流程,捕获避免未处理的 Promise rejection
preloadService.load(nextSong).catch((err) => {
console.warn('预加载下一首失败:', err);
});
}
if (nextSong.picUrl) {
preloadCoverImage(nextSong.picUrl, getImgUrl);
@@ -154,8 +126,18 @@ export const usePlaylistStore = defineStore(
/**
*
* API
*/
let preloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const preloadNextSongs = (currentIndex: number) => {
if (preloadDebounceTimer) clearTimeout(preloadDebounceTimer);
preloadDebounceTimer = setTimeout(() => {
preloadDebounceTimer = null;
doPreloadNextSongs(currentIndex);
}, 800);
};
const doPreloadNextSongs = (currentIndex: number) => {
if (playList.value.length <= 1) return;
let nextIndex: number;
@@ -171,9 +153,11 @@ export const usePlaylistStore = defineStore(
nextIndex = (currentIndex + 1) % playList.value.length;
}
// 预加载下一首和下下首(最多2首)
const endIndex = Math.min(nextIndex + 2, playList.value.length);
if (nextIndex < playList.value.length) {
// 立即执行预加载
fetchSongs(nextIndex, endIndex);
// 循环模式且接近列表末尾,预加载列表开头
@@ -182,9 +166,8 @@ export const usePlaylistStore = defineStore(
nextIndex + 1 >= playList.value.length &&
playList.value.length > 2
) {
setTimeout(() => {
fetchSongs(0, 1);
}, 1000);
// 立即预加载,不等待
fetchSongs(0, 1);
}
}
};
@@ -244,7 +227,10 @@ export const usePlaylistStore = defineStore(
const setPlayList = (
list: SongResult[],
keepIndex: boolean = false,
fromIntelligenceMode: boolean = false
fromIntelligenceMode: boolean = false,
// preserveOrder=true 表示"就地编辑当前队列"(下一首播放/移除单曲),
// 此时即使处于随机模式也不应重新洗牌,保持调用方给定的顺序。
preserveOrder: boolean = false
) => {
// 如果不是从心动模式调用,清除心动模式状态并切换播放模式
if (!fromIntelligenceMode) {
@@ -280,9 +266,37 @@ export const usePlaylistStore = defineStore(
const playerCore = usePlayerCoreStore();
const { playMusic } = storeToRefs(playerCore);
// 根据当前播放模式处理新的播放列表
if (playMode.value === 2) {
// 随机模式
if (preserveOrder) {
// 就地编辑当前队列(下一首播放 / 移除单曲):保留调用方给定的顺序,不重新洗牌。
console.log('就地编辑播放列表,保持给定顺序');
if (playMode.value === 2) {
// 随机模式下同步原始顺序列表:删除已移除项、追加新加入项,保留既有原始顺序
const idSet = new Set(list.map((song) => song.id));
const reconciled = originalPlayList.value.filter((song) => idSet.has(song.id));
const existingIds = new Set(reconciled.map((song) => song.id));
for (const song of list) {
if (!existingIds.has(song.id)) {
reconciled.push(song);
}
}
originalPlayList.value = reconciled;
} else if (originalPlayList.value.length > 0) {
originalPlayList.value = [];
}
// 修正当前索引,指向当前正在播放的歌曲
const currentSong = playMusic.value;
const currentIndex =
currentSong && currentSong.id ? list.findIndex((song) => song.id === currentSong.id) : -1;
playListIndex.value =
currentIndex !== -1
? currentIndex
: Math.min(Math.max(0, playListIndex.value), list.length - 1);
playList.value = list;
} else if (playMode.value === 2) {
// 随机模式:全新列表,保存原始顺序并洗牌
console.log('随机模式下设置新播放列表,保存原始顺序并洗牌');
originalPlayList.value = [...list];
@@ -335,7 +349,8 @@ export const usePlaylistStore = defineStore(
const insertIndex = playListIndex.value + 1;
list.splice(insertIndex, 0, song);
setPlayList(list, true);
// preserveOrder=true:随机模式下也不重新洗牌,确保"下一首播放"位置生效
setPlayList(list, true, false, true);
};
/**
@@ -355,7 +370,8 @@ export const usePlaylistStore = defineStore(
const newPlayList = [...playList.value];
newPlayList.splice(index, 1);
setPlayList(newPlayList);
// preserveOrder=true:随机模式下移除单曲不重新洗牌,仅从队列中删除
setPlayList(newPlayList, false, false, true);
};
/**
@@ -424,17 +440,69 @@ export const usePlaylistStore = defineStore(
}
};
const _nextPlay = async (retryCount: number = 0, autoEnd: boolean = false) => {
/**
* FMFM
*/
const _nextFmPlay = async () => {
const playerCore = usePlayerCoreStore();
try {
const { getPersonalFM } = await import('@/api/home');
const res = await getPersonalFM();
const songs = res.data?.data;
if (!Array.isArray(songs) || songs.length === 0) {
playerCore.setIsPlay(false);
return;
}
const song = songs[0];
const fmSong = {
id: song.id,
name: song.name,
picUrl: song.al?.picUrl || song.album?.picUrl,
ar: song.artists || song.ar,
al: song.al || song.album,
source: 'netease' as const,
song,
...song,
playLoading: false
} as any;
await setPlayList([fmSong], false, false);
playerCore.isFmPlaying = true;
const { playTrack } = await import('@/services/playbackController');
await playTrack(fmSong, true);
} catch (error) {
console.error('FM切换下一首失败:', error);
playerCore.setIsPlay(false);
}
};
/**
* @param autoEnd
* @param fromFailover "播放失败跳歌"
* consecutiveFailCount
*
*/
const _nextPlay = async (autoEnd: boolean = false, fromFailover: boolean = false) => {
try {
const playerCore = usePlayerCoreStore();
// 私人FM模式:忽略 playMode 与列表长度,直接拉取新的 FM 歌曲
if (playerCore.isFmPlaying) {
if (!fromFailover) {
cancelRetryTimer();
consecutiveFailCount.value = 0;
}
await _nextFmPlay();
return;
}
if (playList.value.length === 0) return;
// User-initiated (retryCount=0): reset state
if (retryCount === 0) {
// 用户主动切歌:重置失败状态
if (!fromFailover) {
cancelRetryTimer();
consecutiveFailCount.value = 0;
}
const playerCore = usePlayerCoreStore();
const sleepTimerStore = useSleepTimerStore();
if (consecutiveFailCount.value >= MAX_CONSECUTIVE_FAILS) {
@@ -468,14 +536,8 @@ export const usePlaylistStore = defineStore(
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
const nextSong = { ...playList.value[nowPlayListIndex] };
// Force refresh URL on retry
if (retryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) {
nextSong.playMusicUrl = undefined;
nextSong.expiredAt = undefined;
}
console.log(
`[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}, 重试: ${retryCount}/1`
`[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}`
);
const { playTrack } = await import('@/services/playbackController');
@@ -493,29 +555,21 @@ export const usePlaylistStore = defineStore(
console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`);
sleepTimerStore.handleSongChange();
} else {
// Retry once, then skip to next
if (retryCount < 1) {
console.log(`[nextPlay] 播放失败,1秒后重试`);
// 播放失败直接静默跳过到下一首(不原地重试——同曲的一次静默重试
// 由 url_expired 恢复处理器负责:清坏缓存后换新 URL)
consecutiveFailCount.value++;
console.log(
`[nextPlay] 播放失败,直接跳过,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
);
if (playList.value.length > 1) {
playListIndex.value = nowPlayListIndex;
nextPlayRetryTimer = setTimeout(() => {
nextPlayRetryTimer = null;
_nextPlay(retryCount + 1);
}, 1000);
_nextPlay(false, true);
}, 500);
} else {
consecutiveFailCount.value++;
console.log(
`[nextPlay] 重试用尽,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
);
if (playList.value.length > 1) {
playListIndex.value = nowPlayListIndex;
getMessage().warning(i18n.global.t('player.parseFailedPlayNext'));
nextPlayRetryTimer = setTimeout(() => {
nextPlayRetryTimer = null;
_nextPlay(0);
}, 500);
} else {
getMessage().error(i18n.global.t('player.playFailed'));
playerCore.setIsPlay(false);
}
getMessage().error(i18n.global.t('player.playFailed'));
playerCore.setIsPlay(false);
}
}
} catch (error) {
@@ -527,15 +581,24 @@ export const usePlaylistStore = defineStore(
/** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */
const nextPlayOnEnd = () => {
_nextPlay(0, true);
_nextPlay(true);
};
const _prevPlay = async () => {
try {
const playerCore = usePlayerCoreStore();
// 私人FM模式:FM 不支持回到上一首,与下一曲一致直接拉取新的 FM 歌曲(#682)
if (playerCore.isFmPlaying) {
cancelRetryTimer();
consecutiveFailCount.value = 0;
await _nextFmPlay();
return;
}
if (playList.value.length === 0) return;
cancelRetryTimer();
const playerCore = usePlayerCoreStore();
const nowPlayListIndex =
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
const prevSong = { ...playList.value[nowPlayListIndex] };
@@ -636,7 +699,8 @@ export const usePlaylistStore = defineStore(
if (success) {
playerCore.isPlay = true;
if (songIndex !== -1) {
setTimeout(() => preloadNextSongs(playListIndex.value), 3000);
// 立即预加载,不等待
preloadNextSongs(playListIndex.value);
}
}
return success;
+24
View File
@@ -240,6 +240,30 @@ export const useSleepTimerStore = defineStore('sleepTimer', () => {
showSleepTimer.value = value;
};
/**
* /
* sleepTimer ref setInterval
* TIME store interval
*/
const restoreTimerInterval = () => {
if (sleepTimer.value.type !== SleepTimerType.TIME || !sleepTimer.value.endTime) {
return;
}
if (Date.now() >= sleepTimer.value.endTime) {
// 重启时已过设定时间,直接停止播放
stopPlayback();
return;
}
if (!timerInterval.value) {
timerInterval.value = window.setInterval(() => {
checkSleepTimer();
}, 1000) as unknown as number;
}
};
// store 首次实例化时立即尝试恢复(player store 在启动阶段即会实例化本 store)
restoreTimerInterval();
return {
// 状态
sleepTimer,
+2 -2
View File
@@ -24,8 +24,8 @@ export type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径(userData/AudioCovers/<hash>.<ext>,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
+4
View File
@@ -57,6 +57,10 @@ export interface SongResult {
artists?: Artist[];
al: Album;
album?: Album;
/** 翻译名(外语歌曲的中文译名等,来自网易 API) */
tns?: string[];
/** 别名 */
alia?: string[];
count: number;
playMusicUrl?: string;
playLoading?: boolean;
+68
View File
@@ -0,0 +1,68 @@
// 防抖 localStorage 包装:高频状态变更(volume 拖动、播放/暂停切换)
// 在 2s 内只落盘一次,正常关闭时通过 beforeunload 刷新未写入数据。
// pinia-plugin-persistedstate 默认每次 mutation 都同步写入;换成这个包装即可
// 降低 I/O 与 JSON.stringify/minify 的总开销。
//
// 多 store 共用同一实例:debounce 的回调不带 key 参数,触发时 flush 整个
// pendingWrites map。这样多个 key 在防抖窗口内交替写入也不会互相吞参数
// lodash debounce 只用最后一次的参数触发)。
import { debounce } from 'lodash';
const pendingWrites = new Map<string, string>();
const safeSetItem = (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch (error) {
// 配额超限是预期失败:上层 store 已通过 minifySong 兜底,这里仅记录方便定位
console.error(`[debouncedStorage] localStorage 写入失败 key=${key}(可能超出配额):`, error);
}
};
/** 把 pendingWrites 里所有未落盘的 key 一次性写入并清空 */
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
safeSetItem(key, value);
});
pendingWrites.clear();
};
const debouncedFlush = debounce(flushPendingWrites, 2000);
/**
* localStorage
* Pinia store /
*
* 退kill -9 / / beforeunload
* 2s volume / isPlay / playMusic
*
* localStorage debouncedLocalStorage
*/
export const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedFlush();
},
removeItem: (key: string) => {
// 同步清掉 pendingWrites,防止已排队的 flush 把旧值又写回去
pendingWrites.delete(key);
localStorage.removeItem(key);
}
};
/**
* pending debounce
* flag flush flag
* flag store
*/
export const flushDebouncedStorage = () => {
debouncedFlush.cancel();
flushPendingWrites();
};
// 模块级注册一次:beforeunload 时立即 flush 所有未写入数据,并取消排队的 debounce
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushDebouncedStorage);
}

Some files were not shown because too many files have changed in this diff Show More