Compare commits

..

66 Commits

Author SHA1 Message Date
imsyy
fc49b7ad00 🐳 chore: 优化构建流程 2025-10-18 18:30:15 +08:00
底层用户
fcf78cdd08 🦄 refactor: electron-builder to ts 2025-10-17 17:26:48 +08:00
底层用户
98fbc81d2f ci(workflow): 更新发布工作流配置
移除冗余的文件上传配置并设置矩阵策略不快速失败
2025-10-17 13:55:44 +08:00
底层用户
5ad562ab1c 🐳 chore: 更新构建配置和工作流 2025-10-17 12:38:14 +08:00
底层用户
10dc011f9a 🐳 chore: 更新构建配置和工作流 2025-10-17 12:03:38 +08:00
底层用户
3df6e91f95 🐳 chore: 更新构建配置和工作流 2025-10-17 11:50:18 +08:00
底层用户
31ae15d242 🐳 chore: 移除不必要的环境变量 2025-10-17 11:29:34 +08:00
底层用户
a2cfbb5e52 🐞 fix: 修复构建 pnpm 失败 2025-10-17 11:18:33 +08:00
底层用户
c4bd94daae 🐳 chore: 测试构建 2025-10-17 10:25:49 +08:00
imsyy
e18828cd08 🔧 build: 更新构建内容 2025-10-17 02:47:02 +08:00
imsyy
82f6b4607b 🐳 chore: 修复工作流格式 2025-10-17 01:13:55 +08:00
imsyy
3874ab3483 🐳 chore: 更新工作流 2025-10-17 01:09:01 +08:00
imsyy
fc297cb198 🐳 chore: 测试自动发版 2025-10-17 00:33:46 +08:00
imsyy
aec06c5a55 🐳 chore: 修复构建 2025-10-17 00:11:01 +08:00
imsyy
87ce076f26 🐳 chore: 修复构建打包 2025-10-16 23:09:23 +08:00
底层用户
45b1bf130d feat: 去除报错弹窗 2025-10-16 18:29:27 +08:00
底层用户
3966578015 🐞 fix: 修复 TTML 缺失翻译及音译 2025-10-16 17:50:14 +08:00
底层用户
fed7b3678b feat: 默认歌词支持 TTML 2025-10-16 16:35:37 +08:00
底层用户
ac6ce257b8 Merge pull request #491 from awsl1414/feat/ci-multi-platform
🔧 build: 优化 CI/CD 构建流程,增加多架构支持
2025-10-16 10:30:59 +08:00
awsl1414
b8b8f747d3 🔧 build: 全平台采用原生架构 runner 替代交叉编译
- feat: Windows ARM64 使用 windows-11-arm 原生 runner
- feat: macOS 拆分为独立的 x64 和 ARM64 构建任务
- feat: macOS x64 使用 macos-13 原生 Intel runner
- feat: macOS ARM64 使用 macos-15 原生 Apple Silicon runner
- feat: Linux ARM64 使用 ubuntu-22.04-arm 原生 runner
- fix: 修复 Windows ARM64 交叉编译生成错误 I386 指令集问题
- improve: 移除 QEMU 和交叉编译工具链依赖
- improve: 简化构建配置,提升构建可靠性和性能
- improve: 确保所有平台生成正确架构的可执行文件
2025-10-15 20:16:56 +08:00
awsl1414
53468b2e3a 🔧 build: 优化 CI/CD 构建流程,增加多架构支持
- feat: 为 Windows 构建添加 x64 和 ARM64 分离任务
- feat: 为 Linux 构建添加 ARM64 架构支持
- feat: 优化构建产物命名,包含架构标识
- chore: 更新构建相关依赖版本
- improve: Linux ARM64 使用 QEMU 交叉编译
- improve: 优化工作流任务命名和注释
2025-10-15 04:24:09 +08:00
底层用户
ecadf6ade7 🔧 build: 更新依赖 2025-10-13 09:43:34 +08:00
底层用户
8187b09fcb Merge pull request #484 from MoYingJi/feat
feat: 支持为日语单独设置字体
2025-10-13 09:06:02 +08:00
MoYingJi
89117d4198 规范代码
- 添加了一些注释
- 补全行尾分号 以符合总体代码风格
2025-10-11 01:21:19 +08:00
MoYingJi
6158dd2750 Merge branch 'imsyy:dev' into feat 2025-10-10 00:16:46 +08:00
MoYingJi
86f33d054a 日语单独字体 AM 支持 2025-10-10 00:10:21 +08:00
MoYingJi
52e8458590 fix 2025-10-09 23:13:39 +08:00
MoYingJi
554cf45500 fix 2025-10-09 23:11:50 +08:00
MoYingJi
60f751713a 日文字形设置和 MainLyric 的基础支持 2025-10-09 23:07:57 +08:00
底层用户
edd9b38cfc 🐞 fix: 修复构建失败 2025-10-09 11:43:05 +08:00
底层用户
9a87d73289 Merge pull request #479 from q1zhen/dev
fix: adjust minimum window size 调整最小窗口大小
2025-10-09 10:51:06 +08:00
底层用户
317763e2c3 Merge pull request #481 from MoeFurina/dev
feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词
2025-10-09 10:50:08 +08:00
ImFurina
aa3f9d2ca8 feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词 2025-10-02 12:25:04 +08:00
Yang Chyi-Jen
e64a5ba1bc fix: adjust minimum window size 2025-10-02 11:00:05 +08:00
底层用户
00e6f7bb60 Merge pull request #472 from MoeFurina/dev
fix(getOnlineUrl): 修复网页端登录后获取url的CORS问题
2025-09-25 13:35:15 +08:00
ImFurina
883ef05ab4 fix(getOnlineUrl): 修复网页端登录后获取url的CORS问题 2025-09-24 15:32:30 +08:00
底层用户
d0b5eb3371 Merge pull request #452 from serious-snow/dev
fix: 修复 macos 和linux 本地歌曲路径问题
2025-09-23 18:13:26 +08:00
底层用户
590ef96aa7 Merge pull request #469 from Pissofdvpe/dev-fix
fix:修复歌词显示相关
2025-09-23 18:10:16 +08:00
Pissofdvpe
172ca5a2f3 Update lyric.html
feat:优化调整桌面歌词字体大小设置
2025-09-21 19:26:22 +08:00
Pissofdvpe
6e1e56c1bd Update lyric.html
feat:优化桌面歌词字体调整
2025-09-21 19:18:46 +08:00
Pissofdvpe
105fed4bd0 Update LyricsSetting.vue
feat:优化调整歌词大小设置
2025-09-21 00:38:10 +08:00
Pissofdvpe
8bd3dc56d8 Update MainLyric.vue
fix:修复播放页面歌词设置在居中和居右状态下不能自动换行
2025-09-21 00:32:02 +08:00
底层用户
8e88bf64b1 🐞 fix: 修复类型错误 2025-09-15 12:06:50 +08:00
底层用户
244a832c52 🔧 build: 更新依赖 2025-09-15 11:45:02 +08:00
底层用户
146af3aeba 切换上游 api 库 #459 2025-09-15 11:22:55 +08:00
wangjian
143e8e29d7 fix: 修复 编辑歌曲信息弹窗无法复制(路径和md5) 2025-08-21 13:54:30 +08:00
wangjian
c702e6e01a fix: 修复 macos 和linux 本地歌曲路径问题
- 在 macos 和 linux 读取本地文件需要添加 file:// 前缀
- windows 也支持 file:// 前缀
- 在创建播放器时,为本地歌曲路径添加 file:// 前缀
- 在修改歌曲封面时,为本地路径添加 file:// 前缀
- 在保存元数据时,移除 file:// 前缀以保持兼容性
2025-08-21 09:37:25 +08:00
底层用户
b2ddb9f4e2 feat: 优化随机播放问题 & 播放页字体抖动效果优化 2025-08-20 11:35:07 +08:00
底层用户
201186bab2 🐞 fix: 修复同时请求问题 #448 2025-08-20 10:35:43 +08:00
底层用户
bfcd59daca Merge pull request #417 from serious-snow/dev
feat(Setting): 更新音质选项并添加新选择
2025-05-23 09:12:17 +08:00
wangjian
d5c3843c3f feat(Setting): 更新音质选项并添加新选择
- 参考手机端,更新音质说明文案
- 增加 jyeffect、vivid、dolby 音质
2025-05-22 10:55:17 +08:00
imsyy
d3f307eac5 🐞 fix: 修复登录错误 2025-04-19 22:56:33 +08:00
imsyy
675a52b8d1 🎈 perf: 优化 cookie 登录体验 2025-04-19 00:31:41 +08:00
imsyy
aee90e9c4e 🔧 build: 更新依赖 2025-04-18 23:34:22 +08:00
底层用户
eb39b81d8d Merge pull request #375 from xiaoQQya/dev
Docker 镜像内置 UnblockNeteaseMusic,  支持播放部分无版权歌曲
2025-03-18 14:56:37 +08:00
xiaoQQya
436df47104 feat: Docker 镜像内置 UnblockNeteaseMusic, 支持播放部分无版权歌曲 2025-03-15 11:28:12 +08:00
imsyy
e04e5e34c6 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2025-01-17 17:03:33 +08:00
imsyy
b57d685c03 🔧 build: 更新依赖 2025-01-17 17:03:16 +08:00
底层用户
6684172592 Merge pull request #333 from serious-snow/dev
fix: 更新日志中的超链接跳转由新窗口打开
2025-01-08 11:33:21 +08:00
wangjian
0257e74ff0 fix: 更新日志中的超链接跳转由新窗口打开
- 在 AboutSetting 组件中添加了 jumpLink 函数,实现更新日志中链接的点击跳转
- 修复 CoverList 组件移入移出会出现黑角的bug
2025-01-08 10:59:10 +08:00
imsyy
16c8865651 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-12-27 10:25:39 +08:00
imsyy
1a61aa2458 🐞 fix: 修复网页端跨域 #317 2024-12-27 10:25:01 +08:00
底层用户
e543f07d8e Merge pull request #307 from 239144498/patch-1
Update idMeta.json
2024-12-24 18:22:30 +08:00
imsyy
96a0495a88 🐞 fix: 修复本地目录无法读取 #315 2024-12-23 11:21:22 +08:00
imsyy
02befcd8a4 🐳 chore: 修复工作流 2024-12-12 14:04:30 +08:00
Naihe
1edceeebdd Update idMeta.json
增加会员雷达,里面的每日推荐歌曲质量还不错
https://music.163.com/#/playlist?id=8402996200
2024-12-09 13:23:10 +08:00
58 changed files with 4340 additions and 3454 deletions

View File

@@ -1,5 +1,5 @@
# Dev 分支推送部署预览
## 部署 Win
## 部署 Windows x64 和 ARM64 版本
name: Build Dev
on:
@@ -8,8 +8,9 @@ on:
- dev
jobs:
release:
name: Build and electron app
# Windows x64 架构
release-x64:
name: Build Electron App for Windows (x64)
runs-on: windows-latest
steps:
@@ -21,6 +22,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "22.x"
# 安装 pnpm
- name: Install pnpm
run: npm install -g pnpm
# 复制环境变量文件
- name: Copy .env.example
run: |
@@ -31,31 +35,77 @@ jobs:
}
# 安装项目依赖
- name: Install Dependencies
run: npm install
# 构建 Electron App
- name: Build Electron App
run: npm run build:win
run: pnpm install
# 清理旧的构建产物
- name: Clean dist folder
run: |
if (Test-Path dist) {
Remove-Item -Recurse -Force dist
}
# 构建 Electron App (x64)
- name: Build Electron App for Windows x64
run: pnpm run build:win -- --x64 || true
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 清理不必要的构建产物
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 清理不必要的构建产物(保留 .exe 和 .blockmap 文件)
- name: Cleanup Artifacts
run: npx del-cli "dist/**/*" "!dist/*.exe"
# 上传构建产物
run: npx del-cli "dist/**/*.yaml" "dist/**/*.yml"
# 上传构建产物(仅上传 x64 架构的 .exe 文件)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: SPlayer-dev
path: dist
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
name: SPlayer-dev-x64
path: |
dist/*x64*.exe
dist/*x64*.exe.blockmap
# Windows ARM64 架构
release-arm64:
name: Build Electron App for Windows (ARM64)
runs-on: windows-11-arm
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4
with:
tag_name: ${{ github.ref }}
name: ${{ github.ref }}-rc
body: This version is still under development, currently only provides windows version, non-developers please do not use!
draft: false
prerelease: true
files: dist/*.exe
node-version: "22.x"
# 安装 pnpm
- name: Install pnpm
run: npm install -g pnpm
# 复制环境变量文件
- name: Copy .env.example
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: pnpm install
# 清理旧的构建产物
- name: Clean dist folder
run: |
if (Test-Path dist) {
Remove-Item -Recurse -Force dist
}
# 构建 Electron App (ARM64)
- name: Build Electron App for Windows ARM64
run: pnpm run build:win -- --arm64 || true
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 清理不必要的构建产物(保留 .exe 和 .blockmap 文件)
- name: Cleanup Artifacts
run: npx del-cli "dist/**/*.yaml" "dist/**/*.yml"
# 上传构建产物(仅上传 ARM64 架构的 .exe 文件)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: SPlayer-dev-arm64
path: |
dist/*arm64*.exe
dist/*arm64*.exe.blockmap

View File

@@ -1,164 +1,169 @@
# Release 发行版本部署
## 多端部署
name: Build Release
name: Build & Release SPlayer
on:
push:
tags:
- v*
- v* # 只在 tag v* 时触发
workflow_dispatch:
env:
NODE_VERSION: 22.x
PNPM_VERSION: 8
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
jobs:
# Windows
build-windows:
name: Build for Windows
runs-on: windows-latest
# ===================================================================
# 并行构建所有平台和架构
# ===================================================================
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
# 矩阵策略
# 即使一个矩阵任务失败,其他任务也会继续运行
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
# 开始步骤
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
node-version: "20.x"
version: ${{ env.PNPM_VERSION }}
# 安装 Node.js
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
# 清理旧的构建产物
- name: Clean workspace on Windows
if: runner.os == 'Windows'
run: |
Write-Host "🧹 Cleaning workspace, node_modules, and Electron caches..."
if (Test-Path dist) { Remove-Item -Recurse -Force dist }
if (Test-Path out) { Remove-Item -Recurse -Force out }
if (Test-Path node_modules) { Remove-Item -Recurse -Force node_modules }
if (Test-Path "$env:LOCALAPPDATA\electron-builder") {
Remove-Item "$env:LOCALAPPDATA\electron-builder" -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path "$env:LOCALAPPDATA\electron") {
Remove-Item "$env:LOCALAPPDATA\electron" -Recurse -Force -ErrorAction SilentlyContinue
}
- name: Clean workspace on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux'
run: |
echo "🧹 Cleaning workspace, node_modules, and Electron caches..."
rm -rf dist out node_modules ~/.cache/electron-builder ~/.cache/electron
# 安装项目依赖
- name: Install dependencies
run: pnpm install
# 复制环境变量文件
- name: Copy .env.example
- name: Copy .env file on Windows
if: runner.os == 'Windows'
run: |
if (-not (Test-Path .env)) {
Copy-Item .env.example .env
} else {
Write-Host ".env file already exists. Skipping the copy step."
}
# 安装项目依赖
- name: Install Dependencies
run: npm install
# 构建 Electron App
- name: Build Electron App for Windows
run: npm run build:win || true
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 上传构建产物
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: SPlarer-Win
if-no-files-found: ignore
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Mac
build-macos:
name: Build for macOS
runs-on: macos-latest
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
# 复制环境变量文件
- name: Copy .env.example
- name: Copy .env file on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux'
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
# 构建 Electron App
- name: Build Electron App for macOS
run: npm run build:mac || true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# 上传构建产物
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: SPlarer-Macos
if-no-files-found: ignore
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
with:
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
# Linux
build-linux:
name: Build for Linux
runs-on: ubuntu-22.04
steps:
# 检出 Git 仓库
- name: Check out git repository
uses: actions/checkout@v4
# 安装 Node.js
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
# 更新 Ubuntu 软件源
- name: Ubuntu Update with sudo
if: runner.os == 'Linux'
run: sudo apt-get update
# 安装依赖
- name: Install RPM & Pacman
if: runner.os == 'Linux'
run: |
sudo apt-get install --no-install-recommends -y rpm &&
sudo apt-get install --no-install-recommends -y libarchive-tools &&
sudo apt-get install --no-install-recommends -y libopenjp2-tools
# 安 Snapcraft
# 安 Snapcraft
- name: Install Snapcraft
if: runner.os == 'Linux'
uses: samuelmeuli/action-snapcraft@v2
with:
snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}
# 复制环境变量文件
- name: Copy .env.example
run: |
if [ ! -f .env ]; then
cp .env.example .env
else
echo ".env file already exists. Skipping the copy step."
fi
# 安装项目依赖
- name: Install Dependencies
run: npm install
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
# 构建 Electron App
- name: Build Electron App for Linux
run: npm run build:linux || true
- name: Build Windows x64 & ARM64 App
if: runner.os == 'Windows'
run: pnpm build:win || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build macOS Universal App
if: runner.os == 'macOS'
run: pnpm build:mac || true
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Linux x64 & ARM64 App
if: runner.os == 'Linux'
run: pnpm build:linux || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 上传 Snap 包到 Snapcraft 商店
- name: Publish Snap to Snap Store
if: runner.os == 'Linux'
run: snapcraft upload dist/*.snap --release stable
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
# 上传构建产物
- name: Upload Linux artifact
continue-on-error: true
# 合并所有构建
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: SPlarer-Linux
if-no-files-found: ignore
name: SPlayer-${{ runner.os }}
path: dist/*.*
# 创建 GitHub Release
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
# ===================================================================
# 收集并发布 Release
# ===================================================================
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
# 需要写入权限
permissions:
contents: write
steps:
# 将所有产物下载到 artifacts 文件夹
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
files: dist/*.*
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
path: artifacts
# 创建 GitHub Release 并上传所有产物
- name: Create GitHub Release and Upload Assets
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
# 自动生成 Release 说明
generate_release_notes: true
# 发布为草稿
draft: false
# 发布为预发布
prerelease: false
# 全部上传
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.rpm
artifacts/**/*.pacman
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap

