Compare commits

...

79 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
alger 97220761cf fix(lyric): 重启后桌面歌词显示无歌词
playerCore.playMusic 整体替换 (id 不变) 时, lyric watcher 的响应式追踪不可靠
点击播放走 playTrack 流程也未必能可靠触发解析

- 提取 ensureLyricsLoaded 到 module 级别, 直接读 playMusic.value 解析
- openLyric 入口主动调用 (重启首次打开桌面歌词场景)
- onLyricWindowReady 兜底 (窗口异步就绪时 lyric 字段可能刚到位)
- audioService.on('play') 兜底 (重启后首次点击播放场景)
- 在线歌曲 lyric 字段缺失时, 主动调 getMusicLrc API 兜底
2026-04-19 16:15:09 +08:00
alger 7282e876f4 fix(lyric-window): 锁定状态启动时同步穿透并禁用 resize
- 重启应用恢复锁定时, 主进程主动 setIgnoreMouseEvents(true), 修复鼠标无法穿透
- 锁定时 setResizable(false), 隐藏窗口边缘 resize 光标
- 由 set-lyric-lock-state IPC 统一驱动, 后续 polling 按位置精细纠正
2026-04-19 15:44:28 +08:00
alger 6b22713854 perf(lyric-window): 仅在锁定+可见时启用鼠标位置轮询
- 新增 set-lyric-lock-state IPC, 渲染端 watch isLock 同步到主进程
- 主进程通过 isLyricLocked + isLyricWindowVisible 控制轮询启停
- 监听窗口 show/hide/minimize/restore, 隐藏时停止 50ms 轮询
- 解锁状态下 DOM mouseenter/mouseleave 已足够, 无需轮询兜底
2026-04-19 15:31:23 +08:00
alger 0d960aa8d5 Merge pull request #645 from geewon1i/Lyric-lock-icon
fix(歌词悬窗): 通过主进程周期检测鼠标位置纠正悬停状态,修复鼠标快速移出时锁图标残留问题

Closes #606
2026-04-19 15:29:06 +08:00
kimjiwon e066efb373 add main-process cursor presence sync for locked lyric window 2026-04-12 13:29:02 +08:00
alger b0b3eb3326 ci: 移除 PR 检查中已删除的 dev_electron 分支 2026-04-11 22:53:17 +08:00
alger 4a50886a68 ci: 添加 PR 提交规范检查和 commitlint
- 添加 commitlint 及 Conventional Commits 规范配置
- 添加 commit-msg husky hook 本地校验提交信息
- 添加 GitHub Actions PR 检查 workflow:
  - PR 标题符合 Conventional Commits
  - 所有 commit message 符合规范
  - ESLint + TypeScript 类型检查 + i18n 检查
2026-04-11 22:50:20 +08:00
Alger f9222b699d Merge pull request #644 from algerkong/fix/mpris-review-643
fix(mpris): 修复 MPRIS 模块多项安全和性能问题
2026-04-11 22:44:38 +08:00
alger 030a1f1c85 fix(mpris): 修复 MPRIS 模块多项安全和性能问题
- 将 fix-sandbox.js 从 postinstall 移除,避免 npm install 时执行 sudo
- 修复 play/pause/stop 事件语义错误,不再全部映射到 togglePlay
- 缓存平台信息避免 sendSync 阻塞渲染进程
- 修复 cleanupAppShortcuts 中缺少 MPRIS 监听器清理导致的事件泄漏
- destroyMpris 中添加 IPC 监听器清理
- 清理冗余调试日志,安全加载 dbus-native 模块
- 添加 mpris-service 类型声明解决跨平台类型检查问题
2026-04-11 22:37:26 +08:00
stark81 3f31278131 fix-sandbox 2026-04-11 16:01:06 +08:00
alger 33fc4f768c 1. 实现linux下的mpris和gnome状态栏歌词功能 2026-04-11 15:45:14 +08:00
alger 8e3e4e610c fix(pwa): 修复 manifest.json 未被引用导致浏览器无法识别 PWA (#640)
在 index.html 中添加 manifest 引用,并补全 PWA 必需字段
2026-04-10 23:27:19 +08:00
alger 03b52cd6e2 fix(audio): 移除不必要的麦克风权限请求 (#639)
枚举音频输出设备时不再调用 getUserMedia,避免安全软件误报
2026-04-10 23:27:12 +08:00
4everWZ 8726af556a perf: 长列表渐进式渲染优化与播放栏遮挡修复 (#589)
- 新增 useProgressiveRender composable,提取手工虚拟化逻辑(renderLimit + placeholderHeight)
- FavoritePage/DownloadPage 使用 composable 实现渐进式渲染,避免大量 DOM 一次性渲染
- MusicListPage 初始加载扩大至 200 首,工具栏按钮添加 n-tooltip,新增回到顶部按钮
- 播放栏动态底部间距替代 PlayBottom 组件,修复播放时列表底部被遮挡
- 下载页无下载任务时自动切换到已下载 tab
- i18n: 添加 scrollToTop/compactLayout/normalLayout 翻译(5 种语言)

Inspired-By: https://github.com/algerkong/AlgerMusicPlayer/pull/589
2026-04-10 23:26:34 +08:00
Vanilla-puree 0ab784024c feat(download): 新增未保存下载设置时的确认对话框 (#507)
- feat(download): 关闭下载设置抽屉时检测未保存更改,提供取消/放弃/保存选项
- fix: 自动播放首次暂停无法暂停,移除不必要的 isFirstPlay 检查
- fix: 歌手详情路由添加 props key,修复跳转歌手详情不生效问题
- i18n: 添加 download.save.* 翻译(5 种语言)

Co-Authored-By: 心妄 <1661272893@qq.com>
2026-04-10 23:26:33 +08:00
alger ad2df12957 fix(core): 修复事件监听器泄漏
- App.vue: offline 监听器添加 onUnmounted 清理,移除冗余 console.log
- MusicHook.ts: document.onkeyup 直接赋值改为 addEventListener + 防重复
- MusicHook.ts: audio-ready 监听器提取为命名函数,先移除再注册防堆叠
2026-04-10 23:26:33 +08:00
alger a407045527 fix(player): 修复迷你模式恢复后歌词页面空白偏移
迷你播放栏的 togglePlaylist 设置 document.body.style.height='64px'
和 overflow='hidden',恢复主窗口时未清理,导致歌词 drawer 高度被限制。
在 mini-mode 事件处理中添加 body 样式重置。
2026-04-10 23:26:33 +08:00
alger 38723165a0 refactor(player): 提取播放栏共享逻辑为 composable
- 新增 useVolumeControl:统一音量管理(volumeSlider、mute、滚轮调节)
- 新增 useFavorite:收藏状态与切换
- 新增 usePlaybackControl:播放/暂停、上/下一首
- PlayBar、MiniPlayBar、SimplePlayBar、MobilePlayBar 使用新 composable
- 修复音量存储不一致:MiniPlayBar/SimplePlayBar 原先绕过 playerStore 直接操作 localStorage
2026-04-10 23:26:33 +08:00
alger 042b8ba6f8 fix(i18n): 补充 player.autoResumed/resumeFailed 翻译(5 种语言) 2026-03-29 13:20:45 +08:00
alger eb801cfbfd style(ui): 桌面端 message 毛玻璃样式,本地音乐页面全页滚动优化
- message 提示适配项目设计:全圆角、backdrop-blur、半透明背景、深色/浅色模式
- 本地音乐页面:hero 缩小可滚出、action bar 吸顶、歌曲列表跟随全页滚动
- 顺序播放到最后一首:用户点下一首保持播放仅提示,自然播完才停止
- i18n 新增 playListEnded(5 种语言)
2026-03-29 13:18:56 +08:00
alger 0cfec3dd82 refactor(player): 重构播放控制系统,移除 Howler.js 改用原生 HTMLAudioElement
- 新建 playbackController.ts,使用 generation-based 取消替代 playbackRequestManager 状态机
- audioService 重写:单一持久 HTMLAudioElement + Web Audio API,createMediaElementSource 只调一次
- playerCore 瘦身为纯状态管理,移除 handlePlayMusic/playAudio/checkPlaybackState
- playlist next/prev 简化,区分用户手动切歌和歌曲自然播完
- MusicHook 适配 HTMLAudioElement API(.currentTime/.duration/.paused)
- preloadService 从 Howl 实例缓存改为 URL 可用性验证
- 所有 view/component 调用者迁移到 playbackController.playTrack()

修复:快速切歌竞态、seek 到未缓冲位置失败、重启后自动播放循环提示、EQ 重建崩溃
2026-03-29 13:18:05 +08:00
alger 167f081ee6 fix(download): 下载中列表封面使用缩略图加速加载 2026-03-27 23:06:38 +08:00
alger c28368f783 fix(local-music): 扫描自动清理已删除文件,修复双滚动条
- scanFolders() 扫描时收集磁盘文件路径,完成后自动移除 IndexedDB 中已删除的条目
- 移除外层 n-scrollbar,改用 flex 布局,n-virtual-list 作为唯一滚动容器
2026-03-27 23:02:09 +08:00
alger bc46024499 refactor(download): 重构下载系统,支持暂停/恢复/取消,修复歌词加载
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制
- 新建 Pinia useDownloadStore 作为渲染进程单一数据源
- 支持暂停/恢复/取消下载,支持断点续传(Range header)
- 批量下载全部完成后发送汇总系统通知,单首不重复通知
- 并发数可配置(1-5),队列持久化(重启后恢复)
- 修复下载列表不全、封面加载失败、通知重复等 bug
- 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback
- 删除 useDownloadStatus.ts,统一状态管理
- DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规
- 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
2026-03-27 23:02:08 +08:00
159 changed files with 6684 additions and 4840 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
+83
View File
@@ -0,0 +1,83 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened]
jobs:
# 检查 PR 标题是否符合 Conventional Commits 规范
pr-title:
name: PR Title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install commitlint
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
- name: Validate PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE" | npx commitlint
# 检查所有提交信息是否符合 Conventional Commits 规范
commit-messages:
name: Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install commitlint
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
- name: Validate commit messages
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
# 运行 lint 和类型检查
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
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
- name: I18n check
run: npm run lint:i18n
+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
+1
View File
@@ -0,0 +1 @@
npx --no -- commitlint --edit "$1"
-2
View File
@@ -16,7 +16,5 @@
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
</dict>
</plist>
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

+12
View File
@@ -0,0 +1,12 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'perf', 'refactor', 'docs', 'style', 'test', 'build', 'ci', 'chore', 'revert']
],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never']
}
};
+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: {
+10 -2
View File
@@ -18,6 +18,7 @@
"dev:web": "vite dev",
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"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",
@@ -36,6 +37,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@httptoolkit/dbus-native": "^0.1.5",
"@unblockneteasemusic/server": "^0.27.10",
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
@@ -49,6 +51,7 @@
"form-data": "^4.0.5",
"husky": "^9.1.7",
"jsencrypt": "^3.5.4",
"mpris-service": "^2.1.2",
"music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.30.0",
"node-fetch": "^2.7.0",
@@ -58,6 +61,8 @@
"vue-i18n": "^11.2.2"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^1.0.1",
@@ -148,7 +153,6 @@
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "AlgerMusicPlayer needs access to the microphone for audio visualization.",
"NSCameraUsageDescription": "Application requests access to the device's camera.",
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
@@ -176,7 +180,7 @@
"requestedExecutionLevel": "asInvoker"
},
"linux": {
"icon": "resources/icon.png",
"icon": "build/icons",
"target": [
{
"target": "AppImage",
@@ -222,5 +226,9 @@
"electron",
"esbuild"
]
},
"optionalDependencies": {
"jsbi": "^4.3.2",
"x11": "^2.3.0"
}
}
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

