Compare commits

..

126 Commits

Author SHA1 Message Date
alger
1e30a11881 fix(core): 修复事件监听器泄漏
- App.vue: offline 监听器添加 onUnmounted 清理,移除冗余 console.log
- MusicHook.ts: document.onkeyup 直接赋值改为 addEventListener + 防重复
- MusicHook.ts: audio-ready 监听器提取为命名函数,先移除再注册防堆叠
2026-03-29 14:22:33 +08:00
alger
34713430e1 fix(player): 修复迷你模式恢复后歌词页面空白偏移
迷你播放栏的 togglePlaylist 设置 document.body.style.height='64px'
和 overflow='hidden',恢复主窗口时未清理,导致歌词 drawer 高度被限制。
在 mini-mode 事件处理中添加 body 样式重置。
2026-03-29 14:04:55 +08:00
alger
eaf1636505 refactor(player): 提取播放栏共享逻辑为 composable
- 新增 useVolumeControl:统一音量管理(volumeSlider、mute、滚轮调节)
- 新增 useFavorite:收藏状态与切换
- 新增 usePlaybackControl:播放/暂停、上/下一首
- PlayBar、MiniPlayBar、SimplePlayBar、MobilePlayBar 使用新 composable
- 修复音量存储不一致:MiniPlayBar/SimplePlayBar 原先绕过 playerStore 直接操作 localStorage
2026-03-29 14:04:39 +08:00
alger
e032afeae8 docs: 更新 CLAUDE.md,反映播放系统重构(Howler.js → 原生 HTMLAudioElement) 2026-03-29 13:30:36 +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
alger
59f71148af fix: 修复打包后白屏问题(createDiscreteApi 循环依赖)
playlist.ts 被 Vite 拆分为独立 chunk 后与主 chunk 形成循环依赖,
顶层调用 createDiscreteApi 导致 NMessageProvider 组件 TDZ 错误。
改为延迟初始化解决。同时移除调试代码。
2026-03-22 22:46:59 +08:00
alger
c5417a12ec fix: CI 升级 Node.js 至 24,移除 lock 文件缓存依赖 2026-03-22 19:18:36 +08:00
alger
b6d08b9660 fix: 扫码登录改为默认首选 & 更新 CHANGELOG v5.1.0 2026-03-22 19:13:34 +08:00
alger
3fd8bff7b4 chore: 版本号更新至 5.1.0 2026-03-22 19:09:55 +08:00
alger
2ef08412cf fix: 替换 NeteaseCloudMusicApi 为 netease-cloud-music-api-alger 2026-03-22 19:08:50 +08:00
alger
8e1dcd5c06 fix: 修复移动端全屏歌词前奏阶段第一句歌词不可见
getLrcStyle 在当前行无条件设置 color: transparent,
但前奏阶段 originalStyle 无 backgroundImage,导致文字透明不可见
2026-03-22 18:31:58 +08:00
alger
91ecad7f3d docs: 更新 CHANGELOG v5.1.0 2026-03-22 16:49:11 +08:00
alger
2b8378bbae feat: 重构心动模式与私人FM播放逻辑
- 心动模式从播放模式循环中独立,移至 SearchBar 作为独立按钮
- 新增私人FM自动续播:播放结束后自动获取下一首
- 播放列表设置时自动清除FM模式标志
- 顺序播放模式播放到最后一首后正确停止
- 新增获取关注歌手新歌 API
- 补充心动模式相关 i18n 翻译
2026-03-22 16:49:00 +08:00
alger
7f0b3c6469 fix: 设置桌面端最小窗口尺寸为 900x640 防止内容截断 2026-03-22 16:48:01 +08:00
alger
2f05663093 fix: 优化音乐列表页移动端按钮尺寸 2026-03-22 16:47:48 +08:00
alger
0ea3ac5b60 fix: 移除首页顶部多余 padding 2026-03-22 16:47:38 +08:00
alger
bf3155b80a fix: HomeHero 快捷导航仅在移动端显示 2026-03-22 16:47:28 +08:00
alger
8a83281d1b fix: 修复 NeteaseCloudMusicApi anonymous_token 文件不存在导致启动崩溃
将 NeteaseCloudMusicApi/server 从静态 import 改为动态 require(),
确保 anonymous_token 文件在模块加载前创建
2026-03-22 16:47:15 +08:00
alger
a3f91c45f0 feat: 重构首页Hero、导航菜单与页面布局统一
HomeHero:
- 重建每日推荐(左)+私人FM(右)双栏布局
- FM播放/暂停切换、不喜欢/下一首、背景流动动画、均衡器特效
- 修复FM数据获取(res.data.data双层结构)
- 歌单预加载改为hover懒加载避免502

导航优化:
- SearchBar顶部菜单: 首页/歌单/专辑/排行榜/MV/本地音乐
- 侧边栏隐藏MV和本地音乐(hideInSidebar)
- 修复搜索类型切换时失焦收起(@mousedown.prevent)

页面统一:
- 新建StickyTabPage通用布局组件(标题+吸顶tabs+内容slot)
- 歌单/专辑/MV/播客页面统一使用StickyTabPage重构
- CategorySelector第一项添加ml-0.5防scale裁切

播客优化:
- RadioCard简化去除订阅按钮、容忍radio为undefined
- 去除最近播放section、loadDashboard包含loadSubscribedRadios

i18n: 新碟上架→专辑(5语言)、新增fmTrash/fmNext(5语言)
2026-03-16 23:22:35 +08:00
alger
68b3700f3f feat: 歌曲右键菜单添加下载歌词功能及下载设置中保存歌词文件选项
- 右键菜单新增"下载歌词"选项,支持获取歌词并保存为 .lrc 文件
- 如有翻译歌词会自动合并到 LRC 文件中
- 下载设置面板新增"单独保存歌词文件"开关
- 开启后下载歌曲时自动在同目录生成同名 .lrc 歌词文件
- 主进程新增 save-lyric-file IPC handler
- 完成 5 种语言的国际化翻译
2026-03-16 23:22:17 +08:00
alger
b86661ca11 feat: 替换 netease-cloud-music-api-alger 为官方 NeteaseCloudMusicApi
- 依赖从 netease-cloud-music-api-alger@4.26 升级为 NeteaseCloudMusicApi@4.29
- 新增 fmTrash API 支持私人FM不喜欢功能
- getPersonalFM 移除重复 timestamp(拦截器已自动添加)
2026-03-16 23:11:25 +08:00
alger
51910011c8 fix: 隐藏 Web 端本地音乐菜单项 2026-03-15 16:41:47 +08:00
alger
24aa574176 fix(i18n): 补全 MV/排行榜/歌单/搜索/专辑页面缺失的国际化
- 新增 comp.pages 命名空间,包含页面描述、地区分类、加载状态等 i18n 键
- toplist: 标题和描述文本国际化
- mv: 描述、加载状态、6 个地区分类标签国际化
- list: 描述、加载/无更多状态国际化,提取每日推荐常量
- search: 描述文本国际化
- album: 5 个地区分类标签国际化
- 覆盖全部 5 种语言 (zh-CN/en-US/ja-JP/ko-KR/zh-Hant)
2026-03-15 15:57:17 +08:00
alger
239229a60c fix: 修复自动播放循环与暂停失效问题 (H-UI-05/H-UI-07)
- fix(player): 修复 checkPlaybackState 无限重试循环,添加最大重试次数限制 (3次)
- fix(player): 修复 handlePlayMusic 参数 isPlay 遮蔽同名 ref 导致 play/isPlay/userPlayIntent 状态不同步
- fix(player): 播放成功后清除 isFirstPlay 标记,避免暂停时被 setPlay 误判为新歌从头播放
- fix(ui): 移除 AppMenu z-index 重复声明 (H-UI-05)
- perf(ui): MiniPlayBar 进度条 hover 改用 transform: scaleY() 替代 height 变化 (H-UI-07)
2026-03-15 15:49:59 +08:00
alger
2182c295c1 style: 统一 MiniSongItem/ListSongItem hover 背景色并清理 @apply (M-UI-02) 2026-03-15 15:15:23 +08:00
alger
66b5aac224 style: 清理 CategorySelector 和 TitleBar 中的 @apply 违规 (M-UI-10/M-UI-12) 2026-03-15 15:13:56 +08:00
alger
a7b05e6d02 fix(ui): 播放列表抽屉关闭动画改用 animationend 替代 setTimeout (M-UI-08) 2026-03-15 15:13:44 +08:00
alger
915f4f8965 fix(ui): 优化搜索结果滚动加载触发距离 150px → 100px (M-UI-06) 2026-03-15 15:13:33 +08:00
alger
292706a821 fix(ui): 修复 AppMenu 错误主题色 #10B981 → #22c55e (M-UI-05) 2026-03-15 15:13:21 +08:00
alger
baabb0c273 feat(lyric): 新增 single/double 模式 CSS 样式 2026-03-15 15:08:50 +08:00
alger
87a4773ece feat(lyric): 重构歌词渲染区域为 scroll/single/double 三路分支 2026-03-15 15:07:57 +08:00
alger
c8ba6cbd44 feat(lyric): 控制栏新增翻译开关和显示模式切换按钮 2026-03-15 15:05:24 +08:00
alger
c4b178f925 fix(lyric): 组件卸载时清理 groupFadeTimer 防止内存泄漏 2026-03-15 15:04:19 +08:00
alger
345da7d9e8 feat(lyric): 新增双行分组 computed、淡出动画和 wrapperStyle 守卫 2026-03-15 14:59:53 +08:00
alger
f36f777e65 feat(lyric): 扩展 lyricSetting 支持 showTranslation 和 displayMode 2026-03-15 14:55:08 +08:00
alger
3e6f981379 refactor(ui): 统一 SongItem 圆角、抽象 HistoryItem、新增 EmptyState、修复主题色
- SongItem 5 变体容器/图片圆角统一为 rounded-xl(12px):
  BaseSongItem(rounded-3xl→xl) / Standard(img rounded-2xl→xl) /
  Compact(rounded-lg→xl) / List(rounded-lg→xl) / Mini(rounded-2xl→xl)
- 抽象 HistoryItem.vue:AlbumItem 和 PlaylistItem 提取共享 UI 组件,
  消除 ~80 行重复样式代码,同时迁移至内联 Tailwind class
- 新增 EmptyState.vue:统一空状态组件(icon + text,暗色模式完整适配)
- 动画时长:SearchItem 图片 hover duration-700→duration-500
- MobilePlayBar:进度条颜色 Spotify #1ed760→项目主色 #22c55e
2026-03-15 14:14:52 +08:00
alger
57a441312f feat(ui): 重构 SearchBar、集成 useScrollTitle 标题滚动显示、修复专辑搜索跳转
- 重新设计 SearchBar:左侧 Tab(播放列表/MV/排行榜)+ 滑动指示器 + 搜索框自动展开收缩
- 新增 navTitle store 和 useScrollTitle hook,支持页面滚动后在 SearchBar 显示标题
- 集成 useScrollTitle 到 MusicListPage、歌手详情、关注/粉丝列表、搜索结果页
- 修复搜索结果页专辑点击跳转失败(缺失 type 字段)
- 新增 5 种语言 searchBar tab i18n 键值
2026-03-15 14:11:59 +08:00
alger
067868f786 perf: 优化播放列表持久化,精简序列化字段并添加防抖写入 (H-010)
自定义序列化器仅保留必要字段,排除 lyric/song/playMusicUrl 等大体积数据
添加防抖 localStorage 包装降低写入频率,beforeunload 时刷新未写入数据
2026-03-12 18:31:29 +08:00
alger
479db66eb0 fix(lyric): 修复桌面歌词窗口首次打开无歌词问题
歌词窗口 Vue 加载完成后发送 lyric-ready 信号,主窗口收到后
发送完整歌词数据,替代不可靠的延迟猜测方案
2026-03-12 18:31:16 +08:00
alger
1c222971d5 refactor: 统一进度追踪机制,移除重复的rAF更新循环 (H-007/H-008)
- 移除 Mechanism A (rAF + setTimeout 混用),消除定时器泄漏 bug
- 将逐字歌词进度计算和 localStorage 保存迁移到 Mechanism B (setInterval 50ms)
- 消除 nowTime 竞争写入,从 ~30次/秒 seek 调用降到 20次/秒
- 修复 timer ID 类型 (any -> number)
2026-03-12 18:09:20 +08:00
alger
ec8a07576f fix: 修复播放并发控制死代码、shallowRef响应式、歌词IPC高频调用 (H-005/H-006/H-009)
- H-005: 删除 playerCore.ts 中无效的 playInProgress 局部变量
- H-006: fetchSongs 修改 shallowRef 元素后添加 triggerRef 触发更新
- H-009: sendLyricToWin 从每秒20次全量发送改为每秒5次轻量更新
2026-03-12 18:07:20 +08:00
alger
72fabc6d12 refactor(ui): 优化骨架屏加载效果,修复用户页左侧黑色背景
- 关键布局组件(AppMenu/TitleBar/SearchBar)改为同步导入,消除加载闪烁
- 新增全局 skeleton-shimmer 流光动画替代 animate-pulse 闪烁效果
- 用户页 loading 骨架屏避免使用 .left scoped 样式导致的深色背景
- 全部 n-skeleton 组件替换为原生 div + shimmer,统一圆角风格
- 菜单容器添加背景色防止加载穿透
2026-03-11 23:02:04 +08:00
alger
b5bac30258 refactor(settings): 拆分设置页面为独立Tab组件,优化捐赠列表性能
- 将设置页面拆分为7个独立Tab组件(Basic/Playback/Application/Network/System/About/Donation)
- 抽取自定义SBtn/SSelect/SInput组件替代naive-ui原生组件
- 使用provide/inject共享setData/message/dialog
- 捐赠列表:去除dicebear外部头像改用首字母头像,去除n-popover改用title属性
- 捐赠列表:IntersectionObserver自动分页加载,首字母跳过*号等符号字符
- SInput:有suffix时增大右侧padding防止数值遮挡单位
2026-03-11 22:30:42 +08:00
alger
bf341fa7c8 feat(update): 重构自动更新系统,使用 electron-updater 替代手动下载
- CI 构建 macOS 拆分为 x64/arm64 分别构建,合并 latest-mac.yml
- 主进程使用 electron-updater 管理检查、下载、安装全流程
- 渲染进程 UpdateModal 改为响应式同步主进程更新状态
- IPC 通道统一为 app-update:* 系列
- 窗口拦截外部链接在系统浏览器打开
- 新增 5 语言更新相关国际化文案
2026-03-11 22:30:35 +08:00
alger
a62e6d256e refactor: 重构音乐和歌词缓存逻辑 可配置缓存目录 2026-03-06 19:56:01 +08:00
alger
b02ca859de fix(i18n): 重构键值检查并增加引用告警模式 2026-03-04 21:12:49 +08:00
alger
958549dfb9 fix(本地音乐): 元数据解析改为并发限流并限制封面体积 2026-03-04 21:12:49 +08:00
alger
c714860c96 fix(本地音乐): 扫描阶段直接使用mtime做增量判断 2026-03-04 21:12:48 +08:00
alger
92877d86e9 fix(preload): 修复ipc.on解绑监听器失效问题 2026-03-04 21:12:48 +08:00
alger
e64e97c7bf fix(缓存): 修复歌词缓存IPC通道并接入初始化 2026-03-04 21:12:48 +08:00
alger
15f7e10609 fix(安全): 本地音乐 API 仅监听回环地址 2026-03-04 21:12:48 +08:00
alger
e77e0ce62b fix(安全): 将 LX 脚本执行隔离到 Worker 沙箱 2026-03-04 21:08:58 +08:00
alger
19092647d1 feat: 快捷键整体重构优化 2026-03-04 20:28:38 +08:00
alger
36917a979d feat: 优化音乐播放逻辑 2026-03-04 19:53:50 +08:00
alger
bb2dbc3f00 feat: 优化音源解析 2026-02-10 09:06:25 +08:00
alger
16b2a1cece style: 优化移动端 message 组件样式 2026-02-08 02:13:00 +08:00
alger
ae20f78ec0 feat: 优化页面样式边距 2026-02-08 01:39:20 +08:00
alger
e53a035ebc refactor: 重构历史记录 2026-02-06 20:35:04 +08:00
alger
b955e95edc feat: 优化播放逻辑 2026-02-06 20:34:07 +08:00
alger
0e47c127fe feat: 添加本地音乐扫描播放功能 2026-02-06 17:49:14 +08:00
alger
292751643f feat: 优化 UI 逻辑适配移动端 2026-02-06 12:50:58 +08:00
alger
fab29e5c79 feat: 优化移动端适配 2026-02-04 21:54:28 +08:00
alger
feb041f5c2 chore: ignore .worktrees 2026-02-04 21:32:34 +08:00
alger
7b32bcd3ab style: 调整主题主色 2026-02-04 20:18:29 +08:00
alger
754e17b864 refactor: 调整下载/歌词/MV/歌单/榜单等页面 2026-02-04 20:18:29 +08:00
alger
423167b9b3 refactor: 调整历史/收藏/列表/用户页面 2026-02-04 20:18:28 +08:00
alger
83a6e9381c refactor: 调整搜索相关页面 2026-02-04 20:18:27 +08:00
alger
1d3b065af6 refactor: 调整应用布局与标题栏 2026-02-04 20:18:27 +08:00
alger
6b5382e37a refactor: 调整通用组件与列表项 2026-02-04 20:18:27 +08:00
alger
b06459f10d refactor: 调整播放器与播放条组件 2026-02-04 20:18:27 +08:00
alger
6ff2a0337a feat: 设置页增加音频设备配置 2026-02-04 20:18:27 +08:00
alger
2ef9c1afda feat: 新增专辑页 2026-02-04 20:18:27 +08:00
alger
44929dbfe4 refactor: 重构首页 UI 2026-02-04 20:18:27 +08:00
alger
ab901e633b feat: 新增播客页面与组件 2026-02-04 20:18:27 +08:00
alger
3a3820cf52 feat: 扩展数据层与播放能力 2026-02-04 20:18:27 +08:00
alger
a44addef22 feat: 更新多语言文案并新增播客词条 2026-02-04 20:18:27 +08:00
alger
70c7b35a86 refactor: 调整主进程模块 2026-02-04 20:18:26 +08:00
alger
14e35c7667 chore: 增加 i18n 检查脚本与提交钩子 2026-02-04 20:18:26 +08:00
Alger
cd1c09889f feat: Add LICENSE 2026-01-21 09:43:49 +08:00
alger
939dc85d7d fix: 修复 Windows 安装时 uninstallericon.ico 写入报错并优化 NSIS 配置 2025-12-21 10:43:00 +08:00
alger
c4831966c1 chore: bump version to 5.0.0 2025-12-20 20:04:15 +08:00
alger
50aebcf8de feat(update): 支持 macOS 分架构下载 (x64/arm64) 2025-12-20 20:01:39 +08:00
alger
75d1225b40 feat: v5.0.0 2025-12-20 19:47:38 +08:00
alger
c251ec9dcf fix: 修复榜单 loading 2025-12-20 19:45:41 +08:00
alger
00a251b5b6 feat: mac 添加权限 2025-12-20 18:32:14 +08:00
algerkong
7e59cfee05 feat: 补全国际化 2025-12-20 14:20:25 +08:00
algerkong
c3dd03cc13 feat: 优化歌词颜色检测逻辑 2025-12-20 14:18:27 +08:00
algerkong
999cd6526b feat: 优化播放检测逻辑 2025-12-20 14:16:32 +08:00
algerkong
77bb06c0d6 feat: 添加歌词字体粗细控制并修复 i18n 缺失 2025-12-20 14:09:57 +08:00
alger
85302c611a feat:优化音源配置 2025-12-20 02:30:09 +08:00
alger
0f42bfc6cb fix:修复随机播放问题 2025-12-20 02:29:43 +08:00
alger
5bcef29f10 feat:优化lx音源问题 2025-12-20 02:29:22 +08:00
alger
a9fb487332 feat:添加国际化 2025-12-19 00:24:26 +08:00
alger
8e1259d2aa feat:针对移动端优化 2025-12-19 00:23:24 +08:00
alger
70f1044dd9 feat: 优化设置页面 2025-12-19 00:22:22 +08:00
alger
e2ebbe12e4 feat:优化全屏歌词界面 添加背景和宽度设置 2025-12-19 00:14:24 +08:00
alger
af9117ee5f feat: 优化预加载逻辑和继续播放功能 2025-12-17 15:05:40 +08:00
alger
6bc168c5bd feat: 优化播放错误处理 2025-12-17 13:19:10 +08:00
alger
89c6b11110 feat: 添加 lx 音源导入 2025-12-13 15:00:38 +08:00
alger
b9287e1c36 fix: 修复音源解析致命性错误 2025-12-13 14:46:15 +08:00
alger
1a0e449e13 feat: 一系列播放优化 2025-12-13 11:31:49 +08:00
algerkong
07f6152c56 fix: 修复预加载问题 2025-12-13 11:31:49 +08:00
alger
56adac0d4e feat: 优化 tray 标题长度 2025-11-08 17:37:27 +08:00
alger
452e1d1129 feat: 去除构建麦克风权限 2025-11-08 14:26:59 +08:00
alger
34ba2250bf feat: 重构播放 store 2025-11-08 14:26:04 +08:00
alger
1005718c07 feat: 封面图预先加载 2025-11-08 14:22:44 +08:00
alger
3527da17da feat: 添加心动模式播放 2025-10-22 22:48:52 +08:00
alger
9bf513d35d feat: 添加历史日推功能 2025-10-22 21:52:22 +08:00
alger
35b798b69e fix: 修复远程控制关闭无法保存问题 2025-10-22 21:52:00 +08:00
alger
9535183405 feat: 添加播放记录热力图显示功能 2025-10-22 21:51:45 +08:00
alger
6d7ba6dbae feat: 历史记录页面 添加本地和云端两种记录支持,支持歌曲、歌单、专辑 2025-10-22 21:51:16 +08:00
alger
a9adb6be36 feat: 用户页面添加收藏专辑展示 2025-10-22 21:50:20 +08:00
alger
bee5445b6e fix: 修复mini播放栏主题颜色问题 将mini播放栏设为默认 2025-10-22 21:49:53 +08:00
alger
316d5932e3 feat: 移动端歌词点击跳转 优化国际化和移动端逐字歌词 2025-10-12 17:38:45 +08:00
alger
a5d3ff359c feat: 优化逐字歌词效果,桌面歌词添加逐字歌词效果 2025-10-12 17:11:48 +08:00
alger
77f3069e67 fix: 修复逐字歌词 字间距问题 2025-10-12 13:02:56 +08:00
alger
f3a9f8b979 feat: 自动隐藏menu滚动条 2025-10-11 20:27:15 +08:00
alger
29ba231a7d feat: 平板模式 2025-10-11 20:24:11 +08:00
alger
cb2baeadf5 feat: 逐字歌词 2025-10-11 20:23:54 +08:00
alger
4575e4f26d fix: 重新解析功能修复缓存问题 2025-10-11 20:23:36 +08:00
alger
dc8957dcf2 fix: 修复桌面歌词不透明显示标题栏的问题 2025-10-11 20:23:15 +08:00
291 changed files with 38572 additions and 18641 deletions

View File