View File

@@ -23,8 +23,16 @@ COPY --from=builder /app/out/renderer /usr/share/nginx/html
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache npm
COPY --from=builder /app/docker-entrypoint.sh /docker-entrypoint.sh
RUN npm install -g NeteaseCloudMusicApi
RUN apk add --no-cache npm python3 youtube-dl \
&& npm install -g @unblockneteasemusic/server NeteaseCloudMusicApi \
&& wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \
&& chmod +x /usr/local/bin/yt-dlp \
&& chmod +x /docker-entrypoint.sh
CMD nginx && npx NeteaseCloudMusicApi
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["npx", "NeteaseCloudMusicApi"]

View File

@@ -1,6 +1,12 @@
# SPlayer
> 一个简约的音乐播放器
> A simple music player
![Stars](https://img.shields.io/github/stars/imsyy/SPlayer?style=flat)
![Version](https://img.shields.io/github/v/release/imsyy/SPlayer)
[![Build Release](https://github.com/imsyy/SPlayer/actions/workflows/release.yml/badge.svg)](https://github.com/imsyy/SPlayer/actions/workflows/release.yml)
![License](https://img.shields.io/github/license/imsyy/SPlayer)
![Issues](https://img.shields.io/github/issues/imsyy/SPlayer)
![main](/screenshots/SPlayer.jpg)

View File

@@ -13,6 +13,9 @@ export default {
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ShallowRef": true,
"Slot": true,
"Slots": true,
"VNode": true,
"WritableComputedRef": true,
"asyncComputed": true,
@@ -29,6 +32,7 @@ export default {
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
"createRef": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
@@ -43,6 +47,7 @@ export default {
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"getCurrentWatcher": true,
"h": true,
"ignorableWatch": true,
"inject": true,
@@ -52,6 +57,7 @@ export default {
"isReactive": true,
"isReadonly": true,
"isRef": true,
"isShallow": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
@@ -63,6 +69,7 @@ export default {
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onElementRemoval": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
@@ -145,6 +152,7 @@ export default {
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCountdown": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
@@ -229,12 +237,14 @@ export default {
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePreferredReducedTransparency": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useSSRWidth": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
@@ -261,6 +271,7 @@ export default {
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeAgoIntl": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,

10
auto-imports.d.ts vendored
View File

@@ -21,6 +21,7 @@ declare global {
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
@@ -35,6 +36,7 @@ declare global {
const extendRef: typeof import('@vueuse/core')['extendRef']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
@@ -44,6 +46,7 @@ declare global {
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
@@ -55,6 +58,7 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
@@ -137,6 +141,7 @@ declare global {
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
@@ -221,12 +226,14 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -253,6 +260,7 @@ declare global {
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
@@ -296,6 +304,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

1
components.d.ts vendored
View File

@@ -2,6 +2,7 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */

View File

@@ -10,3 +10,21 @@ services:
ports:
- 25884:25884
restart: always
environment:
# 所有变量都不是必填项
# 网易云服务端 IP, 可在宿主机通过 ping music.163.com 获得
- NETEASE_SERVER_IP=220.197.30.65
# UnblockNeteaseMusic 使用的音源, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E9%9F%B3%E6%BA%90%E6%B8%85%E5%8D%95
- UNBLOCK_SOURCES=kugou kuwo bilibili
# 可添加 UnblockNeteaseMusic 支持的任何环境变量, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F
- ENABLE_FLAC=false
- ENABLE_HTTPDNS=false
- BLOCK_ADS=true
- DISABLE_UPGRADE_CHECK=false
- DEVELOPMENT=false
- FOLLOW_SOURCE_ORDER=true
- JSON_LOG=false
- NO_CACHE=false
- SELECT_MAX_BR=true
- LOG_LEVEL=info
- SEARCH_ALBUM=true

29
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
set -e
# start unblock service in the background
npx unblockneteasemusic -p 80:443 -s -f ${NETEASE_SERVER_IP:-220.197.30.65} -o ${UNBLOCK_SOURCES:-kugou kuwo bilibili} 2>&1 &
# point the neteasemusic address to the unblock service
if ! grep -q "music.163.com" /etc/hosts; then
echo "127.0.0.1 music.163.com" >> /etc/hosts
fi
if ! grep -q "interface.music.163.com" /etc/hosts; then
echo "127.0.0.1 interface.music.163.com" >> /etc/hosts
fi
if ! grep -q "interface3.music.163.com" /etc/hosts; then
echo "127.0.0.1 interface3.music.163.com" >> /etc/hosts
fi
if ! grep -q "interface.music.163.com.163jiasu.com" /etc/hosts; then
echo "127.0.0.1 interface.music.163.com.163jiasu.com" >> /etc/hosts
fi
if ! grep -q "interface3.music.163.com.163jiasu.com" /etc/hosts; then
echo "127.0.0.1 interface3.music.163.com.163jiasu.com" >> /etc/hosts
fi
# start the nginx daemon
nginx
# start the main process
exec "$@"

170
electron-builder.config.ts Normal file
View File

@@ -0,0 +1,170 @@
import type { Configuration } from "electron-builder";
const config: Configuration = {
// 应用程序的唯一标识符
appId: "com.imsyy.splayer",
// 应用程序的产品名称
productName: "SPlayer",
copyright: "Copyright © imsyy 2023",
// 构建资源所在的目录
directories: {
buildResources: "build",
},
// 包含在最终应用程序构建中的文件列表
// 使用通配符 ! 表示排除不需要的文件
files: [
"public/**",
"out/**",
"!**/.vscode/*",
"!src/*",
"!electron.vite.config.{js,ts,mjs,cjs}",
"!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}",
"!{.env,.env.*,.npmrc,pnpm-lock.yaml}",
],
// 哪些文件将不会被压缩,而是解压到构建目录
asarUnpack: ["public/**"],
win: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/logo.ico",
// Windows 平台全局文件名模板
artifactName: "${productName}-${version}-${arch}.${ext}",
// 是否对可执行文件进行签名和编辑
// signAndEditExecutable: false,
// 构建类型(架构由命令行参数 --x64 或 --arm64 指定)
target: [
// 安装版
{
target: "nsis",
arch: ["x64", "arm64"],
},
// 打包版
{
target: "portable",
arch: ["x64", "arm64"],
},
],
},
// NSIS 安装器配置
nsis: {
// 是否一键式安装
oneClick: false,
// 安装程序的生成名称
artifactName: "${productName}-${version}-${arch}-setup.${ext}",
// 创建的桌面快捷方式名称
shortcutName: "${productName}",
// 卸载时显示的名称
uninstallDisplayName: "${productName}",
// 创建桌面图标
createDesktopShortcut: "always",
// 是否允许 UAC 提升权限
allowElevation: true,
// 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true,
// 安装包图标
installerIcon: "public/icons/favicon.ico",
// 卸载命令图标
uninstallerIcon: "public/icons/favicon.ico",
},
// Portable 便携版配置
portable: {
// 便携版文件名
artifactName: "${productName}-${version}-${arch}-portable.${ext}",
},
// macOS 平台配置
mac: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/favicon-512x512.png",
// 权限继承的文件路径
entitlementsInherit: "build/entitlements.mac.plist",
// macOS 平台全局文件名模板
artifactName: "${productName}-${version}-${arch}.${ext}",
// 扩展信息,如权限描述
extendInfo: {
NSCameraUsageDescription: "Application requests access to the device's camera.",
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
NSDocumentsFolderUsageDescription:
"Application requests access to the user's Documents folder.",
NSDownloadsFolderUsageDescription:
"Application requests access to the user's Downloads folder.",
},
// 是否启用应用程序的 Notarization苹果的安全审核
notarize: false,
darkModeSupport: true,
category: "public.app-category.music",
target: [
// DMG 安装版
{
target: "dmg",
arch: ["x64", "arm64"],
},
// 压缩包安装版
{
target: "zip",
arch: ["x64", "arm64"],
},
],
},
// Linux 平台配置
linux: {
// 可执行文件名
executableName: "SPlayer",
// 应用程序的图标文件路径
icon: "public/icons/favicon-512x512.png",
// Linux 所有格式的统一文件名模板
artifactName: "${name}-${version}-${arch}.${ext}",
// 构建类型 - 支持 x64 和 ARM64 架构
target: [
// Pacman 包管理器
{
target: "pacman",
arch: ["x64", "arm64"],
},
// AppImage 格式
{
target: "AppImage",
arch: ["x64", "arm64"],
},
// Debian 包管理器
{
target: "deb",
arch: ["x64", "arm64"],
},
// RPM 包管理器
{
target: "rpm",
arch: ["x64", "arm64"],
},
// Snap 包管理器(仅支持 x64 架构)
{
target: "snap",
arch: ["x64"],
},
// 压缩包格式
{
target: "tar.gz",
arch: ["x64", "arm64"],
},
],
// 维护者信息
maintainer: "imsyy.top",
// 应用程序类别
category: "Audio;Music",
},
// AppImage 特定配置
appImage: {
// AppImage 文件的生成名称
artifactName: "${name}-${version}-${arch}.${ext}",
},
// 是否在构建之前重新编译原生模块
npmRebuild: false,
// Electron 下载镜像配置
electronDownload: {
mirror: "https://npmmirror.com/mirrors/electron/",
},
};
export default config;

View File

@@ -1,115 +0,0 @@
# 应用程序的唯一标识符
appId: com.imsyy.splayer
# 应用程序的产品名称
productName: SPlayer
copyright: Copyright © imsyy 2023
# 构建资源所在的目录
directories:
buildResources: public
# 包含在最终应用程序构建中的文件列表
# 使用通配符 ! 表示排除不需要的文件
files:
- "public/**"
- "out/**"
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
# 哪些文件将不会被压缩,而是解压到构建目录
asarUnpack:
- public/**
win:
# 可执行文件名
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/icons/favicon-512x512.png
# 构建类型
target:
# 安装版
- nsis
# 打包版
- portable
# NSIS 安装器配置
nsis:
# 是否一键式安装
oneClick: false
# 安装程序的生成名称
artifactName: ${productName}-${version}-setup.${ext}
# 创建的桌面快捷方式名称
shortcutName: ${productName}
# 卸载时显示的名称
uninstallDisplayName: ${productName}
# 创建桌面图标
createDesktopShortcut: always
# 是否允许 UAC 提升权限
allowElevation: true
# 是否允许用户更改安装目录
allowToChangeInstallationDirectory: true
# 安装包图标
installerIcon: public/icons/favicon.ico
# 卸载命令图标
uninstallerIcon: public/icons/favicon.ico
# macOS 平台配置
mac:
# 可执行文件名
executableName: SPlayer
# 应用程序的图标文件路径
icon: public/icons/favicon-512x512.png
# 权限继承的文件路径
entitlementsInherit: build/entitlements.mac.plist
# 扩展信息,如权限描述
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
# 是否启用应用程序的 Notarization苹果的安全审核
notarize: false
darkModeSupport: true
category: public.app-category.music
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
# macOS 平台的 DMG 配置
dmg:
# DMG 文件的生成名称
artifactName: ${name}-${version}.${ext}
# Linux 平台配置
linux:
# 可执行文件名
executableName: splayer
# 应用程序的图标文件路径
icon: public/icons/favicon-512x512.png
# 构建类型
target:
- pacman
- AppImage
- deb
- rpm
- snap
- tar.gz
# 维护者信息
maintainer: imsyy.top
# 应用程序类别
category: Audio;Music
# AppImage 配置
appImage:
# AppImage 文件的生成名称
artifactName: ${name}-${version}.${ext}
# 是否在构建之前重新编译原生模块
npmRebuild: false
# 自动更新的配置
publish:
# 更新提供商
provider: github
# 自动更新检查的 URL
# url: https://example.com/auto-updates
owner: "imsyy"
repo: "SPlayer"

View File

@@ -88,7 +88,7 @@ export default defineConfig(({ command, mode }) => {
"/api": {
target: `http://127.0.0.1:${servePort}`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "/api/"),
rewrite: (path) => path.replace(/^\/api/, "/api"),
},
},
},

View File

@@ -1,9 +1,9 @@
import { app, shell, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { electronApp } from "@electron-toolkit/utils";
import { join } from "path";
import { release, type } from "os";
import { isDev, isMac, appName } from "./utils";
import { registerAllShortcuts, unregisterShortcuts } from "./shortcut";
import { unregisterShortcuts } from "./shortcut";
import { initTray, MainTray } from "./tray";
import { initThumbar, Thumbar } from "./thumbar";
import { type StoreType, initStore } from "./store";
@@ -75,8 +75,6 @@ class MainProcess {
this.thumbar,
this.store,
);
// 注册快捷键
registerAllShortcuts(this.mainWindow!);
});
}
// 创建窗口
@@ -118,8 +116,8 @@ class MainProcess {
const options: BrowserWindowConstructorOptions = {
width: this.store?.get("window").width,
height: this.store?.get("window").height,
minHeight: 800,
minWidth: 1280,
minHeight: 600,
minWidth: 800,
// 菜单栏
titleBarStyle: "customButtonsOnHover",
// 立即显示窗口
@@ -218,11 +216,6 @@ class MainProcess {
this.showWindow();
});
// 开发环境控制台
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// 自定义协议
app.on("open-url", (_, url) => {
console.log("Received custom protocol URL:", url);

View File

@@ -17,7 +17,7 @@ import { Thumbar } from "./thumbar";
import { StoreType } from "./store";
import { isDev, getFileID, getFileMD5 } from "./utils";
import { isShortcutRegistered, registerShortcut, unregisterShortcuts } from "./shortcut";
import { join, basename, resolve } from "path";
import { join, basename, resolve, relative, isAbsolute } from "path";
import { download } from "electron-dl";
import { checkUpdate, startDownloadUpdate } from "./update";
import fs from "fs/promises";
@@ -173,8 +173,11 @@ const initWinIpcMain = (
// 遍历音乐文件
ipcMain.handle("get-music-files", async (_, dirPath: string) => {
try {
// 规范化路径
const filePath = resolve(dirPath).replace(/\\/g, "/");
console.info(`📂 Fetching music files from: ${filePath}`);
// 查找指定目录下的所有音乐文件
const musicFiles = await fg("**/*.{mp3,wav,flac}", { cwd: dirPath });
const musicFiles = await fg("**/*.{mp3,wav,flac}", { cwd: filePath });
// 解析元信息
const metadataPromises = musicFiles.map(async (file) => {
const filePath = join(dirPath, file);
@@ -214,18 +217,19 @@ const initWinIpcMain = (
// 获取音乐元信息
ipcMain.handle("get-music-metadata", async (_, path: string) => {
try {
const { common, format } = await parseFile(path);
const filePath = resolve(path).replace(/\\/g, "/");
const { common, format } = await parseFile(filePath);
return {
// 文件名称
fileName: basename(path),
fileName: basename(filePath),
// 文件大小
fileSize: (await fs.stat(path)).size / (1024 * 1024),
fileSize: (await fs.stat(filePath)).size / (1024 * 1024),
// 元信息
common,
// 音质信息
format,
// md5
md5: await getFileMD5(path),
md5: await getFileMD5(filePath),
};
} catch (error) {
log.error("❌ Error fetching music metadata:", error);
@@ -236,24 +240,19 @@ const initWinIpcMain = (
// 获取音乐歌词
ipcMain.handle("get-music-lyric", async (_, path: string): Promise<string> => {
try {
const { common, native } = await parseFile(path);
const filePath = resolve(path).replace(/\\/g, "/");
const { common } = await parseFile(filePath);
const lyric = common?.lyrics;
if (lyric && lyric.length > 0) return String(lyric[0]);
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
else {
// 尝试读取 UNSYNCEDLYRICS
const nativeTags = native["ID3v2.3"] || native["ID3v2.4"];
const usltTag = nativeTags?.find((tag) => tag.id === "USLT");
if (usltTag) return String(usltTag.value.text);
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
else {
const lrcFilePath = path.replace(/\.[^.]+$/, ".lrc");
try {
await fs.access(lrcFilePath);
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
return lrcData || "";
} catch {
return "";
}
const lrcFilePath = filePath.replace(/\.[^.]+$/, ".lrc");
try {
await fs.access(lrcFilePath);
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
return lrcData || "";
} catch {
return "";
}
}
} catch (error) {
@@ -623,6 +622,16 @@ const initLyricIpcMain = (
lyricWin.setIgnoreMouseEvents(false);
}
});
// 检查是否是子文件夹
ipcMain.handle("check-if-subfolder", (_, localFilesPath: string[], selectedDir: string) => {
const resolvedSelectedDir = resolve(selectedDir);
const allPaths = localFilesPath.map((p) => resolve(p));
return allPaths.some((existingPath) => {
const relativePath = relative(existingPath, resolvedSelectedDir);
return relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath);
});
});
};
// tray

View File

@@ -1,123 +1,72 @@
import {
BrowserWindow,
MenuItemConstructorOptions,
Menu,
session,
dialog,
ipcMain,
} from "electron";
import { BrowserWindow, session } from "electron";
import icon from "../../public/icons/favicon.png?asset";
import { join } from "path";
const openLoginWin = (mainWin: BrowserWindow) => {
const loginSession = session.fromPartition("login-win");
const openLoginWin = async (mainWin: BrowserWindow) => {
let loginTimer: NodeJS.Timeout;
const loginSession = session.fromPartition("persist:login");
// 清除 Cookie
await loginSession.clearStorageData({
storages: ["cookies", "localstorage"],
});
const loginWin = new BrowserWindow({
parent: mainWin,
title: "登录网易云音乐",
title: "登录网易云音乐 若遇到无响应请关闭后重试 ",
width: 1280,
height: 800,
center: true,
modal: true,
autoHideMenuBar: true,
icon,
// resizable: false,
// movable: false,
// minimizable: false,
// maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition: "login-win",
session: loginSession,
sandbox: false,
webSecurity: false,
preload: join(__dirname, "../preload/index.mjs"),
},
});
// 打开网易云
loginWin.loadURL("https://music.163.com/#/my/");
loginWin.loadURL("https://music.163.com/#/login/");
// 阻止新窗口创建
loginWin.webContents.setWindowOpenHandler(() => {
return { action: "deny" };
});
// 登录完成
const loginFinish = async () => {
if (!loginWin) return;
// 获取 Cookie
const cookies = await loginWin.webContents.session.cookies.get({ name: "MUSIC_U" });
if (!cookies?.[0]?.value) {
dialog.showMessageBox({
type: "info",
title: "登录失败",
message: "未查找到登录信息,请重试",
// 检查是否登录
const checkLogin = async () => {
try {
loginWin.webContents.executeJavaScript(
"document.title = '登录网易云音乐( 若遇到无响应请关闭后重试 '",
);
// 是否登录?判断 MUSIC_U
const MUSIC_U = await loginSession.cookies.get({
name: "MUSIC_U",
});
return;
if (MUSIC_U && MUSIC_U?.length > 0) {
if (loginTimer) clearInterval(loginTimer);
const value = `MUSIC_U=${MUSIC_U[0].value};`;
// 发送回主进程
mainWin?.webContents.send("send-cookies", value);
loginWin.destroy();
}
} catch (error) {
console.error(error);
}
const value = `MUSIC_U=${cookies[0].value};`;
// 发送回主进程
mainWin?.webContents.send("send-cookies", value);
await loginSession?.clearStorageData();
loginWin.close();
};
// 页面注入
// loginWin.webContents.once("did-finish-load", () => {
// const script = `
// const style = document.createElement('style');
// style.innerHTML = \`
// .login-btn {
// position: fixed;
// left: 0;
// bottom: 0;
// width: 100%;
// height: 80px;
// display: flex;
// align-items: center;
// justify-content: center;
// background-color: #242424;
// z-index: 99999;
// }
// .login-btn span {
// color: white;
// margin-right: 20px;
// }
// .login-btn button {
// border: none;
// outline: none;
// background-color: #c20c0c;
// border-radius: 25px;
// color: white;
// height: 40px;
// padding: 0 20px;
// cursor: pointer;
// }
// \`;
// document.head.appendChild(style);
// const div = document.createElement('div');
// div.className = 'login-btn';
// div.innerHTML = \`
// <span>请在登录成功后点击</span>
// <button>登录完成</button>
// \`;
// div.querySelector('button').addEventListener('click', () => {
// window.electron.ipcRenderer.send("login-success");
// });
// document.body.appendChild(div);
// `;
// loginWin.webContents.executeJavaScript(script);
// });
// 监听事件
ipcMain.on("login-success", loginFinish);
// 菜单栏
const menuTemplate: MenuItemConstructorOptions[] = [
{
label: "登录完成",
click: loginFinish,
},
];
const menu = Menu.buildFromTemplate(menuTemplate);
loginWin.setMenu(menu);
// 循环检查
loginWin.webContents.once("did-finish-load", () => {
loginWin.show();
loginTimer = setInterval(checkLogin, 1000);
loginWin.on("closed", () => {
clearInterval(loginTimer);
});
});
};
export default openLoginWin;

View File

@@ -1,5 +1,4 @@
import { BrowserWindow, globalShortcut } from "electron";
import { isDev } from "./utils";
import { globalShortcut } from "electron";
import log from "../main/logger";
// 注册快捷键并检查
@@ -29,15 +28,3 @@ export const unregisterShortcuts = () => {
globalShortcut.unregisterAll();
log.info("🚫 All shortcuts unregistered.");
};
// 注册所有快捷键
export const registerAllShortcuts = (win: BrowserWindow) => {
// 开启控制台
registerShortcut("CmdOrCtrl+Shift+I", () => {
win.webContents.openDevTools({
title: "SPlayer DevTools",
// 客户端分离
mode: isDev ? "right" : "detach",
});
});
};

View File

@@ -7,7 +7,7 @@ import {
nativeImage,
nativeTheme,
} from "electron";
import { isWin, isLinux, isDev, appName } from "./utils";
import { isWin, appName } from "./utils";
import { join } from "path";
import log from "./logger";
@@ -269,12 +269,8 @@ class CreateTray implements MainTray {
export const initTray = (win: BrowserWindow, lyricWin: BrowserWindow) => {
try {
// 若为 MacOS
if (isWin || isLinux || isDev) {
log.info("🚀 Tray Process Startup");
return new CreateTray(win, lyricWin);
}
return null;
log.info("🚀 Tray Process Startup");
return new CreateTray(win, lyricWin);
} catch (error) {
log.error("❌ Tray Process Error", error);
return null;

View File

@@ -11,8 +11,10 @@ import log from "../main/logger";
const initAppServer = async () => {
try {
const server = fastify({
// 忽略尾随斜杠
ignoreTrailingSlash: true,
routerOptions: {
// 忽略尾随斜杠
ignoreTrailingSlash: true,
},
});
// 注册插件
server.register(fastifyCookie);
@@ -46,7 +48,7 @@ const initAppServer = async () => {
server.register(initNcmAPI, { prefix: "/api" });
server.register(initUnblockAPI, { prefix: "/api" });
// 启动端口
const port = Number(import.meta.env["VITE_SERVER_PORT"] || 25884);
const port = Number(process.env["VITE_SERVER_PORT"] || 25884);
await server.listen({ port });
log.info(`🌐 Starting AppServer on port ${port}`);
return server;

View File

@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { pathCase } from "change-case";
import NeteaseCloudMusicApi from "NeteaseCloudMusicApi";
import NeteaseCloudMusicApi from "@neteaseapireborn/api";
import log from "../../main/logger";
// 获取数据
@@ -33,12 +33,12 @@ const initNcmAPI = async (fastify: FastifyInstance) => {
// 主信息
fastify.get("/netease", (_, reply) => {
reply.send({
name: "NeteaseCloudMusicApi",
version: "4.20.0",
description: "网易云音乐 Node.js API service",
author: "@binaryify",
name: "@neteaseapireborn/api",
version: "4.29.2",
description: "网易云音乐 API Enhanced",
author: "@MoeFurina",
license: "MIT",
url: "https://gitlab.com/Binaryify/neteasecloudmusicapi",
url: "https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced",
});
});

View File

@@ -26,11 +26,7 @@ export default [
"**/components.d.ts",
],
},
...compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
),
...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"),
{
plugins: {
"@typescript-eslint": typescriptEslint,

View File

@@ -15,6 +15,22 @@ server {
rewrite ^(.*)$ /index.html last;
}
location /api/netease/song/url/v1 {
proxy_buffers 16 64k;
proxy_buffer_size 128k;
proxy_busy_buffers_size 256k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $remote_addr;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://localhost:3000/song/url/v1;
sub_filter '"url":"https://music.163.com' '"url":"/music/unblock';
sub_filter_types application/json;
sub_filter_once off;
}
location /api/netease/ {
proxy_buffers 16 64k;
proxy_buffer_size 128k;
@@ -26,4 +42,10 @@ server {
proxy_set_header X-NginX-Proxy true;
proxy_pass http://localhost:3000/;
}
location /music/unblock/ {
proxy_pass https://music.163.com/;
proxy_buffering off;
proxy_request_buffering off;
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "splayer",
"productName": "SPlayer",
"version": "3.0.0-beta.1",
"version": "3.0.0-beta.3",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
@@ -24,39 +24,39 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "npx rimraf dist && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:web": "npm run build",
"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 --config electron-builder.config.ts",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.config.ts",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.config.ts"
},
"dependencies": {
"@applemusic-like-lyrics/core": "^0.1.3",
"@applemusic-like-lyrics/lyric": "^0.2.4",
"@applemusic-like-lyrics/vue": "^0.1.5",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@pixi/app": "^7.4.2",
"@pixi/core": "^7.4.2",
"@pixi/display": "^7.4.2",
"@pixi/filter-blur": "^7.4.2",
"@neteaseapireborn/api": "^4.29.9",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
"@pixi/filter-blur": "^7.4.3",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
"@pixi/sprite": "^7.4.2",
"@vueuse/core": "^12.0.0",
"NeteaseCloudMusicApi": "^4.25.0",
"axios": "^1.7.9",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@vueuse/core": "^13.9.0",
"axios": "^1.12.2",
"change-case": "^5.4.4",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"electron-dl": "^4.0.0",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-updater": "^6.6.2",
"file-saver": "^2.0.5",
"font-list": "^1.5.1",
"font-list": "^2.0.1",
"get-port": "^7.1.0",
"github-markdown-css": "^5.8.1",
"howler": "^2.2.4",
@@ -65,59 +65,68 @@
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lodash-es": "^4.17.21",
"marked": "^14.1.4",
"marked": "^16.4.0",
"md5": "^2.3.0",
"music-metadata": "7.14.0",
"pinia": "^2.3.0",
"pinia-plugin-persistedstate": "^4.1.3",
"plyr": "^3.7.8",
"vue-virt-list": "^1.5.5"
"music-metadata": "^11.9.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"plyr": "^3.8.3",
"vue-virt-list": "^1.6.1"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@fastify/cookie": "^9.4.0",
"@fastify/http-proxy": "^9.5.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron-toolkit/utils": "^4",
"@fastify/cookie": "^11.0.2",
"@fastify/http-proxy": "^11.3.0",
"@fastify/multipart": "^9.2.1",
"@fastify/static": "^8.2.0",
"@types/file-saver": "^2.0.7",
"@types/howler": "^2.2.12",
"@types/js-cookie": "^3.0.6",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-vue": "^5.2.1",
"@types/node": "^24.7.2",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"@vitejs/plugin-vue": "^6.0.1",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
"electron": "^30.5.1",
"electron-builder": "^25.1.8",
"electron-log": "^5.2.4",
"electron-vite": "^2.3.0",
"eslint": "^9.16.0",
"eslint-plugin-vue": "^9.32.0",
"fast-glob": "^3.3.2",
"fastify": "^4.29.0",
"naive-ui": "^2.40.3",
"node-taglib-sharp": "^5.2.3",
"prettier": "^3.4.2",
"sass": "^1.82.0",
"terser": "^5.37.0",
"typescript": "5.6.2",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.5",
"vite": "^5.4.11",
"electron": "^38.2.2",
"electron-builder": "^26.0.12",
"electron-log": "^5.4.3",
"electron-vite": "^4.0.1",
"eslint": "^9.37.0",
"eslint-plugin-vue": "^10.5.0",
"fast-glob": "^3.3.3",
"fastify": "^5.6.1",
"naive-ui": "^2.43.1",
"node-taglib-sharp": "^6.0.1",
"prettier": "^3.6.2",
"sass": "^1.93.2",
"terser": "^5.44.0",
"typescript": "^5.9.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.9",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-wasm": "^3.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-tsc": "2.0.29"
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"vue-tsc": "^3.1.1"
},
"pnpm": {
"overrides": {
"dmg-builder": "25.1.8",
"electron-builder-squirrel-windows": "25.1.8"
}
"dmg-builder": "26.0.12",
"electron-builder-squirrel-windows": "26.0.12"
},
"onlyBuiltDependencies": [
"@applemusic-like-lyrics/lyric",
"@parcel/watcher",
"core-js",
"electron",
"esbuild",
"sharp",
"vue-demi"
]
}
}

5398
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/icons/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -65,6 +65,23 @@ export const songLyric = (id: number) => {
});
};
// 获取格式TTML的歌词
export const songLyricTTML = async (id: number) => {
const url = `https://amll-ttml-db.stevexmh.net/ncm/${id}`;
try {
const response = await fetch(url);
if (response === null || response.status !== 200) {
console.error(`TTML API请求失败或TTML仓库没有歌词, 将会使用默认歌词`);
return null;
}
const data = await response.text();
return data;
} catch (error) {
console.error('TTML API请求出错:', error);
return null;
}
}
/**
* 获取歌曲下载链接
* @param id 音乐 id

View File

@@ -4,6 +4,10 @@
"id": 3136952023,
"name": "私人雷达"
},
{
"id": 8402996200,
"name": "会员雷达"
},
{
"id": 5320167908,
"name": "时光雷达"

View File

@@ -176,11 +176,20 @@ const menuOptions = computed<MenuOption[] | MenuGroupOption[]>(() => {
children: [...likedPlaylist.value],
},
]
: [];
: [
{
key: "local",
link: "local",
label: "本地歌曲",
show: isElectron,
icon: renderIcon("FolderMusic"),
},
];
});
// 生成歌单列表
const renderPlaylist = (playlist: CoverType[], showCover: boolean) => {
if (!isLogin()) return [];
return playlist.map((playlist) => ({
key: playlist.id,
label: () =>

View File

@@ -363,7 +363,6 @@ const getListData = async (id: number): Promise<SongType[]> => {
&:hover {
background-color: rgba(var(--primary), 0.12);
.cover {
border-radius: 16px 16px 0 0;
.cover-img {
transform: scale(1.1);
filter: brightness(0.8);

View File

@@ -38,7 +38,7 @@ const openWeb = () => {
window.$dialog.info({
title: "使用前告知",
content:
"请知悉,该功能仍旧无法确保账号的安全性!请自行决定是否使用!如遇打开窗口后页面出现白屏或者无法点击等情况,请关闭后再试。在登录完成后,请点击菜单栏中的 “登录完成” 按钮以完成登录( 通常位于窗口的左上角macOS 位于顶部的全局菜单栏中 ",
"请知悉,该功能仍旧无法确保账号的安全性!请自行决定是否使用!如遇打开窗口后页面出现白屏或者无法点击等情况,请关闭后重试",
positiveText: "我已了解",
negativeText: "取消",
onPositiveClick: () => window.electron.ipcRenderer.send("open-login-web"),

View File

@@ -200,8 +200,8 @@ const getSongInfo = async () => {
name: common.title || "",
artist: common.artist || "",
album: common.album || "",
alia: common.comment?.[0] || "",
lyric: common.lyrics?.[0] || "",
alia: (common.comment?.[0] as string) || "",
lyric: (common.lyrics?.[0] as unknown as string) || "",
type: format.codec,
duration: format.duration ? Number(format.duration.toFixed(2)) : 0,
size: fileSize,
@@ -212,7 +212,7 @@ const getSongInfo = async () => {
// 获取封面
const coverBuff = common.picture?.[0]?.data || "";
const coverType = common.picture?.[0]?.format || "";
if (coverBuff) coverData.value = blob.createBlobURL(coverBuff, coverType, path);
if (coverBuff) coverData.value = blob.createBlobURL(coverBuff as Buffer, coverType, path);
};
// 在线匹配
@@ -262,7 +262,7 @@ const onlineMatch = debounce(
const changeCover = async () => {
const newPath = await window.electron.ipcRenderer.invoke("choose-image");
if (!newPath) return;
coverData.value = newPath;
coverData.value = `file://${newPath}`;
};
// 实时修改列表
@@ -300,7 +300,9 @@ const saveSongInfo = debounce(async (song: SongType) => {
cover:
coverData.value.startsWith("blob:") || coverData.value === "/images/song.jpg?assest"
? null
: coverData.value,
: coverData.value.startsWith("file://")
? coverData.value.replace(/^file:\/\//, "")
: coverData.value,
};
console.log(song.path, metadata);
await window.electron.ipcRenderer.invoke("set-music-metadata", song.path, metadata);

View File

@@ -1,27 +1,17 @@
<template>
<Transition>
<div
:key="amLyricsData?.[0]?.startTime"
:class="['lyric-am', { pure: statusStore.pureLyricMode }]"
>
<LyricPlayer
ref="lyricPlayerRef"
:lyricLines="amLyricsData"
:currentTime="playSeek"
:playing="statusStore.playStatus"
:enableSpring="settingStore.useAMSpring"
<div :key="amLyricsData?.[0]?.startTime" :class="['lyric-am', { pure: statusStore.pureLyricMode }]">
<LyricPlayer ref="lyricPlayerRef" :lyricLines="amLyricsData" :currentTime="playSeek"
:playing="statusStore.playStatus" :enableSpring="settingStore.useAMSpring"
:enableScale="settingStore.useAMSpring"
:alignPosition="settingStore.lyricsScrollPosition === 'center' ? 0.5 : 0.2"
:enableBlur="settingStore.lyricsBlur"
:style="{
:enableBlur="settingStore.lyricsBlur" :style="{
'--amll-lyric-view-color': mainColor,
'--amll-lyric-player-font-size': settingStore.lyricFontSize + 'px',
'--ja-font-family': settingStore.japaneseLyricFont !== 'follow' ? settingStore.japaneseLyricFont : '',
'font-weight': settingStore.lyricFontBold ? 'bold' : 'normal',
'font-family': settingStore.LyricFont !== 'follow' ? settingStore.LyricFont : '',
}"
class="am-lyric"
@line-click="jumpSeek"
/>
}" class="am-lyric" @line-click="jumpSeek" />
</div>
</Transition>
</template>
@@ -31,7 +21,9 @@ import { LyricPlayer } from "@applemusic-like-lyrics/vue";
import { LyricLine } from "@applemusic-like-lyrics/core";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { msToS } from "@/utils/time";
import { getLyricLanguage } from "@/utils/lyric";
import player from "@/utils/player";
import { watch } from "vue";
const musicStore = useMusicStore();
const statusStore = useStatusStore();
@@ -54,10 +46,39 @@ const mainColor = computed(() => {
return `rgb(${statusStore.mainColor})`;
});
// 检查是否为纯音乐歌词
const isPureInstrumental = (lyrics: LyricLine[]): boolean => {
if (!lyrics || lyrics.length === 0) return false;
const instrumentalKeywords = ['纯音乐', 'instrumental', '请欣赏'];
if (lyrics.length === 1) {
const content = lyrics[0].words?.[0]?.word || '';
return instrumentalKeywords.some(keyword => content.toLowerCase().includes(keyword.toLowerCase()));
}
if (lyrics.length <= 3) {
const allContent = lyrics.map(line => line.words?.[0]?.word || '').join('');
return instrumentalKeywords.some(keyword => allContent.toLowerCase().includes(keyword.toLowerCase()));
}
return false;
};
// 当前歌词
const amLyricsData = computed<LyricLine[]>(() => {
const isYrc = musicStore.songLyric.yrcData?.length && settingStore.showYrc;
return isYrc ? musicStore.songLyric.yrcAMData : musicStore.songLyric.lrcAMData;
const { songLyric } = musicStore;
if (!songLyric) return [];
// 优先使用逐字歌词(YRC/TTML)
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData;
// 简单检查歌词有效性
if (!Array.isArray(lyrics) || lyrics.length === 0) return [];
// 检查是否为纯音乐
if (isPureInstrumental(lyrics)) return [];
return lyrics;
});
// 进度跳转
@@ -68,9 +89,30 @@ const jumpSeek = (line: any) => {
player.play();
};
// 处理歌词语言
const processLyricLanguage = () => {
const lyricLinesEl = lyricPlayerRef.value?.lyricPlayer?.lyricLinesEl ?? [];
// 遍历歌词行
for (let e of lyricLinesEl) {
// 获取歌词行内容 (合并逐字歌词为一句)
const content = e.lyricLine.words.map((word: any) => word.word).join("");
// 获取歌词语言
const lang = getLyricLanguage(content);
// 为主歌词设置 lang 属性 (firstChild 获取主歌词 不为翻译和音译设置属性)
e.element.firstChild.setAttribute("lang", lang);
}
};
// 切换歌曲时处理歌词语言
watch(amLyricsData, () => {
nextTick(() => processLyricLanguage());
});
onMounted(() => {
// 恢复进度
resumeSeek();
// 处理歌词语言
nextTick(() => processLyricLanguage());
});
onBeforeUnmount(() => {
@@ -85,15 +127,14 @@ onBeforeUnmount(() => {
height: 100%;
overflow: hidden;
filter: drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.2));
mask: linear-gradient(
180deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0)
);
mask: linear-gradient(180deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0));
:deep(.am-lyric) {
width: 100%;
height: 100%;
@@ -104,15 +145,22 @@ onBeforeUnmount(() => {
padding-right: 80px;
margin-left: -2rem;
}
&.pure {
text-align: center;
:deep(.am-lyric) {
margin: 0;
padding: 0 80px;
div {
transform-origin: center;
}
}
}
:lang(ja) {
font-family: var(--ja-font-family);
}
}
</style>

View File

@@ -5,6 +5,8 @@
'--lrc-tran-size': settingStore.lyricTranFontSize + 'px',
'--lrc-roma-size': settingStore.lyricRomaFontSize + 'px',
'--lrc-bold': settingStore.lyricFontBold ? 'bold' : 'normal',
'--ja-font-family':
settingStore.japaneseLyricFont !== 'follow' ? settingStore.japaneseLyricFont : '',
'font-family': settingStore.LyricFont !== 'follow' ? settingStore.LyricFont : '',
cursor: statusStore.playerMetaShow ? 'auto' : 'none',
}"
@@ -41,7 +43,19 @@
v-for="(item, index) in musicStore.songLyric.yrcData"
:key="index"
:id="`lrc-${index}`"
:class="['lrc-line', 'is-yrc', { on: statusStore.lyricIndex === index }]"
:class="[
'lrc-line',
'is-yrc',
{
// on: statusStore.lyricIndex === index,
// 当播放时间大于等于当前歌词的开始时间
on:
(playSeek >= item.time && playSeek < item.endTime) ||
statusStore.lyricIndex === index,
'is-bg': item.isBG,
'is-duet': item.isDuet,
},
]"
:style="{
filter: settingStore.lyricsBlur
? `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)`
@@ -60,16 +74,26 @@
'end-with-space': text.endsWithSpace,
}"
>
<span class="word">{{ text.content }}</span>
<span class="filler" :style="getYrcStyle(text, index)">
<span class="word" :lang="getLyricLanguage(text.content)">
{{ text.content }}
</span>
<span
class="filler"
:style="getYrcStyle(text, index)"
:lang="getLyricLanguage(text.content)"
>
{{ text.content }}
</span>
</div>
</div>
<!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran">{{ item.tran }}</span>
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en">
{{ item.tran }}
</span>
<!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma">{{ item.roma }}</span>
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }}
</span>
<!-- 倒计时 -->
<div
v-if="
@@ -113,11 +137,15 @@
@click="jumpSeek(item.time)"
>
<!-- 歌词 -->
<span class="content">{{ item.content }}</span>
<span class="content" :lang="getLyricLanguage(item.content)">{{ item.content }}</span>
<!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran">{{ item.tran }}</span>
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en">
{{ item.tran }}
</span>
<!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma">{{ item.roma }}</span>
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }}
</span>
</div>
<div class="placeholder" />
</template>
@@ -151,6 +179,7 @@ import { NScrollbar } from "naive-ui";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { openSetting } from "@/utils/modal";
import player from "@/utils/player";
import { getLyricLanguage } from "@/utils/lyric";
const musicStore = useMusicStore();
const statusStore = useStatusStore();
@@ -200,12 +229,12 @@ const lyricsScroll = (index: number) => {
const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
if (settingStore.showYrcAnimation) {
// 如果当前歌词索引与播放歌曲的歌词索引不匹配
if (statusStore.lyricIndex !== lyricIndex) {
return {
transitionDuration: `0ms, 0ms, 0.35s`,
transitionDelay: `0ms`,
};
}
// if (statusStore.lyricIndex !== lyricIndex) {
// return {
// transitionDuration: `0ms, 0ms, 0.35s`,
// transitionDelay: `0ms`,
// };
// }
// 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0
if (
statusStore.playLoading === false &&
@@ -217,11 +246,6 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
WebkitMaskPositionX: `${
100 - Math.max(((playSeek.value - wordData.time) / wordData.duration) * 100, 0)
}%`,
// 最大上移2px
// transform: `translateY(-${Math.min(
// ((playSeek.value - wordData.time) / wordData.duration) * 2,
// 2,
// )}px)`,
};
}
// 如果以上条件都不满足
@@ -230,7 +254,6 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
transitionDelay: `${wordData.time - playSeek.value}ms, ${
wordData.time - playSeek.value + wordData.duration * 0.5
}ms, 0ms`,
// transform: "translateY(-2px)",
};
} else {
// 如果当前歌词索引与播放歌曲的歌词索引不匹配,或者播放状态不是加载中且当前单词的时间大于等于播放进度
@@ -299,6 +322,8 @@ onBeforeUnmount(() => {
:deep(.n-scrollbar-content) {
padding-left: 10px;
padding-right: 80px;
max-width: 100%; /* 新增:防止宽度溢出 */
box-sizing: border-box; /* 新增:确保 padding 不影响宽度 */
}
.placeholder {
width: 100%;
@@ -315,6 +340,7 @@ onBeforeUnmount(() => {
.lyric-content {
width: 100%;
height: 100%;
box-sizing: border-box; /* 新增:确保宽度计算正确 */
}
.lrc-line {
position: relative;
@@ -324,21 +350,29 @@ onBeforeUnmount(() => {
padding: 10px 16px;
transform: scale(0.86);
transform-origin: left center;
will-change: filter, opacity, transform;
transition:
filter 0.35s,
opacity 0.35s,
transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
cursor: pointer;
// 歌词
width: 100%;
box-sizing: border-box; /* 新增:确保 padding 不影响宽度 */
.content {
display: block;
font-size: var(--lrc-size);
font-weight: var(--lrc-bold);
word-wrap: break-word;
// 逐字歌词
width: 100%;
overflow-wrap: anywhere; /* 支持超长单词换行 */
word-break: break-word; /* 优先空格或连字符换行,超长单词强制换行 */
white-space: normal; /* 新增:明确文本换行行为 */
hyphens: auto; /* 英文自动连字符 */
.content-text {
position: relative;
display: inline-block;
overflow-wrap: anywhere; /* 新增:逐字歌词单词支持换行 */
word-break: break-word; /* 新增:单词内换行 */
white-space: normal; /* 新增:确保逐字歌词换行 */
.word {
opacity: 0.3;
display: inline-block;
@@ -384,22 +418,32 @@ onBeforeUnmount(() => {
}
}
}
&:lang(ja) {
font-family: var(--ja-font-family);
}
}
// 翻译
.tran {
margin-top: 8px;
opacity: 0.6;
font-size: var(--lrc-tran-size);
transition: opacity 0.35s;
width: 100%;
overflow-wrap: anywhere; /* 支持超长单词换行 */
word-break: break-word; /* 优先空格或连字符换行,超长单词强制换行 */
white-space: normal; /* 新增:明确文本换行行为 */
hyphens: auto; /* 英文自动连字符 */
}
// 音译
.roma {
margin-top: 4px;
opacity: 0.5;
font-size: var(--lrc-roma-size);
transition: opacity 0.35s;
width: 100%;
overflow-wrap: anywhere; /* 支持超长单词换行 */
word-break: break-word; /* 优先空格或连字符换行,超长单词强制换行 */
white-space: normal; /* 新增:明确文本换行行为 */
hyphens: auto; /* 英文自动连字符 */
}
// 倒计时
.count-down-content {
height: 50px;
margin-top: 40px;
@@ -415,16 +459,33 @@ onBeforeUnmount(() => {
.content {
display: flex;
flex-wrap: wrap;
width: 100%;
overflow-wrap: anywhere; /* 逐字歌词支持超长单词换行 */
word-break: break-word; /* 优先空格或连字符换行 */
white-space: normal; /* 确保换行行为 */
}
.tran,
.roma {
opacity: 0.3;
}
&.is-bg {
opacity: 0.4;
transform: scale(0.5);
padding: 0px 32px;
}
&.is-duet {
transform-origin: right;
.content,
.tran,
.roma {
text-align: right;
justify-content: flex-end;
}
}
}
&.on {
opacity: 1;
opacity: 1 !important;
transform: scale(1);
// 逐字歌词
.content-text {
.filler {
opacity: 1;
@@ -437,6 +498,9 @@ onBeforeUnmount(() => {
.roma {
opacity: 0.6;
}
&.is-bg {
opacity: 0.6 !important;
}
}
&::before {
content: "";
@@ -541,7 +605,6 @@ onBeforeUnmount(() => {
}
.lrc-line {
transform-origin: right;
align-items: flex-end;
.content {
text-align: right;
}
@@ -561,7 +624,6 @@ onBeforeUnmount(() => {
}
.lrc-line {
transform-origin: center !important;
align-items: center !important;
.content {
text-align: center !important;
}
@@ -574,6 +636,8 @@ onBeforeUnmount(() => {
&.pure {
:deep(.n-scrollbar-content) {
padding: 0 80px;
max-width: 100%; /* 新增:防止宽度溢出 */
box-sizing: border-box; /* 新增:确保 padding 不影响宽度 */
}
.lyric-content {
.placeholder {

View File

@@ -60,6 +60,7 @@ const { start: dynamicCoverStart, stop: dynamicCoverStop } = useTimeoutFn(
const getDynamicCover = async () => {
if (
isLogin() !== 1 ||
musicStore.playSong.path ||
!musicStore.playSong.id ||
!settingStore.dynamicCover ||
settingStore.playerType !== "cover"

View File

@@ -28,7 +28,7 @@
/>
</Transition>
<!-- 默认内容 -->
<SearchDefault @to-search="toSearch" />
<SearchDefault v-if="settingStore.useOnlineService" @to-search="toSearch" />
<!-- 搜索结果 -->
<SearchSuggest @to-search="toSearch" />
<!-- 右键菜单 -->
@@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import { useStatusStore, useDataStore } from "@/stores";
import { useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { searchDefault } from "@/api/search";
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
import player from "@/utils/player";
@@ -47,13 +47,16 @@ import { formatSongsList } from "@/utils/format";
const router = useRouter();
const dataStore = useDataStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 右键菜单
const searchInpMenuRef = ref<InstanceType<typeof SearchInpMenu> | null>(null);
// 搜索框数据
const searchInputRef = ref<HTMLInputElement | null>(null);
const searchPlaceholder = ref<string>("搜索音乐 / 视频");
const searchPlaceholder = ref<string>(
settingStore.useOnlineService ? "搜索音乐 / 视频" : "搜索本地音乐",
);
const searchRealkeyword = ref<string>("");
// 搜索框输入限制
@@ -94,14 +97,23 @@ const updatePlaceholder = async () => {
// 前往搜索
const toSearch = async (key: any, type: string = "keyword") => {
// 关闭搜索框
statusStore.searchFocus = false;
searchInputRef.value?.blur();
// 未输入内容且不存在推荐
if (!key && searchPlaceholder.value === "搜索音乐 / 视频") return;
if (!key && searchPlaceholder.value !== "搜索音乐 / 视频" && searchRealkeyword.value) {
key = searchRealkeyword.value?.trim();
}
// 关闭搜索
statusStore.searchFocus = false;
searchInputRef.value?.blur();
// 本地搜索
if (!settingStore.useOnlineService) {
// 跳转本地搜索页面
router.push({
name: "search",
query: { keyword: key },
});
return;
}
// 更新推荐
updatePlaceholder();
// 前往搜索
@@ -143,9 +155,10 @@ const toSearch = async (key: any, type: string = "keyword") => {
};
onMounted(() => {
updatePlaceholder();
// 每分钟更新
useIntervalFn(updatePlaceholder, 60 * 1000);
if (settingStore.useOnlineService) {
useIntervalFn(updatePlaceholder, 60 * 1000, { immediate: true });
}
});
</script>

View File

@@ -27,7 +27,11 @@
<!-- 搜索建议 -->
<Transition name="fade" mode="out-in" @after-leave="calcSearchSuggestHeights">
<div
v-if="Object.keys(searchSuggestData)?.length && searchSuggestData?.order"
v-if="
Object.keys(searchSuggestData)?.length &&
searchSuggestData?.order &&
settingStore.useOnlineService
"
ref="searchSuggestRef"
class="all-suggest"
>
@@ -60,13 +64,14 @@
<script setup lang="ts">
import { searchSuggest } from "@/api/search";
import { useStatusStore } from "@/stores";
import { useStatusStore, useSettingStore } from "@/stores";
const emit = defineEmits<{
toSearch: [key: number | string, type: string];
}>();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 搜索建议数据
const searchSuggestData = ref<any>({});
@@ -125,7 +130,7 @@ const calcSearchSuggestHeights = () => {
watchDebounced(
() => statusStore.searchInputValue,
(val) => {
if (!val || val === "") return;
if (!val || val === "" || !settingStore.useOnlineService) return;
getSearchSuggest(val);
},
{ debounce: 300 },

View File

@@ -32,7 +32,7 @@
</n-tag>
<n-text :depth="3" class="time">{{ newVersion?.time }}</n-text>
</n-flex>
<div class="markdown-body" v-html="newVersion?.changelog" />
<div class="markdown-body" v-html="newVersion?.changelog" @click="jumpLink" />
</n-card>
</n-collapse-transition>
</div>
@@ -53,7 +53,7 @@
</n-tag>
<n-text :depth="3" class="time">{{ item?.time }}</n-text>
</n-flex>
<div class="markdown-body" v-html="item?.changelog" />
<div class="markdown-body" v-html="item?.changelog" @click="jumpLink" />
</n-card>
</n-collapse-item>
</n-collapse>
@@ -126,6 +126,16 @@ const checkUpdate = debounce(
{ leading: true, trailing: false },
);
// 链接跳转
const jumpLink = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName !== "A") {
return;
}
e.preventDefault();
openLink((target as HTMLAnchorElement).href);
};
// 获取更新日志
const getUpdateData = async () => (updateData.value = await getUpdateLog());

View File

@@ -142,13 +142,7 @@
<n-text class="name">在线服务</n-text>
<n-text class="tip" :depth="3">是否开启软件的在线服务</n-text>
</div>
<n-switch
class="set"
:disabled="true"
:value="useOnlineService"
:round="false"
@update:value="modeChange"
/>
<n-switch class="set" :value="useOnlineService" :round="false" @update:value="modeChange" />
</n-card>
<n-card class="set-item">
<div class="label">
@@ -197,6 +191,33 @@
/>
</n-flex>
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">日语歌词字体</n-text>
<n-text class="tip" :depth="3"> 是否在歌词为日语时单独设置字体 </n-text>
</div>
<n-flex>
<Transition name="fade" mode="out-in">
<n-button
v-if="settingStore.japaneseLyricFont !== 'follow'"
type="primary"
strong
secondary
@click="settingStore.japaneseLyricFont = 'follow'"
>
恢复默认
</n-button>
</Transition>
<n-select
v-model:value="settingStore.japaneseLyricFont"
:options="[
{ label: '跟随全局', value: 'follow' },
...allFontsData.filter((v) => v.value !== 'default'),
]"
class="set"
/>
</n-flex>
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">关闭软件时</n-text>
@@ -256,12 +277,13 @@
<script setup lang="ts">
import type { SelectOption } from "naive-ui";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { isElectron } from "@/utils/helper";
import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { isDev, isElectron } from "@/utils/helper";
import { isEmpty } from "lodash-es";
import themeColor from "@/assets/data/themeColor.json";
import player from "@/utils/player";
const dataStore = useDataStore();
const musicStore = useMusicStore();
const settingStore = useSettingStore();
const statusStore = useStatusStore();
@@ -319,26 +341,40 @@ const modeChange = (val: boolean) => {
if (val) {
window.$dialog.warning({
title: "开启在线服务",
content: "确定开启软件的在线服务?更改将在重启后生效!",
content: "确定开启软件的在线服务?更改将在热重载后生效!",
positiveText: "开启",
negativeText: "取消",
onPositiveClick: () => {
useOnlineService.value = true;
settingStore.useOnlineService = true;
// 清理播放数据
dataStore.$reset();
musicStore.$reset();
// 清空本地数据
localStorage.removeItem("data-store");
localStorage.removeItem("music-store");
// 热重载
window.location.reload();
},
});
} else {
window.$dialog.warning({
title: "关闭在线服务",
content:
"确定关闭软件的在线服务?将关闭包括搜索、登录、在线音乐播放等在内的全部在线服务,软件将会变为本地播放器!更改将在软件重启后生效!",
"确定关闭软件的在线服务?将关闭包括搜索、登录、在线音乐播放等在内的全部在线服务,并且将会退出登录状态,软件将会变为本地播放器!更改将在重启后生效!",
positiveText: "关闭",
negativeText: "取消",
onPositiveClick: () => {
useOnlineService.value = false;
settingStore.useOnlineService = false;
// 清理播放数据
dataStore.$reset();
musicStore.$reset();
// 清空本地数据
localStorage.removeItem("data-store");
localStorage.removeItem("music-store");
// 重启
window.electron.ipcRenderer.send("win-reload");
if (!isDev) window.electron.ipcRenderer.send("win-reload");
},
onNegativeClick: () => {
useOnlineService.value = true;

View File

@@ -10,6 +10,12 @@
</div>
<n-switch class="set" v-model:value="settingStore.showLocalCover" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">显示本地默认歌曲目录</n-text>
</div>
<n-switch class="set" v-model:value="settingStore.showDefaultLocalPath" :round="false" />
</n-card>
<n-card class="set-item" id="local-list-choose" content-style="flex-direction: column">
<n-flex justify="space-between">
<div class="label">

View File

@@ -29,7 +29,7 @@
<n-card class="set-item">
<div class="label">
<n-text class="name">歌词字体大小</n-text>
<n-text class="tip" :depth="3">单位 px最小 30最大 60</n-text>
<n-text class="tip" :depth="3">单位 px最小 12最大 60</n-text>
</div>
<n-flex>
<Transition name="fade" mode="out-in">
@@ -45,7 +45,7 @@
</Transition>
<n-input-number
v-model:value="settingStore.lyricFontSize"
:min="30"
:min="12"
:max="60"
class="set"
placeholder="请输入歌词字体大小"
@@ -58,7 +58,7 @@
<n-card class="set-item">
<div class="label">
<n-text class="name">翻译歌词大小</n-text>
<n-text class="tip" :depth="3">单位 px最小 12最大 40</n-text>
<n-text class="tip" :depth="3">单位 px最小 5最大 40</n-text>
</div>
<n-flex>
<Transition name="fade" mode="out-in">
@@ -74,7 +74,7 @@
</Transition>
<n-input-number
v-model:value="settingStore.lyricTranFontSize"
:min="12"
:min="5"
:max="40"
:disabled="settingStore.useAMLyrics"
class="set"
@@ -90,7 +90,7 @@
<n-card class="set-item">
<div class="label">
<n-text class="name">音译歌词大小</n-text>
<n-text class="tip" :depth="3">单位 px最小 12最大 40</n-text>
<n-text class="tip" :depth="3">单位 px最小 5最大 40</n-text>
</div>
<n-flex>
<Transition name="fade" mode="out-in">
@@ -106,7 +106,7 @@
</Transition>
<n-input-number
v-model:value="settingStore.lyricRomaFontSize"
:min="12"
:min="5"
:max="40"
:disabled="settingStore.useAMLyrics"
class="set"
@@ -260,6 +260,15 @@
</div>
<n-switch v-model:value="settingStore.useAMSpring" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">启用 TTML 歌词</n-text>
<n-text class="tip" :depth="3">
是否启用 TTML 歌词如有TTML 歌词支持逐字翻译音译等功能, 将会在下一首歌生效
</n-text>
</div>
<n-switch v-model:value="settingStore.enableTTMLLyric" class="set" :round="false" />
</n-card>
</div>
<div v-if="isElectron" class="set-list">
<n-h3 prefix="bar"> 桌面歌词 </n-h3>
@@ -285,8 +294,8 @@
:options="
Array.from({ length: 41 }, (_, i) => {
return {
label: `${20 + i} px`,
value: 20 + i,
label: `${10 + i} px`,
value: 10 + i,
};
})
"

View File

@@ -73,13 +73,6 @@
</div>
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">听歌打卡</n-text>
<n-text class="tip" :depth="3">是否将播放歌曲同步至网易云音乐</n-text>
</div>
<n-switch v-model:value="settingStore.scrobbleSong" class="set" :round="false" />
</n-card>
<n-card v-if="isElectron" class="set-item">
<div class="label">
<n-text class="name">音频输出设备</n-text>
@@ -189,7 +182,13 @@
<n-card class="set-item">
<div class="label">
<n-text class="name">音乐频谱</n-text>
<n-text class="tip" :depth="3">开启音乐频谱会极大影响性能如遇问题请关闭</n-text>
<n-text class="tip" :depth="3">
{{
isElectron
? "开启音乐频谱会影响性能或音频输出切换等功能,如遇问题请关闭"
: "开启可能会造成无法播放或其他问题,如遇任何问题请关闭"
}}
</n-text>
</div>
<n-switch
class="set"
@@ -253,30 +252,45 @@ const songLevelData = {
value: "higher",
},
exhigh: {
label: "极高 HQ",
tip: "近 CD 品质的细节体验,最高 320kbps",
label: "极高 (HQ)",
tip: "近CD品质的细节体验最高320kbps",
value: "exhigh",
},
lossless: {
label: "无损 SQ",
tip: "高保真无损音质,最高 48kHz/16bit",
label: "无损 (SQ)",
tip: "高保真无损音质最高48kHz/16bit",
value: "lossless",
},
hires: {
label: "高清臻音 Spatial Audio",
tip: "环绕声体验声音听感增强96kHz/24bit",
label: "高解析度无损 (Hi-Res)",
tip: "更饱满清晰的高解析度音质最高192kHz/24bit",
value: "hires",
},
jyeffect: {
label: "高清臻音 (Spatial Audio)",
tip: "声音听感增强96kHz/24bit",
value: "jyeffect",
},
jymaster: {
label: "超清母带 Master",
label: "超清母带 (Master)",
tip: "还原音频细节192kHz/24bit",
value: "jymaster",
},
sky: {
label: "沉浸环绕声 Surround Audio",
tip: "沉浸式体验,最高 5.1 声道",
label: "沉浸环绕声 (Surround Audio)",
tip: "沉浸式空间环绕音感最高5.1声道",
value: "sky",
},
vivid: {
label: "臻音全景声 (Audio Vivid)",
tip: "极致沉浸三维空间音频最高7.1.4声道",
value: "vivid",
},
dolby: {
label: "杜比全景声 (Dolby Atmos)",
tip: "杜比全景声音乐,沉浸式聆听体验",
value: "dolby",
},
};
// 获取全部输出设备

View File

@@ -8,7 +8,7 @@
</Transition>
<!-- 真实图片 -->
<img
v-if="src"
v-if="imgSrc"
ref="imgRef"
:src="imgSrc"
:key="imgSrc"
@@ -69,10 +69,27 @@ const imageError = (e: Event) => {
};
// 可视状态变化
watchOnce(isCanLook, (show) => {
emit("update:show", show);
if (show) imgSrc.value = props.src;
});
watch(
isCanLook,
(show) => {
emit("update:show", show);
if (show) imgSrc.value = props.src;
},
{ immediate: true },
);
// 监听 src 变化
watch(
() => props.src,
(val) => {
isLoaded.value = false;
if (isCanLook.value) {
imgSrc.value = val;
} else {
imgSrc.value = undefined;
}
},
);
</script>
<style lang="scss" scoped>

View File

@@ -15,6 +15,7 @@ import { formatCategoryList } from "@/utils/format";
interface ListState {
playList: SongType[];
originalPlayList: SongType[];
historyList: SongType[];
cloudPlayList: SongType[];
searchHistory: string[];
@@ -54,6 +55,8 @@ export const useDataStore = defineStore("data", {
state: (): ListState => ({
// 播放列表
playList: [],
// 原始播放列表
originalPlayList: [],
// 播放历史
historyList: [],
// 搜索历史
@@ -157,6 +160,29 @@ export const useDataStore = defineStore("data", {
throw error;
}
},
// 保存原始播放列表
async setOriginalPlayList(data: SongType[]): Promise<void> {
const snapshot = cloneDeep(data);
this.originalPlayList = snapshot;
await musicDB.setItem("originalPlayList", snapshot);
},
// 获取原始播放列表
async getOriginalPlayList(): Promise<SongType[] | null> {
if (Array.isArray(this.originalPlayList) && this.originalPlayList.length > 0) {
return this.originalPlayList;
}
const data = (await musicDB.getItem("originalPlayList")) as SongType[] | null;
if (Array.isArray(data) && data.length > 0) {
this.originalPlayList = data;
return data;
}
return null;
},
// 清除原始播放列表
async clearOriginalPlayList(): Promise<void> {
this.originalPlayList = [];
await musicDB.setItem("originalPlayList", []);
},
// 新增下一首播放歌曲
async setNextPlaySong(song: SongType, index: number): Promise<number> {
// 若为空,则直接添加

View File

@@ -20,6 +20,7 @@ interface SettingState {
themeFollowCover: boolean;
globalFont: "default" | string;
LyricFont: "follow" | string;
japaneseLyricFont: "follow" | string;
showCloseAppTip: boolean;
closeAppMethod: "exit" | "hide";
showTaskbarProgress: boolean;
@@ -73,6 +74,7 @@ interface SettingState {
showSearchHistory: boolean;
useAMLyrics: boolean;
useAMSpring: boolean;
enableTTMLLyric: boolean;
menuShowCover: boolean;
preventSleep: boolean;
localFilesPath: string[];
@@ -86,6 +88,7 @@ interface SettingState {
dynamicCover: boolean;
useKeepAlive: boolean;
excludeKeywords: string[];
showDefaultLocalPath: boolean;
}
export const useSettingStore = defineStore("setting", {
@@ -98,6 +101,7 @@ export const useSettingStore = defineStore("setting", {
themeGlobalColor: false, // 全局着色
globalFont: "default", // 全局字体
LyricFont: "follow", // 歌词区域字体
japaneseLyricFont: "follow", // 日语歌词字体
hideVipTag: false, // 隐藏 VIP 标签
showSearchHistory: true, // 显示搜索历史
menuShowCover: true, // 菜单显示封面
@@ -137,6 +141,7 @@ export const useSettingStore = defineStore("setting", {
lyricFontBold: true, // 歌词字体加粗
useAMLyrics: false, // 是否使用 AM 歌词
useAMSpring: false, // 是否使用 AM 歌词弹簧效果
enableTTMLLyric: true, // 启用 TTML 歌词
showYrc: true, // 显示逐字歌词
showYrcAnimation: true, // 显示逐字歌词动画
showTran: true, // 显示歌词翻译
@@ -148,6 +153,7 @@ export const useSettingStore = defineStore("setting", {
excludeKeywords: keywords, // 排除歌词关键字
// 本地
localFilesPath: [],
showDefaultLocalPath: true, // 显示默认本地路径
localSeparators: ["/", "&"],
showLocalCover: true,
// 下载

109
src/types/amll.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
/**
* AMLL (Apple Music-like Lyrics) 相关类型定义
*/
/**
* 歌词播放器引用类型
*/
export interface LyricPlayerRef {
setCurrentTime?: (time: number) => void;
setPlaying?: (playing: boolean) => void;
lyricPlayer?: { value?: any };
}
/**
* 歌词单词类型
*/
export interface LyricWord {
word: string;
startTime: number;
endTime: number;
}
/**
* 歌词行类型
*/
export interface LyricLine {
startTime: number;
endTime: number;
words: LyricWord[];
translatedLyric?: string;
romanLyric?: string;
isBG?: boolean;
isDuet?: boolean;
}
/**
* 歌词点击事件类型
*/
export interface LyricClickEvent {
line: {
getLine: () => LyricLine;
lyricLine?: LyricLine;
};
}
/**
* 弹簧参数类型
*/
export interface SpringParam {
mass: number; // 质量,影响弹簧的惯性
damping: number; // 阻尼,影响弹簧的减速速度
stiffness: number; // 刚度,影响弹簧的弹力
soft: boolean; // 是否使用软弹簧模式
}
/**
* 弹簧参数集合
*/
export interface SpringParams {
posX?: SpringParam;
posY?: SpringParam;
scale?: SpringParam;
rotation?: SpringParam;
}
/**
* 背景渲染器引用类型
*/
export interface BackgroundRenderRef {
bgRender: any;
wrapperEl?: HTMLDivElement;
}
/**
* 背景渲染器属性类型
*/
export interface BackgroundRenderProps {
album?: string;
albumIsVideo?: boolean;
fps?: number;
playing?: boolean;
flowSpeed?: number;
hasLyric?: boolean;
lowFreqVolume?: number;
renderScale?: number;
staticMode?: boolean;
renderer?: any;
}
/**
* 歌词处理器设置类型
*/
export interface LyricsProcessorSettings {
showYrc: boolean;
showRoma: boolean;
showTransl: boolean;
}
/**
* 歌曲歌词类型
*/
export interface SongLyric {
lrc?: Array<{time: number, content: string, tran?: string, roma?: string}>;
yrc?: Array<{time: number, endTime?: number, content: any[], tran?: string, roma?: string}>;
ttml?: string;
hasYrc?: boolean;
lrcAMData?: LyricLine[];
yrcAMData?: LyricLine[];
}

8
src/types/main.d.ts vendored
View File

@@ -131,10 +131,18 @@ export type LyricContentType = {
};
export type LyricType = {
/** 歌词开始时间 */
time: number;
/** 歌词结束时间 */
endTime: number;
/** 翻译歌词 */
tran?: string;
/** 音译歌词 */
roma?: string;
/** 是否为背景歌词 */
isBG?: boolean;
/** 是否为对唱歌词 */
isDuet?: boolean;
content: string;
contents: LyricContentType[];
};

View File

@@ -29,6 +29,7 @@ import { radioSub } from "@/api/radio";
*/
export const isLogin = (): 0 | 1 | 2 => {
const dataStore = useDataStore();
if (!dataStore.userLoginStatus) return 0;
if (dataStore.loginType === "uid") return 2;
return getCookie("MUSIC_U") ? 1 : 0;
};

View File

@@ -18,7 +18,7 @@ class BlobURLManager {
// console.log("🌱 Blob URL already exists:", key);
return this.blobURLs.get(key)!;
}
const blob = new Blob([data], { type: format });
const blob = new Blob([new Uint8Array(data)], { type: format });
const blobURL = URL.createObjectURL(blob);
// 存储 Blob URL
this.blobURLs.set(key, blobURL);

View File

@@ -273,9 +273,11 @@ export const changeLocalPath = async (delIndex?: number) => {
// 检查是否为子文件夹
const defaultMusicPath = await window.electron.ipcRenderer.invoke("get-default-dir", "music");
const allPath = [defaultMusicPath, ...settingStore.localFilesPath];
const isSubfolder = allPath.some((existingPath) => {
return selectedDir.startsWith(existingPath);
});
const isSubfolder = await window.electron.ipcRenderer.invoke(
"check-if-subfolder",
allPath,
selectedDir,
);
if (!isSubfolder) {
settingStore.localFilesPath.push(selectedDir);
} else {

View File

@@ -1,4 +1,4 @@
import { LyricLine, parseLrc, parseYrc } from "@applemusic-like-lyrics/lyric";
import { LyricLine, parseLrc, parseYrc, TTMLLyric } from "@applemusic-like-lyrics/lyric";
import type { LyricType } from "@/types/main";
import { useMusicStore, useSettingStore } from "@/stores";
import { msToS } from "./time";
@@ -207,3 +207,108 @@ const parseAMData = (lrcData: LyricLine[], tranData?: LyricLine[], romaData?: Ly
isDuet: line.isDuet ?? false,
}));
};
/**
* 从TTML格式解析歌词并转换为AMLL格式
* @param ttmlContent TTML格式的歌词内容
* @returns AMLL格式的歌词行数组
*/
export const parseTTMLToAMLL = (ttmlContent: TTMLLyric): LyricLine[] => {
if (!ttmlContent) return [];
try {
const validLines = ttmlContent.lines
.filter((line): line is any => line && typeof line === "object" && Array.isArray(line.words))
.map((line) => {
const words = line.words
.filter((word: any) => word && typeof word === "object")
.map((word: any) => ({
word: String(word.word || " "),
startTime: Number(word.startTime) || 0,
endTime: Number(word.endTime) || 0,
}));
if (!words.length) return null;
const startTime = words[0].startTime;
const endTime = words[words.length - 1].endTime;
return {
words,
startTime,
endTime,
translatedLyric: String(line.translatedLyric || ""),
romanLyric: String(line.romanLyric || ""),
isBG: Boolean(line.isBG),
isDuet: Boolean(line.isDuet),
};
})
.filter((line): line is LyricLine => line !== null);
return validLines;
} catch (error) {
console.error("TTML parsing error:", error);
return [];
}
};
/**
* 从TTML格式解析歌词并转换为默认Yrc格式
* @param ttmlContent TTML格式的歌词内容
* @returns 默认Yrc格式的歌词行数组
*/
export const parseTTMLToYrc = (ttmlContent: TTMLLyric): LyricType[] => {
if (!ttmlContent) return [];
try {
// 数据处理
const yrcList = ttmlContent.lines
.map((line) => {
const words = line.words;
const time = msToS(words[0].startTime);
const endTime = msToS(words[words.length - 1].endTime);
const contents = words.map((word) => {
return {
time: msToS(word.startTime),
endTime: msToS(word.endTime),
duration: msToS(word.endTime - word.startTime),
content: word.word.trim(),
endsWithSpace: word.word.endsWith(" "),
};
});
// 完整歌词
const contentStr = contents
.map((word) => word.content + (word.endsWithSpace ? " " : ""))
.join("");
// 排除内容
if (!contentStr || getExcludeKeywords().some((keyword) => contentStr.includes(keyword))) {
return null;
}
return {
time,
endTime,
content: contentStr,
contents,
tran: line.translatedLyric || "",
roma: line.romanLyric || "",
isBG: line.isBG,
isDuet: line.isDuet,
};
})
.filter((line) => line !== null);
return yrcList;
} catch (error) {
console.error("TTML parsing to yrc error:", error);
return [];
}
};
// 检测语言
export const getLyricLanguage = (lyric: string): string => {
// 判断日语 根据平假名和片假名
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(lyric)) return "ja";
// 判断简体中文 根据中日韩统一表意文字基本区
if (/[\u4e00-\u9fa5]/.test(lyric)) return "zh-CN";
// 默认英语
return "en";
};

View File

@@ -90,6 +90,7 @@ export const openSongInfoEditor = (song: SongType) => {
preset: "card",
transformOrigin: "center",
autoFocus: false,
trapFocus: false,
// contentStyle: { padding: 0 },
style: { width: "600px" },
title: "编辑歌曲信息",

View File

@@ -1,10 +1,16 @@
import type { SongType, PlayModeType } from "@/types/main";
import type { SongType, PlayModeType, LyricType } from "@/types/main";
import type { MessageReactive } from "naive-ui";
import { Howl, Howler } from "howler";
import { cloneDeep } from "lodash-es";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { parsedLyricsData, resetSongLyric, parseLocalLyric } from "./lyric";
import { songUrl, unlockSongUrl, songLyric, songChorus } from "@/api/song";
import {
parsedLyricsData,
resetSongLyric,
parseLocalLyric,
parseTTMLToAMLL,
parseTTMLToYrc,
} from "./lyric";
import { songUrl, unlockSongUrl, songLyric, songChorus, songLyricTTML } from "@/api/song";
import { getCoverColorData } from "@/utils/color";
import { calculateProgress } from "./time";
import { isElectron, isDev } from "./helper";
@@ -12,9 +18,10 @@ import { heartRateList } from "@/api/playlist";
import { formatSongsList } from "./format";
import { isLogin } from "./auth";
import { openUserLogin } from "./modal";
import { scrobble } from "@/api/user";
import { personalFm, personalFmToTrash } from "@/api/rec";
import blob from "./blob";
import { parseTTML } from "@applemusic-like-lyrics/lyric";
import { LyricLine } from "@applemusic-like-lyrics/core";
// 播放器核心
// Howler.js
@@ -30,17 +37,30 @@ class Player {
// 频谱数据
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private dataArray: Uint8Array | null = null;
private dataArray: Uint8Array<ArrayBuffer> | null = null;
private source: MediaElementAudioSourceNode | null = null;
// 其他数据
private testNumber: number = 0;
private message: MessageReactive | null = null;
// 预载下一首歌曲播放地址缓存(仅存 URL不创建 Howl
private nextPrefetch: { id: number; url: string | null; ublock: boolean } | null = null;
constructor() {
// 创建播放器实例
this.player = new Howl({ src: [""], format: allowPlayFormat, autoplay: false });
// 初始化媒体会话
this.initMediaSession();
}
/**
* 洗牌数组Fisher-Yates
*/
private shuffleArray<T>(arr: T[]): T[] {
const copy = arr.slice();
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
/**
* 重置状态
*/
@@ -93,7 +113,7 @@ class Player {
* 处理播放状态
*/
private handlePlayStatus() {
const musicStore = useMusicStore();
// const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 清理定时器
@@ -105,14 +125,8 @@ class Player {
const duration = this.player.duration();
// 计算进度条距离
const progress = calculateProgress(currentTime, duration);
// 计算歌词索引
const hasYrc = !musicStore.songLyric.yrcData.length || !settingStore.showYrc;
const lyrics = hasYrc ? musicStore.songLyric.lrcData : musicStore.songLyric.yrcData;
// 歌词实时偏移量
const currentTimeOffset = statusStore.currentTimeOffset;
const index = lyrics?.findIndex((v) => v?.time >= currentTime + currentTimeOffset);
// 歌词跨界处理
const lyricIndex = index === -1 ? lyrics.length - 1 : index - 1;
// 计算歌词索引(支持 LRC 与逐字 YRC对唱重叠处理
const { index: lyricIndex, lyrics } = this.calculateLyricIndex(currentTime);
// 更新状态
statusStore.$patch({ currentTime, duration, progress, lyricIndex });
// 客户端事件
@@ -120,11 +134,7 @@ class Player {
// 歌词变化
window.electron.ipcRenderer.send("play-lyric-change", {
index: lyricIndex,
lyric: cloneDeep(
settingStore.showYrc && musicStore.songLyric.yrcData?.length
? musicStore.songLyric.yrcData
: musicStore.songLyric.lrcData,
),
lyric: cloneDeep(lyrics),
});
// 进度条
if (settingStore.showTaskbarProgress) {
@@ -133,6 +143,66 @@ class Player {
}
}, 250);
}
/**
* 计算歌词索引
* - 普通歌词(LRC):沿用当前按开始时间定位的算法
* - 逐字歌词(YRC):当播放时间位于某句 [time, endTime) 区间内时,索引为该句;
* 若下一句开始时间落在上一句区间(对唱重叠),仍保持上一句索引,直到上一句结束。
*/
private calculateLyricIndex(currentTime: number): { index: number; lyrics: LyricType[] } {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 应用实时偏移 + 0.3s(解决对唱时歌词延迟问题)
const playSeek = currentTime + statusStore.currentTimeOffset + 0.3;
// 选择歌词类型
const useYrc = !!(settingStore.showYrc && musicStore.songLyric.yrcData.length);
const lyrics = useYrc ? musicStore.songLyric.yrcData : musicStore.songLyric.lrcData;
// 无歌词时
if (!lyrics || !lyrics.length) return { index: -1, lyrics: [] };
// 普通歌词:保持原有计算方式
if (!useYrc) {
const idx = lyrics.findIndex((v) => (v?.time ?? 0) >= playSeek);
const index = idx === -1 ? lyrics.length - 1 : idx - 1;
return { index, lyrics };
}
// 逐字歌词(对唱最多两句同时存在):
// - 计算在播放进度下处于激活区间的句子集合 activeIndices[time, endTime)
// - 若激活数 >= 2仅保留最后两句作为对唱对不允许三句同时有效
// - 索引取该对唱对中较早的一句(保持“上一句”高亮)
// - 若无激活句:首句之前返回 -1否则回退到最近一句
const firstStart = lyrics[0]?.time ?? 0;
if (playSeek < firstStart) {
return { index: -1, lyrics };
}
const activeIndices: number[] = [];
for (let i = 0; i < lyrics.length; i++) {
const start = lyrics[i]?.time ?? 0;
const end = lyrics[i]?.endTime ?? Infinity;
if (playSeek >= start && playSeek < end) {
activeIndices.push(i);
}
}
if (activeIndices.length === 0) {
// 不在任何句子的区间里:退回到最近一句(按开始时间)
const nextIdx = lyrics.findIndex((v) => (v?.time ?? 0) > playSeek);
const index = nextIdx === -1 ? lyrics.length - 1 : nextIdx - 1;
return { index, lyrics };
}
if (activeIndices.length === 1) {
return { index: activeIndices[0], lyrics };
}
// 激活句 >= 2限制为最后两句对唱
const pair = activeIndices.slice(-2);
return { index: pair[0], lyrics };
}
/**
* 获取在线播放链接
* @param id 歌曲id
@@ -152,8 +222,14 @@ class Player {
} else return null;
}
// 返回歌曲地址
// 客户端直接返回,网页端转 https
const url = isElectron ? songData.url : songData.url.replace(/^http:/, "https:");
// 客户端直接返回,网页端转 https, 并转换url以便解决音乐链接cors问题
const url = isElectron
? songData.url
: songData.url
.replace(/^http:/, "https:")
.replace(/m804\.music\.126\.net/g, "m801.music.126.net")
.replace(/m704\.music\.126\.net/g, "m701.music.126.net");
console.log(`🎧 ${id} music url:`, url);
return url;
}
/**
@@ -168,18 +244,87 @@ class Player {
const keyWord = songData.name + "-" + artist;
if (!songId || !keyWord) return null;
// 尝试解锁
const [neteaseUrl, kuwoUrl] = await Promise.all([
const results = await Promise.allSettled([
unlockSongUrl(songId, keyWord, "netease"),
unlockSongUrl(songId, keyWord, "kuwo"),
]);
if (neteaseUrl.code === 200 && neteaseUrl.url !== "") return neteaseUrl.url;
if (kuwoUrl.code === 200 && kuwoUrl.url !== "") return kuwoUrl.url;
// 解析结果
const [neteaseRes, kuwoRes] = results;
if (
neteaseRes.status === "fulfilled" &&
neteaseRes.value.code === 200 &&
neteaseRes.value.url
) {
return neteaseRes.value.url;
}
if (kuwoRes.status === "fulfilled" && kuwoRes.value.code === 200 && kuwoRes.value.url) {
return kuwoRes.value.url;
}
return null;
} catch (error) {
console.error("Error in getUnlockSongUrl", error);
return null;
}
}
/**
* 预载下一首歌曲的播放地址(优先官方,失败则并发尝试解灰)
* 仅缓存 URL不实例化播放器
*/
private async prefetchNextSongUrl() {
try {
const dataStore = useDataStore();
const statusStore = useStatusStore();
// const musicStore = useMusicStore();
const settingStore = useSettingStore();
// 无列表或私人FM模式直接跳过
const playList = dataStore.playList;
if (!playList?.length || statusStore.personalFmMode) {
this.nextPrefetch = null;
return;
}
// 计算下一首(循环到首)
let nextIndex = statusStore.playIndex + 1;
if (nextIndex >= playList.length) nextIndex = 0;
const nextSong = playList[nextIndex];
if (!nextSong) {
this.nextPrefetch = null;
return;
}
// 本地歌曲:直接缓存 file URL
if (nextSong.path) {
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
this.nextPrefetch = {
id: Number(songId || nextSong.id),
url: `file://${nextSong.path}`,
ublock: false,
};
return;
}
// 在线歌曲:优先官方,其次解灰
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
if (!songId) {
this.nextPrefetch = null;
return;
}
const canUnlock = isElectron && nextSong.type !== "radio" && settingStore.useSongUnlock;
const unlockUrlPromise = canUnlock ? this.getUnlockSongUrl(nextSong) : null;
const url = await this.getOnlineUrl(songId);
if (url) {
this.nextPrefetch = { id: songId, url, ublock: false };
} else if (unlockUrlPromise) {
const unlockUrl = await unlockUrlPromise;
this.nextPrefetch = { id: songId, url: unlockUrl || null, ublock: !!unlockUrl };
} else {
this.nextPrefetch = { id: songId, url: null, ublock: false };
}
} catch (error) {
console.error("Error prefetching next song url:", error);
}
}
/**
* 创建播放器
* @param src 播放地址
@@ -228,6 +373,8 @@ class Player {
if (!path) this.updateMediaSession();
// 开发模式
if (isDev) window.player = this.player;
// 异步预载下一首播放地址(不阻塞当前播放)
void this.prefetchNextSongUrl();
}
/**
* 播放器事件
@@ -241,14 +388,17 @@ class Player {
// 获取数据
const dataStore = useDataStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const playSongData = this.getPlaySongData();
// 获取配置
const { seek } = options;
// 初次加载
this.player.once("load", () => {
// 允许跨域
const audioDom = this.getAudioDom();
audioDom.crossOrigin = "anonymous";
if (settingStore.showSpectrums) {
const audioDom = this.getAudioDom();
audioDom.crossOrigin = "anonymous";
}
// 恢复进度( 需距离本曲结束大于 2 秒
if (seek && statusStore.duration - statusStore.currentTime > 2) this.setSeek(seek);
// 更新状态
@@ -397,8 +547,42 @@ class Player {
resetSongLyric();
return;
}
const lyricRes = await songLyric(id);
parsedLyricsData(lyricRes);
try {
const musicStore = useMusicStore();
const settingStore = useSettingStore();
const [lyricRes, ttmlContent] = await Promise.all([
songLyric(id),
settingStore.enableTTMLLyric && songLyricTTML(id),
]);
parsedLyricsData(lyricRes);
if (ttmlContent) {
const parsedResult = parseTTML(ttmlContent);
if (!parsedResult?.lines?.length) return;
const ttmlLyric = parseTTMLToAMLL(parsedResult);
const ttmlYrcLyric = parseTTMLToYrc(parsedResult);
console.log("TTML lyrics:", ttmlLyric, ttmlYrcLyric);
// 合并数据
const updates: Partial<{ yrcAMData: LyricLine[]; yrcData: LyricType[] }> = {};
if (ttmlLyric?.length) {
updates.yrcAMData = ttmlLyric;
console.log("✅ TTML AMLL lyrics success");
}
if (ttmlYrcLyric?.length) {
updates.yrcData = ttmlYrcLyric;
console.log("✅ TTML Yrc lyrics success");
}
if (Object.keys(updates).length) {
musicStore.songLyric = {
...musicStore.songLyric,
...updates,
};
}
}
} catch (error) {
console.error("❌ Error loading lyrics:", error);
resetSongLyric();
}
}
/**
* 获取副歌时间
@@ -526,7 +710,7 @@ class Player {
statusStore.playLoading = true;
// 本地歌曲
if (path) {
await this.createPlayer(path, autoPlay, seek);
await this.createPlayer(`file://${path}`, autoPlay, seek);
// 获取歌曲元信息
await this.parseLocalMusicInfo(path);
}
@@ -534,39 +718,51 @@ class Player {
else if (id && dataStore.playList.length) {
const songId = type === "radio" ? dj?.id : id;
if (!songId) throw new Error("Get song id error");
const url = await this.getOnlineUrl(songId);
// 正常播放地址
if (url) {
statusStore.playUblock = false;
await this.createPlayer(url, autoPlay, seek);
}
// 尝试解灰
else if (isElectron && type !== "radio" && settingStore.useSongUnlock) {
const unlockUrl = await this.getUnlockSongUrl(playSongData);
if (unlockUrl) {
statusStore.playUblock = true;
console.log("🎼 Song unlock successfully:", unlockUrl);
await this.createPlayer(unlockUrl, autoPlay, seek);
} else {
statusStore.playUblock = false;
// 是否为最后一首
if (statusStore.playIndex === dataStore.playList.length - 1) {
statusStore.$patch({ playStatus: false, playLoading: false });
window.$message.warning("当前列表歌曲无法播放,请更换歌曲");
} else {
window.$message.error("该歌曲暂无音源,跳至下一首");
this.nextOrPrev("next");
}
}
// 优先使用预载的下一首 URL若命中缓存
const cached = this.nextPrefetch;
if (cached && cached.id === songId && cached.url) {
statusStore.playUblock = cached.ublock;
await this.createPlayer(cached.url, autoPlay, seek);
} else {
if (dataStore.playList.length === 1) {
this.resetStatus();
window.$message.warning("当前播放列表已无可播放歌曲,请更换");
return;
// 并发启动解灰请求(仅在 Electron 且非电台且开启解灰时)
const canUnlock = isElectron && type !== "radio" && settingStore.useSongUnlock;
const unlockUrlPromise = canUnlock ? this.getUnlockSongUrl(playSongData) : null;
// 先请求正常播放地址
const url = await this.getOnlineUrl(songId);
// 正常播放地址
if (url) {
statusStore.playUblock = false;
await this.createPlayer(url, autoPlay, seek);
}
// 尝试解灰
else if (unlockUrlPromise) {
// 若正常地址不可用,则等待并使用并发中的解灰结果
const unlockUrl = await unlockUrlPromise;
if (unlockUrl) {
statusStore.playUblock = true;
console.log("🎼 Song unlock successfully:", unlockUrl);
await this.createPlayer(unlockUrl, autoPlay, seek);
} else {
statusStore.playUblock = false;
// 是否为最后一首
if (statusStore.playIndex === dataStore.playList.length - 1) {
statusStore.$patch({ playStatus: false, playLoading: false });
window.$message.warning("当前列表歌曲无法播放,请更换歌曲");
} else {
window.$message.error("该歌曲暂无音源,跳至下一首");
this.nextOrPrev("next");
}
}
} else {
window.$message.error("该歌曲无法播放,跳至下一首");
this.nextOrPrev();
return;
if (dataStore.playList.length === 1) {
this.resetStatus();
window.$message.warning("当前播放列表已无可播放歌曲,请更换");
return;
} else {
window.$message.error("该歌曲无法播放,跳至下一首");
this.nextOrPrev();
return;
}
}
}
}
@@ -587,10 +783,11 @@ class Player {
return;
}
this.player.play();
statusStore.playStatus = true;
// 淡入
await new Promise<void>((resolve) => {
this.player.once("play", () => {
// 在淡入开始时立即设置播放状态
statusStore.playStatus = true;
this.player.fade(0, statusStore.playVolume, this.getFadeTime());
resolve();
});
@@ -608,12 +805,14 @@ class Player {
return;
}
// 立即设置播放状态
if (changeStatus) statusStore.playStatus = false;
// 淡出
await new Promise<void>((resolve) => {
this.player.fade(statusStore.playVolume, 0, this.getFadeTime());
this.player.once("fade", () => {
this.player.pause();
if (changeStatus) statusStore.playStatus = false;
resolve();
});
});
@@ -644,8 +843,6 @@ class Player {
const playListLength = playList.length;
// 播放列表是否为空
if (playListLength === 0) throw new Error("Play list is empty");
// 打卡
this.scrobbleSong();
// 若为私人FM
if (statusStore.personalFmMode) {
await this.initPersonalFM(true);
@@ -657,19 +854,15 @@ class Player {
this.setSeek(0);
await this.play();
}
// 列表循环或处于心动模式
if (playSongMode === "repeat" || playHeartbeatMode || playSong.type === "radio") {
// 列表循环或处于心动模式或随机模式
if (
playSongMode === "repeat" ||
playSongMode === "shuffle" ||
playHeartbeatMode ||
playSong.type === "radio"
) {
statusStore.playIndex += type === "next" ? 1 : -1;
}
// 随机播放
else if (playSongMode === "shuffle") {
let newIndex: number;
// 确保不会随机到同一首
do {
newIndex = Math.floor(Math.random() * playListLength);
} while (newIndex === statusStore.playIndex);
statusStore.playIndex = newIndex;
}
// 单曲循环
else if (playSongMode === "repeat-once") {
statusStore.lyricIndex = -1;
@@ -698,28 +891,65 @@ class Player {
* 切换播放模式
* @param mode 播放模式 repeat / repeat-once / shuffle
*/
togglePlayMode(mode: PlayModeType | false) {
async togglePlayMode(mode: PlayModeType | false) {
const statusStore = useStatusStore();
const dataStore = useDataStore();
const musicStore = useMusicStore();
// 退出心动模式
if (statusStore.playHeartbeatMode) this.toggleHeartMode(false);
// 若传入了指定模式
// 计算目标模式
let targetMode: PlayModeType;
if (mode) {
statusStore.playSongMode = mode;
targetMode = mode;
} else {
switch (statusStore.playSongMode) {
case "repeat":
statusStore.playSongMode = "repeat-once";
targetMode = "repeat-once";
break;
case "shuffle":
statusStore.playSongMode = "repeat";
targetMode = "repeat";
break;
case "repeat-once":
statusStore.playSongMode = "shuffle";
targetMode = "shuffle";
break;
default:
statusStore.playSongMode = "repeat";
targetMode = "repeat";
}
}
// 进入随机模式:保存原顺序并打乱当前歌单
if (targetMode === "shuffle" && statusStore.playSongMode !== "shuffle") {
const currentList = dataStore.playList;
if (currentList && currentList.length > 1) {
const currentSongId = musicStore.playSong?.id;
await dataStore.setOriginalPlayList(currentList);
const shuffled = this.shuffleArray(currentList);
await dataStore.setPlayList(shuffled);
if (currentSongId) {
const newIndex = shuffled.findIndex((s: any) => s?.id === currentSongId);
if (newIndex !== -1) useStatusStore().playIndex = newIndex;
}
}
}
// 离开随机模式:恢复到原顺序
if (
statusStore.playSongMode === "shuffle" &&
(targetMode === "repeat" || targetMode === "repeat-once")
) {
const original = await dataStore.getOriginalPlayList();
if (original && original.length) {
const currentSongId = musicStore.playSong?.id;
await dataStore.setPlayList(original);
if (currentSongId) {
const origIndex = original.findIndex((s: any) => s?.id === currentSongId);
useStatusStore().playIndex = origIndex !== -1 ? origIndex : 0;
} else {
useStatusStore().playIndex = 0;
}
await dataStore.clearOriginalPlayList();
}
}
// 应用模式
statusStore.playSongMode = targetMode;
this.playModeSyncIpc();
}
/**
@@ -850,11 +1080,19 @@ class Player {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
// 获取配置
const { showTip, scrobble, play } = options;
// 打卡
if (scrobble) this.scrobbleSong();
const { showTip, play } = options;
// 处理随机播放模式
let processedData = cloneDeep(data);
if (statusStore.playSongMode === "shuffle") {
// 保存原始播放列表
await dataStore.setOriginalPlayList(cloneDeep(data));
// 随机排序
processedData = this.shuffleArray(processedData);
}
// 更新列表
await dataStore.setPlayList(cloneDeep(data));
await dataStore.setPlayList(processedData);
// 关闭特殊模式
if (statusStore.playHeartbeatMode) this.toggleHeartMode(false);
if (statusStore.personalFmMode) statusStore.personalFmMode = false;
@@ -864,15 +1102,17 @@ class Player {
if (musicStore.playSong.id === song.id) {
if (play) await this.play();
} else {
// 查找索引
statusStore.playIndex = data.findIndex((item) => item.id === song.id);
// 查找索引(在处理后的列表中查找)
statusStore.playIndex = processedData.findIndex((item) => item.id === song.id);
// 播放
await this.pause(false);
await this.initPlayer();
}
} else {
statusStore.playIndex =
statusStore.playSongMode === "shuffle" ? Math.floor(Math.random() * data.length) : 0;
statusStore.playSongMode === "shuffle"
? Math.floor(Math.random() * processedData.length)
: 0;
// 播放
await this.pause(false);
await this.initPlayer();
@@ -1106,29 +1346,6 @@ class Player {
this.message?.destroy();
}
}
/**
* 听歌打卡
*/
async scrobbleSong() {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
try {
if (!isLogin()) return;
if (!settingStore.scrobbleSong) return;
// 获取所需数据
const playSongData = this.getPlaySongData();
if (!playSongData) return;
const { id, name } = playSongData;
const sourceid = musicStore.playPlaylistId;
const time = statusStore.duration;
// 网易云打卡
console.log("打卡:", id, name, sourceid, time);
await scrobble(id, sourceid, time);
} catch (error) {
console.error("Failed to scrobble song:", error);
}
}
/**
* 初始化私人FM
* @param playNext 是否播放下一首

View File

@@ -83,13 +83,15 @@ server.interceptors.response.use(
// 处理其他状态码或错误条件
console.error("未处理的错误:", error.message);
}
window.$notification.error({
title: "请求错误",
description: `状态码: ${response?.status || ""}`,
content: (response && (response.data as { message?: string }).message) || error.message,
meta: "若持续发生,可尝试软件热重载",
duration: 5000,
});
// window.$notification.error({
// title: "请求错误",
// description: `状态码: ${response?.status || ""}`,
// content: (response && (response.data as { message?: string }).message) || error.message,
// meta: "若持续发生,可尝试软件热重载",
// duration: 5000,
// });
// 控制台输出
window.$message.warning("请求出错,若持续发生,可尝试软件热重载");
// 返回错误
return Promise.reject(error);
},

View File

@@ -178,7 +178,7 @@ import { debounce, isObject } from "lodash-es";
import { useDataStore, useStatusStore } from "@/stores";
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";
import { formatTimestamp } from "@/utils/time";
import { isLogin } from "@/utils/auth";
import { isLogin, updateUserLikePlaylist } from "@/utils/auth";
import player from "@/utils/player";
const router = useRouter();
@@ -408,9 +408,32 @@ onDeactivated(() => loadingMsgShow(false));
onUnmounted(() => loadingMsgShow(false));
onMounted(async () => {
const data: any = await dataStore.getUserLikePlaylist();
const id = data?.detail?.id;
if (id) getPlaylistDetail(id);
// 首先确保用户歌单数据已加载
if (!dataStore.userLikeData.playlists?.length) {
try {
await updateUserLikePlaylist();
} catch (error) {
console.error("Failed to update user playlist data:", error);
loading.value = false;
return;
}
}
// 获取我喜欢的音乐歌单ID
const likedPlaylistId = dataStore.userLikeData.playlists?.[0]?.id;
if (likedPlaylistId) {
getPlaylistDetail(likedPlaylistId);
} else {
// 如果没有找到我喜欢的音乐歌单,尝试从缓存获取
const data: any = await dataStore.getUserLikePlaylist();
const id = data?.detail?.id;
if (id) {
getPlaylistDetail(id);
} else {
loading.value = false;
window.$message.error("无法获取我喜欢的音乐歌单");
}
}
});
</script>

View File

@@ -106,7 +106,14 @@
<template #prefix>
<SvgIcon :size="20" name="FolderMusic" />
</template>
<n-thing :title="defaultMusicPath" description="系统默认音乐文件夹,无法更改" />
<template #suffix>
<n-switch
v-model:value="settingStore.showDefaultLocalPath"
:round="false"
class="set"
/>
</template>
<n-thing :title="defaultMusicPath" description="系统默认音乐文件夹" />
</n-list-item>
<n-list-item v-for="(item, index) in settingStore.localFilesPath" :key="index">
<template #prefix>
@@ -176,7 +183,10 @@ const listData = computed<SongType[]>(() => {
// 获取音乐文件夹
const getMusicFolder = async (): Promise<string[]> => {
defaultMusicPath.value = await window.electron.ipcRenderer.invoke("get-default-dir", "music");
return [defaultMusicPath.value, ...settingStore.localFilesPath];
return [
settingStore.showDefaultLocalPath ? defaultMusicPath.value : "",
...settingStore.localFilesPath,
];
};
// 全部音乐大小
@@ -262,7 +272,7 @@ localEventBus.on(() => getAllLocalMusic());
// 本地目录变化
watch(
() => settingStore.localFilesPath,
() => [settingStore.localFilesPath, settingStore.showDefaultLocalPath],
async () => await getAllLocalMusic(),
{ deep: true },
);

View File

@@ -9,7 +9,8 @@
"electron/main/utils.ts",
"electron/main/index.d.ts",
"electron/preload/index.d.ts",
"electron/preload/index.ts"
"electron/preload/index.ts",
"dist/lastfm.ts"
],
"compilerOptions": {
"composite": true,

View File

@@ -5,15 +5,20 @@
"src/**/*",
"src/**/*.vue",
"electron/main/index.d.ts",
"electron/preload/index.d.ts"
"electron/preload/index.d.ts",
"dist/lastfm.ts"
],
"compilerOptions": {
"composite": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"maxNodeModuleJsDepth": 2,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"]
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"target": "es2022"
}
}

View File

@@ -365,7 +365,7 @@
}
case "font-size-reduce": {
let fontSize = options.fontSize;
if (fontSize > 20) {
if (fontSize > 10) {
fontSize--;
this.changeOptions({ ...options, fontSize });
}