+20 -3
View File
@@ -1,10 +1,27 @@
{
"name": "Alger Music PWA",
"name": "Alger Music Player",
"short_name": "AlgerMusic",
"description": "AlgerMusicPlayer 音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"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', () => {
// 特意留空:不拦截,默认走网络
});
+21
View File
@@ -0,0 +1,21 @@
/**
* 修复 Linux 下 Electron sandbox 权限问题
* chrome-sandbox 需要 root 拥有且权限为 4755
*
* 注意:此脚本需要 sudo 权限,仅在 CI 环境或手动执行时使用
* 用法: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(__dirname, '../node_modules/electron/dist/chrome-sandbox');
if (fs.existsSync(sandboxPath)) {
execSync(`sudo chown root:root ${sandboxPath}`);
execSync(`sudo chmod 4755 ${sandboxPath}`);
console.log('[fix-sandbox] chrome-sandbox permissions fixed');
} else {
console.log('[fix-sandbox] chrome-sandbox not found, skipping');
}
}
+3
View File
@@ -223,6 +223,9 @@ export default {
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist',
locateCurrent: 'Locate current song',
scrollToTop: 'Scroll to top',
compactLayout: 'Compact layout',
normalLayout: 'Normal layout',
historyRecommend: 'Daily History',
fetchDatesFailed: 'Failed to fetch dates',
fetchSongsFailed: 'Failed to fetch songs',
+30 -1
View File
@@ -20,7 +20,21 @@ export default {
downloading: 'Downloading',
completed: 'Completed',
failed: 'Failed',
unknown: 'Unknown'
unknown: 'Unknown',
queued: 'Queued',
paused: 'Paused',
cancelled: 'Cancelled'
},
action: {
pause: 'Pause',
resume: 'Resume',
cancel: 'Cancel',
cancelAll: 'Cancel All',
retrying: 'Re-resolving URL...'
},
batch: {
complete: 'Download complete: {success}/{total} songs succeeded',
allComplete: 'All downloads complete'
},
artist: {
unknown: 'Unknown Artist'
@@ -44,6 +58,14 @@ export default {
success: 'Download records cleared',
failed: 'Failed to clear download records'
},
save: {
title: 'Save Settings',
message: 'Current download settings are not saved. Do you want to save the changes?',
confirm: 'Save',
cancel: 'Cancel',
discard: 'Discard',
saveSuccess: 'Download settings saved'
},
message: {
downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: 'Sort or use arrow buttons to arrange:',
formatVariables: 'Available variables',
preview: 'Preview:',
concurrency: 'Max Concurrent',
concurrencyDesc: 'Maximum number of simultaneous downloads (1-5)',
saveSuccess: 'Download settings saved',
presets: {
songArtist: 'Song - Artist',
@@ -89,5 +113,10 @@ export default {
artistName: 'Artist name',
albumName: 'Album name'
}
},
error: {
incomplete: 'File download incomplete',
urlExpired: 'URL expired, re-resolving',
resumeFailed: 'Resume failed'
}
};
+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)'
};
+4
View File
@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: 'Song parsing failed, playing next',
consecutiveFailsError:
'Playback error, possibly due to network issues or invalid source. Please switch playlist or try again later',
playListEnded: 'Reached the end of the playlist',
autoResumed: 'Playback resumed automatically',
resumeFailed: 'Failed to resume playback, please try manually',
playMode: {
sequence: 'Sequence',
loop: 'Loop',
@@ -56,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
View File
@@ -223,6 +223,9 @@ export default {
addToPlaylistSuccess: 'プレイリストに追加しました',
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
locateCurrent: '再生中の曲を表示',
scrollToTop: 'トップに戻る',
compactLayout: 'コンパクト表示',
normalLayout: '通常表示',
historyRecommend: '履歴の日次推薦',
fetchDatesFailed: '日付リストの取得に失敗しました',
fetchSongsFailed: '楽曲リストの取得に失敗しました',
+30 -1
View File
@@ -20,7 +20,21 @@ export default {
downloading: 'ダウンロード中',
completed: '完了',
failed: '失敗',
unknown: '不明'
unknown: '不明',
queued: 'キュー中',
paused: '一時停止',
cancelled: 'キャンセル済み'
},
action: {
pause: '一時停止',
resume: '再開',
cancel: 'キャンセル',
cancelAll: 'すべてキャンセル',
retrying: 'URL再取得中...'
},
batch: {
complete: 'ダウンロード完了:{success}/{total}曲成功',
allComplete: '全てのダウンロードが完了'
},
artist: {
unknown: '不明なアーティスト'
@@ -44,6 +58,14 @@ export default {
success: 'ダウンロード記録をクリアしました',
failed: 'ダウンロード記録のクリアに失敗しました'
},
save: {
title: '設定を保存',
message: '現在のダウンロード設定が保存されていません。変更を保存しますか?',
confirm: '保存',
cancel: 'キャンセル',
discard: '破棄',
saveSuccess: 'ダウンロード設定を保存しました'
},
message: {
downloadComplete: '{filename}のダウンロードが完了しました',
downloadFailed: '{filename}のダウンロードに失敗しました: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:',
formatVariables: '使用可能な変数',
preview: 'プレビュー効果:',
concurrency: '最大同時ダウンロード数',
concurrencyDesc: '同時にダウンロードする最大曲数(1-5)',
saveSuccess: 'ダウンロード設定を保存しました',
presets: {
songArtist: '楽曲名 - アーティスト名',
@@ -89,5 +113,10 @@ export default {
artistName: 'アーティスト名',
albumName: 'アルバム名'
}
},
error: {
incomplete: 'ファイルのダウンロードが不完全です',
urlExpired: 'URLの有効期限が切れました。再取得中',
resumeFailed: '再開に失敗しました'
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン',
songCount: '{count} 曲'
songCount: '{count} 曲',
removeFromLibrary: 'ライブラリから削除',
removedFromLibrary: 'ライブラリから削除しました(ファイルは削除されません)'
};
+4
View File
@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します',
consecutiveFailsError:
'再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください',
playListEnded: 'プレイリストの最後に到達しました',
autoResumed: '自動的に再生を再開しました',
resumeFailed: '再生の再開に失敗しました。手動でお試しください',
playMode: {
sequence: '順次再生',
loop: 'リピート再生',
@@ -56,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
View File
@@ -222,6 +222,9 @@ export default {
addToPlaylistSuccess: '재생 목록에 추가 성공',
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
locateCurrent: '현재 재생 곡 찾기',
scrollToTop: '맨 위로',
compactLayout: '간결한 레이아웃',
normalLayout: '일반 레이아웃',
historyRecommend: '일일 기록 권장',
fetchDatesFailed: '날짜를 가져오지 못했습니다',
fetchSongsFailed: '곡을 가져오지 못했습니다',
+30 -1
View File
@@ -20,7 +20,21 @@ export default {
downloading: '다운로드 중',
completed: '완료',
failed: '실패',
unknown: '알 수 없음'
unknown: '알 수 없음',
queued: '대기 중',
paused: '일시 정지',
cancelled: '취소됨'
},
action: {
pause: '일시 정지',
resume: '재개',
cancel: '취소',
cancelAll: '모두 취소',
retrying: 'URL 재획득 중...'
},
batch: {
complete: '다운로드 완료: {success}/{total}곡 성공',
allComplete: '모든 다운로드 완료'
},
artist: {
unknown: '알 수 없는 가수'
@@ -44,6 +58,14 @@ export default {
success: '다운로드 기록이 지워졌습니다',
failed: '다운로드 기록 삭제에 실패했습니다'
},
save: {
title: '설정 저장',
message: '현재 다운로드 설정이 저장되지 않았습니다. 변경 사항을 저장하시겠습니까?',
confirm: '저장',
cancel: '취소',
discard: '포기',
saveSuccess: '다운로드 설정이 저장됨'
},
message: {
downloadComplete: '{filename} 다운로드 완료',
downloadFailed: '{filename} 다운로드 실패: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:',
formatVariables: '사용 가능한 변수',
preview: '미리보기 효과:',
concurrency: '최대 동시 다운로드',
concurrencyDesc: '동시에 다운로드할 최대 곡 수 (1-5)',
saveSuccess: '다운로드 설정이 저장됨',
presets: {
songArtist: '곡명 - 가수명',
@@ -89,5 +113,10 @@ export default {
artistName: '가수명',
albumName: '앨범명'
}
},
error: {
incomplete: '파일 다운로드가 불완전합니다',
urlExpired: 'URL이 만료되었습니다. 재획득 중',
resumeFailed: '재개 실패'
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔',
songCount: '{count}곡'
songCount: '{count}곡',
removeFromLibrary: '라이브러리에서 제거',
removedFromLibrary: '라이브러리에서 제거했습니다 (파일은 삭제되지 않음)'
};
+4
View File
@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생',
consecutiveFailsError:
'재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요',
playListEnded: '재생 목록의 마지막 곡에 도달했습니다',
autoResumed: '자동으로 재생이 재개되었습니다',
resumeFailed: '재생 재개에 실패했습니다. 수동으로 시도해 주세요',
playMode: {
sequence: '순차 재생',
loop: '한 곡 반복',
@@ -56,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
View File
@@ -216,6 +216,9 @@ export default {
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
locateCurrent: '定位当前播放',
scrollToTop: '回到顶部',
compactLayout: '紧凑布局',
normalLayout: '常规布局',
historyRecommend: '历史日推',
fetchDatesFailed: '获取日期列表失败',
fetchSongsFailed: '获取歌曲列表失败',
+30 -1
View File
@@ -20,7 +20,21 @@ export default {
downloading: '下载中',
completed: '已完成',
failed: '失败',
unknown: '未知'
unknown: '未知',
queued: '排队中',
paused: '已暂停',
cancelled: '已取消'
},
action: {
pause: '暂停',
resume: '恢复',
cancel: '取消',
cancelAll: '取消全部',
retrying: '重新获取链接...'
},
batch: {
complete: '下载完成:成功 {success}/{total} 首',
allComplete: '全部下载完成'
},
artist: {
unknown: '未知歌手'
@@ -43,6 +57,14 @@ export default {
success: '下载记录已清空',
failed: '清空下载记录失败'
},
save: {
title: '保存设置',
message: '当前下载设置未保存,是否保存更改?',
confirm: '保存',
cancel: '取消',
discard: '放弃',
saveSuccess: '下载设置已保存'
},
message: {
downloadComplete: '{filename} 下载完成',
downloadFailed: '{filename} 下载失败: {error}'
@@ -77,6 +99,8 @@ export default {
dragToArrange: '拖动排序或使用箭头按钮调整顺序:',
formatVariables: '可用变量',
preview: '预览效果:',
concurrency: '最大并发数',
concurrencyDesc: '同时下载的最大歌曲数量(1-5)',
saveSuccess: '下载设置已保存',
presets: {
songArtist: '歌曲名 - 歌手名',
@@ -88,5 +112,10 @@ export default {
artistName: '歌手名',
albumName: '专辑名'
}
},
error: {
incomplete: '文件下载不完整',
urlExpired: '下载链接已过期,正在重新获取',
resumeFailed: '恢复下载失败'
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '从本地列表移除',
removedFromLibrary: '已从本地列表移除(不删除文件)'
};
+4
View File
@@ -16,6 +16,9 @@ export default {
playFailed: '当前歌曲播放失败,播放下一首',
parseFailedPlayNext: '歌曲解析失败,播放下一首',
consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试',
playListEnded: '已播放到列表最后一首',
autoResumed: '已自动恢复播放',
resumeFailed: '恢复播放失败,请手动点击播放',
playMode: {
sequence: '顺序播放',
loop: '单曲循环',
@@ -55,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
View File
@@ -216,6 +216,9 @@ export default {
addToPlaylistSuccess: '新增至播放清單成功',
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
locateCurrent: '定位當前播放',
scrollToTop: '回到頂部',
compactLayout: '緊湊佈局',
normalLayout: '常規佈局',
historyRecommend: '歷史日推',
fetchDatesFailed: '獲取日期列表失敗',
fetchSongsFailed: '獲取歌曲列表失敗',
+30 -1
View File
@@ -20,7 +20,21 @@ export default {
downloading: '下載中',
completed: '已完成',
failed: '失敗',
unknown: '未知'
unknown: '未知',
queued: '排隊中',
paused: '已暫停',
cancelled: '已取消'
},
action: {
pause: '暫停',
resume: '恢復',
cancel: '取消',
cancelAll: '取消全部',
retrying: '重新獲取連結...'
},
batch: {
complete: '下載完成:成功 {success}/{total} 首',
allComplete: '全部下載完成'
},
artist: {
unknown: '未知歌手'
@@ -43,6 +57,14 @@ export default {
success: '下載記錄已清空',
failed: '清空下載記錄失敗'
},
save: {
title: '儲存設定',
message: '目前下載設定尚未儲存,是否儲存變更?',
confirm: '儲存',
cancel: '取消',
discard: '放棄',
saveSuccess: '下載設定已儲存'
},
message: {
downloadComplete: '{filename} 下載完成',
downloadFailed: '{filename} 下載失敗: {error}'
@@ -77,6 +99,8 @@ export default {
dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:',
formatVariables: '可用變數',
preview: '預覽效果:',
concurrency: '最大並發數',
concurrencyDesc: '同時下載的最大歌曲數量(1-5)',
saveSuccess: '下載設定已儲存',
presets: {
songArtist: '歌曲名 - 歌手名',
@@ -88,5 +112,10 @@ export default {
artistName: '歌手名',
albumName: '專輯名'
}
},
error: {
incomplete: '檔案下載不完整',
urlExpired: '下載連結已過期,正在重新獲取',
resumeFailed: '恢復下載失敗'
}
};
+3 -1
View File
@@ -9,5 +9,7 @@ export default {
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描',
songCount: '{count} 首歌曲'
songCount: '{count} 首歌曲',
removeFromLibrary: '從本機清單移除',
removedFromLibrary: '已從本機清單移除(不刪除檔案)'
};
+4
View File
@@ -16,6 +16,9 @@ export default {
playFailed: '目前歌曲播放失敗,播放下一首',
parseFailedPlayNext: '歌曲解析失敗,播放下一首',
consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試',
playListEnded: '已播放到列表最後一首',
autoResumed: '已自動恢復播放',
resumeFailed: '恢復播放失敗,請手動點擊播放',
playMode: {
sequence: '順序播放',
loop: '單曲循環',
@@ -55,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;
}
+59 -3
View File
@@ -1,17 +1,55 @@
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';
import { initializeCacheManager } from './modules/cache';
import { initializeConfig } from './modules/config';
import { initializeDownloadManager, setDownloadManagerWindow } from './modules/downloadManager';
import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts';
import { initializeLocalMusicScanner } from './modules/localMusicScanner';
import { initializeLoginWindow } from './modules/loginWindow';
import { initLxMusicHttp } from './modules/lxMusicHttp';
import { initializeMpris, updateMprisCurrentSong, updateMprisPlayState } from './modules/mpris';
import { initializeOtherApi } from './modules/otherApi';
import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts } from './modules/shortcuts';
@@ -42,6 +80,8 @@ function initialize(configStore: any) {
// 初始化文件管理
initializeFileManager();
// 初始化下载管理
initializeDownloadManager();
// 初始化歌词缓存管理
initializeCacheManager();
// 初始化其他 API (搜索建议等)
@@ -58,6 +98,9 @@ function initialize(configStore: any) {
// 创建主窗口
mainWindow = createMainWindow(icon);
// 设置下载管理器窗口引用
setDownloadManagerWindow(mainWindow);
// 初始化托盘
initializeTray(iconPath, mainWindow);
@@ -76,6 +119,9 @@ function initialize(configStore: any) {
// 初始化远程控制服务
initializeRemoteControl(mainWindow);
// 初始化 MPRIS 服务 (Linux)
initializeMpris(mainWindow);
// 初始化更新处理程序
setupUpdateHandlers(mainWindow);
}
@@ -86,6 +132,11 @@ const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
app.quit();
} else {
// 禁用 Chromium 内置的 MediaSession MPRIS 服务,避免重复显示
if (process.platform === 'linux') {
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
}
// 在应用准备就绪前初始化GPU加速设置
// 必须在 app.ready 之前调用 disableHardwareAcceleration
try {
@@ -127,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;
});
@@ -165,11 +219,13 @@ if (!isSingleInstance) {
// 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing);
updateMprisPlayState(playing);
});
// 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song);
updateMprisCurrentSong(song);
});
// 所有窗口关闭时的处理
+114 -6
View File
@@ -1,15 +1,92 @@
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;
// 添加窗口大小变化防护
let originalSize = { width: 0, height: 0 };
// 鼠标位置轮询仅在"锁定 + 可见"时启用,解锁态下 DOM 事件已足够
let mousePresenceTimer: ReturnType<typeof setInterval> | null = null;
let lastMouseInside: boolean | null = null;
let isLyricLocked = false;
let isLyricWindowVisible = false;
const isPointInsideWindow = (
point: { x: number; y: number },
bounds: { x: number; y: number; width: number; height: number }
) => {
return (
point.x >= bounds.x &&
point.x < bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y < bounds.y + bounds.height
);
};
const stopMousePresenceTracking = () => {
if (mousePresenceTimer) {
clearInterval(mousePresenceTimer);
mousePresenceTimer = null;
}
lastMouseInside = null;
};
const emitMousePresence = () => {
if (!lyricWindow || lyricWindow.isDestroyed()) return;
const mousePoint = screen.getCursorScreenPoint();
const bounds = lyricWindow.getBounds();
const isInside = isPointInsideWindow(mousePoint, bounds);
if (isInside === lastMouseInside) return;
lastMouseInside = isInside;
lyricWindow.webContents.send('lyric-mouse-presence', isInside);
};
const startMousePresenceTracking = () => {
if (mousePresenceTimer) return;
emitMousePresence();
mousePresenceTimer = setInterval(() => {
if (!lyricWindow || lyricWindow.isDestroyed()) {
stopMousePresenceTracking();
return;
}
emitMousePresence();
}, 50);
};
const syncMousePresenceTracking = () => {
if (isLyricLocked && isLyricWindowVisible && lyricWindow && !lyricWindow.isDestroyed()) {
startMousePresenceTracking();
} else {
stopMousePresenceTracking();
}
};
const createWin = () => {
console.log('Creating lyric window');
@@ -102,12 +179,32 @@ const createWin = () => {
// 监听窗口关闭事件
lyricWindow.on('closed', () => {
stopMousePresenceTracking();
isLyricLocked = false;
isLyricWindowVisible = false;
if (lyricWindow) {
lyricWindow.destroy();
lyricWindow = null;
}
});
lyricWindow.on('show', () => {
isLyricWindowVisible = true;
syncMousePresenceTracking();
});
lyricWindow.on('hide', () => {
isLyricWindowVisible = false;
stopMousePresenceTracking();
});
lyricWindow.on('minimize', () => {
isLyricWindowVisible = false;
stopMousePresenceTracking();
});
lyricWindow.on('restore', () => {
isLyricWindowVisible = true;
syncMousePresenceTracking();
});
// 监听窗口大小变化事件,保存新的尺寸
lyricWindow.on('resize', () => {
// 如果正在拖动,忽略大小调整事件
@@ -117,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 });
}
});
@@ -205,6 +302,17 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
}
});
ipcMain.on('set-lyric-lock-state', (_, isLocked: boolean) => {
isLyricLocked = isLocked;
if (lyricWindow && !lyricWindow.isDestroyed()) {
// 锁定时禁用 resize,避免鼠标移到边缘仍显示调整光标
lyricWindow.setResizable(!isLocked);
// 设置初始穿透状态,后续 polling 会按实际位置纠正
lyricWindow.setIgnoreMouseEvents(isLocked, { forward: true });
}
syncMousePresenceTracking();
});
// 处理鼠标事件
ipcMain.on('mouseenter-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
@@ -267,7 +375,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
false
);
// 更新存储的位置
// 更新存储的位置(防抖,拖动结束后统一落盘)
const windowBounds = {
x: newX,
y: newY,
@@ -275,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();
/**
*
File diff suppressed because it is too large Load Diff
+69 -776
View File
@@ -1,30 +1,11 @@
import axios from 'axios';
import { app, dialog, ipcMain, nativeImage, Notification, protocol, shell } from 'electron';
import { app, dialog, ipcMain, protocol, shell } from 'electron';
import Store from 'electron-store';
import { fileTypeFromFile } from 'file-type';
import { FlacTagMap, writeFlacTags } from 'flac-tagger';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as mm from 'music-metadata';
import * as NodeID3 from 'node-id3';
import * as os from 'os';
import * as path from 'path';
import { Readable } from 'stream';
import { getStore } from './config';
const MAX_CONCURRENT_DOWNLOADS = 3;
const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = [];
let activeDownloads = 0;
// 创建一个store实例用于存储下载历史
const downloadStore = new Store({
name: 'downloads',
defaults: {
history: []
}
});
// 创建一个store实例用于存储音频缓存
const audioCacheStore = new Store({
name: 'audioCache',
@@ -33,39 +14,89 @@ const audioCacheStore = new Store({
}
});
// 保存已发送通知的文件,避免重复通知
const sentNotifications = new Map();
/**
*
*/
function sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.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 });
}
});
@@ -130,122 +161,6 @@ export function initializeFileManager() {
return app.getPath('downloads');
});
// 获取存储的配置值
ipcMain.handle('get-store-value', (_, key) => {
const store = new Store();
return store.get(key);
});
// 设置存储的配置值
ipcMain.on('set-store-value', (_, key, value) => {
const store = new Store();
store.set(key, value);
});
// 下载音乐处理
ipcMain.on('download-music', handleDownloadRequest);
// 检查文件是否已下载
ipcMain.handle('check-music-downloaded', (_, filename: string) => {
const store = new Store();
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
const filePath = path.join(downloadPath, `${filename}.mp3`);
return fs.existsSync(filePath);
});
// 删除已下载的音乐
ipcMain.handle('delete-downloaded-music', async (_, filePath: string) => {
try {
if (fs.existsSync(filePath)) {
// 先删除文件
try {
await fs.promises.unlink(filePath);
} catch (error) {
console.error('Error deleting file:', error);
}
// 删除对应的歌曲信息
const store = new Store();
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
delete songInfos[filePath];
store.set('downloadedSongs', songInfos);
return true;
}
return false;
} catch (error) {
console.error('Error deleting file:', error);
return false;
}
});
// 获取已下载音乐列表
ipcMain.handle('get-downloaded-music', async () => {
try {
const store = new Store();
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
// 异步处理文件存在性检查
const entriesArray = Object.entries(songInfos);
const validEntriesPromises = await Promise.all(
entriesArray.map(async ([path, info]) => {
try {
const exists = await fs.promises
.access(path)
.then(() => true)
.catch(() => false);
return exists ? info : null;
} catch (error) {
console.error('Error checking file existence:', error);
return null;
}
})
);
// 过滤有效的歌曲并排序
const validSongs = validEntriesPromises
.filter((song) => song !== null)
.sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0));
// 更新存储,移除不存在的文件记录
const newSongInfos = validSongs.reduce((acc, song) => {
if (song && song.path) {
acc[song.path] = song;
}
return acc;
}, {});
store.set('downloadedSongs', newSongInfos);
return validSongs;
} catch (error) {
console.error('Error getting downloaded music:', error);
return [];
}
});
// 检查歌曲是否已下载并返回本地路径
ipcMain.handle('check-song-downloaded', (_, songId: number) => {
const store = new Store();
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
// 通过ID查找已下载的歌曲
for (const [path, info] of Object.entries(songInfos)) {
if (info.id === songId && fs.existsSync(path)) {
return {
isDownloaded: true,
localPath: `local://${path}`,
songInfo: info
};
}
}
return {
isDownloaded: false,
localPath: '',
songInfo: null
};
});
// 保存歌词文件
ipcMain.handle(
'save-lyric-file',
@@ -273,18 +188,6 @@ export function initializeFileManager() {
}
);
// 添加清除下载历史的处理函数
ipcMain.on('clear-downloads-history', () => {
downloadStore.set('history', []);
});
// 添加清除已下载音乐记录的处理函数
ipcMain.handle('clear-downloaded-music', () => {
const store = new Store();
store.set('downloadedSongs', {});
return true;
});
// 添加清除音频缓存的处理函数
ipcMain.on('clear-audio-cache', () => {
audioCacheStore.set('cache', {});
@@ -378,613 +281,3 @@ export function initializeFileManager() {
}
});
}
/**
*
*/
function handleDownloadRequest(
event: Electron.IpcMainEvent,
{
url,
filename,
songInfo,
type
}: { url: string; filename: string; songInfo?: any; type?: string }
) {
// 检查是否已经在队列中或正在下载
if (downloadQueue.some((item) => item.filename === filename)) {
event.reply('music-download-error', {
filename,
error: '该歌曲已在下载队列中'
});
return;
}
// 检查是否已下载
const store = new Store();
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
// 检查是否已下载(通过ID
const isDownloaded =
songInfo?.id && Object.values(songInfos).some((info: any) => info.id === songInfo.id);
if (isDownloaded) {
event.reply('music-download-error', {
filename,
error: '该歌曲已下载'
});
return;
}
// 添加到下载队列
downloadQueue.push({ url, filename, songInfo, type });
event.reply('music-download-queued', {
filename,
songInfo
});
// 尝试开始下载
processDownloadQueue(event);
}
/**
*
*/
async function processDownloadQueue(event: Electron.IpcMainEvent) {
if (activeDownloads >= MAX_CONCURRENT_DOWNLOADS || downloadQueue.length === 0) {
return;
}
const { url, filename, songInfo, type } = downloadQueue.shift()!;
activeDownloads++;
try {
await downloadMusic(event, { url, filename, songInfo, type });
} finally {
activeDownloads--;
processDownloadQueue(event);
}
}
/**
*
*/
function sanitizeFilename(filename: string): string {
// 替换 Windows 和 Unix 系统中的非法字符
return filename
.replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线
.replace(/\s+/g, ' ') // 将多个空格替换为单个空格
.trim(); // 移除首尾空格
}
/**
*
*/
async function downloadMusic(
event: Electron.IpcMainEvent,
{
url,
filename,
songInfo,
type = 'mp3'
}: { url: string; filename: string; songInfo: any; type?: string }
) {
let finalFilePath = '';
let writer: fs.WriteStream | null = null;
let tempFilePath = '';
try {
// 使用配置Store来获取设置
const configStore = getStore();
const downloadPath =
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
const apiPort = configStore.get('set.musicApiPort') || 30488;
// 获取文件名格式设置
const nameFormat =
(configStore.get('set.downloadNameFormat') as string) || '{songName} - {artistName}';
// 根据格式创建文件名
let formattedFilename = filename;
if (songInfo) {
// 准备替换变量
const artistName = songInfo.ar?.map((a: any) => a.name).join('、') || '未知艺术家';
const songName = songInfo.name || filename;
const albumName = songInfo.al?.name || '未知专辑';
// 应用自定义格式
formattedFilename = nameFormat
.replace(/\{songName\}/g, songName)
.replace(/\{artistName\}/g, artistName)
.replace(/\{albumName\}/g, albumName);
}
// 清理文件名中的非法字符
const sanitizedFilename = sanitizeFilename(formattedFilename);
// 创建临时文件路径 (在系统临时目录中创建)
const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
// 确保临时目录存在
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
// 先获取文件大小
const headResponse = await axios.head(url);
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
// 开始下载到临时文件
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
timeout: 30000, // 30秒超时
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true })
});
writer = fs.createWriteStream(tempFilePath);
let downloadedSize = 0;
// 使用 data 事件来跟踪下载进度
response.data.on('data', (chunk: Buffer) => {
downloadedSize += chunk.length;
const progress = Math.round((downloadedSize / totalSize) * 100);
event.reply('music-download-progress', {
filename,
progress,
loaded: downloadedSize,
total: totalSize,
path: tempFilePath,
status: progress === 100 ? 'completed' : 'downloading',
songInfo: songInfo || {
name: filename,
ar: [{ name: '本地音乐' }],
picUrl: '/images/default_cover.png'
}
});
});
// 等待下载完成
await new Promise((resolve, reject) => {
writer!.on('finish', () => resolve(undefined));
writer!.on('error', (error) => reject(error));
response.data.pipe(writer!);
});
// 验证文件是否完整下载
const stats = fs.statSync(tempFilePath);
if (stats.size !== totalSize) {
throw new Error('文件下载不完整');
}
// 检测文件类型
let fileExtension = '';
try {
// 首先尝试使用file-type库检测
const fileType = await fileTypeFromFile(tempFilePath);
if (fileType && fileType.ext) {
fileExtension = `.${fileType.ext}`;
console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`);
} else {
// 如果file-type无法识别,尝试使用music-metadata
const metadata = await mm.parseFile(tempFilePath);
if (metadata && metadata.format) {
// 根据format.container或codec判断扩展名
const formatInfo = metadata.format;
const container = formatInfo.container || '';
const codec = formatInfo.codec || '';
// 音频格式映射表
const formatMap = {
mp3: ['MPEG', 'MP3', 'mp3'],
aac: ['AAC'],
flac: ['FLAC'],
ogg: ['Ogg', 'Vorbis'],
wav: ['WAV', 'PCM'],
m4a: ['M4A', 'MP4']
};
// 查找匹配的格式
const format = Object.entries(formatMap).find(([_, keywords]) =>
keywords.some((keyword) => container.includes(keyword) || codec.includes(keyword))
);
// 设置文件扩展名,如果没找到则默认为mp3
fileExtension = format ? `.${format[0]}` : '.mp3';
console.log(
`music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}`
);
} else {
// 两种方法都失败,使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`);
}
}
} catch (err) {
console.error('检测文件类型失败:', err);
// 检测失败,使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
}
// 使用检测到的文件扩展名创建最终文件路径
const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`);
// 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath;
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
}
// 将临时文件移动到最终位置
fs.copyFileSync(tempFilePath, finalFilePath);
fs.unlinkSync(tempFilePath); // 删除临时文件
// 下载歌词
let lyricData = null;
let lyricsContent = '';
try {
if (songInfo?.id) {
// 下载歌词,使用配置的端口
const lyricsResponse = await axios.get(
`http://localhost:${apiPort}/lyric?id=${songInfo.id}`
);
if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) {
lyricData = lyricsResponse.data;
// 处理歌词内容
if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) {
lyricsContent = lyricsResponse.data.lrc.lyric;
// 如果有翻译歌词,合并到主歌词中
if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) {
// 解析原歌词和翻译
const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric);
const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric);
// 合并歌词
const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics);
lyricsContent = mergedLyrics;
}
}
console.log('歌词已准备好,将写入元数据');
}
}
} catch (lyricError) {
console.error('下载歌词失败:', lyricError);
// 继续处理,不影响音乐下载
}
// 下载封面
let coverImageBuffer: Buffer | null = null;
try {
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
if (picUrl && picUrl !== '/images/default_cover.png') {
// 处理 base64 Data URL(本地音乐扫描提取的封面)
if (picUrl.startsWith('data:')) {
const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/);
if (base64Match) {
coverImageBuffer = Buffer.from(base64Match[1], 'base64');
console.log('从 base64 Data URL 提取封面');
}
} else {
const coverResponse = await axios({
url: picUrl.replace('http://', 'https://'),
method: 'GET',
responseType: 'arraybuffer',
timeout: 10000
});
const originalCoverBuffer = Buffer.from(coverResponse.data);
const TWO_MB = 2 * 1024 * 1024;
// 检查图片大小是否超过2MB
if (originalCoverBuffer.length > TWO_MB) {
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
try {
// 使用 Electron nativeImage 进行压缩
const image = nativeImage.createFromBuffer(originalCoverBuffer);
const size = image.getSize();
// 计算新尺寸,保持宽高比,最大1600px
const maxSize = 1600;
let newWidth = size.width;
let newHeight = size.height;
if (size.width > maxSize || size.height > maxSize) {
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
newWidth = Math.round(size.width * ratio);
newHeight = Math.round(size.height * ratio);
}
// 调整大小并转换为 JPEG 格式(质量 80)
const resizedImage = image.resize({
width: newWidth,
height: newHeight,
quality: 'good'
});
coverImageBuffer = resizedImage.toJPEG(80);
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
} catch (compressionError) {
console.error('封面图压缩失败,将使用原图:', compressionError);
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
}
} else {
// 如果图片不大于2MB,直接使用原图
coverImageBuffer = originalCoverBuffer;
}
}
console.log('封面已准备好,将写入元数据');
}
}
} catch (coverError) {
console.error('下载封面失败:', coverError);
// 继续处理,不影响音乐下载
}
const fileFormat = fileExtension.toLowerCase();
const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') || '未知艺术家';
// 根据文件类型处理元数据
if (['.mp3'].includes(fileFormat)) {
// 对MP3文件使用NodeID3处理ID3标签
try {
// 在写入ID3标签前,先移除可能存在的旧标签
NodeID3.removeTags(finalFilePath);
const tags = {
title: songInfo?.name,
artist: artistNames,
TPE1: artistNames,
TPE2: artistNames,
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
APIC: {
// 专辑封面
imageBuffer: coverImageBuffer,
type: {
id: 3,
name: 'front cover'
},
description: 'Album cover',
mime: 'image/jpeg'
},
USLT: {
// 歌词
language: 'chi',
description: 'Lyrics',
text: lyricsContent || ''
},
trackNumber: songInfo?.no || undefined,
year: songInfo?.publishTime
? new Date(songInfo.publishTime).getFullYear().toString()
: undefined
};
const success = NodeID3.write(tags, finalFilePath);
if (!success) {
console.error('Failed to write ID3 tags');
} else {
console.log('ID3 tags written successfully');
}
} catch (err) {
console.error('Error writing ID3 tags:', err);
}
} else if (['.flac'].includes(fileFormat)) {
try {
const tagMap: FlacTagMap = {
TITLE: songInfo?.name,
ARTIST: artistNames,
ALBUM: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
LYRICS: lyricsContent || '',
TRACKNUMBER: songInfo?.no ? String(songInfo.no) : '',
DATE: songInfo?.publishTime ? new Date(songInfo.publishTime).getFullYear().toString() : ''
};
await writeFlacTags(
{
tagMap,
picture: coverImageBuffer
? {
buffer: coverImageBuffer,
mime: 'image/jpeg'
}
: undefined
},
finalFilePath
);
console.log('FLAC tags written successfully');
} catch (err) {
console.error('Error writing FLAC tags:', err);
}
}
// 如果启用了单独保存歌词文件,将歌词保存为 .lrc 文件
if (lyricsContent && configStore.get('set.downloadSaveLyric')) {
try {
const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc');
await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8');
console.log('歌词文件已保存:', lrcFilePath);
} catch (lrcError) {
console.error('保存歌词文件失败:', lrcError);
}
}
// 保存下载信息
try {
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
const defaultInfo = {
name: filename,
ar: [{ name: '本地音乐' }],
picUrl: '/images/default_cover.png'
};
const newSongInfo = {
id: songInfo?.id || 0,
name: songInfo?.name || filename,
filename,
picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl,
ar: songInfo?.ar || defaultInfo.ar,
al: songInfo?.al || {
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
name: songInfo?.name || filename
},
size: totalSize,
path: finalFilePath,
downloadTime: Date.now(),
type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
lyric: lyricData
};
// 保存到下载记录
songInfos[finalFilePath] = newSongInfo;
configStore.set('downloadedSongs', songInfos);
// 添加到下载历史
const history = downloadStore.get('history', []) as any[];
history.unshift(newSongInfo);
downloadStore.set('history', history);
// 避免重复发送通知
const notificationId = `download-${finalFilePath}`;
if (!sentNotifications.has(notificationId)) {
sentNotifications.set(notificationId, true);
// 发送桌面通知
try {
const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') ||
'未知艺术家';
const notification = new Notification({
title: '下载完成',
body: `${songInfo?.name || filename} - ${artistNames}`,
silent: false
});
notification.on('click', () => {
shell.showItemInFolder(finalFilePath);
});
notification.show();
// 60秒后清理通知记录,释放内存
setTimeout(() => {
sentNotifications.delete(notificationId);
}, 60000);
} catch (notifyError) {
console.error('发送通知失败:', notifyError);
}
}
// 发送下载完成事件,确保只发送一次
event.reply('music-download-complete', {
success: true,
path: finalFilePath,
filename,
size: totalSize,
songInfo: newSongInfo
});
} catch (error) {
console.error('Error saving download info:', error);
throw new Error('保存下载信息失败');
}
} catch (error: any) {
console.error('Download error:', error);
// 清理未完成的下载
if (writer) {
writer.end();
}
// 清理临时文件
if (tempFilePath && fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch (e) {
console.error('Failed to delete temporary file:', e);
}
}
// 清理未完成的最终文件
if (finalFilePath && fs.existsSync(finalFilePath)) {
try {
fs.unlinkSync(finalFilePath);
} catch (e) {
console.error('Failed to delete incomplete download:', e);
}
}
event.reply('music-download-complete', {
success: false,
error: error.message || '下载失败',
filename
});
}
}
// 辅助函数 - 解析歌词文本成时间戳和内容的映射
function parseLyrics(lyricsText: string): Map<string, string> {
const lyricMap = new Map<string, string>();
const lines = lyricsText.split('\n');
for (const line of lines) {
// 匹配时间标签,形如 [00:00.000]
const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g);
if (!timeTagMatches) continue;
// 提取歌词内容(去除时间标签)
const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim();
if (!content) continue;
// 将每个时间标签与歌词内容关联
for (const timeTag of timeTagMatches) {
lyricMap.set(timeTag, content);
}
}
return lyricMap;
}
// 辅助函数 - 合并原文歌词和翻译歌词
function mergeLyrics(
originalLyrics: Map<string, string>,
translatedLyrics: Map<string, string>
): string {
const mergedLines: string[] = [];
// 对每个时间戳,组合原始歌词和翻译
for (const [timeTag, originalContent] of originalLyrics.entries()) {
const translatedContent = translatedLyrics.get(timeTag);
// 添加原始歌词行
mergedLines.push(`${timeTag}${originalContent}`);
// 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示)
if (translatedContent) {
mergedLines.push(`${timeTag}${translatedContent}`);
}
}
// 按时间顺序排序
mergedLines.sort((a, b) => {
const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
return timeA.localeCompare(timeB);
});
return mergedLines.join('\n');
}
+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);
}
}
+270
View File
@@ -0,0 +1,270 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import Player from 'mpris-service';
let dbusModule: any;
try {
dbusModule = require('@httptoolkit/dbus-native');
} catch {
// dbus-native 不可用(非 Linux 环境)
}
interface SongInfo {
id?: number | string;
name: string;
picUrl?: string;
ar?: Array<{ name: string }>;
artists?: Array<{ name: string }>;
al?: { name: string };
album?: { name: string };
duration?: number;
dt?: number;
song?: {
artists?: Array<{ name: string }>;
album?: { name: string };
duration?: number;
picUrl?: string;
};
[key: string]: any;
}
let mprisPlayer: Player | null = null;
let mainWindow: BrowserWindow | null = null;
let currentPosition = 0;
let trayLyricIface: any = null;
let trayLyricBus: any = null;
// 保存 IPC 处理函数引用,用于清理
let onPositionUpdate: ((event: any, position: number) => void) | null = null;
let onTrayLyricUpdate: ((event: any, lrcObj: string) => void) | null = null;
export function initializeMpris(mainWindowRef: BrowserWindow) {
if (process.platform !== 'linux') return;
if (mprisPlayer) {
return;
}
mainWindow = mainWindowRef;
try {
mprisPlayer = Player({
name: 'AlgerMusicPlayer',
identity: 'Alger Music Player',
supportedUriSchemes: ['file', 'http', 'https'],
supportedMimeTypes: [
'audio/mpeg',
'audio/mp3',
'audio/flac',
'audio/wav',
'audio/ogg',
'audio/aac',
'audio/m4a'
],
supportedInterfaces: ['player']
});
mprisPlayer.on('quit', () => {
app.quit();
});
mprisPlayer.on('raise', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
mprisPlayer.on('next', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
});
mprisPlayer.on('previous', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
});
mprisPlayer.on('pause', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-pause');
}
});
mprisPlayer.on('play', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-play');
}
});
mprisPlayer.on('playpause', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
});
mprisPlayer.on('stop', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-pause');
}
});
mprisPlayer.getPosition = (): number => {
return currentPosition;
};
mprisPlayer.on('seek', (offset: number) => {
if (mainWindow) {
const newPosition = Math.max(0, currentPosition + offset / 1000000);
mainWindow.webContents.send('mpris-seek', newPosition);
}
});
mprisPlayer.on('position', (event: { trackId: string; position: number }) => {
if (mainWindow) {
mainWindow.webContents.send('mpris-set-position', event.position / 1000000);
}
});
onPositionUpdate = (_, position: number) => {
currentPosition = position * 1000 * 1000;
if (mprisPlayer) {
mprisPlayer.seeked(position * 1000 * 1000);
mprisPlayer.getPosition = () => position * 1000 * 1000;
mprisPlayer.position = position * 1000 * 1000;
}
};
ipcMain.on('mpris-position-update', onPositionUpdate);
onTrayLyricUpdate = (_, lrcObj: string) => {
sendTrayLyric(lrcObj);
};
ipcMain.on('tray-lyric-update', onTrayLyricUpdate);
initTrayLyric();
console.log('[MPRIS] Service initialized');
} catch (error) {
console.error('[MPRIS] Failed to initialize:', error);
}
}
export function updateMprisPlayState(playing: boolean) {
if (!mprisPlayer || process.platform !== 'linux') return;
mprisPlayer.playbackStatus = playing ? 'Playing' : 'Paused';
}
export function updateMprisCurrentSong(song: SongInfo | null) {
if (!mprisPlayer || process.platform !== 'linux') return;
if (!song) {
mprisPlayer.metadata = {};
mprisPlayer.playbackStatus = 'Stopped';
return;
}
const artists =
song.ar?.map((a) => a.name).join(', ') ||
song.artists?.map((a) => a.name).join(', ') ||
song.song?.artists?.map((a) => a.name).join(', ') ||
'';
const album = song.al?.name || song.album?.name || song.song?.album?.name || '';
const duration = song.duration || song.dt || song.song?.duration || 0;
mprisPlayer.metadata = {
'mpris:trackid': mprisPlayer.objectPath(`track/${song.id || 0}`),
'mpris:length': duration * 1000,
'mpris:artUrl': song.picUrl || '',
'xesam:title': song.name || '',
'xesam:album': album,
'xesam:artist': artists ? [artists] : []
};
}
export function updateMprisPosition(position: number) {
if (!mprisPlayer || process.platform !== 'linux') return;
mprisPlayer.seeked(position * 1000000);
}
export function destroyMpris() {
if (onPositionUpdate) {
ipcMain.removeListener('mpris-position-update', onPositionUpdate);
onPositionUpdate = null;
}
if (onTrayLyricUpdate) {
ipcMain.removeListener('tray-lyric-update', onTrayLyricUpdate);
onTrayLyricUpdate = null;
}
if (mprisPlayer) {
mprisPlayer.quit();
mprisPlayer = null;
}
}
function initTrayLyric() {
if (process.platform !== 'linux' || !dbusModule) return;
const serviceName = 'org.gnome.Shell.TrayLyric';
try {
const sessionBus = dbusModule.sessionBus({});
trayLyricBus = sessionBus;
const dbusPath = '/org/freedesktop/DBus';
const dbusInterface = 'org.freedesktop.DBus';
sessionBus.invoke(
{
path: dbusPath,
interface: dbusInterface,
member: 'GetNameOwner',
destination: 'org.freedesktop.DBus',
signature: 's',
body: [serviceName]
},
(err: any, result: any) => {
if (err || !result) {
console.log('[TrayLyric] Service not running');
} else {
onServiceAvailable();
}
}
);
} catch (err) {
console.error('[TrayLyric] Failed to init:', err);
}
function onServiceAvailable() {
if (!trayLyricBus) return;
const path = '/' + serviceName.replace(/\./g, '/');
trayLyricBus.getService(serviceName).getInterface(path, serviceName, (err: any, iface: any) => {
if (err) {
console.error('[TrayLyric] Failed to get service interface:', err);
return;
}
trayLyricIface = iface;
console.log('[TrayLyric] Service interface ready');
});
}
}
function sendTrayLyric(lrcObj: string) {
if (!trayLyricIface || !trayLyricBus) return;
trayLyricBus.invoke(
{
path: '/org/gnome/Shell/TrayLyric',
interface: 'org.gnome.Shell.TrayLyric',
member: 'UpdateLyric',
destination: 'org.gnome.Shell.TrayLyric',
signature: 's',
body: [lrcObj]
},
(err: any, _result: any) => {
if (err) {
console.error('[TrayLyric] Failed to invoke UpdateLyric:', err);
}
}
);
}
+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) => {
+23
View File
@@ -0,0 +1,23 @@
declare module 'mpris-service' {
interface PlayerOptions {
name: string;
identity: string;
supportedUriSchemes?: string[];
supportedMimeTypes?: string[];
supportedInterfaces?: string[];
}
interface Player {
on(event: string, callback: (...args: any[]) => void): void;
playbackStatus: string;
metadata: Record<string, any>;
position: number;
getPosition: () => number;
seeked(position: number): void;
objectPath(path: string): string;
quit(): void;
}
function Player(options: PlayerOptions): Player;
export = Player;
}
+19
View File
@@ -44,6 +44,25 @@ interface API {
parseLocalMusicMetadata: (
filePaths: string[]
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
// Download manager
downloadAdd: (task: any) => Promise<string>;
downloadAddBatch: (tasks: any) => Promise<{ batchId: string; taskIds: string[] }>;
downloadPause: (taskId: string) => Promise<void>;
downloadResume: (taskId: string) => Promise<void>;
downloadCancel: (taskId: string) => Promise<void>;
downloadCancelAll: () => Promise<void>;
downloadGetQueue: () => Promise<any[]>;
downloadSetConcurrency: (n: number) => void;
downloadGetCompleted: () => Promise<any[]>;
downloadDeleteCompleted: (filePath: string) => Promise<boolean>;
downloadClearCompleted: () => Promise<boolean>;
getEmbeddedLyrics: (filePath: string) => Promise<string | null>;
downloadProvideUrl: (taskId: string, url: string) => Promise<void>;
onDownloadProgress: (cb: (data: any) => void) => void;
onDownloadStateChange: (cb: (data: any) => void) => void;
onDownloadBatchComplete: (cb: (data: any) => void) => void;
onDownloadRequestUrl: (cb: (data: any) => void) => void;
removeDownloadListeners: () => void;
}
// 自定义IPC渲染进程通信接口
+37 -1
View File
@@ -82,7 +82,43 @@ const api = {
scanLocalMusicWithStats: (folderPath: string) =>
ipcRenderer.invoke('scan-local-music-with-stats', folderPath),
parseLocalMusicMetadata: (filePaths: string[]) =>
ipcRenderer.invoke('parse-local-music-metadata', filePaths)
ipcRenderer.invoke('parse-local-music-metadata', filePaths),
// Download manager
downloadAdd: (task: any) => ipcRenderer.invoke('download:add', task),
downloadAddBatch: (tasks: any) => ipcRenderer.invoke('download:add-batch', tasks),
downloadPause: (taskId: string) => ipcRenderer.invoke('download:pause', taskId),
downloadResume: (taskId: string) => ipcRenderer.invoke('download:resume', taskId),
downloadCancel: (taskId: string) => ipcRenderer.invoke('download:cancel', taskId),
downloadCancelAll: () => ipcRenderer.invoke('download:cancel-all'),
downloadGetQueue: () => ipcRenderer.invoke('download:get-queue'),
downloadSetConcurrency: (n: number) => ipcRenderer.send('download:set-concurrency', n),
downloadGetCompleted: () => ipcRenderer.invoke('download:get-completed'),
downloadDeleteCompleted: (filePath: string) =>
ipcRenderer.invoke('download:delete-completed', filePath),
downloadClearCompleted: () => ipcRenderer.invoke('download:clear-completed'),
getEmbeddedLyrics: (filePath: string) =>
ipcRenderer.invoke('download:get-embedded-lyrics', filePath),
downloadProvideUrl: (taskId: string, url: string) =>
ipcRenderer.invoke('download:provide-url', { taskId, url }),
onDownloadProgress: (cb: (data: any) => void) => {
ipcRenderer.on('download:progress', (_event: any, data: any) => cb(data));
},
onDownloadStateChange: (cb: (data: any) => void) => {
ipcRenderer.on('download:state-change', (_event: any, data: any) => cb(data));
},
onDownloadBatchComplete: (cb: (data: any) => void) => {
ipcRenderer.on('download:batch-complete', (_event: any, data: any) => cb(data));
},
onDownloadRequestUrl: (cb: (data: any) => void) => {
ipcRenderer.on('download:request-url', (_event: any, data: any) => cb(data));
},
removeDownloadListeners: () => {
ipcRenderer.removeAllListeners('download:progress');
ipcRenderer.removeAllListeners('download:state-change');
ipcRenderer.removeAllListeners('download:batch-complete');
ipcRenderer.removeAllListeners('download:request-url');
}
};
// 创建带类型的ipcRenderer对象,暴露给渲染进程
+15 -3
View File
@@ -100,11 +100,18 @@ 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);
router.push('/mini');
} else {
// body
document.body.style.height = '';
document.body.style.overflow = '';
//
const currentRoute = localStorage.getItem('currentRoute');
if (currentRoute) {
@@ -128,18 +135,23 @@ onMounted(async () => {
// 线
if (!navigator.onLine) {
console.log('检测到无网络连接,跳转到本地音乐页面');
router.push('/local-music');
}
//
window.addEventListener('offline', () => {
console.log('网络连接断开,跳转到本地音乐页面');
const handleOffline = () => {
router.push('/local-music');
};
window.addEventListener('offline', handleOffline);
onUnmounted(() => {
window.removeEventListener('offline', handleOffline);
});
// MusicHook playerStore
initMusicHook(playerStore);
// URL
const { setupUrlExpiredHandler } = await import('@/services/playbackController');
setupUrlExpiredHandler();
//
await playerStore.initializePlayState();
+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, ''),
+112
View File
@@ -18,3 +18,115 @@ body {
.settings-slider .n-slider-mark {
font-size: 10px !important;
}
/* ==================== 桌面端 Message 样式 ==================== */
.n-message {
border-radius: 20px !important;
padding: 10px 18px !important;
font-size: 13px !important;
backdrop-filter: blur(16px) saturate(1.8) !important;
-webkit-backdrop-filter: blur(16px) saturate(1.8) !important;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
border: none !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
/* 浅色模式 */
.n-message {
background: rgba(255, 255, 255, 0.72) !important;
color: #1a1a1a !important;
}
/* 深色模式 */
.dark .n-message {
background: rgba(40, 40, 40, 0.75) !important;
color: #e5e5e5 !important;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06) !important;
}
/* 成功 */
.n-message--success-type {
background: rgba(34, 197, 94, 0.15) !important;
color: #16a34a !important;
}
.n-message--success-type .n-message__icon {
color: #22c55e !important;
}
.dark .n-message--success-type {
background: rgba(34, 197, 94, 0.18) !important;
color: #4ade80 !important;
}
.dark .n-message--success-type .n-message__icon {
color: #4ade80 !important;
}
/* 错误 */
.n-message--error-type {
background: rgba(239, 68, 68, 0.12) !important;
color: #dc2626 !important;
}
.n-message--error-type .n-message__icon {
color: #ef4444 !important;
}
.dark .n-message--error-type {
background: rgba(239, 68, 68, 0.18) !important;
color: #f87171 !important;
}
.dark .n-message--error-type .n-message__icon {
color: #f87171 !important;
}
/* 警告 */
.n-message--warning-type {
background: rgba(245, 158, 11, 0.12) !important;
color: #d97706 !important;
}
.n-message--warning-type .n-message__icon {
color: #f59e0b !important;
}
.dark .n-message--warning-type {
background: rgba(245, 158, 11, 0.18) !important;
color: #fbbf24 !important;
}
.dark .n-message--warning-type .n-message__icon {
color: #fbbf24 !important;
}
/* 信息 */
.n-message--info-type {
background: rgba(59, 130, 246, 0.12) !important;
color: #2563eb !important;
}
.n-message--info-type .n-message__icon {
color: #3b82f6 !important;
}
.dark .n-message--info-type {
background: rgba(59, 130, 246, 0.18) !important;
color: #60a5fa !important;
}
.dark .n-message--info-type .n-message__icon {
color: #60a5fa !important;
}
/* Loading */
.n-message--loading-type {
background: rgba(255, 255, 255, 0.72) !important;
}
.dark .n-message--loading-type {
background: rgba(40, 40, 40, 0.75) !important;
}
/* 图标统一大小 */
.n-message__icon {
font-size: 18px !important;
}
/* 间距优化 */
.n-message-wrapper {
margin-bottom: 6px !important;
}
@@ -1,9 +1,13 @@
<template>
<div class="download-drawer-trigger">
<div class="fixed left-6 bottom-24 z-[999]">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="navigateToDownloads">
<n-button
circle
class="bg-white/80 dark:bg-gray-800/80 shadow-lg backdrop-blur-sm hover:bg-light dark:hover:bg-dark-200 text-gray-600 dark:text-gray-300 transition-all duration-300 w-10 h-10"
@click="navigateToDownloads"
>
<template #icon>
<i class="iconfont ri-download-cloud-2-line"></i>
<i class="iconfont ri-download-cloud-2-line text-xl"></i>
</template>
</n-button>
</n-badge>
@@ -11,102 +15,22 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useDownloadStore } from '@/store/modules/download';
const router = useRouter();
const downloadList = ref<any[]>([]);
const downloadStore = useDownloadStore();
//
const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length;
});
const downloadingCount = computed(() => downloadStore.downloadingCount);
//
const navigateToDownloads = () => {
router.push('/downloads');
};
//
onMounted(() => {
//
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
// 100%
if (data.progress === 100) {
data.status = 'completed';
}
if (existingItem) {
Object.assign(existingItem, {
...data,
songInfo: data.songInfo || existingItem.songInfo
});
//
if (data.status === 'completed') {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
}
} else {
downloadList.value.push({
...data,
songInfo: data.songInfo
});
}
});
//
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
if (data.success) {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
} else {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
if (existingItem) {
Object.assign(existingItem, {
status: 'error',
error: data.error,
progress: 0
});
setTimeout(() => {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
}, 3000);
}
}
});
//
window.electron.ipcRenderer.on('music-download-queued', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
if (!existingItem) {
downloadList.value.push({
filename: data.filename,
progress: 0,
loaded: 0,
total: 0,
path: '',
status: 'downloading',
songInfo: data.songInfo
});
}
});
downloadStore.initListeners();
downloadStore.loadPersistedQueue();
});
</script>
<style lang="scss" scoped>
.download-drawer-trigger {
@apply fixed left-6 bottom-24 z-[999];
.n-button {
@apply bg-white/80 dark:bg-gray-800/80 shadow-lg backdrop-blur-sm;
@apply hover:bg-light dark:hover:bg-dark-200;
@apply text-gray-600 dark:text-gray-300;
@apply transition-all duration-300;
@apply w-10 h-10;
.iconfont {
@apply text-xl;
}
}
}
</style>
@@ -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,11 +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();
@@ -757,7 +759,7 @@ const handleProgressBarClick = (e: MouseEvent) => {
console.log(`进度条点击: ${percentage.toFixed(2)}, 新时间: ${newTime.toFixed(2)}`);
sound.value.seek(newTime);
audioService.seek(newTime);
nowTime.value = newTime;
};
@@ -817,7 +819,7 @@ const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
//
sound.value.seek(nowTime.value);
audioService.seek(nowTime.value);
console.log(`鼠标释放,跳转到: ${nowTime.value.toFixed(2)}`);
isMouseDragging.value = false;
@@ -871,14 +873,14 @@ const handleThumbTouchEnd = (e: TouchEvent) => {
// seek
console.log(`拖动结束,跳转到: ${nowTime.value.toFixed(2)}`);
sound.value.seek(nowTime.value);
audioService.seek(nowTime.value);
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 });
//
@@ -936,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;
@@ -991,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);
@@ -1112,7 +1074,7 @@ watch(isVisible, (newVal) => {
if (newVal) {
//
if (targetBackground.value) {
setTextColors(targetBackground.value);
applyBackground(targetBackground.value);
}
} else {
showFullLyrics.value = false;
@@ -1128,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;
+23 -72
View File
@@ -69,6 +69,7 @@
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
:disabled="isMuted"
vertical
@wheel.prevent="handleVolumeWheel"
></n-slider>
@@ -129,6 +130,9 @@ import { computed, provide, ref, useTemplateRef } from 'vue';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { useFavorite } from '@/hooks/useFavorite';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { useVolumeControl } from '@/hooks/useVolumeControl';
import { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store';
import type { SongResult } from '@/types/music';
@@ -138,6 +142,21 @@ const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist();
//
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// playerStore
const {
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
//
const { isFavorite, toggleFavorite } = useFavorite();
withDefaults(
defineProps<{
pureModeEnabled?: boolean;
@@ -155,74 +174,19 @@ const handleClose = () => {
}
};
//
const play = computed(() => playerStore.play as boolean);
//
const playList = computed(() => playerStore.playList as SongResult[]);
//
const audioVolume = ref(
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
localStorage.setItem('volume', (value / 100).toString());
audioService.setVolume(value / 100);
audioVolume.value = value / 100;
}
});
//
const getVolumeIcon = computed(() => {
if (audioVolume.value === 0) return 'ri-volume-mute-line';
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
//
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
//
const handleVolumeWheel = (e: WheelEvent) => {
//
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
//
const isFavorite = computed(() => {
return playerStore.favoriteList.includes(playMusic.value.id);
});
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
let favoriteId = playMusic.value.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(favoriteId);
} else {
playerStore.addToFavorite(favoriteId);
}
};
//
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();
});
// /
@@ -308,19 +272,6 @@ const handleProgressLeave = () => {
isHovering.value = false;
};
//
const handlePrev = () => playerStore.prevPlay();
const handleNext = () => playerStore.nextPlay();
const playMusicEvent = async () => {
try {
playerStore.setPlay(playerStore.playMusic);
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
//
const setMusicFull = () => {
playerStore.setMusicFull(true);
@@ -62,10 +62,11 @@
<script lang="ts" setup>
import { useSwipe } from '@vueuse/core';
import type { Ref } from 'vue';
import { computed, inject, onMounted, ref, watch } from 'vue';
import { inject, onMounted, ref, watch } from 'vue';
import MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
import { artistList, playMusic, textColors } from '@/hooks/MusicHook';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { getImgUrl, setAnimationClass } from '@/utils';
@@ -75,24 +76,15 @@ const shouldShowMobileMenu = inject('shouldShowMobileMenu') as Ref<boolean>;
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
//
const play = computed(() => playerStore.isPlay);
//
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
//
const background = ref('#000');
//
function handleNext() {
playerStore.nextPlay();
}
function handlePrev() {
playerStore.prevPlay();
}
//
const MusicFullRef = ref<any>(null);
// musicFull
const setMusicFull = () => {
playerStore.setMusicFull(!playerStore.musicFull);
if (playerStore.musicFull) {
@@ -107,21 +99,10 @@ watch(
}
);
//
const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true);
};
//
const playMusicEvent = async () => {
try {
playerStore.setPlay(playMusic.value);
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
//
const playBarRef = ref<HTMLElement | null>(null);
onMounted(() => {
+59 -102
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 />
@@ -164,7 +182,6 @@
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { useMessage } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -182,7 +199,11 @@ 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';
import { useVolumeControl } from '@/hooks/useVolumeControl';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
@@ -191,9 +212,35 @@ import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } fr
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
const message = useMessage();
//
const play = computed(() => playerStore.isPlay);
//
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
//
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();
//
const { playbackRate } = storeToRefs(playerStore);
//
const background = ref('#000');
@@ -211,115 +258,41 @@ watch(
const throttledSeek = useThrottleFn((value: number) => {
audioService.seek(value);
nowTime.value = value;
}, 50); // 50ms
}, 50);
// nowTime
//
const dragValue = ref(0);
//
const isDragging = ref(false);
// timeSlider
const timeSlider = computed({
get: () => (isDragging.value ? dragValue.value : nowTime.value),
set: (value) => {
if (isDragging.value) {
// nowTime seek
dragValue.value = value;
return;
}
// () seek
throttledSeek(value);
}
});
//
const handleSliderDragStart = () => {
isDragging.value = true;
//
dragValue.value = nowTime.value;
};
const handleSliderDragEnd = () => {
isDragging.value = false;
//
audioService.seek(dragValue.value);
nowTime.value = dragValue.value;
};
//
const formatTooltip = (value: number) => {
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
};
// - 使 playerStore
const getVolumeIcon = computed(() => {
// 0 ri-volume-mute-line 0.5 ri-volume-down-line 1 ri-volume-up-line
if (playerStore.volume === 0) {
return 'ri-volume-mute-line';
}
if (playerStore.volume <= 0.5) {
return 'ri-volume-down-line';
}
return 'ri-volume-up-line';
});
const volumeSlider = computed({
get: () => playerStore.volume * 100,
set: (value) => {
playerStore.setVolume(value / 100);
}
});
//
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
//
const handleVolumeWheel = (e: WheelEvent) => {
//
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
//
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
//
const { playbackRate } = storeToRefs(playerStore);
function handleNext() {
playerStore.nextPlay();
}
function handlePrev() {
playerStore.prevPlay();
}
const MusicFullRef = ref<any>(null);
const showSliderTooltip = ref(false);
//
const playMusicEvent = async () => {
try {
const result = await playerStore.setPlay({ ...playMusic.value });
if (result) {
playerStore.setPlayMusic(true);
}
} catch (error) {
console.error('重新获取播放链接失败:', error);
message.error(t('player.playFailed'));
}
};
const musicFullVisible = computed({
get: () => playerStore.musicFull,
set: (value) => {
@@ -327,7 +300,6 @@ const musicFullVisible = computed({
}
});
// musicFull
const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value;
playerStore.setMusicFull(musicFullVisible.value);
@@ -336,24 +308,6 @@ const setMusicFull = () => {
}
};
const isFavorite = computed(() => {
if (!playMusic || !playMusic.value) return false;
return playerStore.favoriteList.includes(playMusic.value.id);
});
const toggleFavorite = async (e: Event) => {
console.log('playMusic.value', playMusic.value);
e.stopPropagation();
let favoriteId = playMusic.value.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(favoriteId);
} else {
playerStore.addToFavorite(favoriteId);
}
};
const openLyricWindow = () => {
openLyric();
};
@@ -365,7 +319,6 @@ const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
//
const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true);
};
@@ -461,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;
}
}
}
}
@@ -100,9 +100,9 @@ import { useI18n } from 'vue-i18n';
import { CacheManager } from '@/api/musicParser';
import { playMusic } from '@/hooks/MusicHook';
import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner';
import { reparseCurrentSong } from '@/services/playbackController';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import { useSettingsStore } from '@/store';
import { usePlayerStore } from '@/store/modules/player';
import type { LxMusicScriptConfig } from '@/types/lxMusic';
import type { Platform } from '@/types/music';
import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig';
@@ -119,7 +119,6 @@ type ReparseSourceItem = {
lxScriptId?: string;
};
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
const message = useMessage();
@@ -253,7 +252,7 @@ const reparseWithLxScript = async (source: ReparseSourceItem) => {
selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual');
const success = await playerStore.reparseCurrentSong('lxMusic', false);
const success = await reparseCurrentSong('lxMusic', false);
if (success) {
message.success(t('player.reparse.success'));
@@ -283,7 +282,7 @@ const directReparseMusic = async (source: ReparseSourceItem) => {
selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, [source.platform], 'manual');
const success = await playerStore.reparseCurrentSong(source.platform, false);
const success = await reparseCurrentSong(source.platform, false);
if (success) {
message.success(t('player.reparse.success'));
@@ -68,6 +68,7 @@
v-model:value="volumeSlider"
:step="1"
:tooltip="false"
:disabled="isMuted"
@wheel.prevent="handleVolumeWheel"
></n-slider>
</div>
@@ -80,8 +81,10 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { allTime, nowTime, playMusic } from '@/hooks/MusicHook';
import { allTime, nowTime } from '@/hooks/MusicHook';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayMode } from '@/hooks/usePlayMode';
import { useVolumeControl } from '@/hooks/useVolumeControl';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { secondToMinute } from '@/utils';
@@ -98,61 +101,20 @@ const props = withDefaults(
const playerStore = usePlayerStore();
const playBarRef = ref<HTMLElement | null>(null);
//
const play = computed(() => playerStore.isPlay);
//
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
//
const { playMode, playModeIcon, togglePlayMode } = usePlayMode();
//
const audioVolume = ref(
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
localStorage.setItem('volume', (value / 100).toString());
audioService.setVolume(value / 100);
audioVolume.value = value / 100;
}
});
//
const getVolumeIcon = computed(() => {
if (audioVolume.value === 0) return 'ri-volume-mute-line';
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
//
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
//
const handleVolumeWheel = (e: WheelEvent) => {
const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
volumeSlider.value = newValue;
};
//
const handlePrev = () => playerStore.prevPlay();
const handleNext = () => playerStore.nextPlay();
const playMusicEvent = async () => {
try {
await playerStore.setPlay({ ...playMusic.value });
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
// playerStore
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');
}
}
+239 -203
View File
@@ -1,5 +1,4 @@
import { cloneDeep } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue';
import useIndexedDB from '@/hooks/IndexDBHook';
@@ -45,7 +44,7 @@ export const nowTime = ref(0); // 当前播放时间
export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const sound = ref<HTMLAudioElement | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
export const textColors = ref<any>(getTextColors());
@@ -53,6 +52,11 @@ export const textColors = ref<any>(getTextColors());
export let playMusic: ComputedRef<SongResult>;
export let artistList: ComputedRef<Artist[]>;
let lastIndex = -1;
// 缓存平台信息,避免每次歌词变化时同步 IPC 调用
const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
export const musicDB = await useIndexedDB(
'musicDB',
[
@@ -65,28 +69,28 @@ export const musicDB = await useIndexedDB(
3
);
// 键盘事件处理器,在初始化后设置
const setupKeyboardListeners = () => {
document.onkeyup = (e) => {
// 检查事件目标是否是输入框元素
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
// 键盘事件处理器(提取为命名函数,防止重复注册)
const handleKeyUp = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
const store = getPlayerStore();
switch (e.code) {
case 'Space':
if (store.playMusic?.id) {
void store.setPlay({ ...store.playMusic });
}
break;
default:
}
};
const store = getPlayerStore();
switch (e.code) {
case 'Space':
if (store.playMusic?.id) {
void store.setPlay({ ...store.playMusic });
}
break;
default:
}
};
const { message } = createDiscreteApi(['message']);
const setupKeyboardListeners = () => {
document.removeEventListener('keyup', handleKeyUp);
document.addEventListener('keyup', handleKeyUp);
};
let audioListenersInitialized = false;
@@ -136,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) {
@@ -145,77 +152,130 @@ const parseLyricsString = async (
}
};
// 设置音乐相关的监听器
// 解析当前 playMusic.lyric 写入 lrcArray, 供 watcher / openLyric / onLyricWindowReady 共用
const ensureLyricsLoaded = async (force = false) => {
const songId = playMusic.value?.id;
if (!songId) {
lrcArray.value = [];
lrcTimeArray.value = [];
nowIndex.value = 0;
return;
}
if (!force && lrcArray.value.length > 0) return;
await nextTick();
const lyricData = playMusic.value.lyric;
if (lyricData && typeof lyricData === 'string') {
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(lyricData);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
playMusic.value.lyric.hasWordByWord = hasWordByWord;
}
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
const rawLrc = lyricData.lrcArray || [];
lrcTimeArray.value = lyricData.lrcTimeArray || [];
try {
const { translateLyrics } = await import('@/services/lyricTranslation');
lrcArray.value = await translateLyrics(rawLrc as any);
} catch (e) {
console.error('翻译歌词失败,使用原始歌词:', e);
lrcArray.value = rawLrc as any;
}
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
try {
let filePath = decodeURIComponent(playMusic.value.playMusicUrl.replace('local:///', ''));
// 处理 Windows 路径:/C:/... → C:/...
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
if (embeddedLyrics) {
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(embeddedLyrics);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
}
} else if (typeof songId === 'number') {
try {
const { getMusicLrc } = await import('@/api/music');
const res = await getMusicLrc(songId);
if (res?.data?.lrc?.lyric) {
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
res.data.lrc.lyric
);
lrcArray.value = apiLrcArray;
lrcTimeArray.value = apiTimeArray;
}
} catch (apiErr) {
console.error('API lyrics fallback failed:', apiErr);
}
}
} catch (err) {
console.error('Failed to extract embedded lyrics:', err);
}
} else if (typeof songId === 'number') {
// 在线歌曲但 lyric 字段尚未加载, 主动调 API 兜底
try {
const { getMusicLrc } = await import('@/api/music');
const res = await getMusicLrc(songId);
if (res?.data?.lrc?.lyric) {
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
res.data.lrc.lyric
);
lrcArray.value = apiLrcArray;
lrcTimeArray.value = apiTimeArray;
}
} catch (apiErr) {
console.error('API lyrics fallback failed:', apiErr);
}
}
if (isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
setTimeout(() => sendLyricToWin(), 500);
}
};
const setupMusicWatchers = () => {
const store = getPlayerStore();
// 监听 playerStore.playMusic 的变化以更新歌词数据
// 切歌时 id 变化, 强制重新解析
watch(
() => store.playMusic.id,
async (newId, oldId) => {
// 如果没有歌曲ID,清空歌词
if (!newId) {
lrcArray.value = [];
lrcTimeArray.value = [];
nowIndex.value = 0;
return;
}
// 避免相同ID的重复执行(但允许初始化时执行)
if (newId === oldId && lrcArray.value.length > 0) return;
// 歌曲切换时重置歌词索引
if (newId !== oldId) {
nowIndex.value = 0;
}
await nextTick(async () => {
console.log('歌曲切换,更新歌词数据');
// 检查是否有原始歌词字符串需要解析
const lyricData = playMusic.value.lyric;
if (lyricData && typeof lyricData === 'string') {
// 如果歌词是字符串格式,使用新的解析器
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(lyricData);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
// 更新歌曲的歌词数据结构
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
playMusic.value.lyric.hasWordByWord = hasWordByWord;
}
} else {
// 使用现有的歌词数据结构
const rawLrc = lyricData?.lrcArray || [];
lrcTimeArray.value = lyricData?.lrcTimeArray || [];
try {
const { translateLyrics } = await import('@/services/lyricTranslation');
lrcArray.value = await translateLyrics(rawLrc as any);
} catch (e) {
console.error('翻译歌词失败,使用原始歌词:', e);
lrcArray.value = rawLrc as any;
}
}
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
if (isElectron && isLyricWindowOpen.value) {
console.log('歌词窗口已打开,同步最新歌词数据');
// 不管歌词数组是否为空,都发送最新数据
sendLyricToWin();
// 再次延迟发送,确保歌词窗口已完全加载
setTimeout(() => {
sendLyricToWin();
}, 500);
}
});
if (newId !== oldId) nowIndex.value = 0;
await ensureLyricsLoaded(true);
},
{ immediate: true }
);
// 同一首歌但 lyric 字段后到 (播放后异步加载元数据 / 重启 + autoPlay 关闭场景)
watch(
() => playMusic.value?.lyric,
(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);
}
}
);
};
const setupAudioListeners = () => {
@@ -260,12 +320,7 @@ const setupAudioListeners = () => {
return;
}
if (typeof currentSound.seek !== 'function') {
// seek 方法不可用,跳过本次更新,不清除 interval
return;
}
const currentTime = currentSound.seek() as number;
const currentTime = currentSound.currentTime;
if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) {
// 无效时间,跳过本次更新
return;
@@ -277,7 +332,7 @@ const setupAudioListeners = () => {
}
nowTime.value = currentTime;
allTime.value = currentSound.duration() as number;
allTime.value = currentSound.duration;
// === 歌词索引更新 ===
const newIndex = getLrcIndex(nowTime.value);
@@ -288,6 +343,12 @@ const setupAudioListeners = () => {
sendLyricToWin();
}
}
if (isElectron && lrcArray.value[nowIndex.value]) {
if (lastIndex !== nowIndex.value) {
sendTrayLyric(nowIndex.value);
lastIndex = nowIndex.value;
}
}
// === 逐字歌词行内进度 ===
const { start, end } = currentLrcTiming.value;
@@ -331,6 +392,15 @@ const setupAudioListeners = () => {
);
}
}
// === MPRIS 进度更新(每 ~1 秒)===
if (isElectron && lyricThrottleCounter % 20 === 0) {
try {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
} catch {
// 忽略发送失败
}
}
} catch (error) {
console.error('进度更新 interval 出错:', error);
// 出错时不清除 interval,让下一次 tick 继续尝试
@@ -349,7 +419,7 @@ const setupAudioListeners = () => {
const store = getPlayerStore();
if (store.play && !interval) {
const currentSound = audioService.getCurrentSound();
if (currentSound && currentSound.playing()) {
if (currentSound && !currentSound.paused) {
console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复');
startProgressInterval();
}
@@ -375,10 +445,15 @@ const setupAudioListeners = () => {
const currentSound = audioService.getCurrentSound();
if (currentSound) {
// 立即更新显示时间,不进行任何检查
const currentTime = currentSound.seek() as number;
const currentTime = currentSound.currentTime;
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
nowTime.value = currentTime;
// === MPRIS seek 时同步进度 ===
if (isElectron) {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
}
// 检查是否需要更新歌词
const newIndex = getLrcIndex(nowTime.value);
if (newIndex !== nowIndex.value) {
@@ -400,10 +475,10 @@ const setupAudioListeners = () => {
if (currentSound) {
try {
// 更新当前时间和总时长
const currentTime = currentSound.seek() as number;
const currentTime = currentSound.currentTime;
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
nowTime.value = currentTime;
allTime.value = currentSound.duration() as number;
allTime.value = currentSound.duration;
}
} catch (error) {
console.error('初始化时间和进度失败:', error);
@@ -420,7 +495,10 @@ const setupAudioListeners = () => {
if (isElectron) {
window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
}
// 启动进度更新
// 兜底: 重启后首次点播放时 lrcArray 仍为空则主动加载
if (lrcArray.value.length === 0 && playMusic.value?.id) {
ensureLyricsLoaded();
}
startProgressInterval();
});
@@ -434,34 +512,25 @@ const setupAudioListeners = () => {
}
});
const replayMusic = async (retryCount: number = 0) => {
const replayMusic = async (retryCount = 0) => {
const MAX_REPLAY_RETRIES = 3;
try {
// 如果当前有音频实例,先停止并销毁
const currentSound = audioService.getCurrentSound();
if (currentSound) {
currentSound.stop();
currentSound.unload();
}
sound.value = null;
// 重新播放当前歌曲
if (getPlayerStore().playMusicUrl && playMusic.value) {
const newSound = await audioService.play(getPlayerStore().playMusicUrl, playMusic.value);
sound.value = newSound as Howl;
await audioService.play(getPlayerStore().playMusicUrl, playMusic.value);
sound.value = audioService.getCurrentSound();
setupAudioListeners();
} else {
console.error('单曲循环:无可用 URL 或歌曲数据');
getPlayerStore().nextPlay();
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
}
} catch (error) {
console.error('单曲循环重播失败:', error);
if (retryCount < MAX_REPLAY_RETRIES) {
console.log(`单曲循环重试 ${retryCount + 1}/${MAX_REPLAY_RETRIES}`);
setTimeout(() => replayMusic(retryCount + 1), 1000 * (retryCount + 1));
} else {
console.error('单曲循环重试次数用尽,切换下一首');
getPlayerStore().nextPlay();
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
}
}
};
@@ -474,41 +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 会清除,需重设
await getPlayerStore().handlePlayMusic(fmSong, true);
} else {
getPlayerStore().setIsPlay(false);
}
} catch (error) {
console.error('FM自动播放下一首失败:', error);
getPlayerStore().setIsPlay(false);
}
} else {
// 顺序播放、列表循环、随机播放模式都使用统一的nextPlay方法
getPlayerStore().nextPlay();
return;
}
// 其他模式(FM/顺序/列表循环/随机):交给 playlist store 路由
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
});
audioService.on('previoustrack', () => {
@@ -529,8 +569,6 @@ export const play = () => {
const currentSound = audioService.getCurrentSound();
if (currentSound) {
currentSound.play();
// 在播放时也进行状态检测,防止URL已过期导致无声
getPlayerStore().checkPlaybackState(getPlayerStore().playMusic);
}
};
@@ -539,7 +577,7 @@ export const pause = () => {
if (currentSound) {
try {
// 保存当前播放进度
const currentTime = currentSound.seek() as number;
const currentTime = currentSound.currentTime;
if (getPlayerStore().playMusic && getPlayerStore().playMusic.id) {
localStorage.setItem(
'playProgress',
@@ -692,7 +730,7 @@ export const setAudioTime = (index: number) => {
const currentSound = sound.value;
if (!currentSound) return;
currentSound.seek(lrcTimeArray.value[index]);
audioService.seek(lrcTimeArray.value[index]);
currentSound.play();
};
@@ -775,6 +813,30 @@ export const sendLyricToWin = () => {
}
};
// 发送歌词到系统托盘歌词(TrayLyric)
const sendTrayLyric = (index: number) => {
if (!isElectron || cachedPlatform !== 'linux') return;
try {
const lyric = lrcArray.value[index];
if (!lyric) return;
const currentTime = lrcTimeArray.value[index] || 0;
const nextTime = lrcTimeArray.value[index + 1] || currentTime + 3;
const duration = nextTime - currentTime;
const lrcObj = JSON.stringify({
content: lyric.text || '',
time: duration.toFixed(1),
sender: 'AlgerMusicPlayer'
});
window.electron.ipcRenderer.send('tray-lyric-update', lrcObj);
} catch (error) {
console.error('[TrayLyric] Failed to send:', error);
}
};
// 歌词同步定时器
let lyricSyncInterval: any = null;
@@ -812,28 +874,20 @@ const stopLyricSync = () => {
}
};
// 修改openLyric函数,添加定时同步
export const openLyric = () => {
export const openLyric = async () => {
if (!isElectron) return;
// 检查是否有播放中的歌曲
if (!playMusic.value || !playMusic.value.id) {
console.log('没有正在播放的歌曲,无法打开歌词窗口');
return;
}
console.log('Opening lyric window with current song:', playMusic.value?.name);
isLyricWindowOpen.value = !isLyricWindowOpen.value;
if (isLyricWindowOpen.value) {
// 立即打开窗口
window.api.openLyric();
// 确保有歌词数据,如果没有,则使用默认的"无歌词"提示
// 先发"加载中"占位, 防止窗口启动期间显示"无歌词"
if (!lrcArray.value || lrcArray.value.length === 0) {
// 如果当前播放的歌曲有ID但没有歌词,则尝试加载歌词
console.log('尝试加载歌词数据...');
// 发送默认的"无歌词"数据
const emptyLyricData = {
type: 'empty',
nowIndex: 0,
@@ -847,12 +901,15 @@ export const openLyric = () => {
playMusic: playMusic.value
};
window.api.sendLyric(JSON.stringify(emptyLyricData));
// 关键: 主动加载歌词, 不依赖 watcher
// (重启场景下 playerCore.playMusic 整体替换可能未触发 lyric watcher)
await ensureLyricsLoaded(true);
} else {
// 发送完整歌词数据
sendLyricToWin();
}
// 延迟重发一次,以防窗口加载
// 延迟重发, 防窗口加载慢丢消息
setTimeout(() => {
if (isLyricWindowOpen.value) {
sendLyricToWin();
@@ -974,11 +1031,13 @@ export const initAudioListeners = async () => {
window.api.onLyricWindowClosed(() => {
isLyricWindowOpen.value = false;
});
// 歌词窗口 Vue 加载完成后,发送完整歌词数据
window.api.onLyricWindowReady(() => {
if (isLyricWindowOpen.value) {
sendLyricToWin();
window.api.onLyricWindowReady(async () => {
if (!isLyricWindowOpen.value) return;
// 窗口加载完成时再兜底加载一次, 防止 openLyric 阶段 lyric 字段尚未到位
if (lrcArray.value.length === 0 && playMusic.value?.id) {
await ensureLyricsLoaded(true);
}
sendLyricToWin();
});
}
@@ -995,50 +1054,27 @@ export const initAudioListeners = async () => {
}
};
// 监听URL过期事件,自动重新获取URL并恢复播放
audioService.on('url_expired', async (expiredTrack) => {
if (!expiredTrack) return;
console.log('检测到URL过期事件,准备重新获取URL', expiredTrack.name);
try {
// 使用 handlePlayMusic 重新播放,它会自动处理 URL 获取和状态跟踪
// 我们将 isFirstPlay 设为 true 以强制获取新 URL
const trackToPlay = {
...expiredTrack,
isFirstPlay: true,
playMusicUrl: undefined
};
await getPlayerStore().handlePlayMusic(trackToPlay, getPlayerStore().play);
message.success('已自动恢复播放');
} catch (error) {
console.error('处理URL过期事件失败:', error);
message.error('恢复播放失败,请手动点击播放');
}
});
// 添加音频就绪事件监听器
window.addEventListener('audio-ready', ((event: CustomEvent) => {
// 音频就绪事件处理器(提取为命名函数,防止重复注册)
const handleAudioReady = ((event: CustomEvent) => {
try {
const { sound: newSound } = event.detail;
if (newSound) {
// 更新本地 sound 引用
sound.value = newSound as Howl;
// 设置音频监听器
sound.value = audioService.getCurrentSound();
setupAudioListeners();
// 获取当前播放位置并更新显示
const currentPosition = newSound.seek() as number;
if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) {
nowTime.value = currentPosition;
const currentSound = audioService.getCurrentSound();
if (currentSound) {
const currentPosition = currentSound.currentTime;
if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) {
nowTime.value = currentPosition;
}
}
console.log('音频就绪,已设置监听器并更新进度显示');
}
} catch (error) {
console.error('处理音频就绪事件出错:', error);
}
}) as EventListener);
}) as EventListener;
// 先移除再注册,防止重复
window.removeEventListener('audio-ready', handleAudioReady);
window.addEventListener('audio-ready', handleAudioReady);

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