Compare commits

..

74 Commits

Author SHA1 Message Date
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
241 changed files with 23396 additions and 13984 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: |

11
.gitignore vendored
View File

@@ -32,10 +32,19 @@ 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,5 +1,63 @@
# 更新日志
## v5.1.0
### ✨ 新功能
- 新增本地音乐扫描播放功能
- 新增播客页面与组件
- 新增专辑页面
- 桌面歌词新增 单行/双行/滚动 三种显示模式,支持翻译开关和双行分组淡出动画
- 重构自动更新系统,使用 electron-updater 替代手动下载
- 设置页新增音频设备配置
- 快捷键整体重构优化
- 重构 SearchBar集成标题滚动显示功能
- 优化音源解析策略与播放逻辑
- 优化移动端适配与 UI 布局
### 🐛 Bug 修复
- 修复自动播放循环与暂停失效问题
- 修复桌面歌词窗口首次打开无歌词问题
- 修复播放并发控制死代码、shallowRef 响应式丢失、歌词 IPC 高频调用
- 修复 AppMenu 错误主题色
- 修复播放列表抽屉关闭动画使用 setTimeout 不可靠问题
- 修复搜索结果滚动加载触发距离过大
- 修复本地音乐元数据解析并发限流与封面体积限制
- 修复本地音乐扫描增量判断逻辑
- 修复 preload 层 ipc.on 解绑监听器失效
- 修复歌词缓存 IPC 通道未接入初始化
- 修复歌词组件卸载时 groupFadeTimer 未清理导致内存泄漏
- 补全 MV/排行榜/歌单/搜索/专辑页面缺失的国际化
- 修复 NeteaseCloudMusicApi anonymous_token 文件不存在导致启动崩溃
- 修复移动端全屏歌词前奏阶段第一句歌词不可见
- 修复移动端音乐列表页按钮尺寸过大
- 登录页扫码登录改为默认首选
- 设置桌面端最小窗口尺寸为 900×640 防止内容截断
- 移除首页顶部多余 padding
- HomeHero 快捷导航仅移动端显示
### 🔒 安全
- 本地音乐 API 仅监听回环地址,防止外部访问
- LX Music 脚本执行隔离到 Worker 沙箱
### 🎨 优化
- 全面重构 UI播放器、播放条、通用组件、列表项、布局、标题栏、搜索页等
- 重构首页 UI
- 设置页拆分为 7 个独立 Tab 组件,优化捐赠列表性能
- 重构音乐和歌词缓存逻辑,支持可配置缓存目录
- 统一进度追踪机制,移除重复的 rAF 更新循环
- 优化播放列表持久化,精简序列化字段并添加防抖写入
- 优化骨架屏加载效果,修复用户页左侧黑色背景
- 统一 SongItem 圆角与 hover 背景色
- 重构历史记录模块
- 调整主题主色
- 扩展数据层与播放能力
- 增加 i18n 检查脚本与提交钩子
- 重构 i18n 键值检查并增加引用告警模式
## v5.0.0
### ✨ 新功能

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