@@ -6,12 +6,25 @@ on:
- 'v*'
jobs:
release:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
include:
- id: mac-x64
os: macos-latest
build_command: npm run build:mac:x64
- id: mac-arm64
os: macos-latest
build_command: npm run build:mac:arm64
- id: windows
os: windows-latest
build_command: npm run build:win
- id: linux
os: ubuntu-latest
build_command: npm run build:linux
steps:
- name: Check out Git repository
@@ -20,69 +33,82 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 24
- name: Install Dependencies
- name: Install dependencies
run: npm install
# MacOS Build
- name: Build MacOS
if: matrix.os == 'macos-latest'
run: |
export ELECTRON_BUILDER_EXTRA_ARGS="--universal"
npm run build:mac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false
DEBUG: electron-builder
# Windows Build
- name: Build Windows
if: matrix.os == 'windows-latest'
run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Linux Build
- name: Build Linux
- name: Install Linux build dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
npm run build:linux
- name: Build artifacts
run: ${{ matrix.build_command }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false
# Get version from tag
- name: Get version from tag
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
shell: bash
- name: Prepare mac update metadata
if: startsWith(matrix.id, 'mac-')
run: rm -f dist/latest-mac.yml
# Read release notes
- name: Read release notes
id: release_notes
run: |
NOTES=$(awk "/## \[v${{ env.VERSION }}\]/{p=1;print;next} /## \[v/{p=0}p" CHANGELOG.md)
echo "NOTES<<EOF" >> $GITHUB_ENV
echo "$NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
shell: bash
# Upload artifacts
- name: Upload artifacts
uses: softprops/action-gh-release@v1
- name: Upload release bundle
uses: actions/upload-artifact@v4
with:
files: |
name: ${{ matrix.id }}
if-no-files-found: error
path: |
dist/*.dmg
dist/*.zip
dist/*.exe
dist/*.deb
dist/*.rpm
dist/*.AppImage
dist/latest*.yml
dist/*.blockmap
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Get version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Read release notes
run: |
NOTES=$(awk "/## \[v${{ env.VERSION }}\]/{p=1;print;next} /## \[v/{p=0}p" CHANGELOG.md)
echo "NOTES<<EOF" >> $GITHUB_ENV
echo "$NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts
- name: Prepare release files
run: |
mkdir -p release-upload
find release-artifacts -type f \
! -name 'latest-mac-x64.yml' \
! -name 'latest-mac-arm64.yml' \
-exec cp {} release-upload/ \;
node scripts/merge_latest_mac_yml.mjs \
release-artifacts/mac-x64/latest-mac-x64.yml \
release-artifacts/mac-arm64/latest-mac-arm64.yml \
release-upload/latest-mac.yml
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ env.NOTES }}
draft: false
prerelease: false
files: release-upload/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,9 +13,9 @@ jobs:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '24'
- name: 创建环境变量文件
run: |

14
.gitignore vendored
View File

@@ -30,9 +30,21 @@ resources/android/**/*
android/app/release
.cursor
.windsurf
.agent
.agents
.claude
.kiro
CLAUDE.md
AGENTS.md
.sisyphus
.worktrees
.auto-imports.d.ts
.components.d.ts
src/renderer/auto-imports.d.ts
src/renderer/components.d.ts
src/renderer/components.d.ts

View File

@@ -4,4 +4,7 @@ npx lint-staged
echo "运行类型检查..."
npm run typecheck
echo "运行国际化检查..."
npm run lint:i18n
echo "所有检查通过,准备提交..."

View File

@@ -1,38 +1,106 @@
# 更新日志
## v4.9.0
## v5.1.0
### ✨ 新功能
- 重新设计pc端歌词页面Mini播放栏
- 添加清除歌曲自定义解析功能
- 添加Cookie登录功能及自动获取等相关管理设置 ([16aeaf2](https://github.com/algerkong/AlgerMusicPlayer/commit/16aeaf2)) - 支持通过Cookie方式登录提供更便捷的登录体验
- 添加UID登录功能优化登录流程 ([daa8e75](https://github.com/algerkong/AlgerMusicPlayer/commit/daa8e75)) - 新增用户ID直接登录方式
- 添加主题根据系统切换功能 ([d5ba218](https://github.com/algerkong/AlgerMusicPlayer/commit/d5ba218)) - 支持跟随系统主题自动切换明暗模式
- 桌面歌词添加主题颜色面板组件 ([d1f5c8a](https://github.com/algerkong/AlgerMusicPlayer/commit/d1f5c8a)) - 为桌面歌词提供丰富的主题颜色自定义选项
- 增强播放速度控制,添加滑块控制并改善播放安全性 ([8fb382e](https://github.com/algerkong/AlgerMusicPlayer/commit/8fb382e)) 感谢[Qumo](https://github.com/Hellodwadawd12312312)的pr
- 添加日语和韩语国际化支持,并优化语言相关代码 ([3062156](https://github.com/algerkong/AlgerMusicPlayer/commit/3062156))
- 添加繁体中文本地化支持 ([2cc03cb](https://github.com/algerkong/AlgerMusicPlayer/commit/2cc03cb)) 感谢[dongguacute](https://github.com/dongguacute)的pr
- 播放速度设置弹窗标题添加速度显示 ([aeb7f03](https://github.com/algerkong/AlgerMusicPlayer/commit/aeb7f03))
- 新增本地音乐扫描播放功能
- 新增播客页面与组件
- 新增专辑页面
- 桌面歌词新增 单行/双行/滚动 三种显示模式,支持翻译开关和双行分组淡出动画
- 重构自动更新系统,使用 electron-updater 替代手动下载
- 设置页新增音频设备配置
- 快捷键整体重构优化
- 重构 SearchBar集成标题滚动显示功能
- 优化音源解析策略与播放逻辑
- 优化移动端适配与 UI 布局
### 🐛 Bug 修复
- 修复mac快捷键关闭窗口报错的问题 ([67ef4d7](https://github.com/algerkong/AlgerMusicPlayer/commit/67ef4d7))
- 修复mini窗口恢复时导致的应用窗口变小问题 ([9b3019d](https://github.com/algerkong/AlgerMusicPlayer/commit/9b3019d))
- 修复歌单列表页面翻页类型问题 ([e489ab4](https://github.com/algerkong/AlgerMusicPlayer/commit/e489ab4))
- 修复歌曲初始化问题 ([b7a58a0](https://github.com/algerkong/AlgerMusicPlayer/commit/b7a58a0))
- 修复音量调整不同步的问题 ([679089e](https://github.com/algerkong/AlgerMusicPlayer/commit/679089e))
- 修复菜单显示不全的问题,添加滚动条 ([09ccd9f](https://github.com/algerkong/AlgerMusicPlayer/commit/09ccd9f))
- 修复自动播放循环与暂停失效问题
- 修复桌面歌词窗口首次打开无歌词问题
- 修复播放并发控制死代码、shallowRef 响应式丢失、歌词 IPC 高频调用
- 修复 AppMenu 错误主题色
- 修复播放列表抽屉关闭动画使用 setTimeout 不可靠问题
- 修复搜索结果滚动加载触发距离过大
- 修复本地音乐元数据解析并发限流与封面体积限制
- 修复本地音乐扫描增量判断逻辑
- 修复 preload 层 ipc.on 解绑监听器失效
- 修复歌词缓存 IPC 通道未接入初始化
- 修复歌词组件卸载时 groupFadeTimer 未清理导致内存泄漏
- 补全 MV/排行榜/歌单/搜索/专辑页面缺失的国际化
- 修复 NeteaseCloudMusicApi anonymous_token 文件不存在导致启动崩溃
- 修复移动端全屏歌词前奏阶段第一句歌词不可见
- 修复移动端音乐列表页按钮尺寸过大
- 登录页扫码登录改为默认首选
- 设置桌面端最小窗口尺寸为 900×640 防止内容截断
- 移除首页顶部多余 padding
- HomeHero 快捷导航仅移动端显示
### 🔒 安全
- 本地音乐 API 仅监听回环地址,防止外部访问
- LX Music 脚本执行隔离到 Worker 沙箱
### 🎨 优化
- 更新 eslint 和 prettier 配置,格式化代码 ([c08c2cb](https://github.com/algerkong/AlgerMusicPlayer/commit/c08c2cb))
- 优化类型处理和登录功能 ([3ba85f3](https://github.com/algerkong/AlgerMusicPlayer/commit/3ba85f3))
- 优化Cookie相关文字描述 ([1597fbf](https://github.com/algerkong/AlgerMusicPlayer/commit/1597fbf))
- 全面重构 UI播放器、播放条、通用组件、列表项、布局、标题栏、搜索页等
- 重构首页 UI
- 设置页拆分为 7 个独立 Tab 组件,优化捐赠列表性能
- 重构音乐和歌词缓存逻辑,支持可配置缓存目录
- 统一进度追踪机制,移除重复的 rAF 更新循环
- 优化播放列表持久化,精简序列化字段并添加防抖写入
- 优化骨架屏加载效果,修复用户页左侧黑色背景
- 统一 SongItem 圆角与 hover 背景色
- 重构历史记录模块
- 调整主题主色
- 扩展数据层与播放能力
- 增加 i18n 检查脚本与提交钩子
- 重构 i18n 键值检查并增加引用告警模式
## v5.0.0
### ✨ 新功能
- LX Music 音源脚本导入
- 逐字歌词,支持全屏歌词和桌面歌词同步显示
- 心动模式播放
- 移动设备整体页面风格和效果优化
- 移动端添加平板模式设置
- 歌词页面样式控制优化 支持背景、宽度、字体粗细等个性化设置
- 历史日推查看
- 播放记录热力图
- 历史记录支持本地和云端记录
- 用户页面收藏专辑展示
- 添加 GPU 硬件加速设置
- 菜单展开状态保存 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
- 搜索建议 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
- 歌词繁体中文翻译模块,集成 OpenCC 引擎 - 感谢 [Leko](https://github.com/lekoOwO) 的贡献
- 自定义 API源 支持 [自定义源文档](https://github.com/algerkong/AlgerMusicPlayer/blob/main/docs/custom-api-readme.md) - 感谢 [harenchi](https://github.com/souvenp) 的贡献
### 🐛 Bug 修复
- 修复随机播放顺序异常
- 修复音源解析错误处理
- 修复 Mini 播放栏主题颜色问题
- 修复桌面歌词透明模式标题栏显示
- 修复逐字歌词字间距
- 修复远程控制设置无法保存
- 修复下载无损格式返回 HiRes 音质 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
- 兼容 pnpm 包管理器 - 感谢 [Leko](https://github.com/lekoOwO) 的贡献
### 🎨 优化
- 音源解析缓存
- 完善多语言国际化
- 优化播放检测和错误处理
- FLAC 元数据和封面图片处理 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
- 日推不感兴趣调用官方接口 - 感谢 [harenchi](https://github.com/souvenp) 的贡献
- 代码提交流程优化,添加 lint-staged
## 赞赏支持☕️
[赞赏列表](http://donate.alger.fun/)
[赞赏列表](https://donate.alger.fun/donate)
<table>
<tr>

423
CLAUDE.md Normal file
View File

@@ -0,0 +1,423 @@
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 提供项目指南。
## 项目概述
Alger Music Player 是基于 **Electron + Vue 3 + TypeScript** 构建的第三方网易云音乐播放器支持桌面端Windows/macOS/Linux、Web 和移动端,具备本地 API 服务、桌面歌词、无损音乐下载、音源解锁、EQ 均衡器等功能。
## 技术栈
- **桌面端**: Electron 40 + electron-vite 5
- **前端框架**: Vue 3.5 (Composition API + `<script setup>`)
- **状态管理**: Pinia 3 + pinia-plugin-persistedstate
- **UI 框架**: naive-ui自动导入
- **样式**: Tailwind CSS 3仅在模板中使用 class禁止在 `<style>` 中使用 `@apply`
- **图标**: remixicon
- **音频**: 原生 HTMLAudioElement + Web Audio APIEQ 均衡器)
- **工具库**: VueUse, lodash
- **国际化**: vue-i18n5 种语言zh-CN、en-US、ja-JP、ko-KR、zh-Hant
- **音乐 API**: netease-cloud-music-api-alger + @unblockneteasemusic/server
- **自动更新**: electron-updaterGitHub Releases
- **构建**: Vite 6, electron-builder
## 开发命令
```bash
# 安装依赖(推荐 Node 18+
npm install
# 桌面端开发(推荐)
npm run dev
# Web 端开发(需自建 netease-cloud-music-api 服务)
npm run dev:web
# 类型检查
npm run typecheck # 全部检查
npm run typecheck:node # 主进程
npm run typecheck:web # 渲染进程
# 代码规范
npm run lint # ESLint + i18n 检查
npm run format # Prettier 格式化
# 构建
npm run build # 构建渲染进程和主进程
npm run build:win # Windows 安装包
npm run build:mac # macOS DMG
npm run build:linux # AppImage, deb, rpm
npm run build:unpack # 仅构建不打包
```
## 项目架构
### 目录结构
```
src/
├── main/ # Electron 主进程
│ ├── index.ts # 入口,窗口生命周期
│ ├── modules/ # 功能模块15 个文件)
│ │ ├── window.ts # 窗口管理(主窗口、迷你模式、歌词窗口)
│ │ ├── tray.ts # 系统托盘
│ │ ├── shortcuts.ts # 全局快捷键
│ │ ├── fileManager.ts # 下载管理
│ │ ├── remoteControl.ts # 远程控制 HTTP API
│ │ └── update.ts # 自动更新electron-updater
│ ├── lyric.ts # 歌词窗口
│ ├── server.ts # 本地 API 服务
│ └── unblockMusic.ts # 音源解锁服务
├── preload/index.ts # IPC 桥接(暴露 window.api
├── shared/ # 主进程/渲染进程共享代码
│ └── appUpdate.ts # 更新状态类型定义
├── i18n/ # 国际化
│ ├── lang/ # 语言文件5 语言 × 15 分类 = 75 个文件)
│ ├── main.ts # 主进程 i18n
│ ├── renderer.ts # 渲染进程 i18n
│ └── utils.ts # i18n 工具
└── renderer/ # Vue 应用
├── store/modules/ # Pinia 状态15 个模块)
│ ├── playerCore.ts # 🔑 播放核心状态(纯状态:播放/暂停、音量、倍速)
│ ├── playlist.ts # 🔑 播放列表管理(上/下一首、播放模式)
│ ├── settings.ts # 应用设置
│ ├── user.ts # 用户认证与同步
│ ├── lyric.ts # 歌词状态
│ ├── music.ts # 音乐元数据
│ └── favorite.ts # 收藏管理
├── services/ # 服务层
│ ├── audioService.ts # 🔑 原生 HTMLAudioElement + Web Audio APIEQ、MediaSession
│ ├── playbackController.ts # 🔑 播放控制流playTrack 入口、generation 取消、初始化恢复)
│ ├── playbackRequestManager.ts # 请求 ID 追踪(供 usePlayerHooks 内部取消检查)
│ ├── preloadService.ts # 下一首 URL 预验证
│ ├── SongSourceConfigManager.ts # 单曲音源配置
│ └── translation-engines/ # 翻译引擎策略
├── hooks/ # 组合式函数9 个文件)
│ ├── MusicHook.ts # 🔑 音乐主逻辑(歌词、进度、快捷键)
│ ├── usePlayerHooks.ts # 播放器 hooks
│ ├── useDownload.ts # 下载功能
│ └── IndexDBHook.ts # IndexedDB 封装
├── api/ # API 层16 个文件)
│ ├── musicParser.ts # 🔑 多音源 URL 解析(策略模式)
│ ├── music.ts # 网易云音乐 API
│ ├── bilibili.ts # B站音源
│ ├── gdmusic.ts # GD Music 平台
│ ├── lxMusicStrategy.ts # LX Music 音源策略
│ ├── donation.ts # 捐赠 API
│ └── parseFromCustomApi.ts # 自定义 API 解析
├── components/ # 组件59+ 个文件)
│ ├── common/ # 通用组件24 个)
│ ├── player/ # 播放器组件10 个)
│ ├── settings/ # 设置弹窗组件7 个)
│ └── ...
├── views/ # 页面53 个文件)
│ ├── set/ # 设置页(已拆分为 Tab 组件)
│ │ ├── index.vue # 设置页壳组件(导航 + provide/inject
│ │ ├── keys.ts # InjectionKey 定义
│ │ ├── SBtn.vue # 自定义按钮组件
│ │ ├── SInput.vue # 自定义输入组件
│ │ ├── SSelect.vue # 自定义选择器组件
│ │ ├── SettingItem.vue
│ │ ├── SettingSection.vue
│ │ └── tabs/ # 7 个 Tab 组件
│ │ ├── BasicTab.vue
│ │ ├── PlaybackTab.vue
│ │ ├── ApplicationTab.vue
│ │ ├── NetworkTab.vue
│ │ ├── SystemTab.vue
│ │ ├── AboutTab.vue
│ │ └── DonationTab.vue
│ └── ...
├── router/ # Vue Router3 个文件)
├── types/ # TypeScript 类型20 个文件)
├── utils/ # 工具函数17 个文件)
├── directive/ # 自定义指令
├── const/ # 常量定义
└── assets/ # 静态资源
```
### 核心模块职责
| 模块 | 文件 | 职责 |
|------|------|------|
| 播放控制 | `services/playbackController.ts` | 🔑 播放入口playTrack、generation 取消、初始化恢复、URL 过期处理 |
| 音频服务 | `services/audioService.ts` | 原生 HTMLAudioElement + Web Audio API、EQ 滤波、MediaSession |
| 播放状态 | `store/playerCore.ts` | 纯状态:播放/暂停、音量、倍速、当前歌曲、音频设备 |
| 播放列表 | `store/playlist.ts` | 列表管理、播放模式、上/下一首 |
| 音源解析 | `api/musicParser.ts` | 多音源 URL 解析与缓存 |
| 音乐钩子 | `hooks/MusicHook.ts` | 歌词解析、进度跟踪、键盘快捷键 |
### 播放系统架构
```
用户操作 / 自动播放
playbackController.playTrack(song) ← 唯一入口generation++ 取消旧操作
├─ 加载歌词 + 背景色
├─ 获取播放 URLgetSongDetail
└─ audioService.play(url, track)
├─ audio.src = url ← 单一 HTMLAudioElement换歌改 src
├─ Web Audio API EQ 链 ← createMediaElementSource 只调一次
└─ 原生 DOM 事件 → emit
MusicHook 监听(进度、歌词同步、播放状态)
```
**关键设计**
- **Generation-based 取消**:每次 `playTrack()` 递增 generationawait 后检查是否过期,过期则静默退出
- **单一 HTMLAudioElement**:启动时创建,永不销毁。换歌改 `audio.src`EQ 链不重建
- **Seek**:直接 `audio.currentTime = time`,无 Howler.js 的 pause→play 问题
### 音源解析策略
`musicParser.ts` 使用 **策略模式** 从多个来源解析音乐 URL
**优先级顺序**(可通过 `SongSourceConfigManager` 按曲配置):
1. `custom` - 自定义 API
2. `bilibili` - B站音频
3. `gdmusic` - GD Music 平台
4. `lxmusic` - LX Music HTTP 源
5. `unblock` - UnblockNeteaseMusic 服务
**缓存策略**
- 成功的 URL 在 IndexedDB 缓存 30 分钟(`music_url_cache`
- 失败的尝试在内存中缓存 1 分钟(应用重启自动清除)
- 音源配置变更时缓存失效
### 设置页架构
设置页(`views/set/`)采用 **provide/inject** 模式拆分为 7 个 Tab 组件:
- `index.vue` 作为壳组件:管理 Tab 导航、`setData` 双向绑定与防抖保存
- `keys.ts` 定义类型化的 InjectionKey`SETTINGS_DATA_KEY``SETTINGS_MESSAGE_KEY``SETTINGS_DIALOG_KEY`
- 自定义 UI 组件(`SBtn``SInput``SSelect`)替代部分 naive-ui 组件
- 字体选择器保留 naive-ui `n-select`(需要 filterable + multiple + render-label
## 代码规范
### 命名
- **目录**: kebab-case`components/music-player`
- **组件**: PascalCase`MusicPlayer.vue`
- **组合式函数**: camelCase + `use` 前缀(`usePlayer.ts`
- **Store**: camelCase`playerCore.ts`
- **常量**: UPPER_SNAKE_CASE`MAX_RETRY_COUNT`
### TypeScript
- **优先使用 `type` 而非 `interface`**
- **禁止使用 `enum`,使用 `const` 对象 + `as const`**
- 所有导出函数必须有类型标注
```typescript
// ✅ 正确
type SongResult = { id: number; name: string };
const PlayMode = { ORDER: 'order', LOOP: 'loop' } as const;
// ❌ 避免
interface ISongResult { ... }
enum PlayMode { ... }
```
### Vue 组件结构
```vue
<script setup lang="ts">
// 1. 导入(按类型分组)
import { ref, computed, onMounted } from 'vue';
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/types/music';
// 2. Props & Emits
const props = defineProps<{ id: number }>();
const emit = defineEmits<{ play: [id: number] }>();
// 3. Store
const playerStore = usePlayerStore();
// 4. 响应式状态使用描述性命名isLoading, hasError
const isLoading = ref(false);
// 5. 计算属性
const displayName = computed(() => /* ... */);
// 6. 方法(动词开头命名)
const handlePlay = () => { /* ... */ };
// 7. 生命周期钩子
onMounted(() => { /* ... */ });
</script>
<template>
<!-- naive-ui 组件 + Tailwind CSS -->
</template>
```
### 样式规范
- **禁止在 `<style>` 中使用 `@apply`**,所有 Tailwind 类直接写在模板中
- 如发现代码中有 `@apply` 用法,应优化为内联 Tailwind class
- `<style scoped>` 仅用于无法用 Tailwind 实现的 CSS如 keyframes 动画、`:deep()` 穿透)
### 导入约定
- **naive-ui 组件**:自动导入,无需手动 import
- **Vue 组合式 API**`useDialog``useMessage``useNotification``useLoadingBar` 自动导入
- **路径别名**`@``src/renderer``@i18n``src/i18n`
## 关键实现模式
### 状态持久化
Store 使用 `pinia-plugin-persistedstate` 自动持久化:
```typescript
export const useXxxStore = defineStore('xxx', () => {
// store 逻辑
}, {
persist: {
key: 'xxx-store',
storage: localStorage,
pick: ['fieldsToPersist'] // 仅持久化指定字段
}
});
```
### IPC 通信
```typescript
// 主进程 (src/main/modules/*)
ipcMain.handle('channel-name', async (_, args) => {
return result;
});
// Preload (src/preload/index.ts)
const api = {
methodName: (args) => ipcRenderer.invoke('channel-name', args)
};
contextBridge.exposeInMainWorld('api', api);
// 渲染进程 (src/renderer/*)
const result = await window.api.methodName(args);
```
### IndexedDB 使用
使用 `IndexDBHook` 组合式函数:
```typescript
const db = await useIndexedDB('dbName', [
{ name: 'storeName', keyPath: 'id' }
], version);
const { saveData, getData, deleteData } = db;
await saveData('storeName', { id: 1, data: 'value' });
const data = await getData('storeName', 1);
```
### 新增页面
1. 创建 `src/renderer/views/xxx/index.vue`
2.`src/renderer/router/other.ts` 中添加路由
3.`src/i18n/lang/*/` 下所有 5 种语言中添加 i18n 键值
### 新增 Store
```typescript
// src/renderer/store/modules/xxx.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useXxxStore = defineStore('xxx', () => {
const state = ref(initialValue);
const action = () => { /* ... */ };
return { state, action };
});
// 在 src/renderer/store/index.ts 中导出
export * from './modules/xxx';
```
### 新增音源策略
编辑 `src/renderer/api/musicParser.ts`
```typescript
class NewStrategy implements MusicSourceStrategy {
name = 'new';
priority = 5;
canHandle(sources: string[]) { return sources.includes('new'); }
async parse(id: number, data: any): Promise<ParsedMusicResult> {
// 实现解析逻辑
}
}
// 在 ParserManager 构造函数中注册
this.strategies.push(new NewStrategy());
```
## 平台相关说明
### Web 端开发
运行 `npm run dev:web` 需要:
1. 自建 `netease-cloud-music-api` 服务
2. 在项目根目录创建 `.env.development.local`
```env
VITE_API=https://your-api-server.com
VITE_API_MUSIC=https://your-unblock-server.com
```
### Electron 功能
- **窗口管理**: `src/main/modules/window.ts`(主窗口、迷你模式、歌词窗口)
- **系统托盘**: `src/main/modules/tray.ts`
- **全局快捷键**: `src/main/modules/shortcuts.ts`
- **自动更新**: `src/main/modules/update.ts`electron-updater + GitHub Releases
- **远程控制**: `src/main/modules/remoteControl.ts`HTTP API 远程播放控制)
- **磁盘缓存**: 音乐和歌词文件缓存支持可配置目录、容量上限、LRU/FIFO 清理策略
## API 请求注意事项
- **axios 响应结构**`request.get('/xxx')` 返回 axios response实际数据在 `res.data` 中。若 API 本身也有 `data` 字段(如 `/personal_fm` 返回 `{data: [...], code: 200}`),则需要 `res.data.data` 才能拿到真正的数组,**不要** 直接用 `res.data` 当结果。
- **避免并发请求风暴**:首页不要一次性并发请求大量接口(如 15 个歌单详情),会导致本地 API 服务与 `music.163.com` 的 TLS 连接被 reset502。应使用懒加载hover 时加载)或严格限制并发数。
- **timestamp 参数**:对 `/personal_fm` 等需要实时数据的接口,传 `timestamp: Date.now()` 避免服务端缓存和 stale 连接。`request.ts` 拦截器已自动添加 timestampAPI 层无需重复添加。
### 本地 API 服务调试
- **地址**`http://127.0.0.1:{port}`,默认端口 `30488`,可在设置中修改
- **API 文档**:基于 [NeteaseCloudMusicApi](https://www.npmjs.com/package/NeteaseCloudMusicApi)v4.29),接口文档参见 node_modules/NeteaseCloudMusicApi/public/docs/home.md
- **调试方式**:可直接用 `curl` 测试接口,例如:
```bash
# 测试私人FM需登录 cookie
curl "http://127.0.0.1:30488/personal_fm?timestamp=$(date +%s000)"
# 测试歌单详情
curl "http://127.0.0.1:30488/playlist/detail?id=12449928929"
# 测试FM不喜欢
curl -X POST "http://127.0.0.1:30488/fm_trash?id=歌曲ID&timestamp=$(date +%s000)"
```
- **502 排查**:通常是并发请求过多导致 TLS 连接 reset用 curl 单独调用可验证接口本身是否正常
- **Cookie 传递**:渲染进程通过 `request.ts` 拦截器自动附加 `localStorage` 中的 token
## 重要注意事项
- **主分支**: `dev_electron`PR 目标分支,非 `main`
- **自动导入**: naive-ui 组件、Vue 组合式 API`ref`、`computed` 等)均已自动导入
- **代码风格**: 使用 ESLint + Prettier通过 husky + lint-staged 在 commit 时自动执行
- **国际化**: 所有面向用户的文字必须翻译为 5 种语言
- **提交规范**: commit message 中禁止包含 `Co-Authored-By` 信息
- **IndexedDB 存储**:
- `music`: 歌曲元数据缓存
- `music_lyric`: 歌词缓存
- `api_cache`: 通用 API 响应缓存
- `music_url_cache`: 音乐 URL 缓存30 分钟 TTL

4
DEV.md
View File

@@ -15,7 +15,7 @@
- **国际化**vue-i18n
- **HTTP 客户端**axios
- **本地存储**electron-store localstorage
- **网易云音乐 API**netease-cloud-music-api
- **音乐 API**netease-cloud-music-api
- **音乐解锁**@unblockneteasemusic/server
### 项目结构
@@ -93,7 +93,7 @@ AlgerMusicPlayer/
- **index.ts**: 应用主入口,负责创建窗口和应用生命周期管理
- **lyric.ts**: 歌词解析和处理
- **unblockMusic.ts**: 网易云音乐解锁功能
- **unblockMusic.ts**: 音乐解锁功能
- **server.ts**: 本地服务器
#### 预加载脚本 (src/preload)

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -27,7 +27,7 @@
主要功能如下
- 🎵 音乐推荐
- 🔐 网易云账号登录与同步
- 🔐 账号登录与同步
- 📝 功能
- 播放历史记录
- 歌曲收藏管理

View File

@@ -16,5 +16,7 @@
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
</dict>
</plist>

View File

@@ -13,21 +13,21 @@
导入的配置文件必须是一个合法的 JSON 文件,并包含以下字段:
| 字段名 | 类型 | 是否必须 | 描述 |
| ---------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | 是 | API 名称,将显示在应用的 UI 界面上。 |
| `apiUrl` | `string` | 是 | API 的基础请求地址。 |
| `method` | `string` | 否 | HTTP 请求方法。可以是 `"GET"``"POST"`。**如果省略,默认为 "GET"**。 |
| `params` | `object` | 是 | 请求时需要发送的参数。对于 `GET` 请求,它们会作为查询字符串;对于 `POST` 请求,它们会作为请求体。 |
| `qualityMapping` | `object` | 否 | **音质映射表**。用于将应用内部的音质值(如 `"lossless"`)翻译成你的 API 需要的特定值。如果省略,则直接使用应用内部值。 |
| `responseUrlPath`| `string` | 是 | **URL提取路径**。用于从 API 返回的 JSON 响应中找到最终可播放的音乐链接。支持点 `.` 和方括号 `[]` 语法来访问嵌套对象和数组。 |
| 字段名 | 类型 | 是否必须 | 描述 |
| ----------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | 是 | API 名称,将显示在应用的 UI 界面上。 |
| `apiUrl` | `string` | 是 | API 的基础请求地址。 |
| `method` | `string` | 否 | HTTP 请求方法。可以是 `"GET"``"POST"`。**如果省略,默认为 "GET"**。 |
| `params` | `object` | 是 | 请求时需要发送的参数。对于 `GET` 请求,它们会作为查询字符串;对于 `POST` 请求,它们会作为请求体。 |
| `qualityMapping` | `object` | 否 | **音质映射表**。用于将应用内部的音质值(如 `"lossless"`)翻译成你的 API 需要的特定值。如果省略,则直接使用应用内部值。 |
| `responseUrlPath` | `string` | 是 | **URL提取路径**。用于从 API 返回的 JSON 响应中找到最终可播放的音乐链接。支持点 `.` 和方括号 `[]` 语法来访问嵌套对象和数组。 |
#### 占位符
`params` 对象的值中,你可以使用以下占位符,程序在请求时会自动替换它们:
* `{songId}`: 将被替换为当前歌曲的 ID。
* `{quality}`: 将被替换为当前用户设置的音质字符串 (例如, `"higher"`, `"lossless"`)。
- `{songId}`: 将被替换为当前歌曲的 ID。
- `{quality}`: 将被替换为当前用户设置的音质字符串 (例如, `"higher"`, `"lossless"`)。
#### 音质值列表
@@ -45,18 +45,18 @@
```json
{
"name": "Example API",
"apiUrl": "https://api.example.com/music",
"method": "GET",
"params": {
"song_id": "{songId}",
"bitrate": "{quality}"
},
"qualityMapping": {
"higher": "128000",
"exhigh": "320000",
"lossless": "999000"
},
"responseUrlPath": "data.play_url"
"name": "Example API",
"apiUrl": "https://api.example.com/music",
"method": "GET",
"params": {
"song_id": "{songId}",
"bitrate": "{quality}"
},
"qualityMapping": {
"higher": "128000",
"exhigh": "320000",
"lossless": "999000"
},
"responseUrlPath": "data.play_url"
}
```
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -1,45 +0,0 @@
appId: com.electron.app
productName: electron-lan-file
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: electron-lan-file
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -1,5 +1,5 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { defineConfig } from 'electron-vite';
import { resolve } from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
@@ -8,12 +8,8 @@ import viteCompression from 'vite-plugin-compression';
import VueDevTools from 'vite-plugin-vue-devtools';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
main: {},
preload: {},
renderer: {
resolve: {
alias: {
@@ -40,7 +36,8 @@ export default defineConfig({
],
publicDir: resolve('resources'),
server: {
host: '0.0.0.0'
host: '0.0.0.0',
port: 2389
}
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "AlgerMusicPlayer",
"version": "4.9.0",
"version": "5.1.0",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js",
@@ -8,7 +8,8 @@
"scripts": {
"prepare": "husky",
"format": "prettier --write ./src",
"lint": "eslint ./src --fix",
"lint": "eslint ./src --fix && npm run lint:i18n",
"lint:i18n": "bun scripts/check_i18n.ts",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
@@ -18,9 +19,11 @@
"build": "electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
"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",
"build:mac:x64": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml",
"build:mac:arm64": "npm run build && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml",
"build:linux": "npm run build && electron-builder --linux --publish never"
},
"lint-staged": {
"*.{ts,tsx,vue,js}": [
@@ -33,80 +36,85 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@unblockneteasemusic/server": "^0.27.8-patch.1",
"@unblockneteasemusic/server": "^0.27.10",
"cors": "^2.8.5",
"electron-store": "^8.1.0",
"crypto-js": "^4.2.0",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.18.2",
"file-type": "^21.0.0",
"express": "^4.22.1",
"file-type": "^21.1.1",
"flac-tagger": "^1.0.7",
"font-list": "^1.5.1",
"font-list": "^1.6.0",
"form-data": "^4.0.5",
"husky": "^9.1.7",
"music-metadata": "^11.2.3",
"netease-cloud-music-api-alger": "^4.26.1",
"jsencrypt": "^3.5.4",
"music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.30.0",
"node-fetch": "^2.7.0",
"node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",
"sharp": "^0.34.3",
"vue-i18n": "^11.1.3"
"pinia-plugin-persistedstate": "^4.7.1",
"vue-i18n": "^11.2.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@eslint/js": "^9.31.0",
"@rushstack/eslint-patch": "^1.10.3",
"@electron-toolkit/tsconfig": "^1.0.1",
"@eslint/js": "^9.39.2",
"@rushstack/eslint-patch": "^1.15.0",
"@types/howler": "^2.2.12",
"@types/node": "^20.14.8",
"@types/node": "^20.19.26",
"@types/node-fetch": "^2.6.13",
"@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^8.30.1",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/compiler-sfc": "^3.5.0",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.25",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/runtime-core": "^3.5.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/runtime-core": "^3.5.25",
"@vueuse/core": "^11.3.0",
"@vueuse/electron": "^13.8.0",
"@vueuse/electron": "^13.9.0",
"animate.css": "^4.1.1",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"cross-env": "^7.0.3",
"electron": "^37.4.0",
"electron": "^40.1.0",
"electron-builder": "^26.0.12",
"electron-vite": "^4.0.0",
"eslint": "^9.34.0",
"electron-vite": "^5.0.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-vue": "^10.3.0",
"eslint-plugin-vue-scoped-css": "^2.11.0",
"globals": "^16.3.0",
"eslint-plugin-vue": "^10.6.2",
"eslint-plugin-vue-scoped-css": "^2.12.0",
"globals": "^16.5.0",
"howler": "^2.2.4",
"lint-staged": "^15.2.10",
"lint-staged": "^15.5.2",
"lodash": "^4.17.21",
"marked": "^15.0.4",
"naive-ui": "^2.41.0",
"pinia": "^3.0.1",
"pinyin-match": "^1.2.6",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"remixicon": "^4.6.0",
"sass": "^1.86.0",
"tailwindcss": "^3.4.17",
"marked": "^15.0.12",
"naive-ui": "^2.43.2",
"pinia": "^3.0.4",
"pinyin-match": "^1.2.10",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"remixicon": "^4.7.0",
"sass": "^1.96.0",
"tailwindcss": "^3.4.19",
"tinycolor2": "^1.6.0",
"tunajs": "^1.0.15",
"typescript": "^5.5.2",
"unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.2.2",
"typescript": "^5.9.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.4.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.7.2",
"vue": "^3.5.13",
"vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0",
"vue-router": "^4.5.0",
"vue-tsc": "^2.0.22"
"vue-router": "^4.6.4",
"vue-tsc": "^2.2.12"
},
"build": {
"appId": "com.alger.music",
@@ -130,12 +138,8 @@
"mac": {
"icon": "resources/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"universal"
]
}
"dmg",
"zip"
],
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"darkModeSupport": true,
@@ -143,6 +147,12 @@
"gatekeeperAssess": false,
"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."
},
"notarize": false,
"identity": null,
"type": "distribution",
@@ -202,7 +212,9 @@
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "AlgerMusicPlayer",
"include": "build/installer.nsh"
"include": "build/installer.nsh",
"deleteAppDataOnUninstall": true,
"uninstallDisplayName": "AlgerMusicPlayer"
}
},
"pnpm": {

251
scripts/check_i18n.ts Normal file
View File

@@ -0,0 +1,251 @@
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
type TranslationObject = Record<string, unknown>;
type KeyValueMap = Map<string, string>;
type KeyReference = {
file: string;
line: number;
key: string;
};
const SOURCE_LANG = 'zh-CN';
const TARGET_LANGS = ['en-US', 'ja-JP', 'ko-KR', 'zh-Hant'] as const;
const CHECK_EXTENSIONS = new Set(['.ts', '.vue']);
function isPlainObject(value: unknown): value is TranslationObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function flattenTranslations(
input: TranslationObject,
prefix = '',
output: KeyValueMap = new Map()
): KeyValueMap {
Object.entries(input).forEach(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(value)) {
flattenTranslations(value, fullKey, output);
return;
}
output.set(fullKey, String(value ?? ''));
});
return output;
}
async function loadTranslationFile(filePath: string): Promise<TranslationObject | null> {
if (!fs.existsSync(filePath)) {
return null;
}
const moduleUrl = pathToFileURL(filePath).href;
const loaded = await import(moduleUrl);
const payload = loaded.default;
if (!isPlainObject(payload)) {
throw new Error(`翻译文件默认导出必须是对象: ${filePath}`);
}
return payload;
}
function walkFiles(dirPath: string): string[] {
const results: string[] = [];
if (!fs.existsSync(dirPath)) {
return results;
}
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
results.push(...walkFiles(fullPath));
continue;
}
if (entry.isFile() && CHECK_EXTENSIONS.has(path.extname(entry.name))) {
results.push(fullPath);
}
}
return results;
}
function getLineNumber(content: string, index: number): number {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (content[i] === '\n') {
line += 1;
}
}
return line;
}
function collectReferencesFromContent(content: string, file: string): KeyReference[] {
const references: KeyReference[] = [];
const patterns = [
/\bt\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g,
/\bi18n\.global\.t\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g,
/\$t\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g
];
for (const pattern of patterns) {
let match: RegExpExecArray | null = pattern.exec(content);
while (match) {
references.push({
file,
line: getLineNumber(content, match.index),
key: match[1]
});
match = pattern.exec(content);
}
}
return references;
}
function collectTranslationReferences(projectRoot: string): KeyReference[] {
const scanDirs = ['src/renderer', 'src/main', 'src/preload'];
const references: KeyReference[] = [];
for (const scanDir of scanDirs) {
const absoluteDir = path.join(projectRoot, scanDir);
const files = walkFiles(absoluteDir);
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
references.push(...collectReferencesFromContent(content, path.relative(projectRoot, file)));
}
}
return references;
}
async function main() {
const projectRoot = process.cwd();
const langDir = path.join(projectRoot, 'src/i18n/lang');
const sourceDir = path.join(langDir, SOURCE_LANG);
const fileNames = fs
.readdirSync(sourceDir)
.filter((file) => file.endsWith('.ts'))
.sort();
const missingByLang: Record<string, Record<string, string[]>> = {};
const extraByLang: Record<string, Record<string, string[]>> = {};
const sourceKeys = new Set<string>();
const sourceValues = new Map<string, string>();
let hasBlockingIssue = false;
const strictMode = process.env.I18N_STRICT === '1';
for (const fileName of fileNames) {
const moduleName = fileName.replace(/\.ts$/, '');
const sourcePath = path.join(sourceDir, fileName);
const sourceObject = await loadTranslationFile(sourcePath);
if (!sourceObject) {
continue;
}
const sourceMap = flattenTranslations(sourceObject, moduleName);
const sourceMapKeys = new Set(sourceMap.keys());
sourceMap.forEach((value, key) => {
sourceKeys.add(key);
sourceValues.set(key, value);
});
for (const lang of TARGET_LANGS) {
if (!missingByLang[lang]) {
missingByLang[lang] = {};
}
if (!extraByLang[lang]) {
extraByLang[lang] = {};
}
const targetPath = path.join(langDir, lang, fileName);
const targetObject = await loadTranslationFile(targetPath);
const targetMap = targetObject
? flattenTranslations(targetObject, moduleName)
: new Map<string, string>();
const targetMapKeys = new Set(targetMap.keys());
const missing = Array.from(sourceMapKeys).filter((key) => !targetMapKeys.has(key));
const extra = Array.from(targetMapKeys).filter((key) => !sourceMapKeys.has(key));
if (missing.length > 0) {
missingByLang[lang][fileName] = missing;
hasBlockingIssue = true;
}
if (extra.length > 0) {
extraByLang[lang][fileName] = extra;
}
}
}
const allReferences = collectTranslationReferences(projectRoot);
const invalidReferences = allReferences.filter((item) => !sourceKeys.has(item.key));
const hasWarningIssue =
invalidReferences.length > 0 ||
Object.values(extraByLang).some((item) => Object.keys(item).length > 0);
const shouldFail = hasBlockingIssue || (strictMode && hasWarningIssue);
if (hasBlockingIssue || hasWarningIssue) {
console.error('发现国际化问题:');
for (const lang of TARGET_LANGS) {
const missingFiles = missingByLang[lang];
const extraFiles = extraByLang[lang];
const hasLangIssue =
Object.keys(missingFiles).length > 0 || Object.keys(extraFiles).length > 0;
if (!hasLangIssue) {
continue;
}
console.error(`\n语言: ${lang}`);
for (const fileName of Object.keys(missingFiles)) {
console.error(` 文件: ${fileName}`);
for (const key of missingFiles[fileName]) {
const sourceValue = sourceValues.get(key) ?? '';
console.error(` - 缺失键 [${key}]${sourceValue}`);
}
}
for (const fileName of Object.keys(extraFiles)) {
console.error(` 文件: ${fileName}`);
for (const key of extraFiles[fileName]) {
console.error(` - 多余键 [${key}]`);
}
}
}
if (invalidReferences.length > 0) {
console.error('\n代码中引用了不存在的 i18n key:');
for (const item of invalidReferences) {
console.error(` - ${item.file}:${item.line} -> ${item.key}`);
}
}
if (strictMode && hasWarningIssue && !hasBlockingIssue) {
console.error('\n当前为严格模式告警将导致失败I18N_STRICT=1。');
}
}
if (shouldFail) {
process.exit(1);
}
if (!hasBlockingIssue && !hasWarningIssue) {
console.log('所有国际化键值检查通过!');
return;
}
console.log('国际化检查通过(含告警,建议尽快修复)');
}
main().catch((error) => {
console.error('国际化检查执行失败:', error);
process.exit(1);
});

View File

@@ -0,0 +1,130 @@
import fs from 'fs';
import path from 'path';
async function main() {
const rootDir = process.cwd();
const langDir = path.join(rootDir, 'src/i18n/lang/zh-CN');
const definedKeys = new Set<string>();
const langFiles = fs.readdirSync(langDir).filter((f) => f.endsWith('.ts'));
function getKeys(obj: any, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
getKeys(obj[key], fullKey);
} else {
definedKeys.add(fullKey);
}
}
}
for (const file of langFiles) {
const content = fs.readFileSync(path.join(langDir, file), 'utf-8');
const match = content.match(/export\s+default\s+([\s\S]+);/);
if (match) {
try {
const obj = eval(`(${match[1]})`);
getKeys(obj, file.replace('.ts', ''));
} catch (error) {
console.warn('Failed to parse i18n file:', file, error);
}
}
}
// @ts-ignore
const glob = new Bun.Glob('src/renderer/**/*.{vue,ts,js}');
// @ts-ignore
const files = Array.from(
glob.scanSync({
cwd: rootDir,
onlyFiles: true
})
);
const report = {
hardcodedChinese: [] as any[],
missingKeys: [] as any[]
};
const chineseMatchRegex = /[\u4e00-\u9fa5]+/g;
const i18nRegex = /\bt\(['"]([^'"]+)['"]\)/g;
for (const relativeFile of files) {
const rel = relativeFile as string;
if (
rel.includes('node_modules') ||
rel.includes('android/') ||
rel.includes('resources/') ||
rel.includes('scripts/') ||
rel.endsWith('.d.ts')
)
continue;
const file = path.join(rootDir, rel);
let content = fs.readFileSync(file, 'utf-8');
content = content.replace(/\/\*[\s\S]*?\*\//g, (match) => {
const lines = match.split('\n').length - 1;
return '\n'.repeat(lines);
});
content = content.replace(/<!--[\s\S]*?-->/g, (match) => {
const lines = match.split('\n').length - 1;
return '\n'.repeat(lines);
});
const lines = content.split('\n');
let isInConsole = false;
lines.forEach((line, index) => {
const lineNumber = index + 1;
const cleanLine = line.split('//')[0];
if (cleanLine.includes('console.')) {
isInConsole = true;
}
if (!isInConsole && !cleanLine.includes('import')) {
const chineseMatches = cleanLine.match(chineseMatchRegex);
if (chineseMatches) {
chineseMatches.forEach((text) => {
report.hardcodedChinese.push({
file: rel,
line: lineNumber,
text: text.trim(),
context: line.trim()
});
});
}
}
if (isInConsole && cleanLine.includes(');')) {
isInConsole = false;
}
let i18nMatch;
while ((i18nMatch = i18nRegex.exec(cleanLine)) !== null) {
const key = i18nMatch[1];
if (!definedKeys.has(key)) {
report.missingKeys.push({
file: rel,
line: lineNumber,
key: key,
context: line.trim()
});
}
}
});
}
const outputPath = path.join(rootDir, 'i18n_report.json');
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
console.log(`\n报告生成成功`);
console.log(`- 硬编码中文: ${report.hardcodedChinese.length}`);
console.log(`- 缺失的 Key: ${report.missingKeys.length}`);
console.log(`- 报告路径: ${outputPath}\n`);
}
main();

View File

@@ -0,0 +1,119 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
function readScalar(line, prefix) {
return line.slice(prefix.length).trim().replace(/^'/, '').replace(/'$/, '');
}
function parseLatestMacYml(filePath) {
const content = readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
const result = {
version: '',
files: [],
path: '',
sha512: '',
releaseDate: ''
};
let currentFile = null;
for (const line of lines) {
if (!line.trim()) {
continue;
}
if (line.startsWith('version: ')) {
result.version = readScalar(line, 'version: ');
continue;
}
if (line.startsWith('path: ')) {
result.path = readScalar(line, 'path: ');
continue;
}
if (line.startsWith('sha512: ')) {
result.sha512 = readScalar(line, 'sha512: ');
continue;
}
if (line.startsWith('releaseDate: ')) {
result.releaseDate = readScalar(line, 'releaseDate: ');
continue;
}
if (line.startsWith(' - url: ')) {
currentFile = {
url: readScalar(line, ' - url: ')
};
result.files.push(currentFile);
continue;
}
if (line.startsWith(' sha512: ') && currentFile) {
currentFile.sha512 = readScalar(line, ' sha512: ');
continue;
}
if (line.startsWith(' size: ') && currentFile) {
currentFile.size = Number.parseInt(readScalar(line, ' size: '), 10);
}
}
return result;
}
function uniqueFiles(files) {
const fileMap = new Map();
for (const file of files) {
fileMap.set(file.url, file);
}
return Array.from(fileMap.values());
}
function stringifyLatestMacYml(data) {
const lines = [`version: ${data.version}`, 'files:'];
for (const file of data.files) {
lines.push(` - url: ${file.url}`);
lines.push(` sha512: ${file.sha512}`);
lines.push(` size: ${file.size}`);
}
lines.push(`path: ${data.path}`);
lines.push(`sha512: ${data.sha512}`);
lines.push(`releaseDate: '${data.releaseDate}'`);
return `${lines.join('\n')}\n`;
}
const [x64Path, arm64Path, outputPath] = process.argv.slice(2);
if (!x64Path || !arm64Path || !outputPath) {
console.error(
'Usage: node scripts/merge_latest_mac_yml.mjs <latest-mac-x64.yml> <latest-mac-arm64.yml> <output.yml>'
);
process.exit(1);
}
const x64Data = parseLatestMacYml(x64Path);
const arm64Data = parseLatestMacYml(arm64Path);
if (x64Data.version !== arm64Data.version) {
console.error(
`Version mismatch between mac update files: ${x64Data.version} !== ${arm64Data.version}`
);
process.exit(1);
}
const mergedData = {
...x64Data,
files: uniqueFiles([...x64Data.files, ...arm64Data.files]),
releaseDate: arm64Data.releaseDate || x64Data.releaseDate
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, stringifyLatestMacYml(mergedData), 'utf8');

View File

@@ -1,51 +0,0 @@
export default {
player: {
loading: 'Loading audio...',
retry: 'Retry',
playNow: 'Play Now',
loadingTitle: 'Loading...',
totalDuration: 'Total Duration: {duration}',
partsList: 'Parts List ({count} episodes)',
playStarted: 'Playback started',
switchingPart: 'Switching to part: {part}',
preloadingNext: 'Preloading next part: {part}',
playingCurrent: 'Playing current selected part: {name}',
num: 'M',
errors: {
invalidVideoId: 'Invalid video ID',
loadVideoDetailFailed: 'Failed to load video details',
loadPartInfoFailed: 'Unable to load video part information',
loadAudioUrlFailed: 'Failed to get audio playback URL',
videoDetailNotLoaded: 'Video details not loaded',
missingParams: 'Missing required parameters',
noAvailableAudioUrl: 'No available audio URL found',
loadPartAudioFailed: 'Failed to load part audio URL',
audioListEmpty: 'Audio list is empty, please retry',
currentPartNotFound: 'Current part audio not found',
audioUrlFailed: 'Failed to get audio URL',
playFailed: 'Playback failed, please retry',
getAudioUrlFailed: 'Failed to get audio URL, please retry',
audioNotFound: 'Corresponding audio not found, please retry',
preloadFailed: 'Failed to preload next part',
switchPartFailed: 'Failed to load audio URL when switching parts'
},
console: {
loadingDetail: 'Loading Bilibili video details',
detailData: 'Bilibili video detail data',
multipleParts: 'Video has multiple parts, total {count}',
noPartsData: 'Video has no parts or part data is empty',
loadingAudioSource: 'Loading audio source',
generatedAudioList: 'Generated audio list, total {count}',
getDashAudioUrl: 'Got dash audio URL',
getDurlAudioUrl: 'Got durl audio URL',
loadingPartAudio: 'Loading part audio URL: {part}, cid: {cid}',
loadPartAudioFailed: 'Failed to load part audio URL: {part}',
switchToPart: 'Switching to part: {part}',
audioNotFoundInList: 'Corresponding audio item not found',
preparingToPlay: 'Preparing to play current selected part: {name}',
preloadingNextPart: 'Preloading next part: {part}',
playingSelectedPart: 'Playing current selected part: {name}, audio URL: {url}',
preloadNextFailed: 'Failed to preload next part'
}
}
};

View File

@@ -15,6 +15,7 @@ export default {
hide: 'Hide',
confirm: 'Confirm',
cancel: 'Cancel',
clear: 'Clear',
configure: 'Configure',
open: 'Open',
modify: 'Modify',
@@ -27,6 +28,8 @@ export default {
refresh: 'Refresh',
retry: 'Retry',
reset: 'Reset',
loadFailed: 'Load Failed',
noData: 'No data',
back: 'Back',
copySuccess: 'Copied to clipboard',
copyFailed: 'Copy failed',
@@ -34,15 +37,18 @@ export default {
required: 'This field is required',
invalidInput: 'Invalid input',
selectRequired: 'Please select an option',
numberRange: 'Please enter a number between {min} and {max}',
ipAddress: 'Please enter a valid IP address',
portNumber: 'Please enter a valid port number (1-65535)'
numberRange: 'Please enter a number between {min} and {max}'
},
viewMore: 'View More',
noMore: 'No more',
selectAll: 'Select All',
playAll: 'Play All',
expand: 'Expand',
collapse: 'Collapse',
songCount: '{count} songs',
language: 'Language',
today: 'Today',
yesterday: 'Yesterday',
tray: {
show: 'Show',
quit: 'Quit',
@@ -52,6 +58,5 @@ export default {
pause: 'Pause',
play: 'Play',
favorite: 'Favorite'
},
language: 'Language'
}
};

View File

@@ -1,4 +1,8 @@
export default {
more: 'More',
homeListItem: {
loading: 'Loading...'
},
installApp: {
description: 'Install the application for a better experience',
noPrompt: 'Do not prompt again',
@@ -33,11 +37,17 @@ export default {
title: 'New version found',
currentVersion: 'Current version',
cancel: 'Do not update',
checking: 'Checking for updates...',
prepareDownload: 'Preparing to download...',
downloading: 'Downloading...',
readyToInstall: 'The update package is ready to install',
nowUpdate: 'Update now',
downloadFailed: 'Download failed, please try again or download manually',
startFailed: 'Start download failed, please try again or download manually',
autoUpdateFailed: 'Automatic update failed',
openOfficialSite: 'Open official download page',
manualFallbackHint:
'If automatic update fails, you can download the latest version from the official release page.',
noDownloadUrl:
'No suitable installation package found for the current system, please download manually',
installConfirmTitle: 'Install Update',
@@ -52,6 +62,31 @@ export default {
copyFailed: 'Copy failed',
backgroundDownload: 'Background Download'
},
disclaimer: {
title: 'Terms of Use',
warning:
'This application is a development test version. Functions are not yet perfect, and there may be many problems and bugs. It is for learning and exchange only.',
item1:
'This application is for personal learning, research and technical exchange only. Please do not use it for any commercial purposes.',
item2:
'Please delete it within 24 hours after downloading. If you need to use it for a long time, please support the genuine music service.',
item3:
'By using this application, you understand and assume the relevant risks. The developer is not responsible for any loss.',
agree: 'I have read and agree',
disagree: 'Disagree and Exit'
},
donate: {
title: 'Support Developer',
subtitle: 'Your support is my motivation',
tip: 'Donation is completely voluntary. All functions can be used normally without donation. Thank you for your understanding and support!',
wechat: 'WeChat',
alipay: 'Alipay',
wechatQR: 'WeChat QR Code',
alipayQR: 'Alipay QR Code',
scanTip: 'Please use your phone to scan the QR code above to donate',
enterApp: 'Enter App',
noForce: 'No forced donation, click to enter'
},
coffee: {
title: 'Buy me a coffee',
alipay: 'Alipay',
@@ -79,7 +114,68 @@ export default {
songlist: 'Daily Recommendation List'
},
recommendSonglist: {
title: 'Weekly Hot Music'
title: 'Weekly Hot Music',
empty: 'No playlists available'
},
dailyRecommend: {
title: 'Daily Recommendation',
badge: 'Recommended',
empty: 'No recommended songs',
intelligenceHint: 'Turn on Intelligence Mode to discover more music you love'
},
recommendMV: {
title: 'Recommended MVs'
},
newAlbum: {
title: 'Albums',
empty: 'No new albums'
},
recommendNewMusic: {
title: 'New Songs'
},
privateContent: {
title: 'Exclusive Content'
},
djProgram: {
title: 'Recommended Radio'
},
homeHero: {
dailyRecommend: 'Daily Recommend',
songs: 'Songs',
playNow: 'Play Now',
intelligenceMode: 'Intelligence Mode',
intelligenceModeOn: 'On Air',
intelligenceModeDesc: 'Start smart recommendation',
intelligenceModeActiveDesc: 'Smart recommendations based on your taste',
startIntelligence: 'Start',
stopIntelligence: 'Stop',
playing: 'Playing',
toplistDesc: 'Trending now',
mvDesc: 'Music videos',
playlistDesc: 'Curated playlists',
personalFm: 'Personal FM',
discoverMusic: 'Discover Music',
personalFmDesc: 'Based on your taste',
recentPlays: 'Recent Plays',
viewAll: 'View All',
followedArtists: 'Followed Artists',
newSongs: ' new songs',
fromFollowedArtists: 'From artists you follow',
recommendNewMusic: 'New Music',
newSongExpress: 'New Releases',
discoverNewReleases: 'Discover the latest releases',
hotPlaylists: 'Hot Playlists',
hotArtists: 'Hot Artists',
hotArtistsTitle: 'Popular Artists',
hotArtistsDesc: 'Most popular artists right now',
fmTrash: 'Dislike',
fmNext: 'Next',
quickNav: {
myFavorite: 'My Favorites',
playHistory: 'History',
myProfile: 'My Profile',
toplist: 'Top Charts'
}
},
searchBar: {
login: 'Login',
@@ -94,7 +190,13 @@ export default {
zoom: 'Zoom',
zoom100: 'Zoom 100%',
resetZoom: 'Reset Zoom',
zoomDefault: 'Default Zoom'
zoomDefault: 'Default Zoom',
tabPlaylist: 'Playlist',
tabMv: 'MV',
tabCharts: 'Charts',
cancelSearch: 'Cancel',
intelligenceMode: 'Intelligence Mode',
exitIntelligence: 'Exit Intelligence Mode'
},
titleBar: {
closeTitle: 'Choose how to close',
@@ -119,7 +221,12 @@ export default {
addToPlaylist: 'Add to Playlist',
addToPlaylistSuccess: 'Add to Playlist Success',
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist'
songsAlreadyInPlaylist: 'Songs already in playlist',
locateCurrent: 'Locate current song',
historyRecommend: 'Daily History',
fetchDatesFailed: 'Failed to fetch dates',
fetchSongsFailed: 'Failed to fetch songs',
noSongs: 'No songs'
},
playlist: {
import: {
@@ -145,6 +252,7 @@ export default {
albumNamePlaceholder: 'Album Name',
addSongButton: 'Add Song',
addLinkButton: 'Add Link',
options: 'Options',
importToStarPlaylist: 'Import to My Favorite Music',
playlistNamePlaceholder: 'Enter playlist name',
importButton: 'Start Import',
@@ -189,5 +297,41 @@ export default {
list: 'Playlist',
mv: 'MV',
home: 'Home',
search: 'Search'
search: 'Search',
album: 'Album',
localMusic: 'Local Music',
pages: {
toplist: {
desc: 'The most authoritative music charts, discover the hottest music'
},
mv: {
desc: 'Explore amazing video content',
loadingMore: 'Loading more...',
noMore: '— All content loaded —',
area: {
all: 'All',
mainland: 'Mainland',
hktw: 'HK/TW',
western: 'Western',
japan: 'Japan',
korea: 'Korea'
}
},
list: {
desc: 'Discover more great playlists',
dailyRecommend: 'Daily Picks'
},
search: {
desc: 'Explore the hottest search trends'
},
album: {
area: {
all: 'All',
chinese: 'Chinese',
western: 'Western',
korea: 'Korea',
japan: 'Japan'
}
}
}
};

View File

@@ -16,12 +16,25 @@ export default {
progress: {
total: 'Total Progress: {progress}%'
},
items: 'items',
status: {
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'
@@ -42,17 +55,18 @@ export default {
'Are you sure you want to clear all download records? This will not delete the actual music files, but will clear all records.',
confirm: 'Clear',
cancel: 'Cancel',
success: 'Download records cleared'
success: 'Download records cleared',
failed: 'Failed to clear download records'
},
message: {
downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}',
alreadyDownloading: '{filename} is already downloading'
downloadFailed: '{filename} download failed: {error}'
},
loading: 'Loading...',
playStarted: 'Play started: {name}',
playFailed: 'Play failed: {name}',
path: {
copy: 'Copy Path',
copied: 'Path copied to clipboard',
copyFailed: 'Failed to copy path'
},
@@ -64,6 +78,8 @@ export default {
noPathSelected: 'Please select download path first',
select: 'Select Folder',
open: 'Open Folder',
saveLyric: 'Save Lyrics File',
saveLyricDesc: 'Save a separate .lrc lyrics file alongside the downloaded song',
fileFormat: 'Filename Format',
fileFormatDesc: 'Set how downloaded music files will be named',
customFormat: 'Custom Format',
@@ -76,6 +92,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',
@@ -87,5 +105,10 @@ export default {
artistName: 'Artist name',
albumName: 'Album name'
}
},
error: {
incomplete: 'File download incomplete',
urlExpired: 'URL expired, re-resolving',
resumeFailed: 'Resume failed'
}
};

View File

@@ -2,12 +2,8 @@ export default {
title: 'Favorites',
count: 'Total {count}',
batchDownload: 'Batch Download',
selectAll: 'All',
download: 'Download ({count})',
cancel: 'Cancel',
emptyTip: 'No favorite songs yet',
viewMore: 'View More',
noMore: 'No more',
downloadSuccess: 'Download completed',
downloadFailed: 'Download failed',
downloading: 'Downloading, please wait...',

View File

@@ -1,5 +1,49 @@
export default {
title: 'Play History',
heatmapTitle: 'Heatmap',
playCount: '{count}',
getHistoryFailed: 'Failed to get play history'
getHistoryFailed: 'Failed to get play history',
categoryTabs: {
songs: 'Songs',
playlists: 'Playlists',
albums: 'Albums',
podcasts: 'Podcasts'
},
podcastTabs: {
episodes: 'Episodes',
radios: 'Radios'
},
tabs: {
all: 'All Records',
local: 'Local Records',
cloud: 'Cloud Records'
},
getCloudRecordFailed: 'Failed to get cloud records',
needLogin: 'Please login with cookie to view cloud records',
merging: 'Merging records...',
noDescription: 'No description',
noData: 'No records',
heatmap: {
title: 'Play Heatmap',
loading: 'Loading data...',
unit: 'plays',
footerText: 'Hover to view details',
playCount: 'Played {count} times',
topSongs: 'Top songs of the day',
times: 'times',
totalPlays: 'Total Plays',
activeDays: 'Active Days',
noData: 'No play records',
colorTheme: 'Color Theme',
colors: {
green: 'Green',
blue: 'Blue',
orange: 'Orange',
purple: 'Purple',
red: 'Red'
},
mostPlayedSong: 'Most Played Song',
mostActiveDay: 'Most Active Day',
latestNightSong: 'Latest Night Song'
}
};

View File

@@ -0,0 +1,13 @@
export default {
title: 'Local Music',
scanFolder: 'Scan Folder',
removeFolder: 'Remove Folder',
scanning: 'Scanning...',
scanComplete: 'Scan Complete',
playAll: 'Play All',
search: 'Search local music',
emptyState: 'No local music found. Please select a folder to scan.',
fileNotFound: 'File not found or has been moved',
rescan: 'Rescan',
songCount: '{count} songs'
};

View File

@@ -14,6 +14,12 @@ export default {
addCorrection: 'Add {num} seconds',
subtractCorrection: 'Subtract {num} seconds',
playFailed: 'Play Failed, Play Next Song',
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',
@@ -29,7 +35,8 @@ export default {
list: 'Next'
},
lrc: {
noLrc: 'No lyrics, please enjoy'
noLrc: 'No lyrics, please enjoy',
noAutoScroll: 'This lyrics does not support auto-scroll'
},
reparse: {
title: 'Select Music Source',
@@ -52,6 +59,7 @@ export default {
eq: 'Equalizer',
playList: 'Play List',
reparse: 'Reparse',
miniPlayBar: 'Mini Play Bar',
playMode: {
sequence: 'Sequence',
loop: 'Loop',
@@ -65,7 +73,17 @@ export default {
favorite: 'Favorite {name}',
unFavorite: 'Unfavorite {name}',
playbackSpeed: 'Playback Speed',
advancedControls: 'Advanced Controls'
advancedControls: 'Advanced Controls',
intelligenceMode: {
title: 'Intelligence Mode',
needCookieLogin: 'Please login with Cookie method to use Intelligence Mode',
noFavoritePlaylist: 'Favorite playlist not found',
noLikedSongs: 'You have no liked songs yet',
loading: 'Loading Intelligence Mode',
success: 'Loaded {count} songs',
failed: 'Failed to get Intelligence Mode list',
error: 'Intelligence Mode error'
}
},
eq: {
title: 'Equalizer',
@@ -91,6 +109,11 @@ export default {
custom: 'Custom'
}
},
// Playback settings
settings: {
title: 'Playback Settings',
playbackSpeed: 'Playback Speed'
},
// Sleep timer related
sleepTimer: {
title: 'Sleep Timer',

View File

@@ -0,0 +1,40 @@
export default {
podcast: 'Podcast',
mySubscriptions: 'My Subscriptions',
discover: 'Discover',
categories: 'Categories',
todayPerfered: "Today's Picks",
recommended: 'Recommended',
hotRanking: 'Hot',
newRanking: 'New',
subscribeCount: 'Subscribers',
programCount: 'Episodes',
subscribe: 'Subscribe',
subscribed: 'Subscribed',
unsubscribe: 'Unsubscribe',
unsubscribed: 'Unsubscribed',
subscribeSuccess: 'Subscribed successfully',
unsubscribeFailed: 'Failed to unsubscribe',
subscribeFailed: 'Failed to subscribe',
radioDetail: 'Radio Detail',
programList: 'Episodes',
playProgram: 'Play',
recentPlayed: 'Recently Played',
listeners: 'Listeners',
noSubscriptions: 'No subscriptions',
goDiscover: 'Discover Podcasts',
searchPodcast: 'Search Podcast',
category: 'Category',
all: 'All',
dj: 'DJ',
episodes: 'Eps',
playAll: 'Play All',
popularCategories: 'Popular Categories',
allCategories: 'All Categories',
categoryRadios: 'Category Radios',
exploreCategoryRadios: 'Explore more amazing radios',
hotRadios: 'Hot Radios',
noCategoryRadios: 'No radios in this category',
searchPlaceholder: 'Search podcasts, episodes...',
searchResults: 'Search Results'
};

View File

@@ -11,7 +11,8 @@ export default {
},
loading: {
more: 'Loading...',
failed: 'Search failed'
failed: 'Search failed',
searching: 'Searching...'
},
noMore: 'No more results',
error: {
@@ -22,6 +23,10 @@ export default {
album: 'Album',
playlist: 'Playlist',
mv: 'MV',
djradio: 'Podcast',
bilibili: 'Bilibili'
}
},
history: 'Search History',
hot: 'Hot Searches',
suggestions: 'Search Suggestions'
};

View File

@@ -10,7 +10,7 @@ export default {
network: 'Network Settings',
system: 'System Management',
donation: 'Donation',
regard: 'About'
about: 'About'
},
basic: {
themeMode: 'Theme Mode',
@@ -58,7 +58,9 @@ export default {
'Changing GPU acceleration settings requires application restart to take effect',
gpuAccelerationChangeSuccess:
'GPU acceleration settings updated, restart application to take effect',
gpuAccelerationChangeError: 'Failed to update GPU acceleration settings'
gpuAccelerationChangeError: 'Failed to update GPU acceleration settings',
tabletMode: 'Tablet Mode',
tabletModeDesc: 'Enabling tablet mode allows using PC-style interface on mobile devices'
},
playback: {
quality: 'Audio Quality',
@@ -86,6 +88,10 @@ export default {
'GD Music Station intelligently resolves music from multiple platforms automatically',
autoPlay: 'Auto Play',
autoPlayDesc: 'Auto resume playback when reopening the app',
audioDevice: 'Audio Output Device',
audioDeviceDesc: 'Select audio output device such as speakers, headphones or Bluetooth devices',
testAudio: 'Test',
selectAudioDevice: 'Select output device',
showStatusBar: 'Show Status Bar',
showStatusBarContent:
'You can display the music control function in your mac status bar (effective after a restart)',
@@ -99,9 +105,13 @@ export default {
sourceLabels: {
migu: 'Migu',
kugou: 'Kugou',
kuwo: 'Kuwo',
pyncmd: 'NetEase (Built-in)',
qq: 'QQ Music',
joox: 'JOOX',
bilibili: 'Bilibili',
gdmusic: 'GD Music',
lxMusic: 'LX Music',
custom: 'Custom API'
},
@@ -112,7 +122,38 @@ export default {
notImported: 'No custom source imported yet.',
importSuccess: 'Successfully imported source: {name}',
importFailed: 'Import failed: {message}',
enableHint: 'Import a JSON config file to enable'
enableHint: 'Import a JSON config file to enable',
status: {
imported: 'Custom Source Imported',
notImported: 'Not Imported'
}
},
lxMusic: {
tabs: {
sources: 'Source Selection',
lxMusic: 'LX Music',
customApi: 'Custom API'
},
scripts: {
title: 'Imported Scripts',
importLocal: 'Import Local',
importOnline: 'Import Online',
urlPlaceholder: 'Enter LX Music Script URL',
importBtn: 'Import',
empty: 'No imported LX Music scripts',
notConfigured: 'Not configured (Configure in LX Music Tab)',
importHint: 'Import compatible custom API plugins to extend sources',
noScriptWarning: 'Please import LX Music script first',
noSelectionWarning: 'Please select an LX Music source first',
notFound: 'Source not found',
switched: 'Switched to source: {name}',
deleted: 'Deleted source: {name}',
enterUrl: 'Please enter script URL',
invalidUrl: 'Invalid URL format',
invalidScript: 'Invalid LX Music script, globalThis.lx code not found',
nameRequired: 'Name cannot be empty',
renameSuccess: 'Rename successful'
}
}
},
application: {
@@ -155,6 +196,36 @@ export default {
system: {
cache: 'Cache Management',
cacheDesc: 'Clear cache',
diskCache: 'Disk Cache',
diskCacheDesc: 'Cache played music and lyrics on local disk to speed up repeated playback',
cacheDirectory: 'Cache Directory',
cacheDirectoryDesc: 'Custom directory for music and lyric cache files',
selectDirectory: 'Select Directory',
openDirectory: 'Open Directory',
cacheMaxSize: 'Cache Size Limit',
cacheMaxSizeDesc: 'Older cache items are cleaned automatically when limit is reached',
cleanupPolicy: 'Cleanup Policy',
cleanupPolicyDesc: 'Auto cleanup rule when cache reaches the size limit',
cleanupPolicyOptions: {
lru: 'Least Recently Used',
fifo: 'First In, First Out'
},
cacheStatus: 'Cache Status',
cacheStatusDesc: 'Used {used} / Limit {limit}',
cacheStatusDetail: 'Music {musicCount}, Lyrics {lyricCount}',
manageDiskCache: 'Manual Disk Cache Cleanup',
manageDiskCacheDesc: 'Clean cache by category',
clearMusicCache: 'Clear Music Cache',
clearLyricCache: 'Clear Lyric Cache',
clearAllCache: 'Clear All Cache',
switchDirectoryMigrateTitle: 'Existing Cache Detected',
switchDirectoryMigrateContent: 'Do you want to migrate old cache files to the new directory?',
switchDirectoryMigrateConfirm: 'Migrate',
switchDirectoryDestroyTitle: 'Destroy Old Cache',
switchDirectoryDestroyContent:
'If you do not migrate, do you want to destroy old cache files in the previous directory?',
switchDirectoryDestroyConfirm: 'Destroy',
switchDirectoryKeepOld: 'Keep Old Cache',
cacheClearTitle: 'Select cache types to clear:',
cacheTypes: {
history: {
@@ -189,7 +260,14 @@ export default {
restart: 'Restart',
restartDesc: 'Restart application',
messages: {
clearSuccess: 'Cache cleared successfully, some settings will take effect after restart'
clearSuccess: 'Cache cleared successfully, some settings will take effect after restart',
diskCacheClearSuccess: 'Disk cache cleaned',
diskCacheClearFailed: 'Failed to clean disk cache',
diskCacheStatsLoadFailed: 'Failed to load cache status',
switchDirectorySuccess: 'Cache directory switched, old cache is kept',
switchDirectoryFailed: 'Failed to switch cache directory',
switchDirectoryMigrated: 'Cache directory switched, migrated {count} cache files',
switchDirectoryDestroyed: 'Cache directory switched, destroyed {count} old cache files'
}
},
about: {
@@ -199,6 +277,7 @@ export default {
latest: 'Already latest version',
hasUpdate: 'New version available',
gotoUpdate: 'Go to Update',
manualUpdate: 'Manual Update',
gotoGithub: 'Go to Github',
author: 'Author',
authorDesc: 'algerkong Give a star🌟',
@@ -217,6 +296,7 @@ export default {
display: 'Display',
interface: 'Interface',
typography: 'Typography',
background: 'Background',
mobile: 'Mobile'
},
pureMode: 'Pure Mode',
@@ -239,6 +319,12 @@ export default {
medium: 'Medium',
large: 'Large'
},
fontWeight: 'Font Weight',
fontWeightMarks: {
thin: 'Thin',
normal: 'Normal',
bold: 'Bold'
},
letterSpacing: 'Letter Spacing',
letterSpacingMarks: {
compact: 'Compact',
@@ -251,6 +337,7 @@ export default {
default: 'Default',
loose: 'Loose'
},
contentWidth: 'Content Width',
mobileLayout: 'Mobile Layout',
layoutOptions: {
default: 'Default',
@@ -264,7 +351,46 @@ export default {
full: 'Full Screen'
},
lyricLines: 'Lyric Lines',
mobileUnavailable: 'This setting is only available on mobile devices'
mobileUnavailable: 'This setting is only available on mobile devices',
// Background settings
background: {
useCustomBackground: 'Use Custom Background',
backgroundMode: 'Background Mode',
modeOptions: {
solid: 'Solid',
gradient: 'Gradient',
image: 'Image',
css: 'CSS'
},
solidColor: 'Select Color',
presetColors: 'Preset Colors',
customColor: 'Custom Color',
gradientEditor: 'Gradient Editor',
gradientColors: 'Gradient Colors',
gradientDirection: 'Gradient Direction',
directionOptions: {
toBottom: 'Top to Bottom',
toRight: 'Left to Right',
toBottomRight: 'Top Left to Bottom Right',
angle45: '45 Degrees',
toTop: 'Bottom to Top',
toLeft: 'Right to Left'
},
addColor: 'Add Color',
removeColor: 'Remove Color',
imageUpload: 'Upload Image',
imagePreview: 'Image Preview',
clearImage: 'Clear Image',
imageBlur: 'Blur',
imageBrightness: 'Brightness',
customCss: 'Custom CSS Style',
customCssPlaceholder: 'Enter CSS style, e.g.: background: linear-gradient(...)',
customCssHelp: 'Supports any CSS background property',
reset: 'Reset to Default',
fileSizeLimit: 'Image size limit: 20MB',
invalidImageFormat: 'Invalid image format',
imageTooLarge: 'Image too large, please select an image smaller than 20MB'
}
},
translationEngine: 'Lyric Translation Engine',
translationEngineOptions: {
@@ -295,28 +421,61 @@ export default {
title: 'Shortcut Settings',
shortcut: 'Shortcut',
shortcutDesc: 'Customize global shortcuts',
summaryReady: 'Shortcut configuration is ready to save',
summaryRecording: 'Recording a new shortcut combination',
summaryBlocked: 'Fix conflicts or invalid entries before saving',
platformHintMac: 'On macOS, CommandOrControl is displayed as Cmd',
platformHintWindows: 'On Windows, CommandOrControl is displayed as Ctrl',
platformHintLinux: 'On Linux, CommandOrControl is displayed as Ctrl',
platformHintGeneric: 'CommandOrControl is adapted per operating system',
enabledCount: 'Enabled',
recordingTip: 'Click a shortcut field, press combination. Esc cancels, Delete disables',
shortcutConflict: 'Shortcut Conflict',
inputPlaceholder: 'Click to input shortcut',
clickToRecord: 'Click then press a shortcut',
recording: 'Recording...',
resetShortcuts: 'Reset',
restoreSingle: 'Restore',
disableAll: 'Disable All',
enableAll: 'Enable All',
groups: {
playback: 'Playback',
sound: 'Volume & Favorite',
window: 'Window'
},
togglePlay: 'Play/Pause',
togglePlayDesc: 'Toggle current playback state',
prevPlay: 'Previous',
prevPlayDesc: 'Play the previous track',
nextPlay: 'Next',
nextPlayDesc: 'Play the next track',
volumeUp: 'Volume Up',
volumeUpDesc: 'Increase player volume',
volumeDown: 'Volume Down',
volumeDownDesc: 'Decrease player volume',
toggleFavorite: 'Favorite/Unfavorite',
toggleFavoriteDesc: 'Favorite or unfavorite current track',
toggleWindow: 'Show/Hide Window',
toggleWindowDesc: 'Quickly show or hide the main window',
scopeGlobal: 'Global',
scopeApp: 'App Only',
enabled: 'Enabled',
disabled: 'Disabled',
issueInvalid: 'Invalid combo',
issueReserved: 'System reserved',
registrationWarningTitle: 'These shortcuts could not be registered',
registrationOccupied: 'Occupied by system or another app',
registrationInvalid: 'Invalid shortcut format',
messages: {
resetSuccess: 'Shortcuts reset successfully, please save',
conflict: 'Shortcut conflict, please reset',
saveSuccess: 'Shortcuts saved successfully',
saveError: 'Failed to save shortcuts',
saveValidationError: 'Shortcut validation failed, please review and try again',
partialRegistered: 'Saved, but some global shortcuts were not registered',
cancelEdit: 'Edit cancelled',
clearToDisable: 'Shortcut disabled',
invalidShortcut: 'Invalid shortcut, please use a valid combination',
disableAll: 'All shortcuts disabled, please save to apply',
enableAll: 'All shortcuts enabled, please save to apply'
}

View File

@@ -3,6 +3,7 @@ export default {
play: 'Play',
playNext: 'Play Next',
download: 'Download',
downloadLyric: 'Download Lyrics',
addToPlaylist: 'Add to Playlist',
favorite: 'Like',
unfavorite: 'Unlike',
@@ -15,7 +16,10 @@ export default {
downloadFailed: 'Download failed',
downloadQueued: 'Added to download queue',
addedToNextPlay: 'Added to play next',
getUrlFailed: 'Failed to get music download URL, please check if logged in'
getUrlFailed: 'Failed to get music download URL, please check if logged in',
noLyric: 'No lyrics available for this song',
lyricDownloaded: 'Lyrics downloaded successfully',
lyricDownloadFailed: 'Failed to download lyrics'
},
dialog: {
dislike: {

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count} tracks',
playCount: 'Played {count} times'
},
tabs: {
created: 'Created',
favorite: 'Favorite',
album: 'Album'
},
ranking: {
title: 'Listening History',
playCount: '{count} times'

View File

@@ -1,38 +0,0 @@
# 日本語翻訳 (Japanese Translation)
このディレクトリには、AlgerMusicPlayerの日本語翻訳ファイルが含まれています。
## ファイル構成
- `artist.ts` - アーティスト関連の翻訳
- `common.ts` - 共通の翻訳(ボタン、メッセージなど)
- `comp.ts` - コンポーネント関連の翻訳
- `donation.ts` - 寄付関連の翻訳
- `download.ts` - ダウンロード管理の翻訳
- `favorite.ts` - お気に入り機能の翻訳
- `history.ts` - 履歴機能の翻訳
- `login.ts` - ログイン関連の翻訳
- `player.ts` - プレイヤー機能の翻訳
- `search.ts` - 検索機能の翻訳
- `settings.ts` - 設定画面の翻訳
- `songItem.ts` - 楽曲アイテムの翻訳
- `user.ts` - ユーザー関連の翻訳
- `index.ts` - すべての翻訳をエクスポートするメインファイル
## 使用方法
アプリケーション内で言語を日本語に切り替えるには:
1. 設定画面を開く
2. 「言語設定」セクションを見つける
3. ドロップダウンメニューから「日本語」を選択
## 翻訳の改善
翻訳の改善や修正がある場合は、該当するファイルを編集してプルリクエストを送信してください。
## 注意事項
- すべての翻訳キーは中国語版と英語版に対応しています
- 新しい機能が追加された場合は、対応する日本語翻訳も追加する必要があります
- 文字化けを避けるため、ファイルはUTF-8エンコーディングで保存してください

View File

@@ -1,51 +0,0 @@
export default {
player: {
loading: 'オーディオ読み込み中...',
retry: '再試行',
playNow: '今すぐ再生',
loadingTitle: '読み込み中...',
totalDuration: '総再生時間: {duration}',
partsList: 'パートリスト ({count}話)',
playStarted: '再生を開始しました',
switchingPart: 'パートを切り替え中: {part}',
preloadingNext: '次のパートをプリロード中: {part}',
playingCurrent: '現在選択されたパートを再生中: {name}',
num: '万',
errors: {
invalidVideoId: '無効な動画ID',
loadVideoDetailFailed: '動画詳細の取得に失敗しました',
loadPartInfoFailed: '動画パート情報の読み込みができません',
loadAudioUrlFailed: 'オーディオ再生URLの取得に失敗しました',
videoDetailNotLoaded: '動画詳細が読み込まれていません',
missingParams: '必要なパラメータが不足しています',
noAvailableAudioUrl: '利用可能なオーディオURLが見つかりません',
loadPartAudioFailed: 'パートオーディオURLの読み込みに失敗しました',
audioListEmpty: 'オーディオリストが空です。再試行してください',
currentPartNotFound: '現在のパートのオーディオが見つかりません',
audioUrlFailed: 'オーディオURLの取得に失敗しました',
playFailed: '再生に失敗しました。再試行してください',
getAudioUrlFailed: 'オーディオURLの取得に失敗しました。再試行してください',
audioNotFound: '対応するオーディオが見つかりません。再試行してください',
preloadFailed: '次のパートのプリロードに失敗しました',
switchPartFailed: 'パート切り替え時のオーディオURL読み込みに失敗しました'
},
console: {
loadingDetail: 'Bilibiliビデオ詳細を読み込み中',
detailData: 'Bilibiliビデオ詳細データ',
multipleParts: 'ビデオに複数のパートがあります。合計{count}個',
noPartsData: 'ビデオにパートがないか、パートデータが空です',
loadingAudioSource: 'オーディオソースを読み込み中',
generatedAudioList: 'オーディオリストを生成しました。合計{count}個',
getDashAudioUrl: 'dashオーディオURLを取得しました',
getDurlAudioUrl: 'durlオーディオURLを取得しました',
loadingPartAudio: 'パートオーディオURLを読み込み中: {part}, cid: {cid}',
loadPartAudioFailed: 'パートオーディオURLの読み込みに失敗: {part}',
switchToPart: 'パートに切り替え中: {part}',
audioNotFoundInList: '対応するオーディオアイテムが見つかりません',
preparingToPlay: '現在選択されたパートの再生準備中: {name}',
preloadingNextPart: '次のパートをプリロード中: {part}',
playingSelectedPart: '現在選択されたパートを再生中: {name}、オーディオURL: {url}',
preloadNextFailed: '次のパートのプリロードに失敗しました'
}
}
};

View File

@@ -15,6 +15,7 @@ export default {
hide: '非表示',
confirm: '確認',
cancel: 'キャンセル',
clear: 'クリア',
configure: '設定',
open: '開く',
modify: '変更',
@@ -27,6 +28,8 @@ export default {
refresh: '更新',
retry: '再試行',
reset: 'リセット',
loadFailed: '読み込みに失敗しました',
noData: 'データがありません',
back: '戻る',
copySuccess: 'クリップボードにコピーしました',
copyFailed: 'コピーに失敗しました',
@@ -39,10 +42,13 @@ export default {
viewMore: 'もっと見る',
noMore: 'これ以上ありません',
selectAll: '全選択',
playAll: 'すべて再生',
expand: '展開',
collapse: '折りたたみ',
songCount: '{count}曲',
language: '言語',
today: '今日',
yesterday: '昨日',
tray: {
show: '表示',
quit: '終了',

View File

@@ -1,4 +1,8 @@
export default {
more: 'もっと見る',
homeListItem: {
loading: '読み込み中...'
},
installApp: {
description: 'アプリをインストールして、より良い体験を',
noPrompt: '今後表示しない',
@@ -33,11 +37,17 @@ export default {
title: '新しいバージョンが見つかりました',
currentVersion: '現在のバージョン',
cancel: '後で更新',
checking: '更新を確認中...',
prepareDownload: 'ダウンロード準備中...',
downloading: 'ダウンロード中...',
readyToInstall: '更新パッケージのダウンロードが完了しました。今すぐインストールできます',
nowUpdate: '今すぐ更新',
downloadFailed: 'ダウンロードに失敗しました。再試行するか手動でダウンロードしてください',
startFailed: 'ダウンロードの開始に失敗しました。再試行するか手動でダウンロードしてください',
autoUpdateFailed: '自動更新に失敗しました',
openOfficialSite: '公式サイトから更新',
manualFallbackHint:
'自動更新に失敗した場合は、公式リリースページから最新版をダウンロードできます。',
noDownloadUrl:
'現在のシステムに適したインストールパッケージが見つかりません。手動でダウンロードしてください',
installConfirmTitle: '更新をインストール',
@@ -52,6 +62,31 @@ export default {
copyFailed: 'コピーに失敗しました',
backgroundDownload: 'バックグラウンドダウンロード'
},
disclaimer: {
title: '使用上の注意',
warning:
'このアプリは開発テスト版であり、機能が不完全で、多くの問題やバグが存在する可能性があります。学習と交流のみを目的としています。',
item1:
'このアプリは個人の学習、研究、技術交流のみを目的としています。商業目的で使用しないでください。',
item2:
'ダウンロード後24時間以内に削除してください。長期使用を希望される場合は、正規の音楽サービスをサポートしてください。',
item3:
'このアプリを使用することで、関連するリスクを理解し、負担するものとします。開発者は一切の損失に対して責任を負いません。',
agree: '以上の内容を読み、同意します',
disagree: '同意せずに終了'
},
donate: {
title: '開発者を支援',
subtitle: '皆様のサポートが私の原動力です',
tip: '寄付は完全に任意です。寄付しなくてもすべての機能を通常通り使用できます。ご理解とご支援に感謝します!',
wechat: 'WeChat',
alipay: 'Alipay',
wechatQR: 'WeChat 受取コード',
alipayQR: 'Alipay 受取コード',
scanTip: 'スマートフォンのアプリで上記のQRコードをスキャンして寄付してください',
enterApp: 'アプリに入る',
noForce: '寄付は強制ではありません。クリックして入れます'
},
coffee: {
title: 'コーヒーをおごる',
alipay: 'Alipay',
@@ -79,7 +114,68 @@ export default {
songlist: '毎日のおすすめリスト'
},
recommendSonglist: {
title: '今週の人気音楽'
title: '今週の人気音楽',
empty: 'おすすめのプレイリストがありません'
},
dailyRecommend: {
title: '毎日のおすすめ',
badge: 'おすすめ',
empty: 'おすすめの曲がありません',
intelligenceHint: 'インテリジェンスモードをオンにして、もっと好きな音楽を見つけましょう'
},
recommendMV: {
title: 'おすすめMV'
},
newAlbum: {
title: 'アルバム',
empty: '新しいアルバムがありません'
},
recommendNewMusic: {
title: '新曲速報'
},
privateContent: {
title: '独占配信'
},
djProgram: {
title: 'おすすめラジオ'
},
homeHero: {
dailyRecommend: '毎日のおすすめ',
songs: '曲',
playNow: '今すぐ再生',
intelligenceMode: 'インテリジェンスモード',
intelligenceModeOn: '再生中',
intelligenceModeDesc: 'スマート推薦を開始',
intelligenceModeActiveDesc: 'あなたの好みに基づくスマート推薦',
startIntelligence: '開始',
stopIntelligence: '停止',
playing: '再生中',
toplistDesc: 'トレンド',
mvDesc: 'ミュージックビデオ',
playlistDesc: '厳選プレイリスト',
personalFm: 'パーソナルFM',
discoverMusic: '新しい音楽を発見',
personalFmDesc: 'あなたの好みに基づいて',
recentPlays: '最近再生した曲',
viewAll: 'すべて表示',
followedArtists: 'フォロー中',
newSongs: '曲の新曲',
fromFollowedArtists: 'フォロー中のアーティストから',
recommendNewMusic: 'おすすめ新曲',
newSongExpress: '新曲速報',
discoverNewReleases: '最新リリースを見つけよう',
hotPlaylists: '人気プレイリスト',
hotArtists: '人気アーティスト',
hotArtistsTitle: '人気アーティスト',
hotArtistsDesc: '今最も人気のあるアーティスト',
fmTrash: '嫌い',
fmNext: '次へ',
quickNav: {
myFavorite: 'お気に入り',
playHistory: '再生履歴',
myProfile: 'マイページ',
toplist: 'ランキング'
}
},
searchBar: {
login: 'ログイン',
@@ -94,7 +190,13 @@ export default {
zoom: 'ページズーム',
zoom100: '標準ズーム100%',
resetZoom: 'クリックしてズームをリセット',
zoomDefault: '標準ズーム'
zoomDefault: '標準ズーム',
tabPlaylist: 'プレイリスト',
tabMv: 'MV',
tabCharts: 'チャート',
cancelSearch: 'キャンセル',
intelligenceMode: '心動モード',
exitIntelligence: '心動モードを終了'
},
titleBar: {
closeTitle: '閉じる方法を選択してください',
@@ -119,7 +221,12 @@ export default {
cancelCollect: 'お気に入りから削除',
addToPlaylist: 'プレイリストに追加',
addToPlaylistSuccess: 'プレイリストに追加しました',
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します'
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
locateCurrent: '再生中の曲を表示',
historyRecommend: '履歴の日次推薦',
fetchDatesFailed: '日付リストの取得に失敗しました',
fetchSongsFailed: '楽曲リストの取得に失敗しました',
noSongs: '楽曲がありません'
},
playlist: {
import: {
@@ -145,6 +252,7 @@ export default {
albumNamePlaceholder: 'アルバム名',
addSongButton: '楽曲を追加',
addLinkButton: 'リンクを追加',
options: 'オプション',
importToStarPlaylist: 'お気に入りの音楽にインポート',
playlistNamePlaceholder: 'プレイリスト名を入力してください',
importButton: 'インポート開始',
@@ -189,5 +297,41 @@ export default {
list: 'プレイリスト',
mv: 'MV',
home: 'ホーム',
search: '検索'
search: '検索',
album: 'アルバム',
localMusic: 'ローカル音楽',
pages: {
toplist: {
desc: '最も権威ある音楽チャート、今一番ホットな音楽を発見'
},
mv: {
desc: '素晴らしい動画コンテンツを探索',
loadingMore: 'もっと読み込み中...',
noMore: '— すべて読み込みました —',
area: {
all: 'すべて',
mainland: '中国大陸',
hktw: '香港・台湾',
western: '欧米',
japan: '日本',
korea: '韓国'
}
},
list: {
desc: 'もっと素敵なプレイリストを発見',
dailyRecommend: 'デイリーおすすめ'
},
search: {
desc: '今最もホットな検索トレンドを探索'
},
album: {
area: {
all: 'すべて',
chinese: '中華圏',
western: '欧米',
korea: '韓国',
japan: '日本'
}
}
}
};

View File

@@ -10,7 +10,8 @@ export default {
},
empty: {
noTasks: 'ダウンロードタスクがありません',
noDownloaded: 'ダウンロード済みの楽曲がありません'
noDownloaded: 'ダウンロード済みの楽曲がありません',
noDownloadedHint: '好きな曲をダウンロードしましょう'
},
progress: {
total: '全体の進行状況: {progress}%'
@@ -19,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: '不明なアーティスト'
@@ -40,7 +55,8 @@ export default {
'すべてのダウンロード記録をクリアしますか?この操作はダウンロード済みの音楽ファイルを削除しませんが、すべての記録をクリアします。',
confirm: 'クリア確認',
cancel: 'キャンセル',
success: 'ダウンロード記録をクリアしました'
success: 'ダウンロード記録をクリアしました',
failed: 'ダウンロード記録のクリアに失敗しました'
},
message: {
downloadComplete: '{filename}のダウンロードが完了しました',
@@ -50,6 +66,7 @@ export default {
playStarted: '再生開始: {name}',
playFailed: '再生失敗: {name}',
path: {
copy: 'パスをコピー',
copied: 'パスをクリップボードにコピーしました',
copyFailed: 'パスのコピーに失敗しました'
},
@@ -61,6 +78,8 @@ export default {
noPathSelected: 'まずダウンロードパスを選択してください',
select: 'フォルダを選択',
open: 'フォルダを開く',
saveLyric: '歌詞ファイルを個別に保存',
saveLyricDesc: '楽曲ダウンロード時に .lrc 歌詞ファイルも一緒に保存します',
fileFormat: 'ファイル名形式',
fileFormatDesc: '音楽ダウンロード時のファイル命名形式を設定',
customFormat: 'カスタム形式',
@@ -73,6 +92,8 @@ export default {
dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:',
formatVariables: '使用可能な変数',
preview: 'プレビュー効果:',
concurrency: '最大同時ダウンロード数',
concurrencyDesc: '同時にダウンロードする最大曲数1-5',
saveSuccess: 'ダウンロード設定を保存しました',
presets: {
songArtist: '楽曲名 - アーティスト名',
@@ -84,5 +105,10 @@ export default {
artistName: 'アーティスト名',
albumName: 'アルバム名'
}
},
error: {
incomplete: 'ファイルのダウンロードが不完全です',
urlExpired: 'URLの有効期限が切れました。再取得中',
resumeFailed: '再開に失敗しました'
}
};

View File

@@ -1,5 +1,49 @@
export default {
title: '再生履歴',
heatmapTitle: 'ヒートマップ',
playCount: '{count}',
getHistoryFailed: '履歴の取得に失敗しました'
getHistoryFailed: '履歴の取得に失敗しました',
tabs: {
all: 'すべての記録',
local: 'ローカル記録',
cloud: 'クラウド記録'
},
categoryTabs: {
songs: '楽曲',
playlists: 'プレイリスト',
albums: 'アルバム',
podcasts: 'ポッドキャスト'
},
podcastTabs: {
episodes: 'エピソード',
radios: 'ラジオ'
},
noDescription: '説明なし',
noData: '記録なし',
getCloudRecordFailed: 'クラウド記録の取得に失敗しました',
needLogin: 'cookieを使用してログインしてクラウド記録を表示できます',
merging: '記録を統合中...',
heatmap: {
title: '再生ヒートマップ',
loading: 'データを読み込み中...',
unit: '回再生',
footerText: 'ホバーして詳細を表示',
playCount: '{count} 回再生',
topSongs: 'その日の人気曲',
times: '回',
totalPlays: '総再生回数',
activeDays: 'アクティブ日数',
noData: '再生記録がありません',
colorTheme: 'カラーテーマ',
colors: {
green: 'グリーン',
blue: 'ブルー',
orange: 'オレンジ',
purple: 'パープル',
red: 'レッド'
},
mostPlayedSong: '最も再生された曲',
mostActiveDay: '最もアクティブな日',
latestNightSong: '深夜に再生した曲'
}
};

View File

@@ -0,0 +1,13 @@
export default {
title: 'ローカル音楽',
scanFolder: 'フォルダをスキャン',
removeFolder: 'フォルダを削除',
scanning: 'スキャン中...',
scanComplete: 'スキャン完了',
playAll: 'すべて再生',
search: 'ローカル音楽を検索',
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン',
songCount: '{count} 曲'
};

View File

@@ -42,7 +42,22 @@ export default {
autoGetCookieSuccess: 'Cookie自動取得成功',
autoGetCookieFailed: 'Cookie自動取得失敗',
autoGetCookieTip:
'NetEase Cloud Musicのログインページを開きます。ログイン完了後、ウィンドウを閉じてください'
'NetEase Cloud Musicのログインページを開きます。ログイン完了後、ウィンドウを閉じてください',
loginFailed: 'ログイン失敗',
phoneRequired: '電話番号を入力してください',
passwordRequired: 'パスワードを入力してください',
phoneLoginFailed:
'電話番号でのログインに失敗しました。電話番号とパスワードが正しいか確認してください',
qrCheckFailed: 'QRコードの状態確認に失敗しました。リフレッシュして再試行してください',
qrLoading: 'QRコードを読み込み中...',
qrExpired: 'QRコードの期限が切れました。クリックしてリフレッシュしてください',
qrExpiredShort: 'QRコード期限切れ',
qrExpiredWarning: 'QRコードの期限が切れました。クリックして新しいQRコードを取得してください',
qrScanned: 'QRコードがスキャンされました。スマートフォンでログインを確認してください',
qrScannedShort: 'スキャン済み',
qrScannedInfo: 'QRコードがスキャンされました。スマートフォンでログインを確認してください',
qrConfirmed: 'ログイン成功、リダイレクト中...',
qrGenerating: 'QRコードを生成中...'
},
qrTitle: 'NetEase Cloud Music QRコードログイン',
uidWarning:

View File

@@ -14,6 +14,12 @@ export default {
addCorrection: '{num}秒早める',
subtractCorrection: '{num}秒遅らせる',
playFailed: '現在の楽曲の再生に失敗しました。次の曲を再生します',
parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します',
consecutiveFailsError:
'再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください',
playListEnded: 'プレイリストの最後に到達しました',
autoResumed: '自動的に再生を再開しました',
resumeFailed: '再生の再開に失敗しました。手動でお試しください',
playMode: {
sequence: '順次再生',
loop: 'リピート再生',
@@ -29,7 +35,8 @@ export default {
list: '自動で次の曲を再生'
},
lrc: {
noLrc: '歌詞がありません。お楽しみください'
noLrc: '歌詞がありません。お楽しみください',
noAutoScroll: '本歌詞は自動スクロールをサポートしていません'
},
reparse: {
title: '解析音源を選択',
@@ -66,7 +73,17 @@ export default {
unFavorite: '{name}をお気に入りから削除しました',
miniPlayBar: 'ミニ再生バー',
playbackSpeed: '再生速度',
advancedControls: 'その他の設定'
advancedControls: 'その他の設定',
intelligenceMode: {
title: 'インテリジェンスモード',
needCookieLogin: 'Cookie方式でログインしてからインテリジェンスモードを使用してください',
noFavoritePlaylist: '「お気に入りの音楽」プレイリストが見つかりません',
noLikedSongs: 'まだ「いいね」した楽曲がありません',
loading: 'インテリジェンスモードを読み込み中',
success: '{count} 曲を読み込みました',
failed: 'インテリジェンスモードのリスト取得に失敗しました',
error: 'インテリジェンスモードの再生でエラーが発生しました'
}
},
eq: {
title: 'イコライザー',
@@ -92,6 +109,11 @@ export default {
custom: 'カスタム'
}
},
// プレイヤー設定
settings: {
title: '再生設定',
playbackSpeed: '再生速度'
},
// タイマー機能関連
sleepTimer: {
title: 'スリープタイマー',

View File

@@ -0,0 +1,40 @@
export default {
podcast: 'ポッドキャスト',
mySubscriptions: '購読中',
discover: '発見',
categories: 'カテゴリー',
todayPerfered: '今日のおすすめ',
recommended: 'おすすめ',
hotRanking: '人気',
newRanking: '新着',
subscribeCount: '購読者',
programCount: 'エピソード',
subscribe: '購読',
subscribed: '購読中',
unsubscribe: '購読解除',
unsubscribed: '購読を解除しました',
subscribeSuccess: '購読しました',
unsubscribeFailed: '購読解除に失敗しました',
subscribeFailed: '購読に失敗しました',
radioDetail: 'ラジオ詳細',
programList: 'エピソード一覧',
playProgram: '再生',
recentPlayed: '最近再生',
listeners: 'リスナー',
noSubscriptions: '購読なし',
goDiscover: 'ポッドキャストを探す',
searchPodcast: 'ポッドキャスト検索',
category: 'カテゴリー',
all: 'すべて',
dj: 'パーソナリティ',
episodes: '話',
playAll: 'すべて再生',
popularCategories: '人気カテゴリー',
allCategories: 'すべてのカテゴリー',
categoryRadios: 'カテゴリーラジオ',
exploreCategoryRadios: 'もっと素晴らしいラジオを探す',
hotRadios: '人気ラジオ',
noCategoryRadios: 'このカテゴリーにはラジオがありません',
searchPlaceholder: 'ポッドキャスト、エピソードを検索...',
searchResults: '検索結果'
};

View File

@@ -11,7 +11,8 @@ export default {
},
loading: {
more: '読み込み中...',
failed: '検索に失敗しました'
failed: '検索に失敗しました',
searching: '検索中...'
},
noMore: 'これ以上ありません',
error: {
@@ -22,6 +23,11 @@ export default {
album: 'アルバム',
playlist: 'プレイリスト',
mv: 'MV',
djradio: 'ラジオ',
bilibili: 'Bilibili'
}
},
history: '検索履歴',
hot: '人気検索',
suggestions: '検索候補'
};

View File

@@ -10,7 +10,7 @@ export default {
network: 'ネットワーク設定',
system: 'システム管理',
donation: '寄付サポート',
regard: 'について'
about: 'について'
},
basic: {
themeMode: 'テーマモード',
@@ -57,7 +57,10 @@ export default {
gpuAccelerationRestart: 'GPUアクセラレーション設定の変更はアプリの再起動後に有効になります',
gpuAccelerationChangeSuccess:
'GPUアクセラレーション設定を更新しました。アプリの再起動後に有効になります',
gpuAccelerationChangeError: 'GPUアクセラレーション設定の更新に失敗しました'
gpuAccelerationChangeError: 'GPUアクセラレーション設定の更新に失敗しました',
tabletMode: 'タブレットモード',
tabletModeDesc:
'タブレットモードを有効にすると、モバイルデバイスでPCスタイルのインターフェースを使用できます'
},
playback: {
quality: '音質設定',
@@ -84,6 +87,10 @@ export default {
gdmusicInfo: 'GD音楽台は複数のプラットフォーム音源を自動解析し、最適な結果を自動選択できます',
autoPlay: '自動再生',
autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか',
audioDevice: 'オーディオ出力デバイス',
audioDeviceDesc: 'スピーカー、ヘッドホン、Bluetoothデバイスなどの出力先を選択',
testAudio: 'テスト',
selectAudioDevice: '出力デバイスを選択',
showStatusBar: 'ステータスバーコントロール機能を表示するかどうか',
showStatusBarContent:
'Macのステータスバーに音楽コントロール機能を表示できます再起動後に有効',
@@ -95,9 +102,13 @@ export default {
sourceLabels: {
migu: 'Migu',
kugou: 'Kugou',
kuwo: 'Kuwo',
pyncmd: 'NetEase (内蔵)',
qq: 'QQ Music',
joox: 'JOOX',
bilibili: 'Bilibili',
gdmusic: 'GD 音楽台',
lxMusic: 'LX Music',
custom: 'カスタム API'
},
customApi: {
@@ -108,7 +119,38 @@ export default {
currentSource: '現在の音源',
notImported: 'カスタム音源はまだインポートされていません。',
importSuccess: '音源のインポートに成功しました: {name}',
importFailed: 'インポートに失敗しました: {message}'
importFailed: 'インポートに失敗しました: {message}',
status: {
imported: 'カスタム音源インポート済み',
notImported: '未インポート'
}
},
lxMusic: {
tabs: {
sources: '音源選択',
lxMusic: '落雪音源',
customApi: 'カスタムAPI'
},
scripts: {
title: 'インポート済みのスクリプト',
importLocal: 'ローカルインポート',
importOnline: 'オンラインインポート',
urlPlaceholder: '落雪音源スクリプトのURLを入力',
importBtn: 'インポート',
empty: 'インポート済みの落雪音源はありません',
notConfigured: '未設定(落雪音源タブで設定してください)',
importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します',
noScriptWarning: '先に落雪音源スクリプトをインポートしてください',
noSelectionWarning: '先に落雪音源を選択してください',
notFound: '音源が存在しません',
switched: '音源を切り替えました: {name}',
deleted: '音源を削除しました: {name}',
enterUrl: 'スクリプトURLを入力してください',
invalidUrl: '無効なURL形式',
invalidScript: '無効な落雪音源スクリプトですglobalThis.lxが見つかりません',
nameRequired: '名前を空にすることはできません',
renameSuccess: '名前を変更しました'
}
}
},
application: {
@@ -153,6 +195,35 @@ export default {
system: {
cache: 'キャッシュ管理',
cacheDesc: 'キャッシュをクリア',
diskCache: 'ディスクキャッシュ',
diskCacheDesc: '再生した音楽と歌詞をローカルディスクへ保存し、再生速度を向上します',
cacheDirectory: 'キャッシュディレクトリ',
cacheDirectoryDesc: '音楽・歌詞キャッシュの保存先を指定',
selectDirectory: 'ディレクトリ選択',
openDirectory: 'ディレクトリを開く',
cacheMaxSize: 'キャッシュ上限',
cacheMaxSizeDesc: '上限に達すると古いキャッシュを自動削除します',
cleanupPolicy: 'クリーンアップポリシー',
cleanupPolicyDesc: 'キャッシュ上限到達時の自動削除ルール',
cleanupPolicyOptions: {
lru: '最近未使用優先',
fifo: '先入れ先出し'
},
cacheStatus: 'キャッシュ状態',
cacheStatusDesc: '使用量 {used} / 上限 {limit}',
cacheStatusDetail: '音楽 {musicCount} 曲、歌詞 {lyricCount} 曲',
manageDiskCache: '手動キャッシュクリア',
manageDiskCacheDesc: '種類ごとにキャッシュを削除',
clearMusicCache: '音楽キャッシュを削除',
clearLyricCache: '歌詞キャッシュを削除',
clearAllCache: 'すべて削除',
switchDirectoryMigrateTitle: '既存キャッシュを検出',
switchDirectoryMigrateContent: '旧ディレクトリのキャッシュを新ディレクトリへ移行しますか?',
switchDirectoryMigrateConfirm: '移行する',
switchDirectoryDestroyTitle: '旧キャッシュを削除',
switchDirectoryDestroyContent: '移行しない場合、旧ディレクトリのキャッシュを削除しますか?',
switchDirectoryDestroyConfirm: '削除する',
switchDirectoryKeepOld: '旧キャッシュを保持',
cacheClearTitle: 'クリアするキャッシュタイプを選択してください:',
cacheTypes: {
history: {
@@ -187,7 +258,15 @@ export default {
restart: '再起動',
restartDesc: 'アプリを再起動',
messages: {
clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります'
clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります',
diskCacheClearSuccess: 'ディスクキャッシュを削除しました',
diskCacheClearFailed: 'ディスクキャッシュの削除に失敗しました',
diskCacheStatsLoadFailed: 'キャッシュ状態の取得に失敗しました',
switchDirectorySuccess: 'キャッシュディレクトリを切り替えました(旧キャッシュは保持)',
switchDirectoryFailed: 'キャッシュディレクトリの切り替えに失敗しました',
switchDirectoryMigrated: 'キャッシュディレクトリを切り替え、{count} 件を移行しました',
switchDirectoryDestroyed:
'キャッシュディレクトリを切り替え、旧キャッシュ {count} 件を削除しました'
}
},
about: {
@@ -197,6 +276,7 @@ export default {
latest: '現在最新バージョンです',
hasUpdate: '新しいバージョンが見つかりました',
gotoUpdate: '更新へ',
manualUpdate: '手動更新',
gotoGithub: 'Githubへ',
author: '作者',
authorDesc: 'algerkong スターを付けてください🌟',
@@ -215,6 +295,7 @@ export default {
display: '表示',
interface: 'インターフェース',
typography: 'テキスト',
background: '背景',
mobile: 'モバイル'
},
pureMode: 'ピュアモード',
@@ -237,6 +318,12 @@ export default {
medium: '中',
large: '大'
},
fontWeight: 'フォントの太さ',
fontWeightMarks: {
thin: '細い',
normal: '通常',
bold: '太い'
},
letterSpacing: '文字間隔',
letterSpacingMarks: {
compact: 'コンパクト',
@@ -249,6 +336,7 @@ export default {
default: 'デフォルト',
loose: 'ゆったり'
},
contentWidth: 'コンテンツ幅',
mobileLayout: 'モバイルレイアウト',
layoutOptions: {
default: 'デフォルト',
@@ -262,7 +350,46 @@ export default {
full: 'フルスクリーン'
},
lyricLines: '歌詞行数',
mobileUnavailable: 'この設定はモバイルでのみ利用可能です'
mobileUnavailable: 'この設定はモバイルでのみ利用可能です',
// 背景設定
background: {
useCustomBackground: 'カスタム背景を使用',
backgroundMode: '背景モード',
modeOptions: {
solid: '単色',
gradient: 'グラデーション',
image: '画像',
css: 'CSS'
},
solidColor: '色を選択',
presetColors: 'プリセットカラー',
customColor: 'カスタムカラー',
gradientEditor: 'グラデーションエディター',
gradientColors: 'グラデーションカラー',
gradientDirection: 'グラデーション方向',
directionOptions: {
toBottom: '上から下',
toRight: '左から右',
toBottomRight: '左上から右下',
angle45: '45度',
toTop: '下から上',
toLeft: '右から左'
},
addColor: '色を追加',
removeColor: '色を削除',
imageUpload: '画像をアップロード',
imagePreview: '画像プレビュー',
clearImage: '画像をクリア',
imageBlur: 'ぼかし',
imageBrightness: '明るさ',
customCss: 'カスタム CSS スタイル',
customCssPlaceholder: 'CSSスタイルを入力、例: background: linear-gradient(...)',
customCssHelp: '任意のCSS background プロパティをサポート',
reset: 'デフォルトにリセット',
fileSizeLimit: '画像サイズ制限: 20MB',
invalidImageFormat: '無効な画像形式',
imageTooLarge: '画像が大きすぎます。20MB未満の画像を選択してください'
}
},
translationEngine: '歌詞翻訳エンジン',
translationEngineOptions: {
@@ -293,28 +420,61 @@ export default {
title: 'ショートカット設定',
shortcut: 'ショートカット',
shortcutDesc: 'ショートカットをカスタマイズ',
summaryReady: 'ショートカット設定は保存可能です',
summaryRecording: '新しいショートカットを記録中です',
summaryBlocked: '競合または無効な項目を修正してください',
platformHintMac: 'macOS では CommandOrControl は Cmd と表示されます',
platformHintWindows: 'Windows では CommandOrControl は Ctrl と表示されます',
platformHintLinux: 'Linux では CommandOrControl は Ctrl と表示されます',
platformHintGeneric: 'CommandOrControl はOSに応じて自動変換されます',
enabledCount: '有効',
recordingTip: '欄をクリックしてキー入力。Escでキャンセル、Deleteで無効化',
shortcutConflict: 'ショートカットの競合',
inputPlaceholder: 'クリックしてショートカットを入力',
clickToRecord: 'クリックしてキーを入力',
recording: '記録中...',
resetShortcuts: 'デフォルトに戻す',
restoreSingle: '復元',
disableAll: 'すべて無効',
enableAll: 'すべて有効',
groups: {
playback: '再生操作',
sound: '音量とお気に入り',
window: 'ウィンドウ'
},
togglePlay: '再生/一時停止',
togglePlayDesc: '現在の再生状態を切り替えます',
prevPlay: '前の曲',
prevPlayDesc: '前の曲に切り替えます',
nextPlay: '次の曲',
nextPlayDesc: '次の曲に切り替えます',
volumeUp: '音量を上げる',
volumeUpDesc: 'プレイヤー音量を上げます',
volumeDown: '音量を下げる',
volumeDownDesc: 'プレイヤー音量を下げます',
toggleFavorite: 'お気に入り/お気に入り解除',
toggleFavoriteDesc: '現在の曲をお気に入り切り替えします',
toggleWindow: 'ウィンドウ表示/非表示',
toggleWindowDesc: 'メインウィンドウを表示/非表示にします',
scopeGlobal: 'グローバル',
scopeApp: 'アプリ内',
enabled: '有効',
disabled: '無効',
issueInvalid: '無効な組み合わせ',
issueReserved: 'システム予約',
registrationWarningTitle: '以下のショートカットは登録できませんでした',
registrationOccupied: 'システムまたは他アプリで使用中',
registrationInvalid: 'ショートカット形式が無効',
messages: {
resetSuccess: 'デフォルトのショートカットに戻しました。保存を忘れずに',
conflict: '競合するショートカットがあります。再設定してください',
saveSuccess: 'ショートカット設定を保存しました',
saveError: 'ショートカットの保存に失敗しました。再試行してください',
saveValidationError: 'ショートカット検証に失敗しました。内容を確認してください',
partialRegistered: '保存しましたが、一部のグローバルショートカットは登録されませんでした',
cancelEdit: '変更をキャンセルしました',
clearToDisable: 'このショートカットを無効にしました',
invalidShortcut: '無効なショートカットです。有効な組み合わせを入力してください',
disableAll: 'すべてのショートカットを無効にしました。保存を忘れずに',
enableAll: 'すべてのショートカットを有効にしました。保存を忘れずに'
}

View File

@@ -3,6 +3,7 @@ export default {
play: '再生',
playNext: '次に再生',
download: '楽曲をダウンロード',
downloadLyric: '歌詞をダウンロード',
addToPlaylist: 'プレイリストに追加',
favorite: 'いいね',
unfavorite: 'いいね解除',
@@ -15,7 +16,11 @@ export default {
downloadFailed: 'ダウンロードに失敗しました',
downloadQueued: 'ダウンロードキューに追加しました',
addedToNextPlay: '次の再生に追加しました',
getUrlFailed: '音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください'
getUrlFailed:
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
noLyric: 'この楽曲には歌詞がありません',
lyricDownloaded: '歌詞のダウンロードが完了しました',
lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
},
dialog: {
dislike: {

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}曲',
playCount: '{count}回再生'
},
tabs: {
created: '作成',
favorite: 'お気に入り',
album: 'アルバム'
},
ranking: {
title: '聴取ランキング',
playCount: '{count}回'

View File

@@ -1,51 +0,0 @@
export default {
player: {
loading: '오디오 로딩 중...',
retry: '다시 시도',
playNow: '지금 재생',
loadingTitle: '로딩 중...',
totalDuration: '총 재생시간: {duration}',
partsList: '파트 목록 ({count}화)',
playStarted: '재생이 시작되었습니다',
switchingPart: '파트 전환 중: {part}',
preloadingNext: '다음 파트 미리 로딩 중: {part}',
playingCurrent: '현재 선택된 파트 재생 중: {name}',
num: '만',
errors: {
invalidVideoId: '유효하지 않은 비디오 ID',
loadVideoDetailFailed: '비디오 세부정보 로드 실패',
loadPartInfoFailed: '비디오 파트 정보를 로드할 수 없습니다',
loadAudioUrlFailed: '오디오 재생 URL 가져오기 실패',
videoDetailNotLoaded: '비디오 세부정보가 로드되지 않았습니다',
missingParams: '필수 매개변수가 누락되었습니다',
noAvailableAudioUrl: '사용 가능한 오디오 URL을 찾을 수 없습니다',
loadPartAudioFailed: '파트 오디오 URL 로드 실패',
audioListEmpty: '오디오 목록이 비어있습니다. 다시 시도해주세요',
currentPartNotFound: '현재 파트의 오디오를 찾을 수 없습니다',
audioUrlFailed: '오디오 URL 가져오기 실패',
playFailed: '재생 실패. 다시 시도해주세요',
getAudioUrlFailed: '오디오 URL 가져오기 실패. 다시 시도해주세요',
audioNotFound: '해당 오디오를 찾을 수 없습니다. 다시 시도해주세요',
preloadFailed: '다음 파트 미리 로딩 실패',
switchPartFailed: '파트 전환 시 오디오 URL 로드 실패'
},
console: {
loadingDetail: 'Bilibili 비디오 세부정보 로딩 중',
detailData: 'Bilibili 비디오 세부정보 데이터',
multipleParts: '비디오에 여러 파트가 있습니다. 총 {count}개',
noPartsData: '비디오에 파트가 없거나 파트 데이터가 비어있습니다',
loadingAudioSource: '오디오 소스 로딩 중',
generatedAudioList: '오디오 목록을 생성했습니다. 총 {count}개',
getDashAudioUrl: 'dash 오디오 URL을 가져왔습니다',
getDurlAudioUrl: 'durl 오디오 URL을 가져왔습니다',
loadingPartAudio: '파트 오디오 URL 로딩 중: {part}, cid: {cid}',
loadPartAudioFailed: '파트 오디오 URL 로드 실패: {part}',
switchToPart: '파트로 전환 중: {part}',
audioNotFoundInList: '해당 오디오 항목을 찾을 수 없습니다',
preparingToPlay: '현재 선택된 파트 재생 준비 중: {name}',
preloadingNextPart: '다음 파트 미리 로딩 중: {part}',
playingSelectedPart: '현재 선택된 파트 재생 중: {name}, 오디오 URL: {url}',
preloadNextFailed: '다음 파트 미리 로딩 실패'
}
}
};

View File

@@ -15,6 +15,7 @@ export default {
hide: '숨기기',
confirm: '확인',
cancel: '취소',
clear: '비우기',
configure: '구성',
open: '열기',
modify: '수정',
@@ -27,6 +28,8 @@ export default {
refresh: '새로고침',
retry: '다시 시도',
reset: '재설정',
loadFailed: '로드 실패',
noData: '데이터 없음',
back: '뒤로',
copySuccess: '클립보드에 복사됨',
copyFailed: '복사 실패',
@@ -39,10 +42,13 @@ export default {
viewMore: '더 보기',
noMore: '더 이상 없음',
selectAll: '전체 선택',
playAll: '모두 재생',
expand: '펼치기',
collapse: '접기',
songCount: '{count}곡',
language: '언어',
today: '오늘',
yesterday: '어제',
tray: {
show: '표시',
quit: '종료',

View File

@@ -1,4 +1,8 @@
export default {
more: '더 보기',
homeListItem: {
loading: '로딩 중...'
},
installApp: {
description: '앱을 설치하여 더 나은 경험을 얻으세요',
noPrompt: '다시 묻지 않기',
@@ -33,11 +37,17 @@ export default {
title: '새 버전 발견',
currentVersion: '현재 버전',
cancel: '나중에 업데이트',
checking: '업데이트 확인 중...',
prepareDownload: '다운로드 준비 중...',
downloading: '다운로드 중...',
readyToInstall: '업데이트 패키지 다운로드가 완료되었습니다. 지금 설치할 수 있습니다',
nowUpdate: '지금 업데이트',
downloadFailed: '다운로드 실패, 다시 시도하거나 수동으로 다운로드해주세요',
startFailed: '다운로드 시작 실패, 다시 시도하거나 수동으로 다운로드해주세요',
autoUpdateFailed: '자동 업데이트에 실패했습니다',
openOfficialSite: '공식 페이지에서 업데이트',
manualFallbackHint:
'자동 업데이트에 실패하면 공식 릴리스 페이지에서 최신 버전을 다운로드할 수 있습니다.',
noDownloadUrl: '현재 시스템에 적합한 설치 패키지를 찾을 수 없습니다. 수동으로 다운로드해주세요',
installConfirmTitle: '업데이트 설치',
installConfirmContent: '앱을 닫고 업데이트를 설치하시겠습니까?',
@@ -51,6 +61,31 @@ export default {
copyFailed: '복사 실패',
backgroundDownload: '백그라운드 다운로드'
},
disclaimer: {
title: '이용 안내',
warning:
'본 앱은 개발 테스트 버전으로 기능이 아직 미흡하며, 다수의 문제와 버그가 존재할 수 있습니다. 학습 및 교류 목적으로만 사용하십시오.',
item1:
'본 앱은 개인의 학습, 연구 및 기술 교류 목적으로만 사용되며, 상업적 용도로 사용하지 마십시오.',
item2:
'다운로드 후 24시간 이내에 삭제해 주십시오. 장기 사용을 원하시면 정품 음악 서비스를 이용해 주십시오.',
item3:
'본 앱을 사용함으로써 관련 위험을 이해하고 감수하는 것으로 간주합니다. 개발자는 어떠한 손실에 대해서도 책임을 지지 않습니다.',
agree: '숙지하였으며 이에 동의합니다',
disagree: '동의하지 않음 및 정지'
},
donate: {
title: '개발자 지원',
subtitle: '여러분의 지원이 저의 원동력입니다',
tip: '후원은 완전히 자율적입니다. 후원하지 않더라도 모든 기능을 정상적으로 사용할 수 있습니다. 이해와 지원에 감사드립니다!',
wechat: 'WeChat',
alipay: 'Alipay',
wechatQR: 'WeChat 결제 코드',
alipayQR: 'Alipay 결제 코드',
scanTip: '휴대전화로 위 QR 코드를 스캔하여 후원해 주세요',
enterApp: '앱 시작하기',
noForce: '후원은 강제가 아닙니다. 클릭하여 시작할 수 있습니다'
},
coffee: {
title: '커피 한 잔 사주세요',
alipay: '알리페이',
@@ -78,7 +113,68 @@ export default {
songlist: '일일 추천 목록'
},
recommendSonglist: {
title: '이번 주 인기 음악'
title: '이번 주 인기 음악',
empty: '추천 플레이리스트가 없습니다'
},
dailyRecommend: {
title: '일일 추천',
badge: '추천',
empty: '추천 곡이 없습니다',
intelligenceHint: '하트 모드를 켜서 더 좋아하는 음악을 발견하세요'
},
recommendMV: {
title: '추천 MV'
},
newAlbum: {
title: '앨범',
empty: '새 앨범이 없습니다'
},
recommendNewMusic: {
title: '신곡 속보'
},
privateContent: {
title: '독점 콘텐츠'
},
djProgram: {
title: '추천 라디오'
},
homeHero: {
dailyRecommend: '일일 추천',
songs: '곡',
playNow: '지금 재생',
intelligenceMode: '하트 모드',
intelligenceModeOn: '재생 중',
intelligenceModeDesc: '스마트 추천 시작',
intelligenceModeActiveDesc: '취향에 맞는 스마트 추천',
startIntelligence: '시작',
stopIntelligence: '중지',
playing: '재생 중',
toplistDesc: '인기 차트',
mvDesc: '뮤직비디오',
playlistDesc: '엄선된 플레이리스트',
personalFm: '개인 FM',
discoverMusic: '새로운 음악 발견',
personalFmDesc: '취향에 맞춘 추천',
recentPlays: '최근 재생',
viewAll: '전체 보기',
followedArtists: '팔로우 아티스트',
newSongs: '곡의 신곡',
fromFollowedArtists: '팔로우한 아티스트의 신곡',
recommendNewMusic: '추천 신곡',
newSongExpress: '신곡 속보',
discoverNewReleases: '최신 발매 곡을 발견하세요',
hotPlaylists: '인기 플레이리스트',
hotArtists: '인기 아티스트',
hotArtistsTitle: '인기 아티스트',
hotArtistsDesc: '지금 가장 인기 있는 아티스트',
fmTrash: '싫어요',
fmNext: '다음',
quickNav: {
myFavorite: '내 즐겨찾기',
playHistory: '재생 기록',
myProfile: '내 프로필',
toplist: '순위'
}
},
searchBar: {
login: '로그인',
@@ -93,7 +189,13 @@ export default {
zoom: '페이지 확대/축소',
zoom100: '표준 확대/축소 100%',
resetZoom: '클릭하여 확대/축소 재설정',
zoomDefault: '표준 확대/축소'
zoomDefault: '표준 확대/축소',
tabPlaylist: '플레이리스트',
tabMv: 'MV',
tabCharts: '차트',
cancelSearch: '취소',
intelligenceMode: '심쿵 모드',
exitIntelligence: '심쿵 모드 종료'
},
titleBar: {
closeTitle: '닫기 방법을 선택해주세요',
@@ -118,7 +220,12 @@ export default {
cancelCollect: '수집 취소',
addToPlaylist: '재생 목록에 추가',
addToPlaylistSuccess: '재생 목록에 추가 성공',
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다'
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
locateCurrent: '현재 재생 곡 찾기',
historyRecommend: '일일 기록 권장',
fetchDatesFailed: '날짜를 가져오지 못했습니다',
fetchSongsFailed: '곡을 가져오지 못했습니다',
noSongs: '노래 없음'
},
playlist: {
import: {
@@ -144,6 +251,7 @@ export default {
albumNamePlaceholder: '앨범명',
addSongButton: '곡 추가',
addLinkButton: '링크 추가',
options: '옵션',
importToStarPlaylist: '내가 좋아하는 음악으로 가져오기',
playlistNamePlaceholder: '플레이리스트 이름을 입력하세요',
importButton: '가져오기 시작',
@@ -188,5 +296,41 @@ export default {
list: '플레이리스트',
mv: 'MV',
home: '홈',
search: '검색'
search: '검색',
album: '앨범',
localMusic: '로컬 음악',
pages: {
toplist: {
desc: '가장 권위 있는 음악 차트, 지금 가장 핫한 음악을 발견하세요'
},
mv: {
desc: '멋진 영상 콘텐츠 탐색',
loadingMore: '더 불러오는 중...',
noMore: '— 모든 콘텐츠 로드 완료 —',
area: {
all: '전체',
mainland: '중국 대륙',
hktw: '홍콩/대만',
western: '서양',
japan: '일본',
korea: '한국'
}
},
list: {
desc: '더 많은 멋진 플레이리스트를 발견하세요',
dailyRecommend: '오늘의 추천'
},
search: {
desc: '지금 가장 핫한 검색 트렌드를 탐색하세요'
},
album: {
area: {
all: '전체',
chinese: '중화권',
western: '서양',
korea: '한국',
japan: '일본'
}
}
}
};

View File

@@ -10,7 +10,8 @@ export default {
},
empty: {
noTasks: '다운로드 작업이 없습니다',
noDownloaded: '다운로드된 곡이 없습니다'
noDownloaded: '다운로드된 곡이 없습니다',
noDownloadedHint: '좋아하는 곡을 다운로드하세요'
},
progress: {
total: '전체 진행률: {progress}%'
@@ -19,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: '알 수 없는 가수'
@@ -40,7 +55,8 @@ export default {
'모든 다운로드 기록을 지우시겠습니까? 이 작업은 다운로드된 음악 파일을 삭제하지 않지만 모든 기록을 지웁니다.',
confirm: '지우기 확인',
cancel: '취소',
success: '다운로드 기록이 지워졌습니다'
success: '다운로드 기록이 지워졌습니다',
failed: '다운로드 기록 삭제에 실패했습니다'
},
message: {
downloadComplete: '{filename} 다운로드 완료',
@@ -50,6 +66,7 @@ export default {
playStarted: '재생 시작: {name}',
playFailed: '재생 실패: {name}',
path: {
copy: '경로 복사',
copied: '경로가 클립보드에 복사됨',
copyFailed: '경로 복사 실패'
},
@@ -61,6 +78,8 @@ export default {
noPathSelected: '먼저 다운로드 경로를 선택해주세요',
select: '폴더 선택',
open: '폴더 열기',
saveLyric: '가사 파일 별도 저장',
saveLyricDesc: '곡 다운로드 시 .lrc 가사 파일도 함께 저장합니다',
fileFormat: '파일명 형식',
fileFormatDesc: '음악 다운로드 시 파일 이름 형식 설정',
customFormat: '사용자 정의 형식',
@@ -73,6 +92,8 @@ export default {
dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:',
formatVariables: '사용 가능한 변수',
preview: '미리보기 효과:',
concurrency: '최대 동시 다운로드',
concurrencyDesc: '동시에 다운로드할 최대 곡 수 (1-5)',
saveSuccess: '다운로드 설정이 저장됨',
presets: {
songArtist: '곡명 - 가수명',
@@ -84,5 +105,10 @@ export default {
artistName: '가수명',
albumName: '앨범명'
}
},
error: {
incomplete: '파일 다운로드가 불완전합니다',
urlExpired: 'URL이 만료되었습니다. 재획득 중',
resumeFailed: '재개 실패'
}
};

View File

@@ -1,5 +1,49 @@
export default {
title: '재생 기록',
heatmapTitle: '히트맵',
playCount: '{count}',
getHistoryFailed: '기록 가져오기 실패'
getHistoryFailed: '기록 가져오기 실패',
tabs: {
all: '전체 기록',
local: '로컬 기록',
cloud: '클라우드 기록'
},
categoryTabs: {
songs: '곡',
playlists: '플레이리스트',
albums: '앨범',
podcasts: '팟캐스트'
},
podcastTabs: {
episodes: '에피소드',
radios: '라디오'
},
noDescription: '설명 없음',
noData: '기록 없음',
getCloudRecordFailed: '클라우드 기록 가져오기 실패',
needLogin: 'cookie를 사용하여 로그인하여 클라우드 기록을 볼 수 있습니다',
merging: '기록 병합 중...',
heatmap: {
title: '재생 히트맵',
loading: '데이터 로딩 중...',
unit: '회 재생',
footerText: '마우스를 올려서 자세히 보기',
playCount: '{count}회 재생',
topSongs: '오늘의 인기곡',
times: '회',
totalPlays: '총 재생 횟수',
activeDays: '활동 일수',
noData: '재생 기록이 없습니다',
colorTheme: '색상 테마',
colors: {
green: '그린',
blue: '블루',
orange: '오렌지',
purple: '퍼플',
red: '레드'
},
mostPlayedSong: '가장 많이 재생한 노래',
mostActiveDay: '가장 활발한 날',
latestNightSong: '가장 늘게 재생한 노래'
}
};

View File

@@ -0,0 +1,13 @@
export default {
title: '로컬 음악',
scanFolder: '폴더 스캔',
removeFolder: '폴더 제거',
scanning: '스캔 중...',
scanComplete: '스캔 완료',
playAll: '모두 재생',
search: '로컬 음악 검색',
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔',
songCount: '{count}곡'
};

View File

@@ -42,7 +42,21 @@ export default {
autoGetCookieSuccess: 'Cookie 자동 가져오기 성공',
autoGetCookieFailed: 'Cookie 자동 가져오기 실패',
autoGetCookieTip:
'넷이즈 클라우드 뮤직 로그인 페이지를 열겠습니다. 로그인 완료 후 창을 닫아주세요'
'넷이즈 클라우드 뮤직 로그인 페이지를 열겠습니다. 로그인 완료 후 창을 닫아주세요',
loginFailed: '로그인 실패',
phoneRequired: '휴대폰 번호를 입력하세요',
passwordRequired: '비밀번호를 입력하세요',
phoneLoginFailed: '휴대폰 번호 로그인 실패, 휴대폰 번호와 비밀번호가 올바른지 확인하세요',
qrCheckFailed: 'QR코드 상태 확인 실패, 새로고침하여 다시 시도하세요',
qrLoading: 'QR코드 로딩 중...',
qrExpired: 'QR코드가 만료되었습니다. 클릭하여 새로고침하세요',
qrExpiredShort: 'QR코드 만료됨',
qrExpiredWarning: 'QR코드가 만료되었습니다. 클릭하여 새로운 QR코드를 받으세요',
qrScanned: 'QR코드가 스캔되었습니다. 휴대폰에서 로그인을 확인하세요',
qrScannedShort: '스캔됨',
qrScannedInfo: 'QR코드가 스캔되었습니다. 휴대폰에서 로그인을 확인하세요',
qrConfirmed: '로그인 성공, 리다이렉트 중...',
qrGenerating: 'QR코드를 생성 중...'
},
qrTitle: '넷이즈 클라우드 뮤직 QR코드 로그인',
uidWarning:

View File

@@ -14,6 +14,12 @@ export default {
addCorrection: '{num}초 앞당기기',
subtractCorrection: '{num}초 지연',
playFailed: '현재 곡 재생 실패, 다음 곡 재생',
parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생',
consecutiveFailsError:
'재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요',
playListEnded: '재생 목록의 마지막 곡에 도달했습니다',
autoResumed: '자동으로 재생이 재개되었습니다',
resumeFailed: '재생 재개에 실패했습니다. 수동으로 시도해 주세요',
playMode: {
sequence: '순차 재생',
loop: '한 곡 반복',
@@ -29,7 +35,8 @@ export default {
list: '자동으로 다음 곡 재생'
},
lrc: {
noLrc: '가사가 없습니다. 음악을 감상해주세요'
noLrc: '가사가 없습니다. 음악을 감상해주세요',
noAutoScroll: '본 가사는 자동 스크롤을 지원하지 않습니다'
},
reparse: {
title: '음원 선택',
@@ -66,7 +73,17 @@ export default {
unFavorite: '{name} 즐겨찾기 해제됨',
miniPlayBar: '미니 재생바',
playbackSpeed: '재생 속도',
advancedControls: '고급 설정'
advancedControls: '고급 설정',
intelligenceMode: {
title: '인텔리전스 모드',
needCookieLogin: '쿠키 방식으로 로그인한 후 인텔리전스 모드를 사용할 수 있습니다',
noFavoritePlaylist: '내가 좋아하는 음악 재생목록을 찾을 수 없습니다',
noLikedSongs: '아직 좋아한 노래가 없습니다',
loading: '인텔리전스 모드를 불러오는 중',
success: '총 {count}곡을 불러왔습니다',
failed: '인텔리전스 모드 목록을 가져오는 데 실패했습니다',
error: '인텔리전스 모드 재생 오류'
}
},
eq: {
title: '이퀄라이저',
@@ -92,6 +109,11 @@ export default {
custom: '사용자 정의'
}
},
// 플레이어 설정
settings: {
title: '재생 설정',
playbackSpeed: '재생 속도'
},
sleepTimer: {
title: '타이머 종료',
cancel: '타이머 취소',

View File

@@ -0,0 +1,40 @@
export default {
podcast: '팟캐스트',
mySubscriptions: '내 구독',
discover: '발견',
categories: '카테고리',
todayPerfered: '오늘의 추천',
recommended: '추천',
hotRanking: '인기',
newRanking: '신규',
subscribeCount: '구독자',
programCount: '에피소드',
subscribe: '구독',
subscribed: '구독 중',
unsubscribe: '구독 취소',
unsubscribed: '구독이 취소되었습니다',
subscribeSuccess: '구독되었습니다',
unsubscribeFailed: '구독 취소에 실패했습니다',
subscribeFailed: '구독에 실패했습니다',
radioDetail: '라디오 상세',
programList: '에피소드 목록',
playProgram: '재생',
recentPlayed: '최근 재생',
listeners: '청취자',
noSubscriptions: '구독 없음',
goDiscover: '팟캐스트 찾기',
searchPodcast: '팟캐스트 검색',
category: '카테고리',
all: '전체',
dj: 'DJ',
episodes: '화',
playAll: '전체 재생',
popularCategories: '인기 카테고리',
allCategories: '모든 카테고리',
categoryRadios: '카테고리 라디오',
exploreCategoryRadios: '더 많은 멋진 라디오 탐색',
hotRadios: '인기 라디오',
noCategoryRadios: '이 카테고리에 라디오가 없습니다',
searchPlaceholder: '팟캐스트, 에피소드 검색...',
searchResults: '검색 결과'
};

View File

@@ -11,7 +11,8 @@ export default {
},
loading: {
more: '로딩 중...',
failed: '검색 실패'
failed: '검색 실패',
searching: '검색 중...'
},
noMore: '더 이상 없음',
error: {
@@ -22,6 +23,11 @@ export default {
album: '앨범',
playlist: '플레이리스트',
mv: 'MV',
djradio: '라디오',
bilibili: 'B站'
}
},
history: '검색 기록',
hot: '인기 검색',
suggestions: '검색 제안'
};

View File

@@ -10,7 +10,7 @@ export default {
network: '네트워크 설정',
system: '시스템 관리',
donation: '후원 지원',
regard: '정보'
about: '정보'
},
basic: {
themeMode: '테마 모드',
@@ -57,7 +57,10 @@ export default {
gpuAccelerationRestart: 'GPU 가속 설정을 변경하면 애플리케이션을 다시 시작해야 합니다',
gpuAccelerationChangeSuccess:
'GPU 가속 설정이 업데이트되었습니다. 애플리케이션을 다시 시작하여 적용하십시오',
gpuAccelerationChangeError: 'GPU 가속 설정 업데이트에 실패했습니다'
gpuAccelerationChangeError: 'GPU 가속 설정 업데이트에 실패했습니다',
tabletMode: '태블릿 모드',
tabletModeDesc:
'태블릿 모드를 사용하면 모바일 기기에서 PC 스타일의 인터페이스를 사용할 수 있습니다'
},
playback: {
quality: '음질 설정',
@@ -84,6 +87,10 @@ export default {
gdmusicInfo: 'GD 뮤직은 여러 플랫폼 음원을 자동으로 해석하고 최적의 결과를 자동 선택합니다',
autoPlay: '자동 재생',
autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부',
audioDevice: '오디오 출력 장치',
audioDeviceDesc: '스피커, 헤드폰 또는 블루투스 장치와 같은 오디오 출력 장치 선택',
testAudio: '테스트',
selectAudioDevice: '출력 장치 선택',
showStatusBar: '상태바 제어 기능 표시 여부',
showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)',
fallbackParser: '대체 분석 서비스 (GD Music)',
@@ -96,9 +103,13 @@ export default {
sourceLabels: {
migu: 'Migu',
kugou: 'Kugou',
kuwo: 'Kuwo',
pyncmd: 'NetEase (내장)',
qq: 'QQ Music',
joox: 'JOOX',
bilibili: 'Bilibili',
gdmusic: 'GD Music',
lxMusic: 'LX Music',
custom: '사용자 지정 API'
},
@@ -109,7 +120,38 @@ export default {
notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.',
importSuccess: '음원 가져오기 성공: {name}',
importFailed: '가져오기 실패: {message}',
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요'
enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요',
status: {
imported: '사용자 지정 음원 가져옴',
notImported: '가져오지 않음'
}
},
lxMusic: {
tabs: {
sources: '음원 선택',
lxMusic: '낙설 음원',
customApi: '사용자 정의 API'
},
scripts: {
title: '가져온 스크립트',
importLocal: '로컬 가져오기',
importOnline: '온라인 가져오기',
urlPlaceholder: '낙설 음원 스크립트 URL 입력',
importBtn: '가져오기',
empty: '가져온 낙설 음원이 없습니다',
notConfigured: '설정되지 않음 (낙설 음원 탭에서 설정하세요)',
importHint: '소스 확장을 위해 호환되는 사용자 정의 API 플러그인을 가져옵니다',
noScriptWarning: '먼저 낙설 음원 스크립트를 가져오세요',
noSelectionWarning: '먼저 낙설 음원 소스를 선택하세요',
notFound: '음원이 존재하지 않습니다',
switched: '음원으로 전환되었습니다: {name}',
deleted: '음원이 삭제되었습니다: {name}',
enterUrl: '스크립트 URL을 입력하세요',
invalidUrl: '유효하지 않은 URL 형식',
invalidScript: '유효하지 않은 낙설 음원 스크립트입니다 (globalThis.lx 코드를 찾을 수 없음)',
nameRequired: '이름은 비워둘 수 없습니다',
renameSuccess: '이름이 변경되었습니다'
}
}
},
application: {
@@ -154,6 +196,36 @@ export default {
system: {
cache: '캐시 관리',
cacheDesc: '캐시 지우기',
diskCache: '디스크 캐시',
diskCacheDesc: '재생한 음악과 가사를 로컬 디스크에 캐시하여 재생 속도를 높입니다',
cacheDirectory: '캐시 디렉터리',
cacheDirectoryDesc: '음악 및 가사 캐시 저장 경로를 사용자 지정',
selectDirectory: '디렉터리 선택',
openDirectory: '디렉터리 열기',
cacheMaxSize: '캐시 용량 제한',
cacheMaxSizeDesc: '용량 제한 도달 시 오래된 캐시를 자동 정리합니다',
cleanupPolicy: '정리 정책',
cleanupPolicyDesc: '캐시 용량 제한 도달 시 적용할 자동 정리 규칙',
cleanupPolicyOptions: {
lru: '최근 사용 안 함 우선',
fifo: '선입선출'
},
cacheStatus: '캐시 상태',
cacheStatusDesc: '사용량 {used} / 제한 {limit}',
cacheStatusDetail: '음악 {musicCount}곡, 가사 {lyricCount}곡',
manageDiskCache: '수동 디스크 캐시 정리',
manageDiskCacheDesc: '캐시 유형별로 정리',
clearMusicCache: '음악 캐시 정리',
clearLyricCache: '가사 캐시 정리',
clearAllCache: '전체 캐시 정리',
switchDirectoryMigrateTitle: '기존 캐시가 감지되었습니다',
switchDirectoryMigrateContent: '기존 캐시를 새 디렉터리로 마이그레이션할까요?',
switchDirectoryMigrateConfirm: '마이그레이션',
switchDirectoryDestroyTitle: '기존 캐시 삭제',
switchDirectoryDestroyContent:
'마이그레이션하지 않을 경우, 이전 디렉터리의 캐시 파일을 삭제할까요?',
switchDirectoryDestroyConfirm: '삭제',
switchDirectoryKeepOld: '기존 캐시 유지',
cacheClearTitle: '지울 캐시 유형을 선택하세요:',
cacheTypes: {
history: {
@@ -188,7 +260,14 @@ export default {
restart: '재시작',
restartDesc: '앱 재시작',
messages: {
clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다'
clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다',
diskCacheClearSuccess: '디스크 캐시를 정리했습니다',
diskCacheClearFailed: '디스크 캐시 정리에 실패했습니다',
diskCacheStatsLoadFailed: '캐시 상태를 불러오지 못했습니다',
switchDirectorySuccess: '캐시 디렉터리가 변경되었습니다. 기존 캐시는 유지됩니다',
switchDirectoryFailed: '캐시 디렉터리 변경에 실패했습니다',
switchDirectoryMigrated: '캐시 디렉터리를 변경하고 {count}개 파일을 마이그레이션했습니다',
switchDirectoryDestroyed: '캐시 디렉터리를 변경하고 기존 캐시 {count}개 파일을 삭제했습니다'
}
},
about: {
@@ -198,6 +277,7 @@ export default {
latest: '현재 최신 버전입니다',
hasUpdate: '새 버전 발견',
gotoUpdate: '업데이트하러 가기',
manualUpdate: '수동 업데이트',
gotoGithub: 'Github로 이동',
author: '작성자',
authorDesc: 'algerkong 별점🌟 부탁드려요',
@@ -216,6 +296,7 @@ export default {
display: '표시',
interface: '인터페이스',
typography: '텍스트',
background: '배경',
mobile: '모바일'
},
pureMode: '순수 모드',
@@ -238,6 +319,12 @@ export default {
medium: '중간',
large: '큼'
},
fontWeight: '글꼴 두께',
fontWeightMarks: {
thin: '가늘게',
normal: '보통',
bold: '굵게'
},
letterSpacing: '글자 간격',
letterSpacingMarks: {
compact: '좁음',
@@ -250,6 +337,7 @@ export default {
default: '기본',
loose: '넓음'
},
contentWidth: '콘텐츠 너비',
mobileLayout: '모바일 레이아웃',
layoutOptions: {
default: '기본',
@@ -263,7 +351,46 @@ export default {
full: '전체화면'
},
lyricLines: '가사 줄 수',
mobileUnavailable: '이 설정은 모바일에서만 사용 가능합니다'
mobileUnavailable: '이 설정은 모바일에서만 사용 가능합니다',
// 배경 설정
background: {
useCustomBackground: '사용자 정의 배경 사용',
backgroundMode: '배경 모드',
modeOptions: {
solid: '단색',
gradient: '그라데이션',
image: '이미지',
css: 'CSS'
},
solidColor: '색상 선택',
presetColors: '프리셋 색상',
customColor: '사용자 정의 색상',
gradientEditor: '그라데이션 편집기',
gradientColors: '그라데이션 색상',
gradientDirection: '그라데이션 방향',
directionOptions: {
toBottom: '위에서 아래로',
toRight: '왼쪽에서 오른쪽으로',
toBottomRight: '왼쪽 위에서 오른쪽 아래로',
angle45: '45도',
toTop: '아래에서 위로',
toLeft: '오른쪽에서 왼쪽으로'
},
addColor: '색상 추가',
removeColor: '색상 제거',
imageUpload: '이미지 업로드',
imagePreview: '이미지 미리보기',
clearImage: '이미지 지우기',
imageBlur: '흐림',
imageBrightness: '밝기',
customCss: '사용자 정의 CSS 스타일',
customCssPlaceholder: 'CSS 스타일 입력, 예: background: linear-gradient(...)',
customCssHelp: '모든 CSS background 속성 지원',
reset: '기본값으로 재설정',
fileSizeLimit: '이미지 크기 제한: 20MB',
invalidImageFormat: '잘못된 이미지 형식',
imageTooLarge: '이미지가 너무 큽니다. 20MB 미만의 이미지를 선택하세요'
}
},
translationEngine: '가사 번역 엔진',
translationEngineOptions: {
@@ -294,28 +421,61 @@ export default {
title: '단축키 설정',
shortcut: '단축키',
shortcutDesc: '단축키 사용자 정의',
summaryReady: '단축키 구성이 저장 가능한 상태입니다',
summaryRecording: '새 단축키 조합을 입력 중입니다',
summaryBlocked: '충돌 또는 잘못된 항목을 먼저 수정하세요',
platformHintMac: 'macOS에서는 CommandOrControl이 Cmd로 표시됩니다',
platformHintWindows: 'Windows에서는 CommandOrControl이 Ctrl로 표시됩니다',
platformHintLinux: 'Linux에서는 CommandOrControl이 Ctrl로 표시됩니다',
platformHintGeneric: 'CommandOrControl은 운영체제에 맞게 자동 변환됩니다',
enabledCount: '활성화됨',
recordingTip: '필드를 클릭 후 조합키 입력, Esc 취소, Delete 비활성화',
shortcutConflict: '단축키 충돌',
inputPlaceholder: '클릭하여 단축키 입력',
clickToRecord: '클릭 후 단축키 입력',
recording: '입력 중...',
resetShortcuts: '기본값 복원',
restoreSingle: '복원',
disableAll: '모두 비활성화',
enableAll: '모두 활성화',
groups: {
playback: '재생 제어',
sound: '볼륨 및 즐겨찾기',
window: '창 제어'
},
togglePlay: '재생/일시정지',
togglePlayDesc: '현재 재생 상태를 전환합니다',
prevPlay: '이전 곡',
prevPlayDesc: '이전 곡으로 이동합니다',
nextPlay: '다음 곡',
nextPlayDesc: '다음 곡으로 이동합니다',
volumeUp: '볼륨 증가',
volumeUpDesc: '플레이어 볼륨을 높입니다',
volumeDown: '볼륨 감소',
volumeDownDesc: '플레이어 볼륨을 낮춥니다',
toggleFavorite: '즐겨찾기/즐겨찾기 취소',
toggleFavoriteDesc: '현재 곡 즐겨찾기를 전환합니다',
toggleWindow: '창 표시/숨기기',
toggleWindowDesc: '메인 창을 빠르게 표시/숨김합니다',
scopeGlobal: '전역',
scopeApp: '앱 내',
enabled: '활성화',
disabled: '비활성화',
issueInvalid: '잘못된 조합',
issueReserved: '시스템 예약',
registrationWarningTitle: '다음 단축키는 등록되지 않았습니다',
registrationOccupied: '시스템 또는 다른 앱에서 사용 중',
registrationInvalid: '단축키 형식이 잘못됨',
messages: {
resetSuccess: '기본 단축키로 복원되었습니다. 저장을 잊지 마세요',
conflict: '충돌하는 단축키가 있습니다. 다시 설정하세요',
saveSuccess: '단축키 설정이 저장되었습니다',
saveError: '단축키 저장 실패, 다시 시도하세요',
saveValidationError: '단축키 검증에 실패했습니다. 설정을 확인하세요',
partialRegistered: '저장되었지만 일부 전역 단축키는 등록되지 않았습니다',
cancelEdit: '수정이 취소되었습니다',
clearToDisable: '해당 단축키가 비활성화되었습니다',
invalidShortcut: '잘못된 단축키입니다. 유효한 조합을 입력하세요',
disableAll: '모든 단축키가 비활성화되었습니다. 저장을 잊지 마세요',
enableAll: '모든 단축키가 활성화되었습니다. 저장을 잊지 마세요'
}

View File

@@ -3,6 +3,7 @@ export default {
play: '재생',
playNext: '다음에 재생',
download: '곡 다운로드',
downloadLyric: '가사 다운로드',
addToPlaylist: '플레이리스트에 추가',
favorite: '좋아요',
unfavorite: '좋아요 취소',
@@ -15,7 +16,10 @@ export default {
downloadFailed: '다운로드 실패',
downloadQueued: '다운로드 대기열에 추가됨',
addedToNextPlay: '다음 재생에 추가됨',
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요'
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
noLyric: '이 곡에는 가사가 없습니다',
lyricDownloaded: '가사 다운로드 완료',
lyricDownloadFailed: '가사 다운로드 실패'
},
dialog: {
dislike: {

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}곡',
playCount: '{count}회 재생'
},
tabs: {
created: '생성',
favorite: '즐겨찾기',
album: '앨범'
},
ranking: {
title: '음악 청취 순위',
playCount: '{count}회'

View File

@@ -1,51 +0,0 @@
export default {
player: {
loading: '听书加载中...',
retry: '重试',
playNow: '立即播放',
loadingTitle: '加载中...',
totalDuration: '总时长: {duration}',
partsList: '分P列表 (共{count}集)',
playStarted: '已开始播放',
switchingPart: '切换到分P: {part}',
preloadingNext: '预加载下一个分P: {part}',
playingCurrent: '播放当前选中的分P: {name}',
num: '万',
errors: {
invalidVideoId: '视频ID无效',
loadVideoDetailFailed: '获取视频详情失败',
loadPartInfoFailed: '无法加载视频分P信息',
loadAudioUrlFailed: '获取音频播放地址失败',
videoDetailNotLoaded: '视频详情未加载',
missingParams: '缺少必要参数',
noAvailableAudioUrl: '未找到可用的音频地址',
loadPartAudioFailed: '加载分P音频URL失败',
audioListEmpty: '音频列表为空,请重试',
currentPartNotFound: '未找到当前分P的音频',
audioUrlFailed: '获取音频URL失败',
playFailed: '播放失败,请重试',
getAudioUrlFailed: '获取音频地址失败,请重试',
audioNotFound: '未找到对应的音频,请重试',
preloadFailed: '预加载下一个分P失败',
switchPartFailed: '切换分P时加载音频URL失败'
},
console: {
loadingDetail: '加载B站视频详情',
detailData: 'B站视频详情数据',
multipleParts: '视频有多个分P共{count}个',
noPartsData: '视频无分P或分P数据为空',
loadingAudioSource: '加载音频源',
generatedAudioList: '已生成音频列表,共{count}首',
getDashAudioUrl: '获取到dash音频URL',
getDurlAudioUrl: '获取到durl音频URL',
loadingPartAudio: '加载分P音频URL: {part}, cid: {cid}',
loadPartAudioFailed: '加载分P音频URL失败: {part}',
switchToPart: '切换到分P: {part}',
audioNotFoundInList: '未找到对应的音频项',
preparingToPlay: '准备播放当前选中的分P: {name}',
preloadingNextPart: '预加载下一个分P: {part}',
playingSelectedPart: '播放当前选中的分P: {name}音频URL: {url}',
preloadNextFailed: '预加载下一个分P失败'
}
}
};

View File

@@ -15,6 +15,7 @@ export default {
hide: '隐藏',
confirm: '确认',
cancel: '取消',
clear: '清空',
configure: '配置',
open: '打开',
modify: '修改',
@@ -27,6 +28,8 @@ export default {
refresh: '刷新',
retry: '重试',
reset: '重置',
loadFailed: '加载失败',
noData: '暂无数据',
back: '返回',
copySuccess: '已复制到剪贴板',
copyFailed: '复制失败',
@@ -39,10 +42,13 @@ export default {
viewMore: '查看更多',
noMore: '没有更多了',
selectAll: '全选',
playAll: '播放全部',
expand: '展开',
collapse: '收起',
songCount: '{count}首',
language: '语言',
today: '今天',
yesterday: '昨天',
tray: {
show: '显示',
quit: '退出',

View File

@@ -1,4 +1,8 @@
export default {
more: '更多',
homeListItem: {
loading: '加载中...'
},
installApp: {
description: '安装应用程序,获得更好的体验',
noPrompt: '不再提示',
@@ -33,11 +37,16 @@ export default {
title: '发现新版本',
currentVersion: '当前版本',
cancel: '暂不更新',
checking: '检查更新中...',
prepareDownload: '准备下载...',
downloading: '下载中...',
readyToInstall: '更新包已下载完成,可以立即安装',
nowUpdate: '立即更新',
downloadFailed: '下载失败,请重试或手动下载',
startFailed: '启动下载失败,请重试或手动下载',
autoUpdateFailed: '自动更新失败',
openOfficialSite: '前往官网更新',
manualFallbackHint: '自动更新失败后,可前往官网下载安装最新版本。',
noDownloadUrl: '未找到适合当前系统的安装包,请手动下载',
installConfirmTitle: '安装更新',
installConfirmContent: '是否关闭应用并安装更新?',
@@ -50,6 +59,27 @@ export default {
copyFailed: '复制失败',
backgroundDownload: '后台下载'
},
disclaimer: {
title: '使用须知',
warning: '本应用为开发测试版本,功能尚不完善,可能存在较多问题和 Bug仅供学习交流使用。',
item1: '本应用仅供个人学习、研究和技术交流使用,请勿用于任何商业用途。',
item2: '请在下载后 24 小时内删除,如需长期使用请支持正版音乐服务。',
item3: '使用本应用即表示您理解并承担相关风险,开发者不对任何损失负责。',
agree: '我已阅读并同意',
disagree: '不同意并退出'
},
donate: {
title: '支持开发者',
subtitle: '您的支持是我前进的动力',
tip: '捐赠完全自愿,不捐赠也可以正常使用所有功能,感谢您的理解与支持!',
wechat: '微信',
alipay: '支付宝',
wechatQR: '微信收款码',
alipayQR: '支付宝收款码',
scanTip: '请使用手机扫描上方二维码进行捐赠',
enterApp: '进入应用',
noForce: '不强制捐赠,点击即可进入'
},
coffee: {
title: '请我喝咖啡',
alipay: '支付宝',
@@ -77,7 +107,68 @@ export default {
songlist: '每日推荐列表'
},
recommendSonglist: {
title: '本周最热音乐'
title: '本周最热音乐',
empty: '暂无推荐歌单'
},
dailyRecommend: {
title: '每日推荐',
badge: '推荐',
empty: '暂无推荐歌曲',
intelligenceHint: '开启心动模式,发现更多喜欢的音乐'
},
recommendMV: {
title: '推荐MV'
},
newAlbum: {
title: '专辑',
empty: '暂无新专辑'
},
recommendNewMusic: {
title: '新歌速递'
},
privateContent: {
title: '独家放送'
},
djProgram: {
title: '推荐电台'
},
homeHero: {
dailyRecommend: '每日推荐',
songs: '首',
playNow: '立即播放',
intelligenceMode: '心动模式',
intelligenceModeOn: '心动中',
intelligenceModeDesc: '开启智能推荐播放',
intelligenceModeActiveDesc: '根据你的喜好智能推荐',
startIntelligence: '开启心动',
stopIntelligence: '关闭心动',
playing: '播放中',
toplistDesc: '热门榜单',
mvDesc: '音乐视频',
playlistDesc: '精选歌单',
personalFm: '私人FM',
discoverMusic: '发现新音乐',
personalFmDesc: '根据你的喜好推荐',
recentPlays: '最近播放',
viewAll: '查看全部',
followedArtists: '关注歌手',
newSongs: '首新歌',
fromFollowedArtists: '来自你关注的歌手',
recommendNewMusic: '推荐新音乐',
newSongExpress: '新歌速递',
discoverNewReleases: '发现最新发行的好歌',
hotPlaylists: '精选歌单',
hotArtists: '热门歌手',
hotArtistsTitle: '热门艺人',
hotArtistsDesc: '当下最受欢迎的歌手',
fmTrash: '不喜欢',
fmNext: '下一首',
quickNav: {
myFavorite: '我的收藏',
playHistory: '播放历史',
myProfile: '我的主页',
toplist: '排行榜'
}
},
searchBar: {
login: '登录',
@@ -92,7 +183,13 @@ export default {
zoom: '页面缩放',
zoom100: '标准缩放100%',
resetZoom: '点击重置缩放',
zoomDefault: '标准缩放'
zoomDefault: '标准缩放',
tabPlaylist: '播放列表',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消',
intelligenceMode: '心动模式',
exitIntelligence: '退出心动模式'
},
titleBar: {
closeTitle: '请选择关闭方式',
@@ -117,7 +214,12 @@ export default {
cancelCollect: '取消收藏',
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中'
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
locateCurrent: '定位当前播放',
historyRecommend: '历史日推',
fetchDatesFailed: '获取日期列表失败',
fetchSongsFailed: '获取歌曲列表失败',
noSongs: '暂无歌曲'
},
playlist: {
import: {
@@ -143,6 +245,7 @@ export default {
albumNamePlaceholder: '专辑名称',
addSongButton: '添加歌曲',
addLinkButton: '添加链接',
options: '选项',
importToStarPlaylist: '导入到我喜欢的音乐',
playlistNamePlaceholder: '请输入歌单名称',
importButton: '开始导入',
@@ -186,5 +289,41 @@ export default {
list: '歌单',
mv: 'MV',
home: '首页',
search: '搜索'
search: '搜索',
album: '专辑',
localMusic: '本地音乐',
pages: {
toplist: {
desc: '最具权威的音乐榜单,发现当下最热门的音乐'
},
mv: {
desc: '探索精彩视频内容',
loadingMore: '加载更多中...',
noMore: '— 已加载全部内容 —',
area: {
all: '全部',
mainland: '内地',
hktw: '港台',
western: '欧美',
japan: '日本',
korea: '韩国'
}
},
list: {
desc: '发现更多好听的歌单',
dailyRecommend: '每日推荐'
},
search: {
desc: '探索当下最热门的搜索趋势'
},
album: {
area: {
all: '全部',
chinese: '华语',
western: '欧美',
korea: '韩国',
japan: '日本'
}
}
}
};

View File

@@ -10,7 +10,8 @@ export default {
},
empty: {
noTasks: '暂无下载任务',
noDownloaded: '暂无已下载歌曲'
noDownloaded: '暂无已下载歌曲',
noDownloadedHint: '去下载你喜欢的歌曲吧'
},
progress: {
total: '总进度: {progress}%'
@@ -19,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: '未知歌手'
@@ -39,7 +54,8 @@ export default {
message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。',
confirm: '确定清空',
cancel: '取消',
success: '下载记录已清空'
success: '下载记录已清空',
failed: '清空下载记录失败'
},
message: {
downloadComplete: '{filename} 下载完成',
@@ -49,6 +65,7 @@ export default {
playStarted: '开始播放: {name}',
playFailed: '播放失败: {name}',
path: {
copy: '复制路径',
copied: '路径已复制到剪贴板',
copyFailed: '复制路径失败'
},
@@ -60,6 +77,8 @@ export default {
noPathSelected: '请先选择下载路径',
select: '选择文件夹',
open: '打开文件夹',
saveLyric: '单独保存歌词文件',
saveLyricDesc: '下载歌曲时同时保存一份 .lrc 歌词文件',
fileFormat: '文件名格式',
fileFormatDesc: '设置下载音乐时的文件命名格式',
customFormat: '自定义格式',
@@ -72,6 +91,8 @@ export default {
dragToArrange: '拖动排序或使用箭头按钮调整顺序:',
formatVariables: '可用变量',
preview: '预览效果:',
concurrency: '最大并发数',
concurrencyDesc: '同时下载的最大歌曲数量1-5',
saveSuccess: '下载设置已保存',
presets: {
songArtist: '歌曲名 - 歌手名',
@@ -83,5 +104,10 @@ export default {
artistName: '歌手名',
albumName: '专辑名'
}
},
error: {
incomplete: '文件下载不完整',
urlExpired: '下载链接已过期,正在重新获取',
resumeFailed: '恢复下载失败'
}
};

View File

@@ -1,5 +1,49 @@
export default {
title: '播放历史',
heatmapTitle: '热力图',
playCount: '{count}',
getHistoryFailed: '获取历史记录失败'
getHistoryFailed: '获取历史记录失败',
categoryTabs: {
songs: '歌曲',
playlists: '歌单',
albums: '专辑',
podcasts: '播客'
},
podcastTabs: {
episodes: '节目',
radios: '电台'
},
tabs: {
all: '全部记录',
local: '本地记录',
cloud: '云端记录'
},
getCloudRecordFailed: '获取云端记录失败',
needLogin: '请使用cookie登录以查看云端记录',
merging: '正在合并记录...',
noDescription: '暂无描述',
noData: '暂无记录',
heatmap: {
title: '播放热力图',
loading: '正在加载数据...',
unit: '次播放',
footerText: '鼠标悬停查看详细信息',
playCount: '播放 {count} 次',
topSongs: '当天热门歌曲',
times: '次',
totalPlays: '总播放次数',
activeDays: '活跃天数',
noData: '暂无播放记录',
colorTheme: '配色方案',
colors: {
green: '绿色',
blue: '蓝色',
orange: '橙色',
purple: '紫色',
red: '红色'
},
mostPlayedSong: '播放最多的歌曲',
mostActiveDay: '最活跃的一天',
latestNightSong: '最晚播放的歌曲'
}
};

View File

@@ -0,0 +1,13 @@
export default {
title: '本地音乐',
scanFolder: '扫描文件夹',
removeFolder: '移除文件夹',
scanning: '正在扫描...',
scanComplete: '扫描完成',
playAll: '播放全部',
search: '搜索本地音乐',
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描',
songCount: '{count} 首歌曲'
};

View File

@@ -5,14 +5,14 @@ export default {
cookie: 'Cookie登录',
uid: 'UID登录'
},
qrTip: '使用网易云APP扫码登录',
phoneTip: '使用网易云账号登录',
tokenTip: '输入有效的网易云音乐Cookie即可登录',
qrTip: '使用APP扫码登录',
phoneTip: '使用账号登录',
tokenTip: '输入有效的音乐Cookie即可登录',
uidTip: '输入用户ID快速登录',
placeholder: {
phone: '手机号',
password: '密码',
cookie: '请输入网易云音乐Cookietoken',
cookie: '请输入音乐Cookietoken',
uid: '请输入用户IDUID'
},
button: {
@@ -45,7 +45,7 @@ export default {
phoneLoginFailed: '手机号登录失败,请检查手机号和密码是否正确',
autoGetCookieSuccess: '自动获取Cookie成功',
autoGetCookieFailed: '自动获取Cookie失败',
autoGetCookieTip: '将打开网易云音乐登录页面,请完成登录后关闭窗口',
autoGetCookieTip: '将打开音乐登录页面,请完成登录后关闭窗口',
qrCheckFailed: '检查二维码状态失败,请刷新重试',
qrLoading: '正在加载二维码...',
qrExpired: '二维码已过期,请点击刷新',
@@ -57,6 +57,6 @@ export default {
qrConfirmed: '登录成功,正在跳转...',
qrGenerating: '正在生成二维码...'
},
qrTitle: '扫码登录网易云音乐',
qrTitle: '扫码登录',
uidWarning: '注意UID登录仅用于查看用户公开信息无法访问需要登录权限的功能'
};

View File

@@ -14,6 +14,11 @@ export default {
addCorrection: '提前 {num} 秒',
subtractCorrection: '延迟 {num} 秒',
playFailed: '当前歌曲播放失败,播放下一首',
parseFailedPlayNext: '歌曲解析失败,播放下一首',
consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试',
playListEnded: '已播放到列表最后一首',
autoResumed: '已自动恢复播放',
resumeFailed: '恢复播放失败,请手动点击播放',
playMode: {
sequence: '顺序播放',
loop: '单曲循环',
@@ -29,7 +34,8 @@ export default {
list: '自动播放下一个'
},
lrc: {
noLrc: '暂无歌词, 请欣赏'
noLrc: '暂无歌词, 请欣赏',
noAutoScroll: '本歌词不支持自动滚动'
},
reparse: {
title: '选择解析音源',
@@ -66,7 +72,17 @@ export default {
unFavorite: '已取消收藏{name}',
miniPlayBar: '迷你播放栏',
playbackSpeed: '播放速度',
advancedControls: '更多设置s'
advancedControls: '更多设置',
intelligenceMode: {
title: '心动模式',
needCookieLogin: '请使用 Cookie 方式登录后使用心动模式',
noFavoritePlaylist: '未找到我喜欢的音乐歌单',
noLikedSongs: '您还没有喜欢的歌曲',
loading: '正在加载心动模式',
success: '已加载 {count} 首歌曲',
failed: '获取心动模式列表失败',
error: '心动模式播放出错'
}
},
eq: {
title: '均衡器',
@@ -92,6 +108,11 @@ export default {
custom: '自定义'
}
},
// 播放器设置
settings: {
title: '播放设置',
playbackSpeed: '播放速度'
},
// 定时关闭功能相关
sleepTimer: {
title: '定时关闭',

View File

@@ -0,0 +1,40 @@
export default {
podcast: '播客',
mySubscriptions: '我的订阅',
discover: '发现',
categories: '分类',
todayPerfered: '今日优选',
recommended: '推荐电台',
hotRanking: '热门榜',
newRanking: '新晋榜',
subscribeCount: '订阅',
programCount: '期节目',
subscribe: '订阅',
subscribed: '已订阅',
unsubscribe: '取消订阅',
unsubscribed: '已取消订阅',
subscribeSuccess: '订阅成功',
unsubscribeFailed: '取消订阅失败',
subscribeFailed: '订阅失败',
radioDetail: '电台详情',
programList: '节目列表',
playProgram: '播放节目',
recentPlayed: '最近播放',
listeners: '收听',
noSubscriptions: '暂无订阅',
goDiscover: '去发现播客',
searchPodcast: '搜索播客',
category: '分类',
all: '全部',
dj: '主播',
episodes: '期',
playAll: '播放全部',
popularCategories: '热门分类',
allCategories: '全部分类',
categoryRadios: '分类电台',
exploreCategoryRadios: '探索更多精彩电台',
hotRadios: '热门电台',
noCategoryRadios: '该分类暂无电台',
searchPlaceholder: '搜索播客、电台节目...',
searchResults: '搜索结果'
};

View File

@@ -11,7 +11,8 @@ export default {
},
loading: {
more: '加载中...',
failed: '搜索失败'
failed: '搜索失败',
searching: '搜索中...'
},
noMore: '没有更多了',
error: {
@@ -22,6 +23,10 @@ export default {
album: '专辑',
playlist: '歌单',
mv: 'MV',
djradio: '电台',
bilibili: 'B站'
}
},
history: '搜索历史',
hot: '热门搜索',
suggestions: '搜索建议'
};

View File

@@ -10,7 +10,7 @@ export default {
network: '网络设置',
system: '系统管理',
donation: '捐赠支持',
regard: '关于'
about: '关于'
},
basic: {
themeMode: '主题模式',
@@ -20,7 +20,7 @@ export default {
language: '语言设置',
languageDesc: '切换显示语言',
tokenManagement: 'Cookie管理',
tokenManagementDesc: '管理网易云音乐登录Cookie',
tokenManagementDesc: '管理音乐登录Cookie',
tokenStatus: '当前Cookie状态',
tokenSet: '已设置',
tokenNotSet: '未设置',
@@ -55,11 +55,13 @@ export default {
gpuAccelerationDesc: '启用或禁用硬件加速可以提高渲染性能但可能会增加GPU负载',
gpuAccelerationRestart: '更改GPU加速设置需要重启应用后生效',
gpuAccelerationChangeSuccess: 'GPU加速设置已更新重启应用后生效',
gpuAccelerationChangeError: 'GPU加速设置更新失败'
gpuAccelerationChangeError: 'GPU加速设置更新失败',
tabletMode: '平板模式',
tabletModeDesc: '启用后将在移动设备上使用PC样式界面适合平板等大屏设备'
},
playback: {
quality: '音质设置',
qualityDesc: '选择音乐播放音质(网易云VIP',
qualityDesc: '选择音乐播放音质(不确保有效',
qualityOptions: {
standard: '标准',
higher: '较高',
@@ -82,6 +84,10 @@ export default {
gdmusicInfo: 'GD音乐台可自动解析多个平台音源自动选择最佳结果',
autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放',
audioDevice: '音频输出设备',
audioDeviceDesc: '选择音频输出设备,如扬声器、耳机或蓝牙设备',
testAudio: '测试',
selectAudioDevice: '选择输出设备',
showStatusBar: '是否显示状态栏控制功能',
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
@@ -93,11 +99,15 @@ export default {
// 音源标签
sourceLabels: {
migu: '咪咕音乐',
kugou: '酷狗音乐',
pyncmd: '网易云(内置)',
migu: 'migu',
kugou: 'kugou',
kuwo: 'kuwo',
pyncmd: 'pyncmd',
qq: 'qq',
joox: 'JOOX',
bilibili: 'Bilibili',
gdmusic: 'GD音乐台',
gdmusic: 'gdmusic',
lxMusic: 'lxMusic',
custom: '自定义 API'
},
@@ -109,7 +119,38 @@ export default {
notImported: '尚未导入自定义音源。',
importSuccess: '成功导入音源: {name}',
importFailed: '导入失败: {message}',
enableHint: '请先导入 JSON 配置文件才能启用'
enableHint: '请先导入 JSON 配置文件才能启用',
status: {
imported: '已导入自定义音源',
notImported: '未导入'
}
},
lxMusic: {
tabs: {
sources: '音源选择',
lxMusic: '落雪音源',
customApi: '自定义API'
},
scripts: {
title: '已导入的音源脚本',
importLocal: '本地导入',
importOnline: '在线导入',
urlPlaceholder: '输入落雪音源脚本 URL',
importBtn: '导入',
empty: '暂无已导入的落雪音源',
notConfigured: '未配置 (请去落雪音源Tab配置)',
importHint: '导入兼容的自定义 API 插件以扩展音源',
noScriptWarning: '请先导入落雪音源脚本',
noSelectionWarning: '请先选择一个落雪音源',
notFound: '音源不存在',
switched: '已切换到音源: {name}',
deleted: '已删除音源: {name}',
enterUrl: '请输入脚本 URL',
invalidUrl: '无效的 URL 格式',
invalidScript: '无效的落雪音源脚本,未找到 globalThis.lx 相关代码',
nameRequired: '名称不能为空',
renameSuccess: '重命名成功'
}
}
},
application: {
@@ -152,6 +193,35 @@ export default {
system: {
cache: '缓存管理',
cacheDesc: '清除缓存',
diskCache: '磁盘缓存',
diskCacheDesc: '将播放过的音乐与歌词缓存到本地磁盘,提升二次播放速度',
cacheDirectory: '缓存目录',
cacheDirectoryDesc: '自定义音乐与歌词缓存保存目录',
selectDirectory: '选择目录',
openDirectory: '打开目录',
cacheMaxSize: '缓存上限',
cacheMaxSizeDesc: '达到上限后将自动清理最旧缓存',
cleanupPolicy: '清理策略',
cleanupPolicyDesc: '达到缓存上限时的自动清理规则',
cleanupPolicyOptions: {
lru: '最近最少使用',
fifo: '先进先出'
},
cacheStatus: '缓存状态',
cacheStatusDesc: '已用 {used} / 上限 {limit}',
cacheStatusDetail: '音乐 {musicCount} 首,歌词 {lyricCount} 首',
manageDiskCache: '手动清理磁盘缓存',
manageDiskCacheDesc: '按缓存类型进行清理',
clearMusicCache: '清理音乐缓存',
clearLyricCache: '清理歌词缓存',
clearAllCache: '清理全部缓存',
switchDirectoryMigrateTitle: '检测到已有缓存',
switchDirectoryMigrateContent: '是否将旧目录缓存迁移到新目录?',
switchDirectoryMigrateConfirm: '迁移',
switchDirectoryDestroyTitle: '是否销毁旧缓存',
switchDirectoryDestroyContent: '不迁移时,是否销毁旧目录缓存文件?',
switchDirectoryDestroyConfirm: '销毁',
switchDirectoryKeepOld: '保留旧缓存',
cacheClearTitle: '请选择要清除的缓存类型:',
cacheTypes: {
history: {
@@ -186,7 +256,14 @@ export default {
restart: '重启',
restartDesc: '重启应用',
messages: {
clearSuccess: '清除成功,部分设置在重启后生效'
clearSuccess: '清除成功,部分设置在重启后生效',
diskCacheClearSuccess: '磁盘缓存已清理',
diskCacheClearFailed: '清理磁盘缓存失败',
diskCacheStatsLoadFailed: '读取缓存状态失败',
switchDirectorySuccess: '缓存目录已切换,旧缓存已保留',
switchDirectoryFailed: '缓存目录切换失败',
switchDirectoryMigrated: '缓存目录已切换,已迁移 {count} 个缓存文件',
switchDirectoryDestroyed: '缓存目录已切换,已销毁 {count} 个旧缓存文件'
}
},
about: {
@@ -196,6 +273,7 @@ export default {
latest: '当前已是最新版本',
hasUpdate: '发现新版本',
gotoUpdate: '前往更新',
manualUpdate: '官网更新',
gotoGithub: '前往 Github',
author: '作者',
authorDesc: 'algerkong 点个star🌟呗',
@@ -214,6 +292,7 @@ export default {
display: '显示',
interface: '界面',
typography: '文字',
background: '背景',
mobile: '移动端'
},
pureMode: '纯净模式',
@@ -236,6 +315,12 @@ export default {
medium: '中',
large: '大'
},
fontWeight: '字体粗细',
fontWeightMarks: {
thin: '细',
normal: '常规',
bold: '粗'
},
letterSpacing: '字间距',
letterSpacingMarks: {
compact: '紧凑',
@@ -248,6 +333,7 @@ export default {
default: '默认',
loose: '宽松'
},
contentWidth: '内容区宽度',
mobileLayout: '移动端布局',
layoutOptions: {
default: '默认',
@@ -261,7 +347,46 @@ export default {
full: '全屏'
},
lyricLines: '歌词行数',
mobileUnavailable: '此设置仅在移动端可用'
mobileUnavailable: '此设置仅在移动端可用',
// 背景设置
background: {
useCustomBackground: '使用自定义背景',
backgroundMode: '背景模式',
modeOptions: {
solid: '纯色',
gradient: '渐变',
image: '图片',
css: 'CSS'
},
solidColor: '选择颜色',
presetColors: '预设颜色',
customColor: '自定义颜色',
gradientEditor: '渐变编辑器',
gradientColors: '渐变颜色',
gradientDirection: '渐变方向',
directionOptions: {
toBottom: '上到下',
toRight: '左到右',
toBottomRight: '左上到右下',
angle45: '45度',
toTop: '下到上',
toLeft: '右到左'
},
addColor: '添加颜色',
removeColor: '移除颜色',
imageUpload: '上传图片',
imagePreview: '图片预览',
clearImage: '清除图片',
imageBlur: '模糊度',
imageBrightness: '明暗度',
customCss: '自定义 CSS 样式',
customCssPlaceholder: '输入 CSS 样式,如: background: linear-gradient(...)',
customCssHelp: '支持任意 CSS background 属性',
reset: '重置为默认',
fileSizeLimit: '图片大小限制: 20MB',
invalidImageFormat: '无效的图片格式',
imageTooLarge: '图片过大,请选择小于 20MB 的图片'
}
},
translationEngine: '歌詞翻譯引擎',
translationEngineOptions: {
@@ -292,28 +417,61 @@ export default {
title: '快捷键设置',
shortcut: '快捷键',
shortcutDesc: '自定义快捷键',
summaryReady: '当前快捷键配置可保存',
summaryRecording: '正在录制新的快捷键组合',
summaryBlocked: '存在冲突或无效项,请先修正',
platformHintMac: 'macOS 下 CommandOrControl 会显示为 Cmd',
platformHintWindows: 'Windows 下 CommandOrControl 会显示为 Ctrl',
platformHintLinux: 'Linux 下 CommandOrControl 会显示为 Ctrl',
platformHintGeneric: '不同系统下 CommandOrControl 会自动适配',
enabledCount: '已启用',
recordingTip: '点击快捷键框后按下组合键Esc 取消Delete 可禁用该项',
shortcutConflict: '快捷键冲突',
inputPlaceholder: '点击输入快捷键',
clickToRecord: '点击后按下组合键',
recording: '录制中...',
resetShortcuts: '恢复默认',
restoreSingle: '恢复',
disableAll: '全部禁用',
enableAll: '全部启用',
groups: {
playback: '播放控制',
sound: '音量与收藏',
window: '窗口控制'
},
togglePlay: '播放/暂停',
togglePlayDesc: '切换当前歌曲播放状态',
prevPlay: '上一首',
prevPlayDesc: '切换到上一首歌曲',
nextPlay: '下一首',
nextPlayDesc: '切换到下一首歌曲',
volumeUp: '音量增加',
volumeUpDesc: '提高播放器音量',
volumeDown: '音量减少',
volumeDownDesc: '降低播放器音量',
toggleFavorite: '收藏/取消收藏',
toggleFavoriteDesc: '收藏或取消当前歌曲',
toggleWindow: '显示/隐藏窗口',
toggleWindowDesc: '快速显示或隐藏主窗口',
scopeGlobal: '全局',
scopeApp: '应用内',
enabled: '启用',
disabled: '禁用',
issueInvalid: '组合无效',
issueReserved: '系统保留',
registrationWarningTitle: '以下快捷键未能注册,请更换组合后重试',
registrationOccupied: '被系统或其他应用占用',
registrationInvalid: '键位格式无效',
messages: {
resetSuccess: '已恢复默认快捷键,请记得保存',
conflict: '存在冲突的快捷键,请重新设置',
saveSuccess: '快捷键设置已保存',
saveError: '保存快捷键失败,请重试',
saveValidationError: '快捷键校验未通过,请检查后重试',
partialRegistered: '已保存,但部分全局快捷键未注册成功',
cancelEdit: '已取消修改',
clearToDisable: '已禁用该快捷键',
invalidShortcut: '快捷键无效,请输入有效组合',
disableAll: '已禁用所有快捷键,请记得保存',
enableAll: '已启用所有快捷键,请记得保存'
}
@@ -330,7 +488,7 @@ export default {
},
cookie: {
title: 'Cookie设置',
description: '请输入网易云音乐的Cookie',
description: '请输入音乐的Cookie',
placeholder: '请粘贴完整的Cookie...',
help: {
format: 'Cookie通常以 "MUSIC_U=" 开头',

View File

@@ -3,6 +3,7 @@ export default {
play: '播放',
playNext: '下一首播放',
download: '下载歌曲',
downloadLyric: '下载歌词',
addToPlaylist: '添加到歌单',
favorite: '喜欢',
unfavorite: '取消喜欢',
@@ -15,7 +16,10 @@ export default {
downloadFailed: '下载失败',
downloadQueued: '已加入下载队列',
addedToNextPlay: '已添加到下一首播放',
getUrlFailed: '获取音乐下载地址失败,请检查是否登录'
getUrlFailed: '获取音乐下载地址失败,请检查是否登录',
noLyric: '该歌曲暂无歌词',
lyricDownloaded: '歌词下载成功',
lyricDownloadFailed: '歌词下载失败'
},
dialog: {
dislike: {

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}首',
playCount: '播放{count}次'
},
tabs: {
created: '创建',
favorite: '收藏',
album: '专辑'
},
ranking: {
title: '听歌排行',
playCount: '{count}次'

View File

@@ -1,51 +0,0 @@
export default {
player: {
loading: '聽書載入中...',
retry: '重試',
playNow: '立即播放',
loadingTitle: '載入中...',
totalDuration: '總時長: {duration}',
partsList: '分P列表 (共{count}集)',
playStarted: '已開始播放',
switchingPart: '切換到分P: {part}',
preloadingNext: '預載入下一個分P: {part}',
playingCurrent: '播放當前選中的分P: {name}',
num: '萬',
errors: {
invalidVideoId: '影片ID無效',
loadVideoDetailFailed: '獲取影片詳情失敗',
loadPartInfoFailed: '無法載入影片分P資訊',
loadAudioUrlFailed: '獲取音訊播放地址失敗',
videoDetailNotLoaded: '影片詳情未載入',
missingParams: '缺少必要參數',
noAvailableAudioUrl: '未找到可用的音訊地址',
loadPartAudioFailed: '載入分P音訊URL失敗',
audioListEmpty: '音訊列表為空,請重試',
currentPartNotFound: '未找到當前分P的音訊',
audioUrlFailed: '獲取音訊URL失敗',
playFailed: '播放失敗,請重試',
getAudioUrlFailed: '獲取音訊地址失敗,請重試',
audioNotFound: '未找到對應的音訊,請重試',
preloadFailed: '預載入下一個分P失敗',
switchPartFailed: '切換分P時載入音訊URL失敗'
},
console: {
loadingDetail: '載入B站影片詳情',
detailData: 'B站影片詳情資料',
multipleParts: '影片有多個分P共{count}個',
noPartsData: '影片無分P或分P資料為空',
loadingAudioSource: '載入音訊來源',
generatedAudioList: '已生成音訊列表,共{count}首',
getDashAudioUrl: '獲取到dash音訊URL',
getDurlAudioUrl: '獲取到durl音訊URL',
loadingPartAudio: '載入分P音訊URL: {part}, cid: {cid}',
loadPartAudioFailed: '載入分P音訊URL失敗: {part}',
switchToPart: '切換到分P: {part}',
audioNotFoundInList: '未找到對應的音訊項目',
preparingToPlay: '準備播放當前選中的分P: {name}',
preloadingNextPart: '預載入下一個分P: {part}',
playingSelectedPart: '播放當前選中的分P: {name}音訊URL: {url}',
preloadNextFailed: '預載入下一個分P失敗'
}
}
};

View File

@@ -15,6 +15,7 @@ export default {
hide: '隱藏',
confirm: '確認',
cancel: '取消',
clear: '清除',
configure: '設定',
open: '開啟',
modify: '修改',
@@ -27,6 +28,8 @@ export default {
refresh: '重新整理',
retry: '重試',
reset: '重設',
loadFailed: '載入失敗',
noData: '暫無資料',
back: '返回',
copySuccess: '已複製到剪貼簿',
copyFailed: '複製失敗',
@@ -39,10 +42,13 @@ export default {
viewMore: '查看更多',
noMore: '沒有更多了',
selectAll: '全選',
playAll: '播放全部',
expand: '展開',
collapse: '收合',
songCount: '{count}首',
language: '語言',
today: '今天',
yesterday: '昨天',
tray: {
show: '顯示',
quit: '退出',

View File

@@ -1,4 +1,8 @@
export default {
more: '更多',
homeListItem: {
loading: '載入中...'
},
installApp: {
description: '安裝應用程式,獲得更好的體驗',
noPrompt: '不再提示',
@@ -33,11 +37,16 @@ export default {
title: '發現新版本',
currentVersion: '目前版本',
cancel: '暫不更新',
checking: '檢查更新中...',
prepareDownload: '準備下載...',
downloading: '下載中...',
readyToInstall: '更新包已下載完成,可以立即安裝',
nowUpdate: '立即更新',
downloadFailed: '下載失敗,請重試或手動下載',
startFailed: '啟動下載失敗,請重試或手動下載',
autoUpdateFailed: '自動更新失敗',
openOfficialSite: '前往官網更新',
manualFallbackHint: '自動更新失敗後,可前往官網下載安裝最新版本。',
noDownloadUrl: '未找到適合目前系統的安裝包,請手動下載',
installConfirmTitle: '安裝更新',
installConfirmContent: '是否關閉應用程式並安裝更新?',
@@ -50,6 +59,27 @@ export default {
copyFailed: '複製失敗',
backgroundDownload: '背景下載'
},
disclaimer: {
title: '使用說明',
warning: '本程式為開發測試版本,功能尚未完善,可能存在諸多問題及臭蟲,僅供學習交流使用。',
item1: '本程式僅供個人學習、研究及技術交流之目的,不得用於任何商業用途。',
item2: '請在下載後 24 小時內刪除,若對您有所幫助,請支持正版音樂。',
item3: '使用本程式即代表您已了解並同意相關風險,開發者對任何損失概不負責。',
agree: '我已了解並同意',
disagree: '不同意並退出'
},
donate: {
title: '支援開發者',
subtitle: '您的支援是我持續更新的動力',
tip: '捐贈完全採自願原則。即使不捐贈,您依然可以正常使用所有功能。感謝您的理解與支援!',
wechat: '微信支付',
alipay: '支付寶',
wechatQR: '微信收款碼',
alipayQR: '支付寶收款碼',
scanTip: '請使用手機 App 掃描 QR Code 進行捐贈',
enterApp: '進入程式',
noForce: '捐贈並非強制,您可以點擊按鈕直接進入'
},
coffee: {
title: '請我喝杯咖啡',
alipay: '支付寶',
@@ -77,7 +107,68 @@ export default {
songlist: '每日推薦清單'
},
recommendSonglist: {
title: '本週最熱音樂'
title: '本週最熱音樂',
empty: '暫無推薦歌單'
},
dailyRecommend: {
title: '每日推薦',
badge: '推薦',
empty: '暫無推薦歌曲',
intelligenceHint: '開啟心動模式,發現更多喜歡的音樂'
},
recommendMV: {
title: '推薦MV'
},
newAlbum: {
title: '專輯',
empty: '暫無新專輯'
},
recommendNewMusic: {
title: '新歌速遞'
},
privateContent: {
title: '獨家放送'
},
djProgram: {
title: '推薦電台'
},
homeHero: {
dailyRecommend: '每日推薦',
songs: '首',
playNow: '立即播放',
intelligenceMode: '心動模式',
intelligenceModeOn: '心動中',
intelligenceModeDesc: '開啟智慧推薦播放',
intelligenceModeActiveDesc: '根據你的喜好智慧推薦',
startIntelligence: '開啟心動',
stopIntelligence: '關閉心動',
playing: '播放中',
toplistDesc: '熱門榜單',
mvDesc: '音樂視訊',
playlistDesc: '精選播放清單',
personalFm: '私人FM',
discoverMusic: '發現新音樂',
personalFmDesc: '根據你的喜好推薦',
recentPlays: '最近播放',
viewAll: '查看全部',
followedArtists: '關注歌手',
newSongs: '首新歌',
fromFollowedArtists: '來自你關注的歌手',
recommendNewMusic: '推薦新音樂',
newSongExpress: '新歌速遞',
discoverNewReleases: '發現最新發行的好歌',
hotPlaylists: '精選歌單',
hotArtists: '熱門歌手',
hotArtistsTitle: '熱門藝人',
hotArtistsDesc: '當下最受歡迎的歌手',
fmTrash: '不喜歡',
fmNext: '下一首',
quickNav: {
myFavorite: '我的收藏',
playHistory: '播放歷史',
myProfile: '我的主頁',
toplist: '排行榜'
}
},
searchBar: {
login: '登入',
@@ -92,7 +183,13 @@ export default {
zoom: '頁面縮放',
zoom100: '標準縮放100%',
resetZoom: '點擊重設縮放',
zoomDefault: '標準縮放'
zoomDefault: '標準縮放',
tabPlaylist: '播放清單',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消',
intelligenceMode: '心動模式',
exitIntelligence: '退出心動模式'
},
titleBar: {
closeTitle: '請選擇關閉方式',
@@ -117,7 +214,12 @@ export default {
cancelCollect: '取消收藏',
addToPlaylist: '新增至播放清單',
addToPlaylistSuccess: '新增至播放清單成功',
songsAlreadyInPlaylist: '歌曲已存在於播放清單中'
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
locateCurrent: '定位當前播放',
historyRecommend: '歷史日推',
fetchDatesFailed: '獲取日期列表失敗',
fetchSongsFailed: '獲取歌曲列表失敗',
noSongs: '暫無歌曲'
},
playlist: {
import: {
@@ -143,6 +245,7 @@ export default {
albumNamePlaceholder: '專輯名稱',
addSongButton: '新增歌曲',
addLinkButton: '新增連結',
options: '選項',
importToStarPlaylist: '匯入到我喜歡的音樂',
playlistNamePlaceholder: '請輸入播放清單名稱',
importButton: '開始匯入',
@@ -186,5 +289,41 @@ export default {
list: '播放清單',
mv: 'MV',
home: '首頁',
search: '搜尋'
search: '搜尋',
album: '專輯',
localMusic: '本地音樂',
pages: {
toplist: {
desc: '最具權威的音樂榜單,發現當下最熱門的音樂'
},
mv: {
desc: '探索精彩影片內容',
loadingMore: '載入更多中...',
noMore: '— 已載入全部內容 —',
area: {
all: '全部',
mainland: '內地',
hktw: '港台',
western: '歐美',
japan: '日本',
korea: '韓國'
}
},
list: {
desc: '發現更多好聽的播放清單',
dailyRecommend: '每日推薦'
},
search: {
desc: '探索當下最熱門的搜尋趨勢'
},
album: {
area: {
all: '全部',
chinese: '華語',
western: '歐美',
korea: '韓國',
japan: '日本'
}
}
}
};

View File

@@ -10,7 +10,8 @@ export default {
},
empty: {
noTasks: '暫無下載任務',
noDownloaded: '暫無已下載歌曲'
noDownloaded: '暫無已下載歌曲',
noDownloadedHint: '去下載你喜歡的歌曲吧'
},
progress: {
total: '總進度: {progress}%'
@@ -19,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: '未知歌手'
@@ -39,7 +54,8 @@ export default {
message: '確定要清空所有下載記錄嗎?此操作不會刪除已下載的音樂檔案,但將清空所有記錄。',
confirm: '確定清空',
cancel: '取消',
success: '下載記錄已清空'
success: '下載記錄已清空',
failed: '清空下載記錄失敗'
},
message: {
downloadComplete: '{filename} 下載完成',
@@ -49,6 +65,7 @@ export default {
playStarted: '開始播放: {name}',
playFailed: '播放失敗: {name}',
path: {
copy: '複製路徑',
copied: '路徑已複製到剪貼簿',
copyFailed: '複製路徑失敗'
},
@@ -60,6 +77,8 @@ export default {
noPathSelected: '請先選擇下載路徑',
select: '選擇資料夾',
open: '開啟資料夾',
saveLyric: '單獨儲存歌詞檔案',
saveLyricDesc: '下載歌曲時同時儲存一份 .lrc 歌詞檔案',
fileFormat: '檔名格式',
fileFormatDesc: '設定下載音樂時的檔案命名格式',
customFormat: '自訂格式',
@@ -72,6 +91,8 @@ export default {
dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:',
formatVariables: '可用變數',
preview: '預覽效果:',
concurrency: '最大並發數',
concurrencyDesc: '同時下載的最大歌曲數量1-5',
saveSuccess: '下載設定已儲存',
presets: {
songArtist: '歌曲名 - 歌手名',
@@ -83,5 +104,10 @@ export default {
artistName: '歌手名',
albumName: '專輯名'
}
},
error: {
incomplete: '檔案下載不完整',
urlExpired: '下載連結已過期,正在重新獲取',
resumeFailed: '恢復下載失敗'
}
};

View File

@@ -1,5 +1,49 @@
export default {
title: '播放歷史',
heatmapTitle: '熱力圖',
playCount: '{count}',
getHistoryFailed: '取得歷史記錄失敗'
getHistoryFailed: '取得歷史記錄失敗',
categoryTabs: {
songs: '歌曲',
playlists: '歌單',
albums: '專輯',
podcasts: '播客'
},
podcastTabs: {
episodes: '節目',
radios: '電台'
},
tabs: {
all: '全部記錄',
local: '本地記錄',
cloud: '雲端記錄'
},
getCloudRecordFailed: '取得雲端記錄失敗',
needLogin: '請使用cookie登入以查看雲端記錄',
merging: '正在合併記錄...',
noDescription: '暫無描述',
noData: '暫無記錄',
heatmap: {
title: '播放熱力圖',
loading: '正在載入數據...',
unit: '次播放',
footerText: '滑鼠懸停查看詳細信息',
playCount: '播放 {count} 次',
topSongs: '當天熱門歌曲',
times: '次',
totalPlays: '總播放次數',
activeDays: '活躍天數',
noData: '暫無播放記錄',
colorTheme: '配色方案',
colors: {
green: '綠色',
blue: '藍色',
orange: '橙色',
purple: '紫色',
red: '紅色'
},
mostPlayedSong: '播放最多的歌曲',
mostActiveDay: '最活躍的一天',
latestNightSong: '最晚播放的歌曲'
}
};

View File

@@ -0,0 +1,13 @@
export default {
title: '本地音樂',
scanFolder: '掃描資料夾',
removeFolder: '移除資料夾',
scanning: '正在掃描...',
scanComplete: '掃描完成',
playAll: '播放全部',
search: '搜尋本地音樂',
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描',
songCount: '{count} 首歌曲'
};

View File

@@ -41,7 +41,21 @@ export default {
uidLoginFailed: 'UID登入失敗請檢查使用者ID是否正確',
autoGetCookieSuccess: '自動取得Cookie成功',
autoGetCookieFailed: '自動取得Cookie失敗',
autoGetCookieTip: '將開啟網易雲音樂登入頁面,請完成登入後關閉視窗'
autoGetCookieTip: '將開啟網易雲音樂登入頁面,請完成登入後關閉視窗',
loginFailed: '登入失敗',
phoneRequired: '請輸入手機號',
passwordRequired: '請輸入密碼',
phoneLoginFailed: '手機號登入失敗,請檢查手機號和密碼是否正確',
qrCheckFailed: '檢查二維碼狀態失敗,請刷新重試',
qrLoading: '正在載入二維碼...',
qrExpired: '二維碼已過期,請點擊刷新',
qrExpiredShort: '二維碼已過期',
qrExpiredWarning: '二維碼已過期,請點擊刷新獲取新的二維碼',
qrScanned: '已掃碼,請在手機上確認登入',
qrScannedShort: '已掃碼',
qrScannedInfo: '已扫码,请在手机上确认登录',
qrConfirmed: '登入成功,正在跳轉...',
qrGenerating: '正在生成二維碼...'
},
qrTitle: '掃碼登入網易雲音樂',
uidWarning: '注意UID登入僅用於查看使用者公開資訊無法訪問需要登入權限的功能'

View File

@@ -14,6 +14,11 @@ export default {
addCorrection: '提前 {num} 秒',
subtractCorrection: '延遲 {num} 秒',
playFailed: '目前歌曲播放失敗,播放下一首',
parseFailedPlayNext: '歌曲解析失敗,播放下一首',
consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試',
playListEnded: '已播放到列表最後一首',
autoResumed: '已自動恢復播放',
resumeFailed: '恢復播放失敗,請手動點擊播放',
playMode: {
sequence: '順序播放',
loop: '單曲循環',
@@ -29,7 +34,8 @@ export default {
list: '自動播放下一個'
},
lrc: {
noLrc: '暫無歌詞, 請欣賞'
noLrc: '暫無歌詞, 請欣賞',
noAutoScroll: '本歌詞不支持自動滾動'
},
reparse: {
title: '選擇解析音源',
@@ -66,7 +72,17 @@ export default {
unFavorite: '已取消收藏{name}',
miniPlayBar: '迷你播放列',
playbackSpeed: '播放速度',
advancedControls: '更多設定s'
advancedControls: '更多設定',
intelligenceMode: {
title: '心動模式',
needCookieLogin: '請使用 Cookie 方式登入後使用心動模式',
noFavoritePlaylist: '未找到我喜歡的音樂歌單',
noLikedSongs: '您還沒有喜歡的歌曲',
loading: '正在載入心動模式',
success: '已載入 {count} 首歌曲',
failed: '取得心動模式清單失敗',
error: '心動模式播放出錯'
}
},
eq: {
title: '等化器',
@@ -92,6 +108,11 @@ export default {
custom: '自訂'
}
},
// 播放器設定
settings: {
title: '播放設定',
playbackSpeed: '播放速度'
},
// 定時關閉功能相關
sleepTimer: {
title: '定時關閉',

View File

@@ -0,0 +1,40 @@
export default {
podcast: '播客',
mySubscriptions: '我的訂閱',
discover: '發現',
categories: '分類',
todayPerfered: '今日優選',
recommended: '推薦電台',
hotRanking: '熱門榜',
newRanking: '新晉榜',
subscribeCount: '訂閱',
programCount: '期節目',
subscribe: '訂閱',
subscribed: '已訂閱',
unsubscribe: '取消訂閱',
unsubscribed: '已取消訂閱',
subscribeSuccess: '訂閱成功',
unsubscribeFailed: '取消訂閱失敗',
subscribeFailed: '訂閱失敗',
radioDetail: '電台詳情',
programList: '節目列表',
playProgram: '播放節目',
recentPlayed: '最近播放',
listeners: '收聽',
noSubscriptions: '暫無訂閱',
goDiscover: '去發現播客',
searchPodcast: '搜尋播客',
category: '分類',
all: '全部',
dj: '主播',
episodes: '期',
playAll: '播放全部',
popularCategories: '熱門分類',
allCategories: '全部分類',
categoryRadios: '分類電台',
exploreCategoryRadios: '探索更多精彩電台',
hotRadios: '熱門電台',
noCategoryRadios: '該分類暫無電台',
searchPlaceholder: '搜尋播客、電台節目...',
searchResults: '搜尋結果'
};

View File

@@ -11,7 +11,8 @@ export default {
},
loading: {
more: '載入中...',
failed: '搜尋失敗'
failed: '搜尋失敗',
searching: '搜尋中...'
},
noMore: '沒有更多了',
error: {
@@ -22,6 +23,10 @@ export default {
album: '專輯',
playlist: '歌單',
mv: 'MV',
djradio: '電台',
bilibili: 'B站'
}
},
history: '搜尋歷史',
hot: '熱門搜尋',
suggestions: '搜尋建議'
};

View File

@@ -10,7 +10,7 @@ export default {
network: '網路設定',
system: '系統管理',
donation: '捐贈支持',
regard: '關於'
about: '關於'
},
basic: {
themeMode: '主題模式',
@@ -24,7 +24,7 @@ export default {
tokenStatus: '目前Cookie狀態',
tokenSet: '已設定',
tokenNotSet: '未設定',
setCookie: '設定Cookie',
setToken: '設定Cookie',
modifyToken: '修改Cookie',
clearToken: '清除Cookie',
font: '字體設定',
@@ -50,7 +50,14 @@ export default {
englishText: 'The quick brown fox jumps over the lazy dog',
japaneseText: 'あいうえお かきくけこ さしすせそ',
koreanText: '가나다라마 바사아자차 카타파하'
}
},
gpuAcceleration: 'GPU加速',
gpuAccelerationDesc: '啟用或禁用硬體加速可以提高渲染性能但可能會增加GPU負載',
gpuAccelerationRestart: '更改GPU加速設定需要重啟應用後生效',
gpuAccelerationChangeSuccess: 'GPU加速設定已更新重啟應用後生效',
gpuAccelerationChangeError: 'GPU加速設定更新失敗',
tabletMode: '平板模式',
tabletModeDesc: '啟用後將在移動設備上使用PC樣式界面適合平板等大屏設備'
},
playback: {
quality: '音質設定',
@@ -77,6 +84,10 @@ export default {
gdmusicInfo: 'GD音樂台可自動解析多個平台音源自動選擇最佳結果',
autoPlay: '自動播放',
autoPlayDesc: '重新開啟應用程式時是否自動繼續播放',
audioDevice: '音訊輸出裝置',
audioDeviceDesc: '選擇音訊輸出裝置,如揚聲器、耳機或藍牙裝置',
testAudio: '測試',
selectAudioDevice: '選擇輸出裝置',
showStatusBar: '是否顯示狀態列控制功能',
showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)',
fallbackParser: '備用解析服務 (GD音樂台)',
@@ -86,14 +97,17 @@ export default {
// 音源標籤
sourceLabels: {
migu: '咪咕音樂',
kugou: '酷狗音樂',
pyncmd: '網易雲(內建)',
migu: 'migu',
kugou: 'kugou',
kuwo: 'kuwo',
pyncmd: 'pyncmd',
qq: 'qq',
joox: 'JOOX',
bilibili: 'Bilibili',
gdmusic: 'GD音樂台',
gdmusic: 'gdmusic',
lxMusic: 'lxMusic',
custom: '自訂 API'
},
customApi: {
sectionTitle: '自訂 API 設定',
importConfig: '匯入 JSON 設定',
@@ -101,7 +115,38 @@ export default {
notImported: '尚未匯入自訂音源。',
importSuccess: '成功匯入音源:{name}',
importFailed: '匯入失敗:{message}',
enableHint: '請先匯入 JSON 設定檔才能啟用'
enableHint: '請先匯入 JSON 設定檔才能啟用',
status: {
imported: '已匯入自訂音源',
notImported: '未匯入'
}
},
lxMusic: {
tabs: {
sources: '音源選擇',
lxMusic: '落雪音源',
customApi: '自訂API'
},
scripts: {
title: '已匯入的音源腳本',
importLocal: '本機匯入',
importOnline: '線上匯入',
urlPlaceholder: '輸入落雪音源腳本 URL',
importBtn: '匯入',
empty: '暫無已匯入的落雪音源',
notConfigured: '未設定 (請至落雪音源分頁設定)',
importHint: '匯入相容的自訂 API 外掛以擴充音源',
noScriptWarning: '請先匯入落雪音源腳本',
noSelectionWarning: '請先選擇一個落雪音源',
notFound: '音源不存在',
switched: '已切換到音源: {name}',
deleted: '已刪除音源: {name}',
enterUrl: '請輸入腳本 URL',
invalidUrl: '無效的 URL 格式',
invalidScript: '無效的落雪音源腳本,未找到 globalThis.lx 相關程式碼',
nameRequired: '名稱不能為空',
renameSuccess: '重新命名成功'
}
}
},
application: {
@@ -144,6 +189,35 @@ export default {
system: {
cache: '快取管理',
cacheDesc: '清除快取',
diskCache: '磁碟快取',
diskCacheDesc: '將播放過的音樂與歌詞快取到本機磁碟,加速二次播放',
cacheDirectory: '快取目錄',
cacheDirectoryDesc: '自訂音樂與歌詞快取儲存位置',
selectDirectory: '選擇目錄',
openDirectory: '開啟目錄',
cacheMaxSize: '快取上限',
cacheMaxSizeDesc: '達到上限時會自動清理較舊快取',
cleanupPolicy: '清理策略',
cleanupPolicyDesc: '快取達到上限時的自動清理規則',
cleanupPolicyOptions: {
lru: '最近最少使用',
fifo: '先進先出'
},
cacheStatus: '快取狀態',
cacheStatusDesc: '已用 {used} / 上限 {limit}',
cacheStatusDetail: '音樂 {musicCount} 首,歌詞 {lyricCount} 首',
manageDiskCache: '手動清理磁碟快取',
manageDiskCacheDesc: '依快取類型進行清理',
clearMusicCache: '清理音樂快取',
clearLyricCache: '清理歌詞快取',
clearAllCache: '清理全部快取',
switchDirectoryMigrateTitle: '偵測到既有快取',
switchDirectoryMigrateContent: '是否將舊目錄快取搬移到新目錄?',
switchDirectoryMigrateConfirm: '搬移',
switchDirectoryDestroyTitle: '是否刪除舊快取',
switchDirectoryDestroyContent: '不搬移時,是否刪除舊目錄的快取檔案?',
switchDirectoryDestroyConfirm: '刪除',
switchDirectoryKeepOld: '保留舊快取',
cacheClearTitle: '請選擇要清除的快取類型:',
cacheTypes: {
history: {
@@ -178,7 +252,14 @@ export default {
restart: '重新啟動',
restartDesc: '重新啟動應用程式',
messages: {
clearSuccess: '清除成功,部分設定在重啟後生效'
clearSuccess: '清除成功,部分設定在重啟後生效',
diskCacheClearSuccess: '磁碟快取已清理',
diskCacheClearFailed: '清理磁碟快取失敗',
diskCacheStatsLoadFailed: '讀取快取狀態失敗',
switchDirectorySuccess: '快取目錄已切換,舊快取已保留',
switchDirectoryFailed: '快取目錄切換失敗',
switchDirectoryMigrated: '快取目錄已切換,已搬移 {count} 個快取檔案',
switchDirectoryDestroyed: '快取目錄已切換,已刪除 {count} 個舊快取檔案'
}
},
about: {
@@ -188,6 +269,7 @@ export default {
latest: '目前已是最新版本',
hasUpdate: '發現新版本',
gotoUpdate: '前往更新',
manualUpdate: '官網更新',
gotoGithub: '前往 Github',
author: '作者',
authorDesc: 'algerkong 點個star🌟呗',
@@ -206,6 +288,7 @@ export default {
display: '顯示',
interface: '介面',
typography: '文字',
background: '背景',
mobile: '行動端'
},
pureMode: '純淨模式',
@@ -228,11 +311,77 @@ export default {
medium: '中',
large: '大'
},
fontWeight: '字體粗細',
fontWeightMarks: {
thin: '細',
normal: '常規',
bold: '粗'
},
letterSpacing: '字間距',
letterSpacingMarks: {
compact: '緊湊',
default: '預設',
loose: '寬鬆'
},
lineHeight: '行高',
lineHeightMarks: {
compact: '緊湊',
default: '預設',
loose: '寬鬆'
},
contentWidth: '內容區寬度',
mobileLayout: '行動端佈局',
layoutOptions: {
default: '預設',
ios: 'iOS 風格',
android: 'Android 風格'
},
mobileCoverStyle: '封面風格',
coverOptions: {
record: '唱片',
square: '方形',
full: '全螢幕'
},
lyricLines: '歌詞行數',
mobileUnavailable: '此設定僅在行動端可用',
// 背景設定
background: {
useCustomBackground: '使用自訂背景',
backgroundMode: '背景模式',
modeOptions: {
solid: '純色',
gradient: '漸層',
image: '圖片',
css: 'CSS'
},
solidColor: '選擇顏色',
presetColors: '預設顏色',
customColor: '自訂顏色',
gradientEditor: '漸層編輯器',
gradientColors: '漸層顏色',
gradientDirection: '漸層方向',
directionOptions: {
toBottom: '上到下',
toRight: '左到右',
toBottomRight: '左上到右下',
angle45: '45度',
toTop: '下到上',
toLeft: '右到左'
},
addColor: '新增顏色',
removeColor: '移除顏色',
imageUpload: '上傳圖片',
imagePreview: '圖片預覽',
clearImage: '清除圖片',
imageBlur: '模糊度',
imageBrightness: '明暗度',
customCss: '自訂 CSS 樣式',
customCssPlaceholder: '輸入 CSS 樣式,如: background: linear-gradient(...)',
customCssHelp: '支援任意 CSS background 屬性',
reset: '重設為預設',
fileSizeLimit: '圖片大小限制: 20MB',
invalidImageFormat: '無效的圖片格式',
imageTooLarge: '圖片過大,請選擇小於 20MB 的圖片'
}
},
themeColor: {
@@ -260,6 +409,79 @@ export default {
none: '關閉',
opencc: 'OpenCC 繁化'
},
shortcutSettings: {
title: '快捷鍵設定',
shortcut: '快捷鍵',
shortcutDesc: '自訂快捷鍵',
summaryReady: '目前快捷鍵設定可直接儲存',
summaryRecording: '正在錄製新的快捷鍵組合',
summaryBlocked: '存在衝突或無效項目,請先修正',
platformHintMac: 'macOS 下 CommandOrControl 會顯示為 Cmd',
platformHintWindows: 'Windows 下 CommandOrControl 會顯示為 Ctrl',
platformHintLinux: 'Linux 下 CommandOrControl 會顯示為 Ctrl',
platformHintGeneric: 'CommandOrControl 會依系統自動適配',
enabledCount: '已啟用',
recordingTip: '點擊快捷鍵欄位後輸入組合鍵Esc 取消Delete 可停用',
shortcutConflict: '快捷鍵衝突',
inputPlaceholder: '點擊輸入快捷鍵',
clickToRecord: '點擊後輸入快捷鍵',
recording: '錄製中...',
resetShortcuts: '恢復預設',
restoreSingle: '恢復',
disableAll: '全部停用',
enableAll: '全部啟用',
groups: {
playback: '播放控制',
sound: '音量與收藏',
window: '視窗控制'
},
togglePlay: '播放/暫停',
togglePlayDesc: '切換目前歌曲播放狀態',
prevPlay: '上一首',
prevPlayDesc: '切換到上一首歌曲',
nextPlay: '下一首',
nextPlayDesc: '切換到下一首歌曲',
volumeUp: '增加音量',
volumeUpDesc: '提高播放器音量',
volumeDown: '減少音量',
volumeDownDesc: '降低播放器音量',
toggleFavorite: '收藏/取消收藏',
toggleFavoriteDesc: '收藏或取消目前歌曲',
toggleWindow: '顯示/隱藏視窗',
toggleWindowDesc: '快速顯示或隱藏主視窗',
scopeGlobal: '全域',
scopeApp: '應用程式內',
enabled: '已啟用',
disabled: '已停用',
issueInvalid: '組合無效',
issueReserved: '系統保留',
registrationWarningTitle: '以下快捷鍵未能註冊,請改用其他組合',
registrationOccupied: '被系統或其他應用程式占用',
registrationInvalid: '鍵位格式無效',
messages: {
resetSuccess: '已恢復預設快捷鍵,請記得儲存',
conflict: '存在快捷鍵衝突,請重新設定',
saveSuccess: '快捷鍵設定已儲存',
saveError: '快捷鍵儲存失敗,請重試',
saveValidationError: '快捷鍵校驗未通過,請檢查後重試',
partialRegistered: '已儲存,但部分全域快捷鍵未註冊成功',
cancelEdit: '已取消修改',
clearToDisable: '已停用該快捷鍵',
invalidShortcut: '快捷鍵無效,請輸入有效組合',
disableAll: '已停用所有快捷鍵,請記得儲存',
enableAll: '已啟用所有快捷鍵,請記得儲存'
}
},
remoteControl: {
title: '遠端控制',
enable: '啟用遠端控制',
port: '服務連接埠',
allowedIps: '允許的 IP 位址',
addIp: '新增 IP',
emptyListHint: '空白清單表示允許所有 IP 存取',
saveSuccess: '遠端控制設定已儲存',
accessInfo: '遠端控制存取位址:'
},
cookie: {
title: 'Cookie設定',
description: '請輸入網易雲音樂的Cookie',

View File

@@ -3,6 +3,7 @@ export default {
play: '播放',
playNext: '下一首播放',
download: '下載歌曲',
downloadLyric: '下載歌詞',
addToPlaylist: '新增至播放清單',
favorite: '喜歡',
unfavorite: '取消喜歡',
@@ -15,7 +16,10 @@ export default {
downloadFailed: '下載失敗',
downloadQueued: '已加入下載佇列',
addedToNextPlay: '已新增至下一首播放',
getUrlFailed: '取得音樂下載位址失敗,請檢查是否登入'
getUrlFailed: '取得音樂下載位址失敗,請檢查是否登入',
noLyric: '該歌曲暫無歌詞',
lyricDownloaded: '歌詞下載成功',
lyricDownloadFailed: '歌詞下載失敗'
},
dialog: {
dislike: {

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}首',
playCount: '播放{count}次'
},
tabs: {
created: '建立',
favorite: '收藏',
album: '專輯'
},
ranking: {
title: '聽歌排行',
playCount: '{count}次'

View File

@@ -1,17 +1,21 @@
import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, ipcMain, nativeImage } from 'electron';
import { app, ipcMain, nativeImage, session } from 'electron';
import { join } from 'path';
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 { initializeOtherApi } from './modules/otherApi';
import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeShortcuts } from './modules/shortcuts';
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
@@ -39,6 +43,10 @@ function initialize(configStore: any) {
// 初始化文件管理
initializeFileManager();
// 初始化下载管理
initializeDownloadManager();
// 初始化歌词缓存管理
initializeCacheManager();
// 初始化其他 API (搜索建议等)
initializeOtherApi();
// 初始化窗口管理
@@ -47,16 +55,24 @@ function initialize(configStore: any) {
initializeFonts();
// 初始化登录窗口
initializeLoginWindow();
// 初始化本地音乐扫描模块
initializeLocalMusicScanner();
// 创建主窗口
mainWindow = createMainWindow(icon);
// 设置下载管理器窗口引用
setDownloadManagerWindow(mainWindow);
// 初始化托盘
initializeTray(iconPath, mainWindow);
// 启动音乐API
startMusicApi();
// 初始化落雪音乐 HTTP 请求处理
initLxMusicHttp();
// 加载歌词窗口
loadLyricWindow(ipcMain, mainWindow);
@@ -117,6 +133,19 @@ if (!isSingleInstance) {
// 初始化窗口大小管理器
initWindowSizeManager();
// 设置媒体设备权限 - 允许枚举音频输出设备
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === ('media' as any) || permission === ('audioCapture' as any)) {
callback(true);
return;
}
callback(true);
});
session.defaultSession.setPermissionCheckHandler(() => {
return true;
});
// 重新初始化配置管理以获取完整的配置存储
const store = initializeConfig();
@@ -129,11 +158,6 @@ if (!isSingleInstance) {
});
});
// 监听快捷键更新
ipcMain.on('update-shortcuts', () => {
registerShortcuts(mainWindow);
});
// 监听语言切换
ipcMain.on('change-language', (_, locale: Language) => {
// 更新主进程的语言设置

View File

@@ -172,6 +172,13 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
});
});
// 歌词窗口 Vue 应用加载完成,通知主窗口发送完整歌词数据
ipcMain.on('lyric-ready', () => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send('lyric-window-ready');
}
});
ipcMain.on('send-lyric', (_, data) => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
try {
@@ -218,9 +225,6 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
// 记录原始窗口大小
const [width, height] = lyricWindow.getSize();
originalSize = { width, height };
// 在拖动时暂时禁用大小调整
lyricWindow.setResizable(false);
}
});
@@ -230,9 +234,6 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
if (lyricWindow && !lyricWindow.isDestroyed()) {
// 确保窗口大小恢复原样
lyricWindow.setSize(originalSize.width, originalSize.height);
// 拖动结束后恢复可调整大小
lyricWindow.setResizable(true);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
import { app, ipcMain } from 'electron';
import Store from 'electron-store';
import * as path from 'path';
import { createDefaultShortcuts, type ShortcutsConfig } from '../../shared/shortcuts';
import set from '../set.json';
import { defaultShortcuts } from './shortcuts';
type SetConfig = {
isProxy: boolean;
@@ -26,10 +27,15 @@ type SetConfig = {
language: string;
showTopAction: boolean;
enableGpuAcceleration: boolean;
downloadPath: string;
enableDiskCache: boolean;
diskCacheDir: string;
diskCacheMaxSizeMB: number;
diskCacheCleanupPolicy: 'lru' | 'fifo';
};
interface StoreType {
set: SetConfig;
shortcuts: typeof defaultShortcuts;
shortcuts: ShortcutsConfig;
}
let store: Store<StoreType>;
@@ -42,11 +48,22 @@ export function initializeConfig() {
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: defaultShortcuts
shortcuts: createDefaultShortcuts()
}
});
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);
}
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,10 @@
import axios from 'axios';
import { app, dialog, ipcMain, 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 sharp from 'sharp';
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',
@@ -34,8 +13,15 @@ const audioCacheStore = new Store({
}
});
// 保存已发送通知的文件,避免重复通知
const sentNotifications = new Map();
/**
* 清理文件名中的非法字符
*/
function sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
/**
* 初始化文件管理相关的IPC监听
@@ -131,133 +117,32 @@ export function initializeFileManager() {
return app.getPath('downloads');
});
// 获取存储的配置值
ipcMain.handle('get-store-value', (_, key) => {
const store = new Store();
return store.get(key);
});
// 保存歌词文件
ipcMain.handle(
'save-lyric-file',
async (_, { filename, lrcContent }: { filename: string; lrcContent: string }) => {
try {
const configStore = getStore();
const downloadPath =
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
const sanitizedName = sanitizeFilename(filename);
let filePath = path.join(downloadPath, `${sanitizedName}.lrc`);
// 设置存储的配置值
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);
// 文件已存在时添加序号
let counter = 1;
while (fs.existsSync(filePath)) {
filePath = path.join(downloadPath, `${sanitizedName} (${counter}).lrc`);
counter++;
}
// 删除对应的歌曲信息
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
};
await fs.promises.writeFile(filePath, lrcContent, 'utf-8');
return { success: true, path: filePath };
} catch (error: any) {
console.error('保存歌词文件失败:', error);
return { success: false, error: error.message };
}
}
return {
isDownloaded: false,
localPath: '',
songInfo: null
};
});
// 添加清除下载历史的处理函数
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', () => {
@@ -310,587 +195,45 @@ export function initializeFileManager() {
throw new Error(`文件读取或解析失败: ${error.message}`);
}
});
}
/**
* 处理下载请求
*/
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: '该歌曲已在下载队列中'
// 处理导入落雪音源脚本的请求
ipcMain.handle('import-lx-music-script', async () => {
const result = await dialog.showOpenDialog({
title: '选择落雪音源脚本文件',
filters: [{ name: 'JavaScript Files', extensions: ['js'] }],
properties: ['openFile']
});
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);
if (result.canceled || result.filePaths.length === 0) {
return null;
}
// 清理文件名中的非法字符
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 = '';
const filePath = result.filePaths[0];
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 fileContent = fs.readFileSync(filePath, 'utf-8');
// 音频格式映射表
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}`);
}
// 验证脚本格式:检查是否包含落雪音源特征
if (
!fileContent.includes('globalThis.lx') &&
!fileContent.includes('lx.on') &&
!fileContent.includes('EVENT_NAMES')
) {
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码。');
}
} 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('歌词已准备好,将写入元数据');
}
// 检查是否包含必要的元信息注释
const hasMetaComment = fileContent.includes('@name');
if (!hasMetaComment) {
console.warn('警告: 脚本缺少 @name 元信息注释');
}
} 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') {
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 {
// 使用 sharp 进行压缩
coverImageBuffer = await sharp(originalCoverBuffer)
.resize({
width: 1600,
height: 1600,
fit: 'inside',
withoutEnlargement: true
})
.jpeg({
quality: 80,
mozjpeg: true
})
.toBuffer();
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) : undefined,
DATE: songInfo?.publishTime
? new Date(songInfo.publishTime).getFullYear().toString()
: undefined
};
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);
}
}
// 保存下载信息
try {
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
const defaultInfo = {
name: filename,
ar: [{ name: '本地音乐' }],
picUrl: '/images/default_cover.png'
return {
name: path.basename(filePath, '.js'),
content: fileContent
};
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('读取落雪音源脚本失败:', error);
throw new Error(`脚本读取失败: ${error.message}`);
}
} 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');
}

View File

@@ -0,0 +1,329 @@
// 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as mm from 'music-metadata';
import * as os from 'os';
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;
/**
* 主进程返回的原始音乐元数据
* 与渲染进程 LocalMusicMeta 类型保持一致
*/
type LocalMusicMeta = {
/** 文件绝对路径 */
filePath: string;
/** 歌曲标题 */
title: string;
/** 艺术家名称 */
artist: string;
/** 专辑名称 */
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
fileSize: number;
/** 文件修改时间戳 */
modifiedTime: number;
};
type ScannedMusicFile = {
path: string;
modifiedTime: number;
};
/**
* 判断文件扩展名是否为支持的音频格式
* @param ext 文件扩展名(含点号,如 .mp3
* @returns 是否为支持的格式
*/
function isSupportedFormat(ext: string): boolean {
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext.toLowerCase());
}
/**
* 从文件路径中提取歌曲标题(去除目录和扩展名)
* @param filePath 文件路径
* @returns 歌曲标题
*/
function extractTitleFromFilename(filePath: string): string {
const basename = path.basename(filePath);
const dotIndex = basename.lastIndexOf('.');
if (dotIndex > 0) {
return basename.slice(0, dotIndex);
}
return basename;
}
/**
* 将封面图片数据转换为 base64 Data URL
* @param picture music-metadata 解析出的封面图片对象
* @returns base64 Data URL 字符串,转换失败返回 null
*/
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
if (!picture) {
return null;
}
try {
if (picture.data.length > MAX_COVER_BYTES) {
return null;
}
const mime = picture.format ?? 'image/jpeg';
const base64 = Buffer.from(picture.data).toString('base64');
return `data:${mime};base64,${base64}`;
} catch (error) {
console.error('封面提取失败:', error);
return null;
}
}
/**
* 从 music-metadata 解析结果中提取歌词文本
* @param lyrics music-metadata 解析出的歌词数组
* @returns 歌词文本,提取失败返回 null
*/
function extractLyrics(lyrics: mm.ILyricsTag[] | undefined): string | null {
if (!lyrics || lyrics.length === 0) {
return null;
}
try {
// 优先取第一条歌词的文本内容
const firstLyric = lyrics[0];
return firstLyric?.text ?? null;
} catch (error) {
console.error('歌词提取失败:', error);
return null;
}
}
/**
* 递归扫描指定文件夹,返回所有支持格式的音乐文件路径
* @param folderPath 要扫描的文件夹路径
* @returns 音乐文件绝对路径列表
*/
async function scanMusicFiles(folderPath: string): Promise<string[]> {
const results: string[] = [];
// 检查文件夹是否存在
if (!fs.existsSync(folderPath)) {
throw new Error(`文件夹不存在: ${folderPath}`);
}
// 检查是否为目录
const stat = await fs.promises.stat(folderPath);
if (!stat.isDirectory()) {
throw new Error(`路径不是文件夹: ${folderPath}`);
}
/**
* 递归遍历目录
* @param dirPath 当前目录路径
*/
async function walkDirectory(dirPath: string): Promise<void> {
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
// 递归扫描子目录
await walkDirectory(fullPath);
} else if (entry.isFile()) {
// 检查文件扩展名是否为支持的音频格式
const ext = path.extname(entry.name);
if (isSupportedFormat(ext)) {
results.push(fullPath);
}
}
}
} catch (error) {
// 单个目录读取失败不中断整体扫描,记录错误后继续
console.error(`扫描目录失败: ${dirPath}`, error);
}
}
await walkDirectory(folderPath);
return results;
}
/**
* 递归扫描指定文件夹,返回包含修改时间的音乐文件信息
* @param folderPath 要扫描的文件夹路径
* @returns 音乐文件信息列表
*/
async function scanMusicFilesWithStats(folderPath: string): Promise<ScannedMusicFile[]> {
const results: ScannedMusicFile[] = [];
if (!fs.existsSync(folderPath)) {
throw new Error(`文件夹不存在: ${folderPath}`);
}
const stat = await fs.promises.stat(folderPath);
if (!stat.isDirectory()) {
throw new Error(`路径不是文件夹: ${folderPath}`);
}
async function walkDirectory(dirPath: string): Promise<void> {
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await walkDirectory(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (!isSupportedFormat(ext)) {
continue;
}
try {
const fileStat = await fs.promises.stat(fullPath);
results.push({
path: fullPath,
modifiedTime: fileStat.mtimeMs
});
} catch (error) {
console.error(`读取文件信息失败: ${fullPath}`, error);
}
}
}
} catch (error) {
console.error(`扫描目录失败: ${dirPath}`, error);
}
}
await walkDirectory(folderPath);
return results;
}
/**
* 解析单个音乐文件的元数据
* 解析失败时使用 fallback 默认值(文件名作标题),不抛出异常
* @param filePath 音乐文件绝对路径
* @returns 音乐元数据对象
*/
async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
// 获取文件信息(大小和修改时间)
let fileSize = 0;
let modifiedTime = 0;
try {
const stat = await fs.promises.stat(filePath);
fileSize = stat.size;
modifiedTime = stat.mtimeMs;
} catch (error) {
console.error(`获取文件信息失败: ${filePath}`, error);
}
// 构建 fallback 默认值
const fallback: LocalMusicMeta = {
filePath,
title: extractTitleFromFilename(filePath),
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
lyrics: null,
fileSize,
modifiedTime
};
try {
const metadata = await mm.parseFile(filePath);
const { common, format } = metadata;
return {
filePath,
title: common.title || fallback.title,
artist: common.artist || fallback.artist,
album: common.album || fallback.album,
duration: format.duration ? Math.round(format.duration * 1000) : 0,
cover: extractCoverAsDataUrl(common.picture?.[0]),
lyrics: extractLyrics(common.lyrics),
fileSize,
modifiedTime
};
} catch (error) {
// 解析失败使用 fallback不中断流程
console.error(`元数据解析失败,使用 fallback: ${filePath}`, error);
return fallback;
}
}
/**
* 批量解析音乐文件元数据
* 内部逐个调用 parseMetadata单文件失败不影响其他文件
* @param filePaths 音乐文件路径列表
* @returns 元数据对象列表
*/
async function batchParseMetadata(filePaths: string[]): Promise<LocalMusicMeta[]> {
if (filePaths.length === 0) {
return [];
}
const results = new Array<LocalMusicMeta>(filePaths.length);
const workerCount = Math.min(METADATA_PARSE_CONCURRENCY, filePaths.length);
let index = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (index < filePaths.length) {
const current = index;
index += 1;
results[current] = await parseMetadata(filePaths[current]);
}
});
await Promise.all(workers);
return results;
}
/**
* 初始化本地音乐扫描模块
* 注册 IPC handler供渲染进程调用
*/
export function initializeLocalMusicScanner(): void {
// 扫描指定文件夹中的音乐文件
ipcMain.handle('scan-local-music', async (_, folderPath: string) => {
try {
const files = await scanMusicFiles(folderPath);
return { files, count: files.length };
} catch (error: any) {
console.error('扫描本地音乐失败:', error);
return { error: error.message || '扫描失败' };
}
});
// 扫描指定文件夹中的音乐文件(包含修改时间)
ipcMain.handle('scan-local-music-with-stats', async (_, folderPath: string) => {
try {
const files = await scanMusicFilesWithStats(folderPath);
return { files, count: files.length };
} catch (error: any) {
console.error('扫描本地音乐(含文件信息)失败:', error);
return { error: error.message || '扫描失败' };
}
});
// 批量解析音乐文件元数据
ipcMain.handle('parse-local-music-metadata', async (_, filePaths: string[]) => {
try {
const metadataList = await batchParseMetadata(filePaths);
return metadataList;
} catch (error: any) {
console.error('解析本地音乐元数据失败:', error);
return [];
}
});
}

View File

@@ -42,7 +42,7 @@ const openLoginWindow = async (mainWin: BrowserWindow) => {
}
});
// 打开网易云登录页面
// 打开登录页面
loginWindow.loadURL(loginUrl);
// 阻止新窗口创建

View File

@@ -0,0 +1,153 @@
/**
* 落雪音乐 HTTP 请求处理(主进程)
* 绕过渲染进程的 CORS 限制
*/
import { ipcMain } from 'electron';
import fetch, { type RequestInit } from 'node-fetch';
interface LxHttpRequest {
url: string;
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
form?: Record<string, string>;
formData?: Record<string, string>;
timeout?: number;
};
requestId: string;
}
interface LxHttpResponse {
statusCode: number;
headers: Record<string, string | string[]>;
body: any;
}
// 取消控制器映射
const abortControllers = new Map<string, AbortController>();
/**
* 初始化 HTTP 请求处理
*/
export const initLxMusicHttp = () => {
// 处理 HTTP 请求
ipcMain.handle(
'lx-music-http-request',
async (_, request: LxHttpRequest): Promise<LxHttpResponse> => {
const { url, options, requestId } = request;
const controller = new AbortController();
// 保存取消控制器
abortControllers.set(requestId, controller);
try {
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
const fetchOptions: RequestInit = {
method: options.method || 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(options.headers || {})
},
signal: controller.signal
};
// 处理请求体
if (options.body) {
fetchOptions.body = options.body;
} else if (options.form) {
const formData = new URLSearchParams(options.form);
fetchOptions.body = formData.toString();
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/x-www-form-urlencoded'
};
} else if (options.formData) {
// node-fetch 的 FormData 需要特殊处理
const FormData = (await import('form-data')).default;
const formData = new FormData();
for (const [key, value] of Object.entries(options.formData)) {
formData.append(key, value);
}
fetchOptions.body = formData as any;
// FormData 会自动设置 Content-Type
}
// 设置超时
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => {
console.warn(`[LxMusicHttp] 请求超时: ${url}`);
controller.abort();
}, timeout);
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
// 读取响应体
const rawBody = await response.text();
// 尝试解析 JSON
let parsedBody: any = rawBody;
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('application/json') ||
rawBody.startsWith('{') ||
rawBody.startsWith('[')
) {
try {
parsedBody = JSON.parse(rawBody);
} catch {
// 解析失败则使用原始字符串
}
}
// 转换 headers 为普通对象
const headers: Record<string, string | string[]> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const result: LxHttpResponse = {
statusCode: response.status,
headers,
body: parsedBody
};
return result;
} catch (error: any) {
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
throw error;
} finally {
// 清理取消控制器
abortControllers.delete(requestId);
}
}
);
// 处理请求取消
ipcMain.handle('lx-music-http-cancel', (_, requestId: string) => {
const controller = abortControllers.get(requestId);
if (controller) {
console.log(`[LxMusicHttp] 取消请求: ${requestId}`);
controller.abort();
abortControllers.delete(requestId);
}
});
console.log('[LxMusicHttp] HTTP 请求处理已初始化');
};
/**
* 清理所有正在进行的请求
*/
export const cleanupLxMusicHttp = () => {
for (const [requestId, controller] of abortControllers.entries()) {
console.log(`[LxMusicHttp] 清理请求: ${requestId}`);
controller.abort();
}
abortControllers.clear();
};

View File

@@ -5,7 +5,7 @@ import { ipcMain } from 'electron';
* 初始化其他杂项 API如搜索建议等
*/
export function initializeOtherApi() {
// 搜索建议(从酷狗获取)
// 搜索建议
ipcMain.handle('get-search-suggestions', async (_, keyword: string) => {
if (!keyword || !keyword.trim()) {
return [];

View File

@@ -1,122 +1,398 @@
import { globalShortcut, ipcMain } from 'electron';
import { type BrowserWindow, globalShortcut, ipcMain } from 'electron';
import {
defaultShortcuts,
getReservedAccelerators,
getShortcutConflicts,
hasShortcutAction,
isModifierOnlyShortcut,
normalizeShortcutAccelerator,
normalizeShortcutsConfig,
type ShortcutAction,
shortcutActionOrder,
type ShortcutPlatform,
type ShortcutsConfig,
type ShortcutScope
} from '../../shared/shortcuts';
import { getStore } from './config';
// 添加获取平台信息的 IPC 处理程序
ipcMain.on('get-platform', (event) => {
event.returnValue = process.platform;
});
type ShortcutRegistrationFailureReason = 'invalid' | 'occupied';
// 定义快捷键配置接口
export interface ShortcutConfig {
type ShortcutRegistrationFailure = {
action: ShortcutAction;
key: string;
enabled: boolean;
scope: 'global' | 'app';
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig;
}
// 定义默认快捷键
export const defaultShortcuts: ShortcutsConfig = {
togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
reason: ShortcutRegistrationFailureReason;
};
let mainWindowRef: Electron.BrowserWindow | null = null;
type ShortcutRegistrationResult = {
success: boolean;
failed: ShortcutRegistrationFailure[];
};
// 注册快捷键
export function registerShortcuts(
mainWindow: Electron.BrowserWindow,
shortcutsConfig?: ShortcutsConfig
) {
mainWindowRef = mainWindow;
type ShortcutValidationReason = 'invalid' | 'conflict' | 'reserved';
type ShortcutValidationIssue = {
action: ShortcutAction;
key: string;
scope: ShortcutScope;
reason: ShortcutValidationReason;
conflictWith?: ShortcutAction;
};
type ShortcutValidationResult = {
shortcuts: ShortcutsConfig;
hasBlockingIssue: boolean;
issues: ShortcutValidationIssue[];
};
type ShortcutSaveResult = {
ok: boolean;
validation: ShortcutValidationResult;
registration: ShortcutRegistrationResult;
};
let mainWindowRef: BrowserWindow | null = null;
let shortcutsEnabled = true;
let shortcutIpcReady = false;
const managedGlobalShortcuts = new Map<ShortcutAction, string>();
function currentPlatform(): ShortcutPlatform {
if (
process.platform === 'darwin' ||
process.platform === 'win32' ||
process.platform === 'linux'
) {
return process.platform;
}
return 'linux';
}
function hasAvailableMainWindow(): boolean {
return Boolean(mainWindowRef && !mainWindowRef.isDestroyed());
}
function isShortcutsConfigEqual(left: ShortcutsConfig, right: ShortcutsConfig): boolean {
return shortcutActionOrder.every((action) => {
const leftConfig = left[action];
const rightConfig = right[action];
return (
leftConfig.key === rightConfig.key &&
leftConfig.enabled === rightConfig.enabled &&
leftConfig.scope === rightConfig.scope
);
});
}
function getStoredShortcuts(): ShortcutsConfig {
const store = getStore();
const shortcuts =
shortcutsConfig || (store.get('shortcuts') as ShortcutsConfig) || defaultShortcuts;
const rawShortcuts = store.get('shortcuts');
const normalizedShortcuts = normalizeShortcutsConfig(rawShortcuts);
// 注销所有已注册的快捷键
globalShortcut.unregisterAll();
const serializedRaw = JSON.stringify(rawShortcuts ?? null);
const serializedNormalized = JSON.stringify(normalizedShortcuts);
// 对旧格式数据进行兼容处理
if (shortcuts && typeof shortcuts.togglePlay === 'string') {
// 将 shortcuts 强制转换为 unknown再转为 Record<string, string>
const oldShortcuts = { ...shortcuts } as unknown as Record<string, string>;
const newShortcuts: ShortcutsConfig = {};
if (serializedRaw !== serializedNormalized) {
store.set('shortcuts', normalizedShortcuts);
}
Object.entries(oldShortcuts).forEach(([key, value]) => {
newShortcuts[key] = {
key: value,
enabled: true,
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
};
});
return normalizedShortcuts;
}
store.set('shortcuts', newShortcuts);
registerShortcuts(mainWindow, newShortcuts);
function persistShortcuts(shortcuts: ShortcutsConfig) {
const store = getStore();
const currentShortcuts = normalizeShortcutsConfig(store.get('shortcuts'));
if (!isShortcutsConfigEqual(currentShortcuts, shortcuts)) {
store.set('shortcuts', shortcuts);
}
}
function emitShortcutsChanged(
shortcuts: ShortcutsConfig,
registration: ShortcutRegistrationResult
): void {
if (!hasAvailableMainWindow()) {
return;
}
// 注册全局快捷键
Object.entries(shortcuts).forEach(([action, config]) => {
const { key, enabled, scope } = config as ShortcutConfig;
mainWindowRef!.webContents.send('update-app-shortcuts', shortcuts);
mainWindowRef!.webContents.send('shortcuts-updated', shortcuts, registration);
}
// 只注册启用且作用域为全局的快捷键
if (!enabled || scope !== 'global') return;
function unregisterManagedGlobalShortcuts() {
managedGlobalShortcuts.forEach((accelerator) => {
try {
globalShortcut.unregister(accelerator);
} catch (error) {
console.error(`[Shortcuts] 注销快捷键失败: ${accelerator}`, error);
}
});
managedGlobalShortcuts.clear();
}
function handleShortcutAction(action: ShortcutAction) {
if (!hasAvailableMainWindow()) {
return;
}
const mainWindow = mainWindowRef!;
if (action === 'toggleWindow') {
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return;
}
mainWindow.webContents.send('global-shortcut', action);
}
function registerManagedGlobalShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult {
unregisterManagedGlobalShortcuts();
const failed: ShortcutRegistrationFailure[] = [];
if (!shortcutsEnabled) {
return {
success: true,
failed
};
}
shortcutActionOrder.forEach((action) => {
const config = shortcuts[action];
if (!config.enabled || config.scope !== 'global') {
return;
}
const accelerator = normalizeShortcutAccelerator(config.key);
if (!accelerator || isModifierOnlyShortcut(accelerator)) {
failed.push({
action,
key: config.key,
reason: 'invalid'
});
return;
}
try {
switch (action) {
case 'toggleWindow':
globalShortcut.register(key, () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
break;
default:
globalShortcut.register(key, () => {
mainWindow.webContents.send('global-shortcut', action);
});
break;
const registered = globalShortcut.register(accelerator, () => {
handleShortcutAction(action);
});
if (!registered) {
failed.push({
action,
key: accelerator,
reason: 'occupied'
});
return;
}
managedGlobalShortcuts.set(action, accelerator);
} catch (error) {
console.error(`注册快捷键 ${key} 失败:`, error);
console.error(`[Shortcuts] 注册快捷键失败: ${accelerator}`, error);
failed.push({
action,
key: accelerator,
reason: 'invalid'
});
}
});
// 通知渲染进程更新应用内快捷键
mainWindow.webContents.send('update-app-shortcuts', shortcuts);
return {
success: failed.length === 0,
failed
};
}
// 初始化快捷键
export function initializeShortcuts(mainWindow: Electron.BrowserWindow) {
mainWindowRef = mainWindow;
registerShortcuts(mainWindow);
function validateShortcuts(rawShortcuts: unknown): ShortcutValidationResult {
const shortcuts = normalizeShortcutsConfig(rawShortcuts);
const issues: ShortcutValidationIssue[] = [];
const issueKeys = new Set<string>();
const rawShortcutMap =
rawShortcuts && typeof rawShortcuts === 'object'
? (rawShortcuts as Record<string, unknown>)
: {};
const pushIssue = (issue: ShortcutValidationIssue) => {
const issueKey = `${issue.reason}:${issue.action}:${issue.scope}:${issue.key}:${issue.conflictWith ?? ''}`;
if (issueKeys.has(issueKey)) {
return;
}
issueKeys.add(issueKey);
issues.push(issue);
};
shortcutActionOrder.forEach((action) => {
const rawActionConfig = rawShortcutMap[action];
if (!rawActionConfig) {
return;
}
const rawKey =
typeof rawActionConfig === 'string'
? rawActionConfig
: typeof rawActionConfig === 'object' && rawActionConfig !== null
? (rawActionConfig as { key?: unknown }).key
: null;
if (typeof rawKey !== 'string') {
return;
}
const normalizedKey = normalizeShortcutAccelerator(rawKey);
if (!normalizedKey || isModifierOnlyShortcut(rawKey)) {
pushIssue({
action,
key: rawKey,
scope: shortcuts[action].scope,
reason: 'invalid'
});
}
});
const conflicts = getShortcutConflicts(shortcuts);
conflicts.forEach((conflict) => {
conflict.actions.forEach((action, index) => {
const conflictWith = conflict.actions[(index + 1) % conflict.actions.length];
pushIssue({
action,
key: conflict.key,
scope: conflict.scope,
reason: 'conflict',
conflictWith
});
});
});
const reservedAccelerators = new Set(getReservedAccelerators(currentPlatform()));
shortcutActionOrder.forEach((action) => {
const config = shortcuts[action];
if (!config.enabled || config.scope !== 'global') {
return;
}
const accelerator = normalizeShortcutAccelerator(config.key);
if (accelerator && reservedAccelerators.has(accelerator)) {
pushIssue({
action,
key: accelerator,
scope: config.scope,
reason: 'reserved'
});
}
});
return {
shortcuts,
hasBlockingIssue: issues.length > 0,
issues
};
}
function applyShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult {
const registration = registerManagedGlobalShortcuts(shortcuts);
emitShortcutsChanged(shortcuts, registration);
return registration;
}
function saveShortcuts(rawShortcuts: unknown): ShortcutSaveResult {
const validation = validateShortcuts(rawShortcuts);
if (validation.hasBlockingIssue) {
return {
ok: false,
validation,
registration: {
success: false,
failed: []
}
};
}
persistShortcuts(validation.shortcuts);
const registration = applyShortcuts(validation.shortcuts);
return {
ok: true,
validation,
registration
};
}
function setupShortcutIpcHandlers() {
if (shortcutIpcReady) {
return;
}
shortcutIpcReady = true;
ipcMain.on('get-platform', (event) => {
event.returnValue = process.platform;
});
// 监听禁用快捷键事件
ipcMain.on('disable-shortcuts', () => {
globalShortcut.unregisterAll();
shortcutsEnabled = false;
unregisterManagedGlobalShortcuts();
});
// 监听启用快捷键事件
ipcMain.on('enable-shortcuts', () => {
if (mainWindowRef) {
registerShortcuts(mainWindowRef);
}
shortcutsEnabled = true;
const shortcuts = getStoredShortcuts();
applyShortcuts(shortcuts);
});
// 监听快捷键更新事件
ipcMain.on('update-shortcuts', (_, shortcutsConfig: ShortcutsConfig) => {
if (mainWindowRef) {
registerShortcuts(mainWindowRef, shortcutsConfig);
}
ipcMain.on('update-shortcuts', (_, shortcutsConfig: unknown) => {
saveShortcuts(shortcutsConfig);
});
ipcMain.handle('shortcuts:get-config', () => {
return getStoredShortcuts();
});
ipcMain.handle('shortcuts:validate', (_, shortcutsConfig: unknown) => {
return validateShortcuts(shortcutsConfig);
});
ipcMain.handle('shortcuts:save', (_, shortcutsConfig: unknown) => {
return saveShortcuts(shortcutsConfig);
});
}
export function registerShortcuts(mainWindow: BrowserWindow, shortcutsConfig?: ShortcutsConfig) {
mainWindowRef = mainWindow;
const shortcuts = shortcutsConfig
? normalizeShortcutsConfig(shortcutsConfig)
: getStoredShortcuts();
if (shortcutsConfig) {
persistShortcuts(shortcuts);
}
return applyShortcuts(shortcuts);
}
export function initializeShortcuts(mainWindow: BrowserWindow) {
mainWindowRef = mainWindow;
setupShortcutIpcHandlers();
const shortcuts = getStoredShortcuts();
applyShortcuts(shortcuts);
}
export function isShortcutActionSupported(action: string): action is ShortcutAction {
return hasShortcutAction(action);
}
export { defaultShortcuts };

View File

@@ -59,6 +59,13 @@ function getSongTitle(song: SongInfo | null): string {
return artistStr ? `${song.name} - ${artistStr}` : song.name;
}
// 截断歌曲标题,防止菜单中显示过长
function getTruncatedSongTitle(song: SongInfo | null, maxLength: number = 14): string {
const fullTitle = getSongTitle(song);
if (fullTitle.length <= maxLength) return fullTitle;
return fullTitle.slice(0, maxLength) + '...';
}
// 更新当前播放的音乐信息
export function updateCurrentSong(song: SongInfo | null) {
currentSong = song;
@@ -143,7 +150,7 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
if (currentSong) {
menu.append(
new MenuItem({
label: getSongTitle(currentSong),
label: getTruncatedSongTitle(currentSong),
enabled: false,
type: 'normal'
})
@@ -250,7 +257,7 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
...((currentSong
? [
{
label: getSongTitle(currentSong),
label: getTruncatedSongTitle(currentSong),
enabled: false,
type: 'normal'
},

View File

@@ -1,101 +1,296 @@
import axios from 'axios';
import { spawn } from 'child_process';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import electronUpdater, {
type ProgressInfo,
type UpdateDownloadedEvent,
type UpdateInfo
} from 'electron-updater';
export function setupUpdateHandlers(_mainWindow: BrowserWindow) {
ipcMain.on('start-download', async (event, url: string) => {
import {
APP_UPDATE_RELEASE_URL,
APP_UPDATE_STATUS,
type AppUpdateState,
createDefaultAppUpdateState
} from '../../shared/appUpdate';
const { autoUpdater } = electronUpdater;
type CheckUpdateOptions = {
manual?: boolean;
};
let updateState: AppUpdateState = createDefaultAppUpdateState(app.getVersion());
let isInitialized = false;
let checkForUpdatesPromise: Promise<AppUpdateState> | null = null;
let downloadUpdatePromise: Promise<AppUpdateState> | null = null;
const isAutoUpdateSupported = (): boolean => {
// if (!app.isPackaged) {
// return false;
// }
if (process.platform === 'linux') {
return Boolean(process.env.APPIMAGE);
}
return true;
};
const normalizeReleaseNotes = (releaseNotes: UpdateInfo['releaseNotes']): string => {
if (typeof releaseNotes === 'string') {
return releaseNotes;
}
if (Array.isArray(releaseNotes)) {
return releaseNotes
.map((item) => {
const version = item.version ? `## ${item.version}` : '';
return [version, item.note].filter(Boolean).join('\n');
})
.join('\n\n');
}
return '';
};
const broadcastUpdateState = () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('app-update:state', updateState);
}
};
const setUpdateState = (partial: Partial<AppUpdateState>) => {
updateState = {
...updateState,
...partial
};
broadcastUpdateState();
};
const resetUpdateState = () => {
updateState = {
...createDefaultAppUpdateState(app.getVersion()),
supported: isAutoUpdateSupported()
};
};
const getUnsupportedMessage = () => {
if (!app.isPackaged) {
return '当前环境为开发模式,自动更新仅在打包后的应用内可用';
}
if (process.platform === 'linux') {
return '当前 Linux 安装方式不支持自动更新,请前往官网下载安装包更新';
}
return '当前环境不支持自动更新,请前往官网下载安装包更新';
};
const applyUpdateInfo = (
status: AppUpdateState['status'],
info?: Pick<UpdateInfo, 'version' | 'releaseDate' | 'releaseNotes'>
) => {
setUpdateState({
status,
availableVersion: info?.version ?? null,
releaseDate: info?.releaseDate ?? null,
releaseNotes: info ? normalizeReleaseNotes(info.releaseNotes) : '',
releasePageUrl: APP_UPDATE_RELEASE_URL,
errorMessage: null,
checkedAt: Date.now()
});
};
const checkForUpdates = async (options: CheckUpdateOptions = {}): Promise<AppUpdateState> => {
if (!updateState.supported) {
const errorMessage = options.manual ? getUnsupportedMessage() : null;
setUpdateState({
status: options.manual ? APP_UPDATE_STATUS.error : APP_UPDATE_STATUS.idle,
errorMessage
});
return updateState;
}
if (
updateState.status === APP_UPDATE_STATUS.available ||
updateState.status === APP_UPDATE_STATUS.downloading ||
updateState.status === APP_UPDATE_STATUS.downloaded
) {
return updateState;
}
if (checkForUpdatesPromise) {
return await checkForUpdatesPromise;
}
checkForUpdatesPromise = (async () => {
try {
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
onDownloadProgress: (progressEvent: { loaded: number; total?: number }) => {
if (!progressEvent.total) return;
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
const downloaded = (progressEvent.loaded / 1024 / 1024).toFixed(2);
const total = (progressEvent.total / 1024 / 1024).toFixed(2);
event.sender.send('download-progress', percent, `已下载 ${downloaded}MB / ${total}MB`);
}
});
const fileName = url.split('/').pop() || 'update.exe';
const downloadPath = path.join(app.getPath('downloads'), fileName);
// 创建写入流
const writer = fs.createWriteStream(downloadPath);
// 将响应流写入文件
response.data.pipe(writer);
// 处理写入完成
writer.on('finish', () => {
event.sender.send('download-complete', true, downloadPath);
});
// 处理写入错误
writer.on('error', (error) => {
console.error('Write file error:', error);
event.sender.send('download-complete', false, '');
setUpdateState({
status: APP_UPDATE_STATUS.checking,
errorMessage: null,
checkedAt: Date.now()
});
await autoUpdater.checkForUpdates();
return updateState;
} catch (error) {
console.error('Download failed:', error);
event.sender.send('download-complete', false, '');
const errorMessage = error instanceof Error ? error.message : '检查更新失败';
setUpdateState({
status: APP_UPDATE_STATUS.error,
errorMessage,
checkedAt: Date.now()
});
return updateState;
} finally {
checkForUpdatesPromise = null;
}
})();
return await checkForUpdatesPromise;
};
const downloadUpdate = async (): Promise<AppUpdateState> => {
if (!updateState.supported) {
setUpdateState({
status: APP_UPDATE_STATUS.error,
errorMessage: getUnsupportedMessage()
});
return updateState;
}
if (updateState.status === APP_UPDATE_STATUS.downloaded) {
return updateState;
}
if (!hasDownloadableUpdate()) {
setUpdateState({
status: APP_UPDATE_STATUS.error,
errorMessage: '当前没有可下载的更新'
});
return updateState;
}
if (downloadUpdatePromise) {
return await downloadUpdatePromise;
}
downloadUpdatePromise = (async () => {
try {
await autoUpdater.downloadUpdate();
return updateState;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '下载更新失败';
setUpdateState({
status: APP_UPDATE_STATUS.error,
errorMessage
});
return updateState;
} finally {
downloadUpdatePromise = null;
}
})();
return await downloadUpdatePromise;
};
const hasDownloadableUpdate = () => {
return updateState.status === APP_UPDATE_STATUS.available;
};
const openReleasePage = async (): Promise<boolean> => {
await shell.openExternal(updateState.releasePageUrl || APP_UPDATE_RELEASE_URL);
return true;
};
export function setupUpdateHandlers(mainWindow: BrowserWindow) {
if (isInitialized) {
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.webContents.send('app-update:state', updateState);
});
return;
}
isInitialized = true;
resetUpdateState();
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => {
setUpdateState({
status: APP_UPDATE_STATUS.checking,
errorMessage: null,
checkedAt: Date.now()
});
});
ipcMain.on('install-update', (_event, filePath: string) => {
if (!fs.existsSync(filePath)) {
console.error('Installation file not found:', filePath);
return;
}
autoUpdater.on('update-available', (info) => {
applyUpdateInfo(APP_UPDATE_STATUS.available, info);
});
const { platform } = process;
autoUpdater.on('update-not-available', () => {
setUpdateState({
status: APP_UPDATE_STATUS.notAvailable,
availableVersion: null,
releaseNotes: '',
releaseDate: null,
errorMessage: null,
checkedAt: Date.now()
});
});
// 先启动安装程序,再退出应用
try {
if (platform === 'win32') {
// 使用spawn替代exec并使用detached选项确保子进程独立运行
const child = spawn(filePath, [], {
detached: true,
stdio: 'ignore'
});
child.unref();
} else if (platform === 'darwin') {
// 挂载 DMG 文件
const child = spawn('open', [filePath], {
detached: true,
stdio: 'ignore'
});
child.unref();
} else if (platform === 'linux') {
const ext = path.extname(filePath);
if (ext === '.AppImage') {
// 先添加执行权限
fs.chmodSync(filePath, '755');
const child = spawn(filePath, [], {
detached: true,
stdio: 'ignore'
});
child.unref();
} else if (ext === '.deb') {
const child = spawn('pkexec', ['dpkg', '-i', filePath], {
detached: true,
stdio: 'ignore'
});
child.unref();
}
}
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
setUpdateState({
status: APP_UPDATE_STATUS.downloading,
downloadProgress: progress.percent,
downloadedBytes: progress.transferred,
totalBytes: progress.total,
bytesPerSecond: progress.bytesPerSecond,
errorMessage: null
});
});
// 给安装程序一点时间启动
setTimeout(() => {
app.quit();
}, 500);
} catch (error) {
console.error('启动安装程序失败:', error);
// 尽管出错,仍然尝试退出应用
app.quit();
}
autoUpdater.on('update-downloaded', (info: UpdateDownloadedEvent) => {
setUpdateState({
status: APP_UPDATE_STATUS.downloaded,
availableVersion: info.version,
releaseNotes: normalizeReleaseNotes(info.releaseNotes),
releaseDate: info.releaseDate,
downloadProgress: 100,
downloadedBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0),
totalBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0),
bytesPerSecond: 0,
errorMessage: null
});
});
autoUpdater.on('error', (error) => {
setUpdateState({
status: APP_UPDATE_STATUS.error,
errorMessage: error?.message ?? '自动更新失败'
});
});
ipcMain.handle('app-update:get-state', async () => {
return updateState;
});
ipcMain.handle('app-update:check', async (_event, options?: CheckUpdateOptions) => {
return await checkForUpdates(options);
});
ipcMain.handle('app-update:download', async () => {
return await downloadUpdate();
});
ipcMain.handle('app-update:quit-and-install', async () => {
autoUpdater.quitAndInstall(false, true);
return true;
});
ipcMain.handle('app-update:open-release-page', async () => {
return await openReleasePage();
});
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.webContents.send('app-update:state', updateState);
});
}

View File

@@ -13,9 +13,11 @@ export const DEFAULT_MINI_EXPANDED_HEIGHT = 400;
// 用于存储窗口状态的键名
export const WINDOW_STATE_KEY = 'windowState';
// 最小窗口尺寸
let MIN_WIDTH = Math.round(DEFAULT_MAIN_WIDTH * 0.5);
let MIN_HEIGHT = Math.round(DEFAULT_MAIN_HEIGHT * 0.5);
// 最小窗口尺寸(确保内容不会被截断)
const ABSOLUTE_MIN_WIDTH = 900;
const ABSOLUTE_MIN_HEIGHT = 640;
let MIN_WIDTH = ABSOLUTE_MIN_WIDTH;
let MIN_HEIGHT = ABSOLUTE_MIN_HEIGHT;
// 标记IPC处理程序是否已注册
let ipcHandlersRegistered = false;
@@ -98,19 +100,16 @@ class WindowSizeManager {
try {
const { width: workAreaWidth, height: workAreaHeight } = screen.getPrimaryDisplay().workArea;
// 根据工作区大小设置合理的最小尺寸
MIN_WIDTH = Math.min(Math.round(DEFAULT_MAIN_WIDTH * 0.5), Math.round(workAreaWidth * 0.3));
MIN_HEIGHT = Math.min(
Math.round(DEFAULT_MAIN_HEIGHT * 0.5),
Math.round(workAreaHeight * 0.3)
);
// 根据工作区大小设置合理的最小尺寸,但不低于绝对最小值
MIN_WIDTH = Math.max(ABSOLUTE_MIN_WIDTH, Math.round(workAreaWidth * 0.3));
MIN_HEIGHT = Math.max(ABSOLUTE_MIN_HEIGHT, Math.round(workAreaHeight * 0.3));
console.log(`设置最小窗口尺寸: ${MIN_WIDTH}x${MIN_HEIGHT}`);
} catch (error) {
console.error('初始化最小窗口尺寸失败:', error);
// 使用默认值
MIN_WIDTH = Math.round(DEFAULT_MAIN_WIDTH * 0.5);
MIN_HEIGHT = Math.round(DEFAULT_MAIN_HEIGHT * 0.5);
MIN_WIDTH = ABSOLUTE_MIN_WIDTH;
MIN_HEIGHT = ABSOLUTE_MIN_HEIGHT;
}
}
@@ -344,7 +343,6 @@ class WindowSizeManager {
*/
saveWindowState(win: BrowserWindow): WindowState {
// 如果窗口已销毁,则返回之前的状态或默认状态
console.log('win.isDestroyed()', win.isDestroyed());
if (win.isDestroyed()) {
return (
this.savedState || {

View File

@@ -15,6 +15,7 @@ import { join } from 'path';
import {
applyContentZoom,
applyInitialState,
calculateMinimumWindowSize,
DEFAULT_MAIN_HEIGHT,
DEFAULT_MAIN_WIDTH,
DEFAULT_MINI_HEIGHT,
@@ -143,6 +144,12 @@ export function initializeWindowManager() {
}
});
// 强制退出应用(用于免责声明拒绝等场景)
ipcMain.on('quit-app', () => {
setAppQuitting(true);
app.quit();
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
@@ -198,10 +205,8 @@ export function initializeWindowManager() {
console.log('从迷你模式恢复,使用保存的状态:', JSON.stringify(preMiniModeState));
// 设置适当的最小尺寸
win.setMinimumSize(
Math.max(DEFAULT_MAIN_WIDTH * 0.5, 600),
Math.max(DEFAULT_MAIN_HEIGHT * 0.5, 400)
);
const { minWidth, minHeight } = calculateMinimumWindowSize();
win.setMinimumSize(minWidth, minHeight);
// 恢复窗口状态
win.setAlwaysOnTop(false);
@@ -311,6 +316,42 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
// 创建窗口
const mainWindow = new BrowserWindow(options);
const appOrigin = (() => {
if (!is.dev || !process.env.ELECTRON_RENDERER_URL) return null;
try {
return new URL(process.env.ELECTRON_RENDERER_URL).origin;
} catch {
return null;
}
})();
const shouldOpenInBrowser = (targetUrl: string): boolean => {
try {
const parsedUrl = new URL(targetUrl);
if (parsedUrl.protocol === 'mailto:' || parsedUrl.protocol === 'tel:') {
return true;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return false;
}
if (appOrigin && parsedUrl.origin === appOrigin) {
return false;
}
return true;
} catch {
return false;
}
};
const openInSystemBrowser = (targetUrl: string) => {
shell.openExternal(targetUrl).catch((error) => {
console.error('打开外部链接失败:', targetUrl, error);
});
};
// 移除菜单
mainWindow.removeMenu();
@@ -374,8 +415,16 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
}, 100);
});
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
if (!shouldOpenInBrowser(targetUrl)) return;
event.preventDefault();
openInSystemBrowser(targetUrl);
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
if (shouldOpenInBrowser(details.url)) {
openInSystemBrowser(details.url);
}
return { action: 'deny' };
});

View File

@@ -1,17 +1,19 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs';
import server from 'netease-cloud-music-api-alger/server';
import os from 'os';
import path from 'path';
import { type Platform, unblockMusic } from './unblockMusic';
const store = new Store();
// 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件
// 否则模块加载时 readFileSync 会因文件不存在而崩溃
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}
const store = new Store();
// 设置音乐解析的处理程序
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
try {
@@ -23,14 +25,60 @@ ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) =>
}
});
async function startMusicApi(): Promise<void> {
console.log('MUSIC API STARTED');
const port = (store.get('set') as any).musicApiPort || 30488;
await server.serveNcmApi({
port
/**
* 检查端口是否可用
*/
function checkPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const net = require('net');
const tester = net
.createServer()
.once('error', () => {
resolve(false);
})
.once('listening', () => {
tester.close(() => resolve(true));
})
.listen(port);
});
}
async function startMusicApi(): Promise<void> {
console.log('MUSIC API STARTING...');
const settings = store.get('set') as any;
let port = settings?.musicApiPort || 30488;
const maxRetries = 10;
// 检查端口是否可用,如果不可用则尝试下一个端口
for (let i = 0; i < maxRetries; i++) {
const isAvailable = await checkPortAvailable(port);
if (isAvailable) {
break;
}
console.log(`端口 ${port} 被占用,尝试切换到端口 ${port + 1}`);
port++;
}
// 如果端口发生变化,保存新端口到配置
const originalPort = settings?.musicApiPort || 30488;
if (port !== originalPort) {
console.log(`端口从 ${originalPort} 切换到 ${port}`);
store.set('set', { ...settings, musicApiPort: port });
}
try {
const server = require('netease-cloud-music-api-alger/server');
await server.serveNcmApi({
port,
// 安全默认值:仅监听本机回环地址,避免对局域网暴露
host: '127.0.0.1'
});
console.log(`MUSIC API STARTED on port ${port}`);
} catch (error) {
console.error(`MUSIC API 启动失败:`, error);
throw error;
}
}
export { startMusicApi };

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