@@ -1,6 +1,6 @@
{
"name": "AlgerMusicPlayer",
"version": "5.0.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}": [
@@ -47,12 +50,11 @@
"husky": "^9.1.7",
"jsencrypt": "^3.5.4",
"music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.26.1",
"netease-cloud-music-api-alger": "^4.30.0",
"node-fetch": "^2.7.0",
"node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",
"pinia-plugin-persistedstate": "^4.7.1",
"sharp": "^0.34.5",
"vue-i18n": "^11.2.2"
},
"devDependencies": {
@@ -78,7 +80,7 @@
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"cross-env": "^7.0.3",
"electron": "^39.2.7",
"electron": "^40.1.0",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.2",
@@ -136,12 +138,8 @@
"mac": {
"icon": "resources/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"universal"
]
}
"dmg",
"zip"
],
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"darkModeSupport": true,

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,13 +37,12 @@ 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',

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',
@@ -104,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',
@@ -119,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',
@@ -145,6 +222,7 @@ export default {
addToPlaylistSuccess: 'Add to Playlist Success',
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist',
locateCurrent: 'Locate current song',
historyRecommend: 'Daily History',
fetchDatesFailed: 'Failed to fetch dates',
fetchSongsFailed: 'Failed to fetch songs',
@@ -174,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',
@@ -218,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,7 +16,6 @@ export default {
progress: {
total: 'Total Progress: {progress}%'
},
items: 'items',
status: {
downloading: 'Downloading',
completed: 'Completed',
@@ -42,17 +41,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 +64,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',

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

@@ -6,7 +6,12 @@ export default {
categoryTabs: {
songs: 'Songs',
playlists: 'Playlists',
albums: 'Albums'
albums: 'Albums',
podcasts: 'Podcasts'
},
podcastTabs: {
episodes: 'Episodes',
radios: 'Radios'
},
tabs: {
all: 'All Records',
@@ -18,7 +23,6 @@ export default {
merging: 'Merging records...',
noDescription: 'No description',
noData: 'No records',
newKey: 'New translation',
heatmap: {
title: 'Play Heatmap',
loading: 'Loading data...',

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

@@ -131,10 +131,7 @@ export default {
timerEnded: 'Sleep timer ended',
playbackStopped: 'Music playback stopped',
minutesRemaining: '{minutes} min remaining',
songsRemaining: '{count} songs remaining',
activeTime: 'Timer Active',
activeSongs: 'Counting Songs',
activeEnd: 'End After List'
songsRemaining: '{count} songs remaining'
},
playList: {
clearAll: 'Clear Playlist',

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

@@ -23,6 +23,7 @@ export default {
album: 'Album',
playlist: 'Playlist',
mv: 'MV',
djradio: 'Podcast',
bilibili: 'Bilibili'
},
history: 'Search History',

View File

@@ -88,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)',
@@ -101,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'
},
@@ -188,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: {
@@ -222,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: {
@@ -232,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🌟',
@@ -375,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

@@ -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,6 +42,7 @@ export default {
viewMore: 'もっと見る',
noMore: 'これ以上ありません',
selectAll: '全選択',
playAll: 'すべて再生',
expand: '展開',
collapse: '折りたたみ',
songCount: '{count}曲',

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: '更新をインストール',
@@ -104,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: 'ログイン',
@@ -119,7 +190,13 @@ export default {
zoom: 'ページズーム',
zoom100: '標準ズーム100%',
resetZoom: 'クリックしてズームをリセット',
zoomDefault: '標準ズーム'
zoomDefault: '標準ズーム',
tabPlaylist: 'プレイリスト',
tabMv: 'MV',
tabCharts: 'チャート',
cancelSearch: 'キャンセル',
intelligenceMode: '心動モード',
exitIntelligence: '心動モードを終了'
},
titleBar: {
closeTitle: '閉じる方法を選択してください',
@@ -145,6 +222,7 @@ export default {
addToPlaylist: 'プレイリストに追加',
addToPlaylistSuccess: 'プレイリストに追加しました',
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
locateCurrent: '再生中の曲を表示',
historyRecommend: '履歴の日次推薦',
fetchDatesFailed: '日付リストの取得に失敗しました',
fetchSongsFailed: '楽曲リストの取得に失敗しました',
@@ -174,6 +252,7 @@ export default {
albumNamePlaceholder: 'アルバム名',
addSongButton: '楽曲を追加',
addLinkButton: 'リンクを追加',
options: 'オプション',
importToStarPlaylist: 'お気に入りの音楽にインポート',
playlistNamePlaceholder: 'プレイリスト名を入力してください',
importButton: 'インポート開始',
@@ -218,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}%'
@@ -40,7 +41,8 @@ export default {
'すべてのダウンロード記録をクリアしますか?この操作はダウンロード済みの音楽ファイルを削除しませんが、すべての記録をクリアします。',
confirm: 'クリア確認',
cancel: 'キャンセル',
success: 'ダウンロード記録をクリアしました'
success: 'ダウンロード記録をクリアしました',
failed: 'ダウンロード記録のクリアに失敗しました'
},
message: {
downloadComplete: '{filename}のダウンロードが完了しました',
@@ -50,6 +52,7 @@ export default {
playStarted: '再生開始: {name}',
playFailed: '再生失敗: {name}',
path: {
copy: 'パスをコピー',
copied: 'パスをクリップボードにコピーしました',
copyFailed: 'パスのコピーに失敗しました'
},
@@ -61,6 +64,8 @@ export default {
noPathSelected: 'まずダウンロードパスを選択してください',
select: 'フォルダを選択',
open: 'フォルダを開く',
saveLyric: '歌詞ファイルを個別に保存',
saveLyricDesc: '楽曲ダウンロード時に .lrc 歌詞ファイルも一緒に保存します',
fileFormat: 'ファイル名形式',
fileFormatDesc: '音楽ダウンロード時のファイル命名形式を設定',
customFormat: 'カスタム形式',

View File

@@ -11,7 +11,12 @@ export default {
categoryTabs: {
songs: '楽曲',
playlists: 'プレイリスト',
albums: 'アルバム'
albums: 'アルバム',
podcasts: 'ポッドキャスト'
},
podcastTabs: {
episodes: 'エピソード',
radios: 'ラジオ'
},
noDescription: '説明なし',
noData: '記録なし',

View File

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

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

@@ -23,8 +23,10 @@ export default {
album: 'アルバム',
playlist: 'プレイリスト',
mv: 'MV',
djradio: 'ラジオ',
bilibili: 'Bilibili'
},
history: '検索履歴',
hot: '人気検索',
suggestions: '検索候補'

View File

@@ -87,6 +87,10 @@ export default {
gdmusicInfo: 'GD音楽台は複数のプラットフォーム音源を自動解析し、最適な結果を自動選択できます',
autoPlay: '自動再生',
autoPlayDesc: 'アプリを再起動した際に自動的に再生を継続するかどうか',
audioDevice: 'オーディオ出力デバイス',
audioDeviceDesc: 'スピーカー、ヘッドホン、Bluetoothデバイスなどの出力先を選択',
testAudio: 'テスト',
selectAudioDevice: '出力デバイスを選択',
showStatusBar: 'ステータスバーコントロール機能を表示するかどうか',
showStatusBarContent:
'Macのステータスバーに音楽コントロール機能を表示できます再起動後に有効',
@@ -98,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: {
@@ -187,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: {
@@ -221,7 +258,15 @@ export default {
restart: '再起動',
restartDesc: 'アプリを再起動',
messages: {
clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります'
clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります',
diskCacheClearSuccess: 'ディスクキャッシュを削除しました',
diskCacheClearFailed: 'ディスクキャッシュの削除に失敗しました',
diskCacheStatsLoadFailed: 'キャッシュ状態の取得に失敗しました',
switchDirectorySuccess: 'キャッシュディレクトリを切り替えました(旧キャッシュは保持)',
switchDirectoryFailed: 'キャッシュディレクトリの切り替えに失敗しました',
switchDirectoryMigrated: 'キャッシュディレクトリを切り替え、{count} 件を移行しました',
switchDirectoryDestroyed:
'キャッシュディレクトリを切り替え、旧キャッシュ {count} 件を削除しました'
}
},
about: {
@@ -231,6 +276,7 @@ export default {
latest: '現在最新バージョンです',
hasUpdate: '新しいバージョンが見つかりました',
gotoUpdate: '更新へ',
manualUpdate: '手動更新',
gotoGithub: 'Githubへ',
author: '作者',
authorDesc: 'algerkong スターを付けてください🌟',
@@ -374,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

@@ -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,6 +42,7 @@ export default {
viewMore: '더 보기',
noMore: '더 이상 없음',
selectAll: '전체 선택',
playAll: '모두 재생',
expand: '펼치기',
collapse: '접기',
songCount: '{count}곡',

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: '앱을 닫고 업데이트를 설치하시겠습니까?',
@@ -103,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: '로그인',
@@ -118,7 +189,13 @@ export default {
zoom: '페이지 확대/축소',
zoom100: '표준 확대/축소 100%',
resetZoom: '클릭하여 확대/축소 재설정',
zoomDefault: '표준 확대/축소'
zoomDefault: '표준 확대/축소',
tabPlaylist: '플레이리스트',
tabMv: 'MV',
tabCharts: '차트',
cancelSearch: '취소',
intelligenceMode: '심쿵 모드',
exitIntelligence: '심쿵 모드 종료'
},
titleBar: {
closeTitle: '닫기 방법을 선택해주세요',
@@ -144,6 +221,7 @@ export default {
addToPlaylist: '재생 목록에 추가',
addToPlaylistSuccess: '재생 목록에 추가 성공',
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
locateCurrent: '현재 재생 곡 찾기',
historyRecommend: '일일 기록 권장',
fetchDatesFailed: '날짜를 가져오지 못했습니다',
fetchSongsFailed: '곡을 가져오지 못했습니다',
@@ -173,6 +251,7 @@ export default {
albumNamePlaceholder: '앨범명',
addSongButton: '곡 추가',
addLinkButton: '링크 추가',
options: '옵션',
importToStarPlaylist: '내가 좋아하는 음악으로 가져오기',
playlistNamePlaceholder: '플레이리스트 이름을 입력하세요',
importButton: '가져오기 시작',
@@ -217,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}%'
@@ -40,7 +41,8 @@ export default {
'모든 다운로드 기록을 지우시겠습니까? 이 작업은 다운로드된 음악 파일을 삭제하지 않지만 모든 기록을 지웁니다.',
confirm: '지우기 확인',
cancel: '취소',
success: '다운로드 기록이 지워졌습니다'
success: '다운로드 기록이 지워졌습니다',
failed: '다운로드 기록 삭제에 실패했습니다'
},
message: {
downloadComplete: '{filename} 다운로드 완료',
@@ -50,6 +52,7 @@ export default {
playStarted: '재생 시작: {name}',
playFailed: '재생 실패: {name}',
path: {
copy: '경로 복사',
copied: '경로가 클립보드에 복사됨',
copyFailed: '경로 복사 실패'
},
@@ -61,6 +64,8 @@ export default {
noPathSelected: '먼저 다운로드 경로를 선택해주세요',
select: '폴더 선택',
open: '폴더 열기',
saveLyric: '가사 파일 별도 저장',
saveLyricDesc: '곡 다운로드 시 .lrc 가사 파일도 함께 저장합니다',
fileFormat: '파일명 형식',
fileFormatDesc: '음악 다운로드 시 파일 이름 형식 설정',
customFormat: '사용자 정의 형식',

View File

@@ -11,7 +11,12 @@ export default {
categoryTabs: {
songs: '곡',
playlists: '플레이리스트',
albums: '앨범'
albums: '앨범',
podcasts: '팟캐스트'
},
podcastTabs: {
episodes: '에피소드',
radios: '라디오'
},
noDescription: '설명 없음',
noData: '기록 없음',

View File

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

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

@@ -23,8 +23,10 @@ export default {
album: '앨범',
playlist: '플레이리스트',
mv: 'MV',
djradio: '라디오',
bilibili: 'B站'
},
history: '검색 기록',
hot: '인기 검색',
suggestions: '검색 제안'

View File

@@ -87,6 +87,10 @@ export default {
gdmusicInfo: 'GD 뮤직은 여러 플랫폼 음원을 자동으로 해석하고 최적의 결과를 자동 선택합니다',
autoPlay: '자동 재생',
autoPlayDesc: '앱을 다시 열 때 자동으로 재생을 계속할지 여부',
audioDevice: '오디오 출력 장치',
audioDeviceDesc: '스피커, 헤드폰 또는 블루투스 장치와 같은 오디오 출력 장치 선택',
testAudio: '테스트',
selectAudioDevice: '출력 장치 선택',
showStatusBar: '상태바 제어 기능 표시 여부',
showStatusBarContent: 'Mac 상태바에 음악 제어 기능을 표시할 수 있습니다 (재시작 후 적용)',
fallbackParser: '대체 분석 서비스 (GD Music)',
@@ -99,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'
},
@@ -188,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: {
@@ -222,7 +260,14 @@ export default {
restart: '재시작',
restartDesc: '앱 재시작',
messages: {
clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다'
clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다',
diskCacheClearSuccess: '디스크 캐시를 정리했습니다',
diskCacheClearFailed: '디스크 캐시 정리에 실패했습니다',
diskCacheStatsLoadFailed: '캐시 상태를 불러오지 못했습니다',
switchDirectorySuccess: '캐시 디렉터리가 변경되었습니다. 기존 캐시는 유지됩니다',
switchDirectoryFailed: '캐시 디렉터리 변경에 실패했습니다',
switchDirectoryMigrated: '캐시 디렉터리를 변경하고 {count}개 파일을 마이그레이션했습니다',
switchDirectoryDestroyed: '캐시 디렉터리를 변경하고 기존 캐시 {count}개 파일을 삭제했습니다'
}
},
about: {
@@ -232,6 +277,7 @@ export default {
latest: '현재 최신 버전입니다',
hasUpdate: '새 버전 발견',
gotoUpdate: '업데이트하러 가기',
manualUpdate: '수동 업데이트',
gotoGithub: 'Github로 이동',
author: '작성자',
authorDesc: 'algerkong 별점🌟 부탁드려요',
@@ -375,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

@@ -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,6 +42,7 @@ export default {
viewMore: '查看更多',
noMore: '没有更多了',
selectAll: '全选',
playAll: '播放全部',
expand: '展开',
collapse: '收起',
songCount: '{count}首',

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: '是否关闭应用并安装更新?',
@@ -98,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: '登录',
@@ -113,7 +183,13 @@ export default {
zoom: '页面缩放',
zoom100: '标准缩放100%',
resetZoom: '点击重置缩放',
zoomDefault: '标准缩放'
zoomDefault: '标准缩放',
tabPlaylist: '播放列表',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消',
intelligenceMode: '心动模式',
exitIntelligence: '退出心动模式'
},
titleBar: {
closeTitle: '请选择关闭方式',
@@ -139,6 +215,7 @@ export default {
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
locateCurrent: '定位当前播放',
historyRecommend: '历史日推',
fetchDatesFailed: '获取日期列表失败',
fetchSongsFailed: '获取歌曲列表失败',
@@ -168,6 +245,7 @@ export default {
albumNamePlaceholder: '专辑名称',
addSongButton: '添加歌曲',
addLinkButton: '添加链接',
options: '选项',
importToStarPlaylist: '导入到我喜欢的音乐',
playlistNamePlaceholder: '请输入歌单名称',
importButton: '开始导入',
@@ -211,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}%'
@@ -39,7 +40,8 @@ export default {
message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。',
confirm: '确定清空',
cancel: '取消',
success: '下载记录已清空'
success: '下载记录已清空',
failed: '清空下载记录失败'
},
message: {
downloadComplete: '{filename} 下载完成',
@@ -49,6 +51,7 @@ export default {
playStarted: '开始播放: {name}',
playFailed: '播放失败: {name}',
path: {
copy: '复制路径',
copied: '路径已复制到剪贴板',
copyFailed: '复制路径失败'
},
@@ -60,6 +63,8 @@ export default {
noPathSelected: '请先选择下载路径',
select: '选择文件夹',
open: '打开文件夹',
saveLyric: '单独保存歌词文件',
saveLyricDesc: '下载歌曲时同时保存一份 .lrc 歌词文件',
fileFormat: '文件名格式',
fileFormatDesc: '设置下载音乐时的文件命名格式',
customFormat: '自定义格式',

View File

@@ -6,7 +6,12 @@ export default {
categoryTabs: {
songs: '歌曲',
playlists: '歌单',
albums: '专辑'
albums: '专辑',
podcasts: '播客'
},
podcastTabs: {
episodes: '节目',
radios: '电台'
},
tabs: {
all: '全部记录',

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

@@ -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

@@ -23,6 +23,7 @@ export default {
album: '专辑',
playlist: '歌单',
mv: 'MV',
djradio: '电台',
bilibili: 'B站'
},
history: '搜索历史',

View File

@@ -20,7 +20,7 @@ export default {
language: '语言设置',
languageDesc: '切换显示语言',
tokenManagement: 'Cookie管理',
tokenManagementDesc: '管理网易云音乐登录Cookie',
tokenManagementDesc: '管理音乐登录Cookie',
tokenStatus: '当前Cookie状态',
tokenSet: '已设置',
tokenNotSet: '未设置',
@@ -61,7 +61,7 @@ export default {
},
playback: {
quality: '音质设置',
qualityDesc: '选择音乐播放音质(网易云VIP',
qualityDesc: '选择音乐播放音质(不确保有效',
qualityOptions: {
standard: '标准',
higher: '较高',
@@ -84,6 +84,10 @@ export default {
gdmusicInfo: 'GD音乐台可自动解析多个平台音源自动选择最佳结果',
autoPlay: '自动播放',
autoPlayDesc: '重新打开应用时是否自动继续播放',
audioDevice: '音频输出设备',
audioDeviceDesc: '选择音频输出设备,如扬声器、耳机或蓝牙设备',
testAudio: '测试',
selectAudioDevice: '选择输出设备',
showStatusBar: '是否显示状态栏控制功能',
showStatusBarContent: '可以在您的mac状态栏显示音乐控制功能(重启后生效)',
@@ -95,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'
},
@@ -185,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: {
@@ -219,7 +256,14 @@ export default {
restart: '重启',
restartDesc: '重启应用',
messages: {
clearSuccess: '清除成功,部分设置在重启后生效'
clearSuccess: '清除成功,部分设置在重启后生效',
diskCacheClearSuccess: '磁盘缓存已清理',
diskCacheClearFailed: '清理磁盘缓存失败',
diskCacheStatsLoadFailed: '读取缓存状态失败',
switchDirectorySuccess: '缓存目录已切换,旧缓存已保留',
switchDirectoryFailed: '缓存目录切换失败',
switchDirectoryMigrated: '缓存目录已切换,已迁移 {count} 个缓存文件',
switchDirectoryDestroyed: '缓存目录已切换,已销毁 {count} 个旧缓存文件'
}
},
about: {
@@ -229,6 +273,7 @@ export default {
latest: '当前已是最新版本',
hasUpdate: '发现新版本',
gotoUpdate: '前往更新',
manualUpdate: '官网更新',
gotoGithub: '前往 Github',
author: '作者',
authorDesc: 'algerkong 点个star🌟呗',
@@ -372,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: '已启用所有快捷键,请记得保存'
}
@@ -410,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

@@ -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,6 +42,7 @@ export default {
viewMore: '查看更多',
noMore: '沒有更多了',
selectAll: '全選',
playAll: '播放全部',
expand: '展開',
collapse: '收合',
songCount: '{count}首',

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: '是否關閉應用程式並安裝更新?',
@@ -98,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: '登入',
@@ -113,7 +183,13 @@ export default {
zoom: '頁面縮放',
zoom100: '標準縮放100%',
resetZoom: '點擊重設縮放',
zoomDefault: '標準縮放'
zoomDefault: '標準縮放',
tabPlaylist: '播放清單',
tabMv: 'MV',
tabCharts: '排行榜',
cancelSearch: '取消',
intelligenceMode: '心動模式',
exitIntelligence: '退出心動模式'
},
titleBar: {
closeTitle: '請選擇關閉方式',
@@ -139,6 +215,7 @@ export default {
addToPlaylist: '新增至播放清單',
addToPlaylistSuccess: '新增至播放清單成功',
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
locateCurrent: '定位當前播放',
historyRecommend: '歷史日推',
fetchDatesFailed: '獲取日期列表失敗',
fetchSongsFailed: '獲取歌曲列表失敗',
@@ -168,6 +245,7 @@ export default {
albumNamePlaceholder: '專輯名稱',
addSongButton: '新增歌曲',
addLinkButton: '新增連結',
options: '選項',
importToStarPlaylist: '匯入到我喜歡的音樂',
playlistNamePlaceholder: '請輸入播放清單名稱',
importButton: '開始匯入',
@@ -211,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}%'
@@ -39,7 +40,8 @@ export default {
message: '確定要清空所有下載記錄嗎?此操作不會刪除已下載的音樂檔案,但將清空所有記錄。',
confirm: '確定清空',
cancel: '取消',
success: '下載記錄已清空'
success: '下載記錄已清空',
failed: '清空下載記錄失敗'
},
message: {
downloadComplete: '{filename} 下載完成',
@@ -49,6 +51,7 @@ export default {
playStarted: '開始播放: {name}',
playFailed: '播放失敗: {name}',
path: {
copy: '複製路徑',
copied: '路徑已複製到剪貼簿',
copyFailed: '複製路徑失敗'
},
@@ -60,6 +63,8 @@ export default {
noPathSelected: '請先選擇下載路徑',
select: '選擇資料夾',
open: '開啟資料夾',
saveLyric: '單獨儲存歌詞檔案',
saveLyricDesc: '下載歌曲時同時儲存一份 .lrc 歌詞檔案',
fileFormat: '檔名格式',
fileFormatDesc: '設定下載音樂時的檔案命名格式',
customFormat: '自訂格式',

View File

@@ -6,7 +6,12 @@ export default {
categoryTabs: {
songs: '歌曲',
playlists: '歌單',
albums: '專輯'
albums: '專輯',
podcasts: '播客'
},
podcastTabs: {
episodes: '節目',
radios: '電台'
},
tabs: {
all: '全部記錄',

View File

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

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

@@ -23,6 +23,7 @@ export default {
album: '專輯',
playlist: '歌單',
mv: 'MV',
djradio: '電台',
bilibili: 'B站'
},
history: '搜尋歷史',

View File

@@ -25,7 +25,6 @@ export default {
tokenSet: '已設定',
tokenNotSet: '未設定',
setToken: '設定Cookie',
setCookie: '設定Cookie',
modifyToken: '修改Cookie',
clearToken: '清除Cookie',
font: '字體設定',
@@ -85,6 +84,10 @@ export default {
gdmusicInfo: 'GD音樂台可自動解析多個平台音源自動選擇最佳結果',
autoPlay: '自動播放',
autoPlayDesc: '重新開啟應用程式時是否自動繼續播放',
audioDevice: '音訊輸出裝置',
audioDeviceDesc: '選擇音訊輸出裝置,如揚聲器、耳機或藍牙裝置',
testAudio: '測試',
selectAudioDevice: '選擇輸出裝置',
showStatusBar: '是否顯示狀態列控制功能',
showStatusBarContent: '可以在您的mac狀態列顯示音樂控制功能(重啟後生效)',
fallbackParser: '備用解析服務 (GD音樂台)',
@@ -94,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 設定',
@@ -183,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: {
@@ -217,7 +252,14 @@ export default {
restart: '重新啟動',
restartDesc: '重新啟動應用程式',
messages: {
clearSuccess: '清除成功,部分設定在重啟後生效'
clearSuccess: '清除成功,部分設定在重啟後生效',
diskCacheClearSuccess: '磁碟快取已清理',
diskCacheClearFailed: '清理磁碟快取失敗',
diskCacheStatsLoadFailed: '讀取快取狀態失敗',
switchDirectorySuccess: '快取目錄已切換,舊快取已保留',
switchDirectoryFailed: '快取目錄切換失敗',
switchDirectoryMigrated: '快取目錄已切換,已搬移 {count} 個快取檔案',
switchDirectoryDestroyed: '快取目錄已切換,已刪除 {count} 個舊快取檔案'
}
},
about: {
@@ -227,6 +269,7 @@ export default {
latest: '目前已是最新版本',
hasUpdate: '發現新版本',
gotoUpdate: '前往更新',
manualUpdate: '官網更新',
gotoGithub: '前往 Github',
author: '作者',
authorDesc: 'algerkong 點個star🌟呗',
@@ -370,28 +413,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

@@ -1,23 +1,25 @@
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 { 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';
import { initWindowSizeManager } from './modules/window-size';
import { startMusicApi } from './server';
import { initLxMusicHttp } from './modules/lxMusicHttp';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
@@ -40,6 +42,8 @@ function initialize(configStore: any) {
// 初始化文件管理
initializeFileManager();
// 初始化歌词缓存管理
initializeCacheManager();
// 初始化其他 API (搜索建议等)
initializeOtherApi();
// 初始化窗口管理
@@ -48,6 +52,8 @@ function initialize(configStore: any) {
initializeFonts();
// 初始化登录窗口
initializeLoginWindow();
// 初始化本地音乐扫描模块
initializeLocalMusicScanner();
// 创建主窗口
mainWindow = createMainWindow(icon);
@@ -121,6 +127,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();
@@ -133,11 +152,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 {

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) => {

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { app, dialog, ipcMain, Notification, protocol, shell } from 'electron';
import { app, dialog, ipcMain, nativeImage, Notification, protocol, shell } from 'electron';
import Store from 'electron-store';
import { fileTypeFromFile } from 'file-type';
import { FlacTagMap, writeFlacTags } from 'flac-tagger';
@@ -10,7 +10,6 @@ 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';
@@ -247,6 +246,33 @@ export function initializeFileManager() {
};
});
// 保存歌词文件
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`);
// 文件已存在时添加序号
let counter = 1;
while (fs.existsSync(filePath)) {
filePath = path.join(downloadPath, `${sanitizedName} (${counter}).lrc`);
counter++;
}
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 };
}
}
);
// 添加清除下载历史的处理函数
ipcMain.on('clear-downloads-history', () => {
downloadStore.set('history', []);
@@ -645,43 +671,61 @@ async function downloadMusic(
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; // 如果压缩失败,则回退使用原始图片
// 处理 base64 Data URL本地音乐扫描提取的封面
if (picUrl.startsWith('data:')) {
const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/);
if (base64Match) {
coverImageBuffer = Buffer.from(base64Match[1], 'base64');
console.log('从 base64 Data URL 提取封面');
}
} else {
// 如果图片不大于2MB直接使用原图
coverImageBuffer = originalCoverBuffer;
const coverResponse = await axios({
url: picUrl.replace('http://', 'https://'),
method: 'GET',
responseType: 'arraybuffer',
timeout: 10000
});
const originalCoverBuffer = Buffer.from(coverResponse.data);
const TWO_MB = 2 * 1024 * 1024;
// 检查图片大小是否超过2MB
if (originalCoverBuffer.length > TWO_MB) {
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
try {
// 使用 Electron nativeImage 进行压缩
const image = nativeImage.createFromBuffer(originalCoverBuffer);
const size = image.getSize();
// 计算新尺寸保持宽高比最大1600px
const maxSize = 1600;
let newWidth = size.width;
let newHeight = size.height;
if (size.width > maxSize || size.height > maxSize) {
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
newWidth = Math.round(size.width * ratio);
newHeight = Math.round(size.height * ratio);
}
// 调整大小并转换为 JPEG 格式(质量 80
const resizedImage = image.resize({
width: newWidth,
height: newHeight,
quality: 'good'
});
coverImageBuffer = resizedImage.toJPEG(80);
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
} catch (compressionError) {
console.error('封面图压缩失败,将使用原图:', compressionError);
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
}
} else {
// 如果图片不大于2MB直接使用原图
coverImageBuffer = originalCoverBuffer;
}
}
console.log('封面已准备好,将写入元数据');
@@ -747,10 +791,8 @@ async function downloadMusic(
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
TRACKNUMBER: songInfo?.no ? String(songInfo.no) : '',
DATE: songInfo?.publishTime ? new Date(songInfo.publishTime).getFullYear().toString() : ''
};
await writeFlacTags(
@@ -771,6 +813,17 @@ async function downloadMusic(
}
}
// 如果启用了单独保存歌词文件,将歌词保存为 .lrc 文件
if (lyricsContent && configStore.get('set.downloadSaveLyric')) {
try {
const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc');
await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8');
console.log('歌词文件已保存:', lrcFilePath);
} catch (lrcError) {
console.error('保存歌词文件失败:', lrcError);
}
}
// 保存下载信息
try {
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;

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

@@ -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

@@ -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,
@@ -204,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);
@@ -317,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();
@@ -380,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 {
@@ -66,8 +68,11 @@ async function startMusicApi(): Promise<void> {
}
try {
const server = require('netease-cloud-music-api-alger/server');
await server.serveNcmApi({
port
port,
// 安全默认值:仅监听本机回环地址,避免对局域网暴露
host: '127.0.0.1'
});
console.log(`MUSIC API STARTED on port ${port}`);
} catch (error) {

View File

@@ -34,5 +34,9 @@
"customApiPluginName": "",
"lxMusicScripts": [],
"activeLxMusicApiId": null,
"enableGpuAcceleration": true
"enableGpuAcceleration": true,
"enableDiskCache": true,
"diskCacheDir": "",
"diskCacheMaxSizeMB": 4096,
"diskCacheCleanupPolicy": "lru"
}

View File

@@ -1,6 +1,6 @@
import match from '@unblockneteasemusic/server';
type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'bilibili';
type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox';
interface SongData {
name: string;
@@ -30,7 +30,7 @@ interface UnblockResult {
}
// 所有可用平台
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili'];
export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd'];
/**
* 确保对象数据结构完整处理null或undefined的情况

View File

@@ -1,5 +1,7 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import type { AppUpdateState } from '../shared/appUpdate';
interface API {
minimize: () => void;
maximize: () => void;
@@ -17,17 +19,31 @@ interface API {
sendSong: (data: any) => void;
unblockMusic: (id: number, data: any, enabledSources?: string[]) => Promise<any>;
onLyricWindowClosed: (callback: () => void) => void;
startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
onLyricWindowReady: (callback: () => void) => void;
getAppUpdateState: () => Promise<AppUpdateState>;
checkAppUpdate: (manual?: boolean) => Promise<AppUpdateState>;
downloadAppUpdate: () => Promise<AppUpdateState>;
installAppUpdate: () => Promise<boolean>;
openAppUpdatePage: () => Promise<boolean>;
onAppUpdateState: (callback: (state: AppUpdateState) => void) => void;
removeAppUpdateListeners: () => void;
onLanguageChanged: (callback: (locale: string) => void) => void;
removeDownloadListeners: () => void;
importCustomApiPlugin: () => Promise<{ name: string; content: string } | null>;
importLxMusicScript: () => Promise<{ name: string; content: string } | null>;
invoke: (channel: string, ...args: any[]) => Promise<any>;
getSearchSuggestions: (keyword: string) => Promise<any>;
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) => Promise<any>;
lxMusicHttpCancel: (requestId: string) => Promise<void>;
/** 扫描指定文件夹中的本地音乐文件 */
scanLocalMusic: (folderPath: string) => Promise<{ files: string[]; count: number }>;
/** 扫描指定文件夹中的本地音乐文件(包含修改时间) */
scanLocalMusicWithStats: (
folderPath: string
) => Promise<{ files: { path: string; modifiedTime: number }[]; count: number }>;
/** 批量解析本地音乐文件元数据 */
parseLocalMusicMetadata: (
filePaths: string[]
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
}
// 自定义IPC渲染进程通信接口

View File

@@ -1,6 +1,9 @@
import { electronAPI } from '@electron-toolkit/preload';
import type { IpcRendererEvent } from 'electron';
import { contextBridge, ipcRenderer } from 'electron';
import type { AppUpdateState } from '../shared/appUpdate';
// Custom APIs for renderer
const api = {
minimize: () => ipcRenderer.send('minimize-window'),
@@ -25,13 +28,21 @@ const api = {
onLyricWindowClosed: (callback: () => void) => {
ipcRenderer.on('lyric-window-closed', () => callback());
},
// 更新相关
startDownload: (url: string) => ipcRenderer.send('start-download', url),
onDownloadProgress: (callback: (progress: number, status: string) => void) => {
ipcRenderer.on('download-progress', (_event, progress, status) => callback(progress, status));
// 歌词窗口就绪事件Vue 加载完成,可以接收数据)
onLyricWindowReady: (callback: () => void) => {
ipcRenderer.on('lyric-window-ready', () => callback());
},
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => {
ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath));
getAppUpdateState: () => ipcRenderer.invoke('app-update:get-state') as Promise<AppUpdateState>,
checkAppUpdate: (manual = false) =>
ipcRenderer.invoke('app-update:check', { manual }) as Promise<AppUpdateState>,
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download') as Promise<AppUpdateState>,
installAppUpdate: () => ipcRenderer.invoke('app-update:quit-and-install') as Promise<boolean>,
openAppUpdatePage: () => ipcRenderer.invoke('app-update:open-release-page') as Promise<boolean>,
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
ipcRenderer.on('app-update:state', (_event, state: AppUpdateState) => callback(state));
},
removeAppUpdateListeners: () => {
ipcRenderer.removeAllListeners('app-update:state');
},
// 语言相关
onLanguageChanged: (callback: (locale: string) => void) => {
@@ -39,10 +50,6 @@ const api = {
callback(locale);
});
},
removeDownloadListeners: () => {
ipcRenderer.removeAllListeners('download-progress');
ipcRenderer.removeAllListeners('download-complete');
},
// 歌词缓存相关
invoke: (channel: string, ...args: any[]) => {
const validChannels = [
@@ -51,7 +58,10 @@ const api = {
'get-system-fonts',
'get-cached-lyric',
'cache-lyric',
'clear-lyric-cache'
'clear-lyric-cache',
'scan-local-music',
'scan-local-music-with-stats',
'parse-local-music-metadata'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
@@ -65,7 +75,14 @@ const api = {
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) =>
ipcRenderer.invoke('lx-music-http-request', request),
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId)
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId),
// 本地音乐扫描相关
scanLocalMusic: (folderPath: string) => ipcRenderer.invoke('scan-local-music', folderPath),
scanLocalMusicWithStats: (folderPath: string) =>
ipcRenderer.invoke('scan-local-music-with-stats', folderPath),
parseLocalMusicMetadata: (filePaths: string[]) =>
ipcRenderer.invoke('parse-local-music-metadata', filePaths)
};
// 创建带类型的ipcRenderer对象暴露给渲染进程
@@ -80,9 +97,10 @@ const ipc = {
},
// 监听主进程消息
on: (channel: string, listener: (...args: any[]) => void) => {
ipcRenderer.on(channel, (_, ...args) => listener(...args));
const wrappedListener = (_event: IpcRendererEvent, ...args: any[]) => listener(...args);
ipcRenderer.on(channel, wrappedListener);
return () => {
ipcRenderer.removeListener(channel, listener);
ipcRenderer.removeListener(channel, wrappedListener);
};
},
// 移除所有监听器

View File

@@ -1,5 +1,5 @@
<template>
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
<div class="app-container h-full w-full" :class="{ mobile: isMobile, noElectron: !isElectron }">
<n-config-provider :theme="theme === 'dark' ? darkTheme : lightTheme">
<n-dialog-provider>
<n-message-provider>
@@ -22,6 +22,7 @@ import { useRouter } from 'vue-router';
import DisclaimerModal from '@/components/common/DisclaimerModal.vue';
import TrafficWarningDrawer from '@/components/TrafficWarningDrawer.vue';
import { usePlayerStore } from '@/store/modules/player';
import { usePlayerCoreStore } from '@/store/modules/playerCore';
import { useSettingsStore } from '@/store/modules/settings';
import { useUserStore } from '@/store/modules/user';
import { isElectron, isLyricWindow } from '@/utils';
@@ -36,6 +37,7 @@ import { useAppShortcuts } from './utils/appShortcuts';
const { locale } = useI18n();
const settingsStore = useSettingsStore();
const playerStore = usePlayerStore();
const playerCoreStore = usePlayerCoreStore();
const userStore = useUserStore();
const router = useRouter();
@@ -123,11 +125,27 @@ onMounted(async () => {
if (isLyricWindow.value) {
return;
}
// 检查网络状态,离线时自动跳转到本地音乐页面
if (!navigator.onLine) {
console.log('检测到无网络连接,跳转到本地音乐页面');
router.push('/local-music');
}
// 监听网络状态变化,断网时跳转到本地音乐页面
window.addEventListener('offline', () => {
console.log('网络连接断开,跳转到本地音乐页面');
router.push('/local-music');
});
// 初始化 MusicHook注入 playerStore
initMusicHook(playerStore);
// 初始化播放状态
await playerStore.initializePlayState();
// 初始化音频设备变化监听器
playerCoreStore.initAudioDeviceListener();
// 初始化落雪音源(如果有激活的音源)
const activeLxApiId = settingsStore.setData?.activeLxMusicApiId;
if (activeLxApiId) {
@@ -159,7 +177,6 @@ onMounted(async () => {
<style lang="scss" scoped>
.app-container {
@apply h-full w-full;
user-select: none;
}

View File

@@ -0,0 +1,5 @@
import request from '@/utils/request';
export const getNewAlbums = (params: { limit: number; offset: number; area: string }) => {
return request.get<any>('/album/new', { params });
};

View File

@@ -19,3 +19,8 @@ export const getArtistTopSongs = (params) => {
export const getArtistAlbums = (params) => {
return request.get('/artist/album', { params });
};
// 获取关注歌手新歌
export const getArtistNewSongs = (limit: number = 20) => {
return request.get<any>('/artist/new/song', { params: { limit } });
};

View File

@@ -1,444 +0,0 @@
import type { IBilibiliPage, IBilibiliPlayUrl, IBilibiliVideoDetail } from '@/types/bilibili';
import type { SongResult } from '@/types/music';
import { getSetData, isElectron } from '@/utils';
import request from '@/utils/request';
interface ISearchParams {
keyword: string;
page?: number;
pagesize?: number;
search_type?: string;
}
/**
* 搜索B站视频带自动重试
* 最多重试10次每次间隔100ms
* @param params 搜索参数
*/
export const searchBilibili = async (params: ISearchParams): Promise<any> => {
console.log('调用B站搜索API参数:', params);
const maxRetries = 10;
const delayMs = 100;
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
let lastError: unknown = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await request.get('/bilibili/search', { params });
console.log('B站搜索API响应:', response);
const hasTitle = Boolean(response?.data?.data?.result?.length);
if (response?.status === 200 && hasTitle) {
return response;
}
lastError = new Error(
`搜索结果不符合成功条件(缺少 data.title ) (attempt ${attempt}/${maxRetries})`
);
console.warn('B站搜索API响应不符合要求将重试。调试信息', {
status: response?.status,
hasData: Boolean(response?.data),
hasInnerData: Boolean(response?.data?.data),
title: response?.data?.data?.title
});
} catch (error) {
lastError = error;
console.warn(`B站搜索API错误[第${attempt}次],将重试:`, error);
}
if (attempt === maxRetries) {
console.error('B站搜索API重试达到上限仍然失败');
if (lastError instanceof Error) throw lastError;
throw new Error('B站搜索失败且达到最大重试次数');
}
await delay(delayMs);
}
// 理论上不会到达这里添加以满足TS控制流分析
throw new Error('B站搜索在重试后未返回有效结果');
};
interface IBilibiliResponse<T> {
code: number;
message: string;
ttl: number;
data: T;
}
/**
* 获取B站视频详情
* @param bvid B站视频BV号
* @returns 视频详情响应
*/
export const getBilibiliVideoDetail = (
bvid: string
): Promise<IBilibiliResponse<IBilibiliVideoDetail>> => {
console.log('调用B站视频详情APIbvid:', bvid);
return new Promise((resolve, reject) => {
request
.get('/bilibili/video/detail', {
params: { bvid }
})
.then((response) => {
console.log('B站视频详情API响应:', response.status);
// 检查响应状态和数据格式
if (response.status === 200 && response.data && response.data.data) {
console.log('B站视频详情API成功标题:', response.data.data.title);
resolve(response.data);
} else {
console.error('B站视频详情API响应格式不正确:', response.data);
reject(new Error('获取视频详情响应格式不正确'));
}
})
.catch((error) => {
console.error('B站视频详情API错误:', error);
reject(error);
});
});
};
/**
* 获取B站视频播放地址
* @param bvid B站视频BV号
* @param cid 视频分P的id
* @param qn 视频质量默认为0
* @param fnval 视频格式标志默认为80
* @param fnver 视频格式版本默认为0
* @param fourk 是否允许4K视频默认为1
* @returns 视频播放地址响应
*/
export const getBilibiliPlayUrl = (
bvid: string,
cid: number,
qn: number = 0,
fnval: number = 80,
fnver: number = 0,
fourk: number = 1
): Promise<IBilibiliResponse<IBilibiliPlayUrl>> => {
console.log('调用B站视频播放地址APIbvid:', bvid, 'cid:', cid);
return new Promise((resolve, reject) => {
request
.get('/bilibili/playurl', {
params: {
bvid,
cid,
qn,
fnval,
fnver,
fourk
}
})
.then((response) => {
console.log('B站视频播放地址API响应:', response.status);
// 检查响应状态和数据格式
if (response.status === 200 && response.data && response.data.data) {
if (response.data.data.dash?.audio?.length > 0) {
console.log(
'B站视频播放地址API成功获取到',
response.data.data.dash.audio.length,
'个音频地址'
);
} else if (response.data.data.durl?.length > 0) {
console.log(
'B站视频播放地址API成功获取到',
response.data.data.durl.length,
'个播放地址'
);
}
resolve(response.data);
} else {
console.error('B站视频播放地址API响应格式不正确:', response.data);
reject(new Error('获取视频播放地址响应格式不正确'));
}
})
.catch((error) => {
console.error('B站视频播放地址API错误:', error);
reject(error);
});
});
};
export const getBilibiliProxyUrl = (url: string) => {
const setData = getSetData();
const baseURL = isElectron
? `http://127.0.0.1:${setData?.musicApiPort}`
: import.meta.env.VITE_API;
const AUrl = url.startsWith('http') ? url : `https:${url}`;
return `${baseURL}/bilibili/stream-proxy?url=${encodeURIComponent(AUrl)}`;
};
export const getBilibiliAudioUrl = async (bvid: string, cid: number): Promise<string> => {
console.log('获取B站音频URL', { bvid, cid });
try {
const res = await getBilibiliPlayUrl(bvid, cid);
const playUrlData = res.data;
let url = '';
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
url = playUrlData.dash.audio[playUrlData.dash.audio.length - 1].baseUrl;
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
url = playUrlData.durl[0].url;
} else {
throw new Error('未找到可用的音频地址');
}
return getBilibiliProxyUrl(url);
} catch (error) {
console.error('获取B站音频URL失败:', error);
throw error;
}
};
// 根据音乐名称搜索并直接返回音频URL
export const searchAndGetBilibiliAudioUrl = async (keyword: string): Promise<string> => {
try {
// 搜索B站视频取第一页第一个结果
const res = await searchBilibili({ keyword, page: 1, pagesize: 1 });
if (!res) {
throw new Error('B站搜索返回为空');
}
const result = res.data?.data?.result;
if (!result || result.length === 0) {
throw new Error('未找到相关B站视频');
}
const first = result[0];
const bvid = first.bvid;
// 需要获取视频详情以获得cid
const detailRes = await getBilibiliVideoDetail(bvid);
const pages = detailRes.data.pages;
if (!pages || pages.length === 0) {
throw new Error('未找到视频分P信息');
}
const cid = pages[0].cid;
// 获取音频URL
return await getBilibiliAudioUrl(bvid, cid);
} catch (error) {
console.error('根据名称搜索B站音频URL失败:', error);
throw error;
}
};
/**
* 解析B站ID格式
* @param biliId B站ID可能是字符串格式bvid--pid--cid
* @returns 解析后的对象 {bvid, pid, cid} 或 null
*/
export const parseBilibiliId = (
biliId: string | number
): { bvid: string; pid: string; cid: number } | null => {
const strBiliId = String(biliId);
if (strBiliId.includes('--')) {
const [bvid, pid, cid] = strBiliId.split('--');
if (!bvid || !pid || !cid) {
console.warn(`B站ID格式错误: ${strBiliId}, 正确格式应为 bvid--pid--cid`);
return null;
}
return { bvid, pid, cid: Number(cid) };
}
return null;
};
/**
* 创建默认的Artist对象
* @param name 艺术家名称
* @param id 艺术家ID
* @returns Artist对象
*/
const createDefaultArtist = (name: string, id: number = 0) => ({
name,
id,
picId: 0,
img1v1Id: 0,
briefDesc: '',
img1v1Url: '',
albumSize: 0,
alias: [],
trans: '',
musicSize: 0,
topicPerson: 0,
picUrl: ''
});
/**
* 创建默认的Album对象
* @param name 专辑名称
* @param picUrl 专辑图片URL
* @param artistName 艺术家名称
* @param artistId 艺术家ID
* @returns Album对象
*/
const createDefaultAlbum = (
name: string,
picUrl: string,
artistName: string,
artistId: number = 0
) => ({
name,
picUrl,
id: 0,
type: '',
size: 0,
picId: 0,
blurPicUrl: '',
companyId: 0,
pic: 0,
publishTime: 0,
description: '',
tags: '',
company: '',
briefDesc: '',
artist: createDefaultArtist(artistName, artistId),
songs: [],
alias: [],
status: 0,
copyrightId: 0,
commentThreadId: '',
artists: [],
subType: '',
transName: null,
onSale: false,
mark: 0,
picId_str: ''
});
/**
* 创建基础的B站SongResult对象
* @param config 配置对象
* @returns SongResult对象
*/
const createBaseBilibiliSong = (config: {
id: string | number;
name: string;
picUrl: string;
artistName: string;
artistId?: number;
albumName: string;
bilibiliData?: { bvid: string; cid: number };
playMusicUrl?: string;
duration?: number;
}): SongResult => {
const {
id,
name,
picUrl,
artistName,
artistId = 0,
albumName,
bilibiliData,
playMusicUrl,
duration
} = config;
const baseResult: SongResult = {
id,
name,
picUrl,
ar: [createDefaultArtist(artistName, artistId)],
al: createDefaultAlbum(albumName, picUrl, artistName, artistId),
count: 0,
source: 'bilibili' as const
};
if (bilibiliData) {
baseResult.bilibiliData = bilibiliData;
}
if (playMusicUrl) {
baseResult.playMusicUrl = playMusicUrl;
}
if (duration !== undefined) {
baseResult.duration = duration;
}
return baseResult as SongResult;
};
/**
* 从B站视频详情和分P信息创建SongResult对象
* @param videoDetail B站视频详情
* @param page 分P信息
* @param bvid B站视频ID
* @returns SongResult对象
*/
export const createSongFromBilibiliVideo = (
videoDetail: IBilibiliVideoDetail,
page: IBilibiliPage,
bvid: string
): SongResult => {
const pageName = page.part || '';
const title = `${pageName} - ${videoDetail.title}`;
const songId = `${bvid}--${page.page}--${page.cid}`;
const picUrl = getBilibiliProxyUrl(videoDetail.pic);
return createBaseBilibiliSong({
id: songId,
name: title,
picUrl,
artistName: videoDetail.owner.name,
artistId: videoDetail.owner.mid,
albumName: videoDetail.title,
bilibiliData: {
bvid,
cid: page.cid
}
});
};
/**
* 创建简化的SongResult对象用于搜索结果直接播放
* @param item 搜索结果项
* @param audioUrl 音频URL
* @returns SongResult对象
*/
export const createSimpleBilibiliSong = (item: any, audioUrl: string): SongResult => {
const duration = typeof item.duration === 'string' ? 0 : item.duration * 1000; // 转换为毫秒
return createBaseBilibiliSong({
id: item.id,
name: item.title,
picUrl: item.pic,
artistName: item.author,
albumName: item.title,
playMusicUrl: audioUrl,
duration
});
};
/**
* 批量处理B站视频从ID列表获取SongResult列表
* @param bilibiliIds B站ID列表
* @returns SongResult列表
*/
export const processBilibiliVideos = async (
bilibiliIds: (string | number)[]
): Promise<SongResult[]> => {
const bilibiliSongs: SongResult[] = [];
for (const biliId of bilibiliIds) {
const parsedId = parseBilibiliId(biliId);
if (!parsedId) continue;
try {
const res = await getBilibiliVideoDetail(parsedId.bvid);
const videoDetail = res.data;
// 找到对应的分P
const page = videoDetail.pages.find((p) => p.cid === parsedId.cid);
if (!page) {
console.warn(`未找到对应的分P: cid=${parsedId.cid}`);
continue;
}
const songData = createSongFromBilibiliVideo(videoDetail, page, parsedId.bvid);
bilibiliSongs.push(songData);
} catch (error) {
console.error(`获取B站视频详情失败 (${biliId}):`, error);
}
}
return bilibiliSongs;
};

View File

@@ -50,3 +50,38 @@ export const getDayRecommend = () => {
export const getNewAlbum = () => {
return request.get<IAlbumNew>('/album/newest');
};
// 获取轮播图
export const getBanners = (type: number = 0) => {
return request.get<any>('/banner', { params: { type } });
};
// 获取推荐歌单
export const getPersonalizedPlaylist = (limit: number = 30) => {
return request.get<any>('/personalized', { params: { limit } });
};
// 获取私人漫游request 拦截器已自动添加 timestamp
export const getPersonalFM = () => {
return request.get<any>('/personal_fm');
};
// 获取独家放送
export const getPrivateContent = () => {
return request.get<any>('/personalized/privatecontent');
};
// 获取推荐MV
export const getPersonalizedMV = () => {
return request.get<any>('/personalized/mv');
};
// 获取新碟上架
export const getTopAlbum = (params?: { limit?: number; offset?: number; area?: string }) => {
return request.get<any>('/top/album', { params });
};
// 获取推荐电台
export const getPersonalizedDJ = () => {
return request.get<any>('/personalized/djprogram');
};

View File

@@ -108,7 +108,7 @@ const convertToLxMusicInfo = (songResult: SongResult): LxMusicInfo => {
singer: artistName,
album: albumName,
albumId,
source: 'wy', // 默认使用网易云作为源,因为我们的数据来自网易云
source: 'wy',
interval,
img: songResult.picUrl || songResult.al?.picUrl || ''
};
@@ -116,13 +116,11 @@ const convertToLxMusicInfo = (songResult: SongResult): LxMusicInfo => {
/**
* 获取最佳匹配的落雪音源
* 因为我们的数据来自网易云,优先尝试 wy 音源
*/
const getBestMatchingSource = (
availableSources: LxSourceKey[],
_songSource?: string
): LxSourceKey | null => {
// 优先级顺序:网易云 > 酷我 > 咪咕 > 酷狗 > QQ音乐
const priority: LxSourceKey[] = ['wy', 'kw', 'mg', 'kg', 'tx'];
for (const source of priority) {
@@ -196,7 +194,9 @@ export class LxMusicStrategy {
return null;
}
console.log(`[LxMusicStrategy] 使用激活的音源: ${activeScript.name} (ID: ${activeScript.id})`);
console.log(
`[LxMusicStrategy] 使用激活的音源: ${activeScript.name} (ID: ${activeScript.id})`
);
// 获取或初始化执行器
let runner = getLxMusicRunner();

View File

@@ -8,6 +8,13 @@ import { MusicParser, type MusicParseResult } from './musicParser';
const { addData, getData, deleteData } = musicDB;
// 将 FM 歌曲移至垃圾桶(不喜欢)
export const fmTrash = (id: number) => {
return request.post('/fm_trash', null, {
params: { id, timestamp: Date.now() }
});
};
// 获取音乐音质详情
export const getMusicQualityDetail = (id: number) => {
return request.get('/song/music/detail', { params: { id } });
@@ -26,7 +33,6 @@ export const getMusicUrl = async (id: number, isDownloaded: boolean = false) =>
id,
level: settingStore.setData.musicQuality || 'higher',
encodeType: settingStore.setData.musicQuality == 'lossless' ? 'aac' : 'flac',
// level为lossless时encodeType=flac时网易云会返回hires音质encodeType=aac时网易云会返回lossless音质
cookie: `${localStorage.getItem('token')} os=pc;`
}
});

View File

@@ -7,7 +7,6 @@ import type { SongResult } from '@/types/music';
import { isElectron } from '@/utils';
import requestMusic from '@/utils/request_music';
import { searchAndGetBilibiliAudioUrl } from './bilibili';
import type { ParsedMusicResult } from './gdmusic';
import { parseFromGDMusic } from './gdmusic';
import { LxMusicStrategy } from './lxMusicStrategy';
@@ -164,7 +163,7 @@ export class CacheManager {
console.log(`清除歌曲 ${id} 的URL缓存`);
// 清除失败缓存 - 需要遍历所有策略
const strategies = ['custom', 'bilibili', 'gdmusic', 'unblockMusic'];
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
for (const strategy of strategies) {
const cacheKey = `${id}_${strategy}`;
try {
@@ -211,30 +210,6 @@ class RetryHelper {
}
}
/**
* 从Bilibili获取音频URL
* @param data 歌曲数据
* @returns 解析结果
*/
const getBilibiliAudio = async (data: SongResult) => {
const songName = data?.name || '';
const artistName =
Array.isArray(data?.ar) && data.ar.length > 0 && data.ar[0]?.name ? data.ar[0].name : '';
const albumName = data?.al && typeof data.al === 'object' && data.al?.name ? data.al.name : '';
const searchQuery = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
console.log('开始搜索bilibili音频:', searchQuery);
const url = await searchAndGetBilibiliAudioUrl(searchQuery);
return {
data: {
code: 200,
message: 'success',
data: { url }
}
};
};
/**
* 从GD音乐台获取音频URL
* @param id 歌曲ID
@@ -363,46 +338,6 @@ class CustomApiStrategy implements MusicSourceStrategy {
}
}
/**
* Bilibili解析策略
*/
class BilibiliStrategy implements MusicSourceStrategy {
name = 'bilibili';
priority = 2;
canHandle(sources: string[]): boolean {
return sources.includes('bilibili');
}
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
// 检查失败缓存
if (CacheManager.isInFailedCache(id, this.name)) {
return null;
}
try {
console.log('尝试使用Bilibili解析...');
const result = await RetryHelper.withRetry(async () => {
return await getBilibiliAudio(data);
});
const adaptedResult = adaptParseResult(result);
if (adaptedResult?.data?.data?.url) {
console.log('Bilibili解析成功');
return adaptedResult;
}
// 解析失败,添加失败缓存
CacheManager.addFailedCache(id, this.name);
return null;
} catch (error) {
console.error('Bilibili解析失败:', error);
CacheManager.addFailedCache(id, this.name);
return null;
}
}
}
/**
* GD音乐台解析策略
*/
@@ -451,9 +386,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
priority = 4;
canHandle(sources: string[]): boolean {
const unblockSources = sources.filter(
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
);
const unblockSources = sources.filter((source) => !['custom', 'gdmusic'].includes(source));
return unblockSources.length > 0;
}
@@ -470,7 +403,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
try {
const unblockSources = (sources || []).filter(
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
(source) => !['custom', 'gdmusic'].includes(source)
);
console.log('尝试使用UnblockMusic解析:', unblockSources);
@@ -502,7 +435,6 @@ class MusicSourceStrategyFactory {
private static strategies: MusicSourceStrategy[] = [
new LxMusicStrategy(),
new CustomApiStrategy(),
new BilibiliStrategy(),
new GDMusicStrategy(),
new UnblockMusicStrategy()
];

View File

@@ -0,0 +1,86 @@
import type {
DjCategoryListResponse,
DjDetailResponse,
DjProgramDetailResponse,
DjProgramResponse,
DjRadioHotResponse,
DjRecommendResponse,
DjSublistResponse,
DjTodayPerferedResponse,
DjToplistResponse,
PersonalizedDjProgramResponse,
RecentDjResponse
} from '@/types/podcast';
import request from '@/utils/request';
export const subscribeDj = (rid: number, t: 1 | 0) => {
return request.get('/dj/sub', { params: { rid, t } });
};
export const getDjSublist = () => {
return request.get<DjSublistResponse>('/dj/sublist');
};
export const getDjDetail = (rid: number) => {
return request.get<DjDetailResponse>('/dj/detail', { params: { rid } });
};
export const getDjProgram = (rid: number, limit = 30, offset = 0, asc = false) => {
return request.get<DjProgramResponse>('/dj/program', {
params: { rid, limit, offset, asc }
});
};
export const getDjProgramDetail = (id: number) => {
return request.get<DjProgramDetailResponse>('/dj/program/detail', { params: { id } });
};
export const getDjRecommend = () => {
return request.get<DjRecommendResponse>('/dj/recommend');
};
export const getDjCategoryList = () => {
return request.get<DjCategoryListResponse>('/dj/catelist');
};
export const getDjRecommendByType = (type: number) => {
return request.get<DjRecommendResponse>('/dj/recommend/type', { params: { type } });
};
export const getDjCategoryRecommend = () => {
return request.get('/dj/category/recommend');
};
export const getDjTodayPerfered = () => {
return request.get<DjTodayPerferedResponse>('/dj/today/perfered');
};
export const getDjPersonalizeRecommend = (limit = 5) => {
return request.get<DjTodayPerferedResponse>('/dj/personalize/recommend', { params: { limit } });
};
export const getDjBanner = () => {
return request.get('/dj/banner');
};
export const getPersonalizedDjProgram = () => {
return request.get<PersonalizedDjProgramResponse>('/personalized/djprogram');
};
export const getDjToplist = (type: 'new' | 'hot', limit = 100) => {
return request.get<DjToplistResponse>('/dj/toplist', { params: { type, limit } });
};
export const getDjRadioHot = (cateId: number, limit = 30, offset = 0) => {
return request.get<DjRadioHotResponse>('/dj/radio/hot', {
params: { cateId, limit, offset }
});
};
export const getRecentDj = () => {
return request.get<RecentDjResponse>('/record/recent/dj');
};
export const getDjComment = (id: number, limit = 20, offset = 0) => {
return request.get('/comment/dj', { params: { id, limit, offset } });
};

View File

@@ -25,7 +25,7 @@ interface KugouSuggestionResponse {
data: Suggestion[];
}
// 网易云搜索建议返回的数据结构(部分字段)
// 搜索建议返回的数据结构(部分字段)
interface NeteaseSuggestResult {
result?: {
songs?: Array<{ name: string }>;
@@ -36,7 +36,7 @@ interface NeteaseSuggestResult {
}
/**
* 从酷狗获取搜索建议
* 获取搜索建议
* @param keyword 搜索关键词
*/
export const getSearchSuggestions = async (keyword: string) => {
@@ -54,7 +54,7 @@ export const getSearchSuggestions = async (keyword: string) => {
console.log('[API] Running in Electron, using IPC proxy.');
responseData = await window.api.getSearchSuggestions(keyword);
} else {
// 非 Electron 环境下,使用网易云接口
// 非 Electron 环境下,使用接口
const res = await request.get<NeteaseSuggestResult>('/search/suggest', {
params: { keywords: keyword }
});
@@ -67,7 +67,7 @@ export const getSearchSuggestions = async (keyword: string) => {
// 去重并截取前10个
const unique = Array.from(new Set(names)).slice(0, 10);
console.log('[API] getSearchSuggestions: 网易云建议解析成功:', unique);
console.log('[API] getSearchSuggestions: 解析成功:', unique);
return unique;
}

View File

@@ -0,0 +1,95 @@
/* 移动端 message 容器:移到底部 */
.mobile .n-message-container {
top: auto !important;
bottom: calc(200px + var(--safe-area-inset-bottom, 0px)) !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: auto !important;
min-width: 70vw !important;
max-width: 90vw !important;
padding: 0 !important;
z-index: 99999999999 !important;
}
/* 移动端 message 项目样式 */
.mobile .n-message-wrapper {
margin: 0 0 8px 0 !important;
}
.mobile .n-message {
padding: 12px 20px !important;
border-radius: 24px !important;
font-size: 14px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
backdrop-filter: blur(10px) !important;
max-width: 85vw !important;
}
/* 深色模式下的 message */
.mobile.noElectron .n-message {
background: rgba(50, 50, 50, 0.95) !important;
color: #fff !important;
}
/* 浅色模式下的 message */
.mobile:not(.noElectron) .n-message,
.mobile .theme-light .n-message {
background: rgba(255, 255, 255, 0.5) !important;
color: #333 !important;
}
/* 成功消息 */
.mobile .n-message--success-type {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.95), rgba(22, 163, 74, 0.95)) !important;
color: #fff !important;
}
.mobile .n-message--success-type .n-message__icon {
color: #fff !important;
}
/* 错误消息 */
.mobile .n-message--error-type {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95), rgba(220, 38, 38, 0.95)) !important;
color: #fff !important;
}
.mobile .n-message--error-type .n-message__icon {
color: #fff !important;
}
/* 警告消息 */
.mobile .n-message--warning-type {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.95), rgba(217, 119, 6, 0.95)) !important;
color: #fff !important;
}
.mobile .n-message--warning-type .n-message__icon {
color: #fff !important;
}
/* 信息消息 */
.mobile .n-message--info-type {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(37, 99, 235, 0.95)) !important;
color: #fff !important;
}
.mobile .n-message--info-type .n-message__icon {
color: #fff !important;
}
/* loading 消息 */
.mobile .n-message--loading-type {
background: rgba(50, 50, 50, 0.95) !important;
color: #fff !important;
}
/* 隐藏关闭按钮让设计更简洁 */
.mobile .n-message__close {
display: none !important;
}
/* 图标样式调整 */
.mobile .n-message__icon {
margin-right: 8px !important;
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="eq-control">
<div class="eq-header">
<h3>
<div class="eq-control p-6 rounded-lg bg-gray-100 dark:bg-gray-900 w-full max-w-[700px]">
<div class="eq-header flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-gray-800 dark:text-gray-200">
{{ t('player.eq.title') }}
<n-tag type="warning" size="small" round v-if="!isElectron">
桌面版可用网页端不支持
@@ -15,7 +15,7 @@
</div>
</div>
<div class="eq-presets">
<div class="eq-presets mb-2 relative h-10">
<n-scrollbar x-scrollable>
<n-space :size="6" :wrap="false">
<n-tag
@@ -34,9 +34,19 @@
</n-scrollbar>
</div>
<div class="eq-sliders">
<div v-for="freq in frequencies" :key="freq" class="eq-slider">
<div class="freq-label">{{ formatFreq(freq) }}</div>
<div
class="eq-sliders flex justify-between items-end bg-gray-50 dark:bg-gray-800 gap-1 rounded-lg p-2 h-[300px]"
>
<div
v-for="freq in frequencies"
:key="freq"
class="eq-slider flex flex-col items-center w-[45px] h-full"
>
<div
class="freq-label text-xs font-medium text-center text-gray-600 dark:text-gray-400 whitespace-nowrap m-2 h-5"
>
{{ formatFreq(freq) }}
</div>
<n-slider
v-model:value="eqValues[freq.toString()]"
:min="-12"
@@ -45,8 +55,13 @@
vertical
:disabled="!isEnabled"
@update:value="updateEQ(freq.toString(), $event)"
class="flex-1 my-3 min-h-[180px]"
/>
<div class="gain-value">{{ eqValues[freq.toString()] }}dB</div>
<div
class="gain-value text-xs font-medium text-center text-gray-600 dark:text-gray-400 whitespace-nowrap my-1 h-4"
>
{{ eqValues[freq.toString()] }}dB
</div>
</div>
</div>
</div>
@@ -267,91 +282,39 @@ const formatFreq = (freq: number) => {
</script>
<style lang="scss" scoped>
.eq-control {
@apply p-6 rounded-lg;
@apply bg-light dark:bg-dark;
width: 100%;
max-width: 700px;
:deep(.n-scrollbar) {
margin-left: -0.5rem;
margin-right: -0.5rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.eq-header {
@apply flex justify-between items-center mb-4;
:deep(.n-tag) {
cursor: pointer;
transition: all 0.2s;
text-align: center;
h3 {
@apply text-xl font-semibold;
@apply text-gray-800 dark:text-gray-200;
}
&:hover {
transform: translateY(-2px);
}
}
.eq-presets {
@apply mb-2 relative;
height: 40px;
:deep(.n-scrollbar) {
@apply -mx-2 px-2;
}
:deep(.n-tag) {
@apply cursor-pointer transition-all duration-200;
text-align: center;
&:hover {
transform: translateY(-2px);
}
}
:deep(.n-space) {
flex-wrap: nowrap;
padding: 4px 0;
}
}
.eq-sliders {
@apply flex justify-between items-end;
@apply bg-gray-50 dark:bg-gray-800 gap-1;
@apply rounded-lg p-2;
height: 300px;
.eq-slider {
@apply flex flex-col items-center;
width: 45px;
height: 100%;
.n-slider {
flex: 1;
margin: 12px 0;
min-height: 180px;
}
.freq-label {
@apply text-xs font-medium text-center;
@apply text-gray-600 dark:text-gray-400;
white-space: nowrap;
margin: 8px 0;
height: 20px;
}
.gain-value {
@apply text-xs font-medium text-center;
@apply text-gray-600 dark:text-gray-400;
white-space: nowrap;
margin: 4px 0;
height: 16px;
}
}
}
:deep(.n-space) {
flex-wrap: nowrap;
padding: 4px 0;
}
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-hover: theme('colors.gray.300');
--n-fill-color: theme('colors.green.500');
--n-fill-color-hover: theme('colors.green.600');
--n-handle-color: theme('colors.green.500');
--n-rail-color: #e5e7eb;
--n-rail-color-hover: #d1d5db;
--n-fill-color: #22c55e;
--n-fill-color-hover: #16a34a;
--n-handle-color: #22c55e;
--n-handle-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.n-slider-handle {
@apply transition-all duration-200;
transition: all 0.2s;
&:hover {
transform: scale(1.2);
}

View File

@@ -1,729 +0,0 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100%' : '80%'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
:z-index="zIndex"
@mask-click="close"
>
<div class="music-page">
<div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1" class="flex-shrink-0 mr-3">
<div class="music-title">
{{ name }}
</div>
</n-ellipsis>
<!-- 搜索框 -->
<div class="flex-grow flex-1 flex items-center justify-end">
<div class="search-container">
<n-input
v-model:value="searchKeyword"
:placeholder="t('comp.musicList.searchSongs')"
clearable
round
size="small"
>
<template #prefix>
<i class="icon iconfont ri-search-line text-sm"></i>
</template>
</n-input>
</div>
</div>
<div class="music-close flex-shrink-0 ml-3">
<i class="icon iconfont ri-close-line" @click="close"></i>
</div>
</div>
<div class="music-content">
<!-- 左侧歌单信息 -->
<div class="music-info">
<div class="music-cover">
<n-image
:src="getCoverImgUrl"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
object-fit="cover"
/>
</div>
<div v-if="listInfo?.creator" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div>
<div v-if="total" class="music-total">{{ t('player.songNum', { num: total }) }}</div>
<n-scrollbar style="max-height: 200px">
<div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }}
</div>
</n-scrollbar>
</div>
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div class="music-list">
<n-spin :show="loadingList || loading">
<div class="music-list-content">
<div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
{{ t('comp.musicList.noSearchResults') }}
</div>
<!-- 虚拟列表设置正确的固定高度 -->
<n-virtual-list
ref="songListRef"
class="song-virtual-list"
style="height: calc(70vh - 60px)"
:items="filteredSongs"
:item-size="70"
item-resizable
key-field="id"
@scroll="handleVirtualScroll"
>
<template #default="{ item }">
<div class="double-item">
<song-item
:item="formatSong(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="(id) => emit('remove-song', id)"
/>
</div>
</template>
</n-virtual-list>
</div>
</n-spin>
</div>
<play-bottom />
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import PinyinMatch from 'pinyin-match';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { usePlayerStore } from '@/store/modules/player';
import { SongResult } from '@/types/music';
import { getImgUrl, isMobile, setAnimationClass } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const { t } = useI18n();
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
show: boolean;
name: string;
zIndex?: number;
songList: any[];
loading?: boolean;
listInfo?: {
trackIds: { id: number }[];
[key: string]: any;
};
cover?: boolean;
canRemove?: boolean;
}>(),
{
loading: false,
cover: true,
zIndex: 9996,
canRemove: false
}
);
const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
const page = ref(0);
const pageSize = 40;
const isLoadingMore = ref(false);
const displayedSongs = ref<SongResult[]>([]);
const loadingList = ref(false);
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
const completePlaylist = ref<SongResult[]>([]); // 存储完整的播放列表
const hasMore = ref(true); // 标记是否还有更多数据可加载
const searchKeyword = ref(''); // 搜索关键词
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
// 计算总数
const total = computed(() => {
if (props.listInfo?.trackIds) {
return props.listInfo.trackIds.length;
}
return props.songList.length;
});
const getCoverImgUrl = computed(() => {
if (props.listInfo?.coverImgUrl) {
return props.listInfo.coverImgUrl;
}
const song = props.songList[0];
if (song?.picUrl) {
return song.picUrl;
}
if (song?.al?.picUrl) {
return song.al.picUrl;
}
if (song?.album?.picUrl) {
return song.album.picUrl;
}
return '';
});
// 过滤歌曲列表
const filteredSongs = computed(() => {
if (!searchKeyword.value) {
return displayedSongs.value;
}
const keyword = searchKeyword.value.toLowerCase().trim();
return displayedSongs.value.filter((song) => {
const songName = song.name?.toLowerCase() || '';
const albumName = song.al?.name?.toLowerCase() || '';
const artists = song.ar || song.artists || [];
// 原始文本匹配
const nameMatch = songName.includes(keyword);
const albumMatch = albumName.includes(keyword);
const artistsMatch = artists.some((artist: any) => {
return artist.name?.toLowerCase().includes(keyword);
});
// 拼音匹配
const namePinyinMatch = song.name && PinyinMatch.match(song.name, keyword);
const albumPinyinMatch = song.al?.name && PinyinMatch.match(song.al.name, keyword);
const artistsPinyinMatch = artists.some((artist: any) => {
return artist.name && PinyinMatch.match(artist.name, keyword);
});
return (
nameMatch ||
albumMatch ||
artistsMatch ||
namePinyinMatch ||
albumPinyinMatch ||
artistsPinyinMatch
);
});
});
// 格式化歌曲数据
const formatSong = (item: any) => {
if (!item) {
return null;
}
return {
...item,
picUrl: item.al?.picUrl || item.picUrl,
song: {
artists: item.ar || item.artists,
name: item.al?.name || item.name,
id: item.al?.id || item.id
}
};
};
/**
* 加载歌曲数据的核心函数
* @param ids 要加载的歌曲ID数组
* @param appendToList 是否将加载的歌曲追加到现有列表
* @param updateComplete 是否更新完整播放列表
*/
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
if (ids.length === 0) return [];
try {
console.log(`请求歌曲详情ID数量: ${ids.length}`);
const { data } = await getMusicDetail(ids);
if (data?.songs) {
console.log(`API返回歌曲数量: ${data.songs.length}`);
// 直接使用API返回的所有歌曲不再过滤已加载的歌曲
// 因为当需要完整加载列表时我们希望获取所有歌曲即使ID可能重复
const { songs } = data;
// 只在非更新完整列表时执行过滤
let newSongs = songs;
if (!updateComplete) {
// 在普通加载模式下继续过滤已加载的歌曲,避免重复
newSongs = songs.filter((song: any) => !loadedIds.value.has(song.id));
console.log(`过滤已加载ID后剩余歌曲数量: ${newSongs.length}`);
}
// 更新已加载ID集合
songs.forEach((song: any) => {
loadedIds.value.add(song.id);
});
// 追加到显示列表 - 仅当appendToList=true时添加到displayedSongs
if (appendToList) {
displayedSongs.value.push(...newSongs);
}
// 更新完整播放列表 - 仅当updateComplete=true时添加到completePlaylist
if (updateComplete) {
completePlaylist.value.push(...songs);
console.log(`已添加到完整播放列表,当前完整列表长度: ${completePlaylist.value.length}`);
}
return updateComplete ? songs : newSongs;
}
console.log('API返回无歌曲数据');
return [];
} catch (error) {
console.error('加载歌曲失败:', error);
}
return [];
};
// 加载完整播放列表
const loadFullPlaylist = async () => {
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
isPlaylistLoading.value = true;
// 记录开始时间
const startTime = Date.now();
console.log(`开始加载完整播放列表,当前显示列表长度: ${displayedSongs.value.length}`);
try {
// 如果没有trackIds直接使用当前歌曲列表并标记为已完成
if (!props.listInfo?.trackIds) {
isFullPlaylistLoaded.value = true;
console.log('无trackIds信息使用当前列表作为完整列表');
return;
}
// 获取所有trackIds
const allIds = props.listInfo.trackIds.map((item) => item.id);
console.log(`歌单共有歌曲ID: ${allIds.length}首`);
// 重置completePlaylist和当前显示歌曲ID集合保证不会重复添加歌曲
completePlaylist.value = [];
// 使用Set记录所有已加载的歌曲ID
const loadedSongIds = new Set<number>();
// 将当前显示列表中的歌曲和ID添加到集合中
displayedSongs.value.forEach((song) => {
loadedSongIds.add(song.id as number);
// 将已有歌曲添加到completePlaylist
completePlaylist.value.push(song);
});
console.log(
`已有显示歌曲: ${displayedSongs.value.length}首已有ID数量: ${loadedSongIds.size}`
);
// 过滤出尚未加载的歌曲ID
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
console.log(`还需要加载的歌曲ID数量: ${unloadedIds.length}`);
if (unloadedIds.length === 0) {
console.log('所有歌曲已加载,无需再次加载');
isFullPlaylistLoaded.value = true;
hasMore.value = false;
return;
}
// 分批加载所有未加载的歌曲
const batchSize = 500; // 每批加载的歌曲数量
for (let i = 0; i < unloadedIds.length; i += batchSize) {
const batchIds = unloadedIds.slice(i, i + batchSize);
if (batchIds.length === 0) continue;
console.log(`请求第${Math.floor(i / batchSize) + 1}批歌曲,数量: ${batchIds.length}`);
// 关键修改: 设置appendToList为false避免loadSongs直接添加到displayedSongs
const loadedBatch = await loadSongs(batchIds, false, false);
// 添加新加载的歌曲到displayedSongs
if (loadedBatch.length > 0) {
// 过滤掉已有的歌曲,确保不会重复添加
const newSongs = loadedBatch.filter((song) => !loadedSongIds.has(song.id as number));
// 更新已加载ID集合
newSongs.forEach((song) => {
loadedSongIds.add(song.id as number);
});
console.log(`新增${newSongs.length}首歌曲到显示列表`);
// 更新显示列表和完整播放列表
if (newSongs.length > 0) {
// 添加到显示列表
displayedSongs.value = [...displayedSongs.value, ...newSongs];
// 添加到完整播放列表
completePlaylist.value.push(...newSongs);
// 如果当前正在播放的列表与这个列表匹配,实时更新播放列表
const currentPlaylist = playerStore.playList;
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
console.log('实时更新当前播放列表');
playerStore.setPlayList(displayedSongs.value.map(formatSong));
}
}
}
// 添加小延迟避免请求过于密集
if (i + batchSize < unloadedIds.length) {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100);
});
}
}
// 加载完成,更新状态
isFullPlaylistLoaded.value = true;
hasMore.value = false;
// 计算加载耗时
const endTime = Date.now();
const timeUsed = Math.round(((endTime - startTime) / 1000) * 100) / 100;
console.log(
`完整播放列表加载完成,共加载${displayedSongs.value.length}首歌曲,耗时${timeUsed}秒`
);
console.log(`歌单应有${allIds.length}首歌,实际加载${displayedSongs.value.length}首`);
// 检查加载的歌曲数量是否与预期相符
if (displayedSongs.value.length !== allIds.length) {
console.warn(
`警告: 加载的歌曲数量(${displayedSongs.value.length})与歌单应有数量(${allIds.length})不符`
);
// 如果数量不符可能是API未返回所有歌曲打印缺失的歌曲ID
if (displayedSongs.value.length < allIds.length) {
const loadedIds = new Set(displayedSongs.value.map((song) => song.id));
const missingIds = allIds.filter((id) => !loadedIds.has(id));
console.warn(`缺失的歌曲ID: ${missingIds.join(', ')}`);
}
}
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
// 处理播放
const handlePlay = async () => {
// 当搜索状态下播放时,只播放过滤后的歌曲
if (searchKeyword.value) {
playerStore.setPlayList(filteredSongs.value.map(formatSong));
return;
}
// 如果完整播放列表已加载完成
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
playerStore.setPlayList(completePlaylist.value.map(formatSong));
return;
}
// 如果完整播放列表未加载完成,先使用当前已加载的歌曲开始播放
playerStore.setPlayList(displayedSongs.value.map(formatSong));
// 如果完整播放列表正在加载中,不需要重新触发加载
if (isPlaylistLoading.value) {
return;
}
// 在后台继续加载完整播放列表(如果未加载完成)
if (!isFullPlaylistLoaded.value) {
console.log('播放时继续在后台加载完整列表');
loadFullPlaylist();
}
};
const close = () => {
emit('update:show', false);
};
// 加载更多歌曲
const loadMoreSongs = async () => {
if (isFullPlaylistLoaded.value) {
hasMore.value = false;
return;
}
if (searchKeyword.value) {
return;
}
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
hasMore.value = false;
return;
}
isLoadingMore.value = true;
try {
const start = displayedSongs.value.length;
const end = Math.min(start + pageSize, total.value);
if (props.listInfo?.trackIds) {
const trackIdsToLoad = props.listInfo.trackIds
.slice(start, end)
.map((item) => item.id)
.filter((id) => !loadedIds.value.has(id));
if (trackIdsToLoad.length > 0) {
await loadSongs(trackIdsToLoad, true, false);
}
} else if (start < props.songList.length) {
const newSongs = props.songList.slice(start, end);
newSongs.forEach((song) => {
if (!loadedIds.value.has(song.id)) {
loadedIds.value.add(song.id);
displayedSongs.value.push(song);
}
});
}
hasMore.value = displayedSongs.value.length < total.value;
} catch (error) {
console.error('加载更多歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
// 处理虚拟列表滚动事件
const handleVirtualScroll = (e: any) => {
if (!e || !e.target) return;
const { scrollTop, scrollHeight, clientHeight } = e.target;
const threshold = 200;
if (
scrollHeight - scrollTop - clientHeight < threshold &&
!isLoadingMore.value &&
hasMore.value &&
!searchKeyword.value // 搜索状态下不触发加载更多
) {
loadMoreSongs();
}
};
// 重置列表状态
const resetListState = () => {
page.value = 0;
loadedIds.value.clear();
displayedSongs.value = [];
completePlaylist.value = [];
hasMore.value = true;
loadingList.value = false;
searchKeyword.value = ''; // 重置搜索关键词
isFullPlaylistLoaded.value = false; // 重置完整播放列表状态
};
// 初始化歌曲列表
const initSongList = (songs: any[]) => {
if (songs.length > 0) {
displayedSongs.value = [...songs];
songs.forEach((song) => loadedIds.value.add(song.id));
page.value = Math.ceil(songs.length / pageSize);
}
// 检查是否还有更多数据可加载
hasMore.value = displayedSongs.value.length < total.value;
};
watch(
() => props.listInfo,
(newListInfo) => {
if (newListInfo?.trackIds) {
loadFullPlaylist();
}
},
{ deep: true }
);
// 修改 songList 监听器
watch(
() => props.songList,
(newSongs) => {
// 重置所有状态
resetListState();
// 初始化歌曲列表
initSongList(newSongs);
// 如果还有更多歌曲需要加载,且差距较小,立即加载
if (hasMore.value && props.listInfo?.trackIds) {
setTimeout(() => {
loadMoreSongs();
}, 300);
}
},
{ immediate: true }
);
// 监听搜索关键词变化
watch(searchKeyword, () => {
// 当搜索关键词为空时,考虑加载更多歌曲
if (!searchKeyword.value && hasMore.value && displayedSongs.value.length < total.value) {
loadMoreSongs();
}
});
// 组件卸载时清理状态
onUnmounted(() => {
isPlaylistLoading.value = false;
});
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
&-total {
@apply text-sm font-normal text-gray-500 dark:text-gray-400;
}
&-page {
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-close {
@apply cursor-pointer text-gray-500 dark:text-white hover:text-gray-900 dark:hover:text-gray-300 flex gap-2 items-center transition;
.icon {
@apply text-3xl;
}
}
&-content {
@apply flex h-[calc(100%-60px)];
}
&-info {
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
.music-cover {
@apply w-full aspect-square rounded-2xl overflow-hidden mb-4 min-h-[250px];
.cover-img {
@apply w-full h-full object-cover;
}
}
.creator-info {
@apply flex items-center mb-4;
.creator-name {
@apply ml-2 text-gray-700 dark:text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed pr-4;
}
}
&-list {
@apply flex-grow min-h-0;
&-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-content {
@apply min-h-[calc(80vh-60px)];
}
}
}
.search-container {
@apply max-w-md;
:deep(.n-input) {
@apply bg-light-200 dark:bg-dark-200;
}
.icon {
@apply text-gray-500 dark:text-gray-400;
}
}
.no-result {
@apply text-center py-8 text-gray-500 dark:text-gray-400;
}
/* 虚拟列表样式 */
.song-virtual-list {
:deep(.n-virtual-list__scroll) {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-600 rounded;
}
}
}
.mobile {
.music-page {
@apply px-4;
}
.music-content {
@apply flex-col;
width: 100vw !important;
}
.music-info {
@apply w-full pr-0 mb-2 flex flex-row;
.music-cover {
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
}
.music-detail {
@apply flex-1 ml-4;
}
}
.music-title {
@apply text-base;
}
.search-container {
@apply max-w-[50%];
}
.song-virtual-list {
height: calc(80vh - 120px) !important;
}
}
.loading-more {
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.double-item {
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
.mobile {
.music-info {
@apply hidden;
}
.music-list-content {
@apply pb-[100px];
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,34 @@
<template>
<div class="album-item" @click="handleClick">
<n-image
:src="getImgUrl(item.picUrl || '', '100y100')"
class="album-item-img"
lazy
preview-disabled
/>
<div class="album-item-info">
<div class="album-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="album-item-desc">
{{ getDescription() }}
</div>
</div>
<div v-if="showCount && item.count" class="album-item-count">
{{ item.count }}
</div>
<div v-if="showDelete" class="album-item-delete" @click.stop="handleDelete">
<i class="iconfont icon-close"></i>
</div>
</div>
<history-item
:image-url="getImgUrl(item.picUrl || '', '100y100')"
:name="item.name"
:description="getDescription()"
:count="item.count"
:show-count="showCount"
:show-delete="showDelete"
@click="emit('click', item)"
@delete="emit('delete', item)"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { AlbumHistoryItem } from '@/hooks/AlbumHistoryHook';
import HistoryItem from '@/components/common/HistoryItem.vue';
import type { AlbumHistoryItem } from '@/store/modules/playHistory';
import { getImgUrl } from '@/utils';
interface Props {
item: AlbumHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
showDelete: false
});
const props = withDefaults(
defineProps<{
item: AlbumHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}>(),
{
showCount: false,
showDelete: false
}
);
const emit = defineEmits<{
click: [item: AlbumHistoryItem];
@@ -49,64 +39,8 @@ const { t } = useI18n();
const getDescription = () => {
const parts: string[] = [];
if (props.item.artist?.name) {
parts.push(props.item.artist.name);
}
if (props.item.size !== undefined) {
parts.push(t('user.album.songCount', { count: props.item.size }));
}
if (props.item.artist?.name) parts.push(props.item.artist.name);
if (props.item.size !== undefined) parts.push(t('common.songCount', { count: props.item.size }));
return parts.join(' · ') || t('history.noDescription');
};
const handleClick = () => {
emit('click', props.item);
};
const handleDelete = () => {
emit('delete', props.item);
};
</script>
<style scoped lang="scss">
.album-item {
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply bg-light-100 dark:bg-dark-100;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply mb-2;
&-img {
@apply flex items-center justify-center rounded-xl;
@apply w-[60px] h-[60px] flex-shrink-0;
@apply bg-light-300 dark:bg-dark-300;
}
&-info {
@apply ml-3 flex-1 min-w-0;
}
&-name {
@apply text-gray-900 dark:text-white text-base mb-1;
}
&-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&-count {
@apply px-4 text-lg text-center min-w-[60px];
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
@apply transition-all;
}
}
</style>

View File

@@ -1,121 +0,0 @@
<template>
<div class="bilibili-item" @click="handleClick">
<div class="bilibili-item-img">
<n-image class="w-full h-full" :src="item.pic" lazy preview-disabled />
<div class="play">
<i class="ri-play-fill text-4xl"></i>
</div>
<div class="duration">{{ formatDuration(item.duration) }}</div>
</div>
<div class="bilibili-item-info">
<p class="bilibili-item-title" v-html="item.title"></p>
<p class="bilibili-item-author"><i class="ri-user-line mr-1"></i>{{ item.author }}</p>
<div class="bilibili-item-stats">
<span><i class="ri-play-line mr-1"></i>{{ formatNumber(item.view) }}</span>
<span><i class="ri-chat-1-line mr-1"></i>{{ formatNumber(item.danmaku) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { IBilibiliSearchResult } from '@/types/bilibili';
const { t } = useI18n();
const props = defineProps<{
item: IBilibiliSearchResult;
}>();
const emit = defineEmits<{
(e: 'play', item: IBilibiliSearchResult): void;
}>();
const handleClick = () => {
emit('play', props.item);
};
/**
* 格式化数字显示
*/
const formatNumber = (num?: number) => {
if (!num) return '0';
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}${t('bilibili.player.num')}`;
}
return num.toString();
};
/**
* 格式化视频时长
*/
const formatDuration = (duration?: number | string) => {
if (!duration) return '00:00:00';
// 处理字符串格式 (例如 "4352:29")
if (typeof duration === 'string') {
// 检查是否是合法的格式
if (/^\d+:\d+$/.test(duration)) {
// 分解分钟和秒数
const [minutes, seconds] = duration.split(':').map(Number);
// 转换为时:分:秒格式
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return '00:00:00';
}
// 数字处理逻辑 (秒数转为"时:分:秒"格式)
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
</script>
<style scoped lang="scss">
.bilibili-item {
@apply rounded-lg flex items-start hover:bg-light-200 dark:hover:bg-dark-200 p-3 transition cursor-pointer border-none;
&-img {
@apply w-40 rounded-lg overflow-hidden relative mr-4;
aspect-ratio: 16/9;
&:hover {
.play {
@apply opacity-80;
}
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity text-white;
}
.duration {
@apply absolute bottom-1 right-1 text-xs text-white px-1 py-0.5 rounded-sm bg-black/60 backdrop-blur-sm;
}
}
&-info {
@apply flex-1 overflow-hidden;
}
&-title {
@apply text-gray-800 dark:text-gray-200 text-sm font-medium mb-1 line-clamp-2 leading-tight;
}
&-author {
@apply text-gray-500 dark:text-gray-400 text-xs flex items-center mb-1;
}
&-stats {
@apply flex items-center text-xs text-gray-500 dark:text-gray-400 gap-3;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-black z-10">
<n-scrollbar ref="scrollbarRef" x-scrollable>
<div
class="flex items-center py-4 page-padding"
style="white-space: nowrap"
@wheel.prevent="handleWheel"
>
<span
v-for="(category, index) in categories"
:key="getItemKey(category, index)"
class="py-1.5 px-4 mr-3 inline-block rounded-full cursor-pointer transition-all duration-300 text-sm font-medium bg-gray-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white"
:class="[
animationClass,
index === 0 ? 'ml-0.5' : '',
isActive(category) ? 'bg-primary text-white shadow-lg shadow-primary/25 scale-105' : ''
]"
:style="getAnimationDelay(index)"
@click="handleClickCategory(category)"
>
{{ getItemLabel(category) }}
</span>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { NScrollbar } from 'naive-ui';
import { computed, ref } from 'vue';
import { setAnimationDelay } from '@/utils';
type Category = string | number | { [key: string]: any };
type CategorySelectorProps = {
categories: Category[];
modelValue: any;
labelKey?: string;
valueKey?: string;
animationClass?: string;
};
const props = withDefaults(defineProps<CategorySelectorProps>(), {
labelKey: 'label',
valueKey: 'value',
animationClass: 'animate__bounceIn'
});
const emit = defineEmits<{
'update:modelValue': [value: any];
change: [value: any];
}>();
const scrollbarRef = ref();
const getItemKey = (item: Category, index: number): string | number => {
if (typeof item === 'object' && item !== null) {
return item[props.valueKey] ?? item[props.labelKey] ?? index;
}
return item;
};
const getItemLabel = (item: Category): string => {
if (typeof item === 'object' && item !== null) {
return item[props.labelKey] ?? String(item);
}
return String(item);
};
const getItemValue = (item: Category): any => {
if (typeof item === 'object' && item !== null) {
return item[props.valueKey] ?? item;
}
return item;
};
const isActive = (item: Category): boolean => {
const itemValue = getItemValue(item);
return itemValue === props.modelValue;
};
const getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
const handleClickCategory = (item: Category) => {
const value = getItemValue(item);
if (value === props.modelValue) return;
emit('change', value);
};
const handleWheel = (e: WheelEvent) => {
const scrollbar = scrollbarRef.value;
if (scrollbar) {
const delta = e.deltaY || e.detail;
scrollbar.scrollBy({ left: delta });
}
};
defineExpose({
scrollbarRef
});
</script>

View File

@@ -1,7 +1,6 @@
<template>
<Teleport to="body">
<Transition name="disclaimer-modal">
<!-- 免责声明页面 -->
<div
v-if="showDisclaimer"
class="fixed inset-0 z-[999999] flex items-center justify-center bg-black/60 backdrop-blur-md"
@@ -9,17 +8,13 @@
<div
class="w-full max-w-md mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
>
<!-- 顶部渐变装饰 -->
<div class="h-2 bg-gradient-to-r from-amber-400 via-orange-500 to-red-500"></div>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white px-6 mt-10">
{{ t('comp.disclaimer.title') }}
</h2>
<!-- 内容区域 -->
<div class="px-6 py-6">
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
<!-- 警告框 -->
<div
class="p-4 rounded-2xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"
>
@@ -31,7 +26,6 @@
</div>
</div>
<!-- 免责条款列表 -->
<div class="space-y-3">
<div class="flex items-start gap-3">
<div
@@ -63,7 +57,6 @@
</div>
</div>
<!-- 操作按钮 -->
<div class="px-6 pb-8 space-y-3">
<button
@click="handleAgree"
@@ -86,7 +79,6 @@
</div>
</Transition>
<!-- 捐赠页面 -->
<Transition name="donate-modal">
<div
v-if="showDonate"
@@ -95,10 +87,8 @@
<div
class="w-full max-w-md mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
>
<!-- 顶部渐变装饰 -->
<div class="h-2 bg-gradient-to-r from-pink-400 via-rose-500 to-red-500"></div>
<!-- 图标区域 -->
<div class="flex justify-center pt-8 pb-4">
<div
class="w-20 h-20 rounded-2xl bg-gradient-to-br from-pink-400 to-rose-500 flex items-center justify-center shadow-lg"
@@ -107,7 +97,6 @@
</div>
</div>
<!-- 标题 -->
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white px-6">
{{ t('comp.donate.title') }}
</h2>
@@ -116,9 +105,7 @@
{{ t('comp.donate.subtitle') }}
</p>
<!-- 内容区域 -->
<div class="px-6 py-6">
<!-- 提示信息 -->
<div
class="p-4 rounded-2xl bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 mb-6"
>
@@ -130,7 +117,6 @@
</div>
</div>
<!-- 捐赠方式 -->
<div class="grid grid-cols-2 gap-4">
<button
@click="openDonateLink('wechat')"
@@ -158,7 +144,6 @@
</div>
</div>
<!-- 进入应用按钮 -->
<div class="px-6 pb-8">
<button
@click="handleEnterApp"
@@ -178,7 +163,6 @@
</div>
</Transition>
<!-- 收款码弹窗 -->
<Transition name="qrcode-modal">
<div
v-if="showQRCode"
@@ -188,7 +172,6 @@
<div
class="w-full max-w-sm mx-4 bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-2xl"
>
<!-- 顶部渐变装饰 -->
<div
class="h-2"
:class="
@@ -198,7 +181,6 @@
"
></div>
<!-- 标题 -->
<div class="flex items-center justify-between px-6 py-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
{{ qrcodeType === 'wechat' ? t('comp.donate.wechatQR') : t('comp.donate.alipayQR') }}
@@ -211,7 +193,6 @@
</button>
</div>
<!-- 二维码图片 -->
<div class="px-6 pb-6">
<div class="bg-white p-4 rounded-2xl">
<img
@@ -234,28 +215,33 @@
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
// 导入收款码图片
import alipayQRCode from '@/assets/alipay.png';
import wechatQRCode from '@/assets/wechat.png';
import { isElectron, isLyricWindow } from '@/utils';
import config from '../../../../package.json';
const { t } = useI18n();
// 缓存键
const DISCLAIMER_AGREED_KEY = 'disclaimer_agreed_timestamp';
const DONATION_SHOWN_VERSION_KEY = 'donation_shown_version';
const showDisclaimer = ref(false);
const showDonate = ref(false);
const showQRCode = ref(false);
const qrcodeType = ref<'wechat' | 'alipay'>('wechat');
const isTransitioning = ref(false); // 防止用户点击过快
const isTransitioning = ref(false);
// 检查是否需要显示免责声明
const shouldShowDisclaimer = () => {
return !localStorage.getItem(DISCLAIMER_AGREED_KEY);
};
// 处理同意
const shouldShowDonateAfterUpdate = () => {
if (!localStorage.getItem(DISCLAIMER_AGREED_KEY)) return false;
const shownVersion = localStorage.getItem(DONATION_SHOWN_VERSION_KEY);
return shownVersion !== config.version;
};
const handleAgree = () => {
if (isTransitioning.value) return;
isTransitioning.value = true;
@@ -267,22 +253,18 @@ const handleAgree = () => {
}, 300);
};
// 处理不同意 - 退出应用
const handleDisagree = () => {
if (isTransitioning.value) return;
isTransitioning.value = true;
if (isElectron) {
// Electron 环境下强制退出应用
window.api?.quitApp?.();
} else {
// Web 环境下尝试关闭窗口
window.close();
}
isTransitioning.value = false;
};
// 打开捐赠链接
const openDonateLink = (type: 'wechat' | 'alipay') => {
if (isTransitioning.value) return;
@@ -290,18 +272,16 @@ const openDonateLink = (type: 'wechat' | 'alipay') => {
showQRCode.value = true;
};
// 关闭二维码弹窗
const closeQRCode = () => {
showQRCode.value = false;
};
// 进入应用
const handleEnterApp = () => {
if (isTransitioning.value) return;
isTransitioning.value = true;
// 记录同意时间
localStorage.setItem(DISCLAIMER_AGREED_KEY, Date.now().toString());
localStorage.setItem(DONATION_SHOWN_VERSION_KEY, config.version);
showDonate.value = false;
setTimeout(() => {
@@ -310,18 +290,20 @@ const handleEnterApp = () => {
};
onMounted(() => {
// 歌词窗口不显示免责声明
if (isLyricWindow.value) return;
// 检查是否需要显示免责声明
if (shouldShowDisclaimer()) {
showDisclaimer.value = true;
return;
}
if (shouldShowDonateAfterUpdate()) {
showDonate.value = true;
}
});
</script>
<style scoped>
/* 免责声明弹窗动画 */
.disclaimer-modal-enter-active,
.disclaimer-modal-leave-active {
transition: opacity 0.3s ease;
@@ -332,7 +314,6 @@ onMounted(() => {
opacity: 0;
}
/* 捐赠弹窗动画 */
.donate-modal-enter-active,
.donate-modal-leave-active {
transition: opacity 0.3s ease;
@@ -343,7 +324,6 @@ onMounted(() => {
opacity: 0;
}
/* 二维码弹窗动画 */
.qrcode-modal-enter-active,
.qrcode-modal-leave-active {
transition: opacity 0.3s ease;

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