mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa4e21d40 | ||
|
|
4c11c19139 | ||
|
|
f82d4271a7 | ||
|
|
42dcf52d59 | ||
|
|
d9d2bdab93 | ||
|
|
6060aa2ef4 | ||
|
|
0b2e8eef64 | ||
|
|
57de7b49e8 | ||
|
|
42e17e83e7 | ||
|
|
380c273329 | ||
|
|
6f56f5e240 | ||
|
|
7af7779e5c | ||
|
|
669a348218 | ||
|
|
f02264c80c | ||
|
|
d0d5f918bd | ||
|
|
761d265d18 | ||
|
|
204df64535 | ||
|
|
cc814eddbd |
66
.github/workflows/deploydocs.yml
vendored
66
.github/workflows/deploydocs.yml
vendored
@@ -1,66 +0,0 @@
|
|||||||
# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
|
|
||||||
#
|
|
||||||
name: Deploy VitePress site to Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
# 在针对 `main` 分支的推送上运行。如果你
|
|
||||||
# 使用 `master` 分支作为默认分支,请将其更改为 `master`
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# 允许你从 Actions 选项卡手动运行此工作流程
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
|
|
||||||
# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# 构建工作
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # 如果未启用 lastUpdated,则不需要
|
|
||||||
- uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消此区域注释
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
# - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm # 或 pnpm / yarn
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v4
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm add -D vitepress@next # 或 pnpm install / yarn install / bun install
|
|
||||||
- name: Build with VitePress
|
|
||||||
run: pnpm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: docs/.vitepress/dist
|
|
||||||
|
|
||||||
# 部署工作
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Deploy
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
146
.github/workflows/main.yml
vendored
146
.github/workflows/main.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Build Electron App for macos
|
- name: Build Electron App for macos
|
||||||
if: matrix.os == 'macos-latest' # 只在macOS上运行
|
if: matrix.os == 'macos-latest' # 只在macOS上运行
|
||||||
run: |
|
run: |
|
||||||
yarn run build:mac
|
yarn run build:mac:universal
|
||||||
|
|
||||||
- name: Build Electron App for linux
|
- name: Build Electron App for linux
|
||||||
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
|
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
|
||||||
@@ -70,146 +70,4 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: 'dist/**' # 将dist目录下所有文件添加到release
|
files: 'dist/**' # 将dist目录下所有文件添加到release
|
||||||
|
body: ${{ github.event.head_commit.message }} # 自动将commit message写入release描述
|
||||||
# 新增:自动同步到 WebDAV
|
|
||||||
sync-to-webdav:
|
|
||||||
name: Sync to WebDAV
|
|
||||||
needs: release # 等待 release 任务完成
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') # 只在标签推送时执行
|
|
||||||
steps:
|
|
||||||
- name: Wait for release to be ready
|
|
||||||
run: |
|
|
||||||
echo "等待 Release 准备就绪..."
|
|
||||||
sleep 30 # 等待30秒确保 release 完全创建
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y curl jq
|
|
||||||
|
|
||||||
- name: Get latest release info
|
|
||||||
id: get-release
|
|
||||||
run: |
|
|
||||||
# 获取当前标签对应的 release 信息
|
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
|
||||||
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# 获取 release 详细信息
|
|
||||||
response=$(curl -s \
|
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
|
|
||||||
|
|
||||||
release_id=$(echo "$response" | jq -r '.id')
|
|
||||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT
|
|
||||||
echo "找到 Release ID: $release_id"
|
|
||||||
|
|
||||||
- name: Sync release to WebDAV
|
|
||||||
run: |
|
|
||||||
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
|
|
||||||
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
|
|
||||||
|
|
||||||
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
|
|
||||||
echo "Release ID: $RELEASE_ID"
|
|
||||||
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
|
||||||
|
|
||||||
# 获取该release的所有资源文件
|
|
||||||
assets_json=$(curl -s \
|
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
|
|
||||||
|
|
||||||
assets_count=$(echo "$assets_json" | jq '. | length')
|
|
||||||
echo "找到 $assets_count 个资源文件"
|
|
||||||
|
|
||||||
if [ "$assets_count" -eq 0 ]; then
|
|
||||||
echo "⚠️ 该版本没有资源文件,跳过同步"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 先创建版本目录
|
|
||||||
dir_path="/yd/ceru/$TAG_NAME"
|
|
||||||
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
|
||||||
|
|
||||||
echo "创建版本目录: $dir_path"
|
|
||||||
curl -s -X MKCOL \
|
|
||||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
|
||||||
"$dir_url" || echo "目录可能已存在"
|
|
||||||
|
|
||||||
# 处理每个asset
|
|
||||||
success_count=0
|
|
||||||
failed_count=0
|
|
||||||
|
|
||||||
for i in $(seq 0 $(($assets_count - 1))); do
|
|
||||||
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
|
||||||
asset_name=$(echo "$asset" | jq -r '.name')
|
|
||||||
asset_url=$(echo "$asset" | jq -r '.url')
|
|
||||||
asset_size=$(echo "$asset" | jq -r '.size')
|
|
||||||
|
|
||||||
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
|
|
||||||
|
|
||||||
# 下载资源文件
|
|
||||||
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
|
|
||||||
|
|
||||||
if ! curl -sL -o "$safe_filename" \
|
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
|
||||||
-H "Accept: application/octet-stream" \
|
|
||||||
"$asset_url"; then
|
|
||||||
echo "❌ 下载失败: $asset_name"
|
|
||||||
failed_count=$((failed_count + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$safe_filename" ]; then
|
|
||||||
actual_size=$(wc -c < "$safe_filename")
|
|
||||||
if [ "$actual_size" -ne "$asset_size" ]; then
|
|
||||||
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
|
|
||||||
rm -f "$safe_filename"
|
|
||||||
failed_count=$((failed_count + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⬆️ 上传到 WebDAV: $asset_name"
|
|
||||||
|
|
||||||
# 构建远程路径
|
|
||||||
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
|
|
||||||
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
|
|
||||||
|
|
||||||
# 使用 WebDAV PUT 方法上传文件
|
|
||||||
if curl -s -f -X PUT \
|
|
||||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
|
||||||
-T "$safe_filename" \
|
|
||||||
"$full_url"; then
|
|
||||||
|
|
||||||
echo "✅ 上传成功: $asset_name"
|
|
||||||
success_count=$((success_count + 1))
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "❌ 上传失败: $asset_name"
|
|
||||||
failed_count=$((failed_count + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清理临时文件
|
|
||||||
rm -f "$safe_filename"
|
|
||||||
echo "----------------------------------------"
|
|
||||||
else
|
|
||||||
echo "❌ 临时文件不存在: $safe_filename"
|
|
||||||
failed_count=$((failed_count + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "========================================"
|
|
||||||
echo "🎉 同步完成!"
|
|
||||||
echo "成功: $success_count 个文件"
|
|
||||||
echo "失败: $failed_count 个文件"
|
|
||||||
echo "总计: $assets_count 个文件"
|
|
||||||
|
|
||||||
- name: Notify completion
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ "${{ job.status }}" == "success" ]; then
|
|
||||||
echo "✅ 版本 ${{ steps.get-release.outputs.tag_name }} 已成功同步到 alist"
|
|
||||||
else
|
|
||||||
echo "❌ 版本 ${{ steps.get-release.outputs.tag_name }} 同步失败"
|
|
||||||
fi
|
|
||||||
|
|||||||
215
.workflow/main copy.yml
Normal file
215
.workflow/main copy.yml
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
name: AutoBuild # 工作流的名称
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # 给予写入仓库内容的权限
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*' # 当推送以v开头的标签时触发此工作流
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: build and release electron app # 任务名称
|
||||||
|
runs-on: ${{ matrix.os }} # 在matrix.os定义的操作系统上运行
|
||||||
|
strategy:
|
||||||
|
fail-fast: false # 如果一个任务失败,其他任务继续运行
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, macos-latest, ubuntu-latest] # 在Windows和macOS上运行任务
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v4 # 检出代码仓库
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22 # 安装Node.js 22 (这里node环境是能够运行代码的环境)
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
npm i -g yarn
|
||||||
|
yarn install # 安装项目依赖
|
||||||
|
|
||||||
|
- name: Build Electron App for windows
|
||||||
|
if: matrix.os == 'windows-latest' # 只在Windows上运行
|
||||||
|
run: yarn run build:win # 构建Windows版应用
|
||||||
|
|
||||||
|
- name: Build Electron App for macos
|
||||||
|
if: matrix.os == 'macos-latest' # 只在macOS上运行
|
||||||
|
run: |
|
||||||
|
yarn run build:mac
|
||||||
|
|
||||||
|
- name: Build Electron App for linux
|
||||||
|
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
|
||||||
|
run: yarn run build:linux # 构建Linux版应用
|
||||||
|
|
||||||
|
- name: Cleanup Artifacts for Windows
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
run: |
|
||||||
|
npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件
|
||||||
|
|
||||||
|
- name: Cleanup Artifacts for MacOS
|
||||||
|
if: matrix.os == 'macos-latest'
|
||||||
|
run: |
|
||||||
|
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|latest*.yml)" # 清理macOS构建产物,只保留特定文件
|
||||||
|
|
||||||
|
- name: Cleanup Artifacts for Linux
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.snap|latest*.yml)" # 清理Linux构建产物,只保留特定文件
|
||||||
|
|
||||||
|
- name: upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.os }}
|
||||||
|
path: dist # 上传构建产物作为工作流artifact
|
||||||
|
|
||||||
|
- name: release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: 'dist/**' # 将dist目录下所有文件添加到release
|
||||||
|
|
||||||
|
# 新增:自动同步到 WebDAV
|
||||||
|
sync-to-webdav:
|
||||||
|
name: Sync to WebDAV
|
||||||
|
needs: release # 等待 release 任务完成
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') # 只在标签推送时执行
|
||||||
|
steps:
|
||||||
|
- name: Wait for release to be ready
|
||||||
|
run: |
|
||||||
|
echo "等待 Release 准备就绪..."
|
||||||
|
sleep 30 # 等待30秒确保 release 完全创建
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y curl jq
|
||||||
|
|
||||||
|
- name: Get latest release info
|
||||||
|
id: get-release
|
||||||
|
run: |
|
||||||
|
# 获取当前标签对应的 release 信息
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 获取 release 详细信息
|
||||||
|
response=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
|
||||||
|
|
||||||
|
release_id=$(echo "$response" | jq -r '.id')
|
||||||
|
echo "release_id=$release_id" >> $GITHUB_OUTPUT
|
||||||
|
echo "找到 Release ID: $release_id"
|
||||||
|
|
||||||
|
- name: Sync release to WebDAV
|
||||||
|
run: |
|
||||||
|
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
|
||||||
|
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
|
||||||
|
|
||||||
|
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
|
||||||
|
echo "Release ID: $RELEASE_ID"
|
||||||
|
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
||||||
|
|
||||||
|
# 获取该release的所有资源文件
|
||||||
|
assets_json=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
|
||||||
|
|
||||||
|
assets_count=$(echo "$assets_json" | jq '. | length')
|
||||||
|
echo "找到 $assets_count 个资源文件"
|
||||||
|
|
||||||
|
if [ "$assets_count" -eq 0 ]; then
|
||||||
|
echo "⚠️ 该版本没有资源文件,跳过同步"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 先创建版本目录
|
||||||
|
dir_path="/yd/ceru/$TAG_NAME"
|
||||||
|
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
||||||
|
|
||||||
|
echo "创建版本目录: $dir_path"
|
||||||
|
curl -s -X MKCOL \
|
||||||
|
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||||
|
"$dir_url" || echo "目录可能已存在"
|
||||||
|
|
||||||
|
# 处理每个asset
|
||||||
|
success_count=0
|
||||||
|
failed_count=0
|
||||||
|
|
||||||
|
for i in $(seq 0 $(($assets_count - 1))); do
|
||||||
|
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
||||||
|
asset_name=$(echo "$asset" | jq -r '.name')
|
||||||
|
asset_url=$(echo "$asset" | jq -r '.url')
|
||||||
|
asset_size=$(echo "$asset" | jq -r '.size')
|
||||||
|
|
||||||
|
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
|
||||||
|
|
||||||
|
# 下载资源文件
|
||||||
|
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
|
||||||
|
|
||||||
|
if ! curl -sL -o "$safe_filename" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Accept: application/octet-stream" \
|
||||||
|
"$asset_url"; then
|
||||||
|
echo "❌ 下载失败: $asset_name"
|
||||||
|
failed_count=$((failed_count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$safe_filename" ]; then
|
||||||
|
actual_size=$(wc -c < "$safe_filename")
|
||||||
|
if [ "$actual_size" -ne "$asset_size" ]; then
|
||||||
|
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
|
||||||
|
rm -f "$safe_filename"
|
||||||
|
failed_count=$((failed_count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⬆️ 上传到 WebDAV: $asset_name"
|
||||||
|
|
||||||
|
# 构建远程路径
|
||||||
|
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
|
||||||
|
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
|
||||||
|
|
||||||
|
# 使用 WebDAV PUT 方法上传文件
|
||||||
|
if curl -s -f -X PUT \
|
||||||
|
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||||
|
-T "$safe_filename" \
|
||||||
|
"$full_url"; then
|
||||||
|
|
||||||
|
echo "✅ 上传成功: $asset_name"
|
||||||
|
success_count=$((success_count + 1))
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ 上传失败: $asset_name"
|
||||||
|
failed_count=$((failed_count + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
rm -f "$safe_filename"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
else
|
||||||
|
echo "❌ 临时文件不存在: $safe_filename"
|
||||||
|
failed_count=$((failed_count + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "🎉 同步完成!"
|
||||||
|
echo "成功: $success_count 个文件"
|
||||||
|
echo "失败: $failed_count 个文件"
|
||||||
|
echo "总计: $assets_count 个文件"
|
||||||
|
|
||||||
|
- name: Notify completion
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ "${{ job.status }}" == "success" ]; then
|
||||||
|
echo "✅ 版本 ${{ steps.get-release.outputs.tag_name }} 已成功同步到 alist"
|
||||||
|
else
|
||||||
|
echo "❌ 版本 ${{ steps.get-release.outputs.tag_name }} 同步失败"
|
||||||
|
fi
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# 🎯 自定义右键菜单组件 - 完整功能实现
|
|
||||||
|
|
||||||
## ✅ 项目完成状态
|
|
||||||
|
|
||||||
**已完成** - 功能完整的自定义右键菜单组件,包含所有要求的特性和优化
|
|
||||||
|
|
||||||
## 🚀 核心功能特性
|
|
||||||
|
|
||||||
### 📋 基础功能
|
|
||||||
|
|
||||||
- ✅ **可配置菜单项** - 支持图标、文字、快捷键显示
|
|
||||||
- ✅ **多级子菜单** - 支持无限层级嵌套
|
|
||||||
- ✅ **菜单项状态** - 支持禁用、隐藏、分割线
|
|
||||||
- ✅ **事件回调** - 完整的点击事件处理机制
|
|
||||||
|
|
||||||
### 🎨 样式与主题
|
|
||||||
|
|
||||||
- ✅ **自定义主题** - 支持亮色/暗色/自动主题切换
|
|
||||||
- ✅ **现代化设计** - 圆角、阴影、渐变、动画效果
|
|
||||||
- ✅ **响应式布局** - 适配不同屏幕尺寸
|
|
||||||
- ✅ **无障碍支持** - 高对比度、减少动画模式
|
|
||||||
|
|
||||||
### 🔧 智能定位与边界处理
|
|
||||||
|
|
||||||
- ✅ **智能定位** - 自动检测屏幕边界并调整位置
|
|
||||||
- ✅ **向上展开** - 底部空间不足时自动向上显示
|
|
||||||
- ✅ **滚动支持** - 菜单过长时支持滚动和滚动指示器
|
|
||||||
- ✅ **子菜单定位** - 子菜单智能避让边界
|
|
||||||
|
|
||||||
### ⌨️ 交互优化
|
|
||||||
|
|
||||||
- ✅ **键盘导航** - 支持方向键、ESC、回车等快捷键
|
|
||||||
- ✅ **鼠标交互** - 悬停显示子菜单,点击外部关闭
|
|
||||||
- ✅ **滚轮支持** - 长菜单支持滚轮滚动
|
|
||||||
- ✅ **触摸友好** - 移动端优化的交互体验
|
|
||||||
|
|
||||||
## 📁 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/renderer/src/components/ContextMenu/
|
|
||||||
├── types.ts # TypeScript 类型定义
|
|
||||||
├── ContextMenu.vue # 主菜单组件
|
|
||||||
├── ContextMenuItem.vue # 菜单项组件
|
|
||||||
├── useContextMenu.ts # 组合式 API 钩子
|
|
||||||
├── index.ts # 组件导出入口
|
|
||||||
└── README.md # 使用文档
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 使用示例
|
|
||||||
|
|
||||||
### 基础用法
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div @contextmenu="handleContextMenu">右键点击此区域</div>
|
|
||||||
|
|
||||||
<ContextMenu
|
|
||||||
v-model:visible="visible"
|
|
||||||
:items="menuItems"
|
|
||||||
:position="position"
|
|
||||||
@item-click="handleItemClick"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { ContextMenu, createMenuItem, commonMenuItems } from '@renderer/components/ContextMenu'
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const position = ref({ x: 0, y: 0 })
|
|
||||||
const menuItems = ref([
|
|
||||||
createMenuItem('copy', '复制', {
|
|
||||||
icon: 'copy',
|
|
||||||
shortcut: 'Ctrl+C',
|
|
||||||
onClick: () => console.log('复制')
|
|
||||||
}),
|
|
||||||
commonMenuItems.divider,
|
|
||||||
createMenuItem('paste', '粘贴', {
|
|
||||||
icon: 'paste',
|
|
||||||
onClick: () => console.log('粘贴')
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleContextMenu = (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
position.value = { x: event.clientX, y: event.clientY }
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = (item, event) => {
|
|
||||||
if (item.onClick) {
|
|
||||||
item.onClick(item, event)
|
|
||||||
}
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 多级菜单
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const menuItems = [
|
|
||||||
createMenuItem('file', '文件', {
|
|
||||||
icon: 'folder',
|
|
||||||
children: [
|
|
||||||
createMenuItem('new', '新建', {
|
|
||||||
icon: 'add',
|
|
||||||
children: [
|
|
||||||
createMenuItem('vue', 'Vue 组件', {
|
|
||||||
onClick: () => console.log('新建 Vue 组件')
|
|
||||||
}),
|
|
||||||
createMenuItem('ts', 'TypeScript 文件', {
|
|
||||||
onClick: () => console.log('新建 TS 文件')
|
|
||||||
})
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
createMenuItem('open', '打开', {
|
|
||||||
icon: 'folder-open',
|
|
||||||
onClick: () => console.log('打开文件')
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 样式特性
|
|
||||||
|
|
||||||
### 现代化视觉效果
|
|
||||||
|
|
||||||
- **毛玻璃效果** - `backdrop-filter: blur(8px)`
|
|
||||||
- **多层阴影** - 立体感阴影效果
|
|
||||||
- **流畅动画** - `cubic-bezier` 缓动函数
|
|
||||||
- **悬停反馈** - 微妙的变换和颜色变化
|
|
||||||
|
|
||||||
### 响应式设计
|
|
||||||
|
|
||||||
- **桌面端** - 最小宽度 160px,最大宽度 300px
|
|
||||||
- **平板端** - 适配中等屏幕尺寸
|
|
||||||
- **移动端** - 优化触摸交互,增大点击区域
|
|
||||||
|
|
||||||
## 🔧 高级功能
|
|
||||||
|
|
||||||
### 智能边界处理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 自动检测屏幕边界
|
|
||||||
if (x + menuWidth > viewportWidth) {
|
|
||||||
x = viewportWidth - menuWidth - 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向上展开逻辑
|
|
||||||
if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
|
||||||
y = y - menuHeight
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 滚动功能
|
|
||||||
|
|
||||||
- **自动滚动** - 菜单超出屏幕高度时启用
|
|
||||||
- **滚动指示器** - 显示可滚动方向
|
|
||||||
- **键盘滚动** - 支持方向键和 Home/End 键
|
|
||||||
- **鼠标滚轮** - 平滑滚动体验
|
|
||||||
|
|
||||||
### 无障碍支持
|
|
||||||
|
|
||||||
- **高对比度模式** - 自动适配系统设置
|
|
||||||
- **减少动画模式** - 尊重用户偏好设置
|
|
||||||
- **键盘导航** - 完整的键盘操作支持
|
|
||||||
|
|
||||||
## 🧪 测试页面
|
|
||||||
|
|
||||||
访问 `http://localhost:5174/#/context-menu-test` 查看完整的功能演示:
|
|
||||||
|
|
||||||
1. **基础功能测试** - 图标、快捷键、禁用项
|
|
||||||
2. **多级菜单测试** - 嵌套子菜单
|
|
||||||
3. **长菜单滚动** - 25+ 菜单项滚动测试
|
|
||||||
4. **边界处理测试** - 四个角落的边界测试
|
|
||||||
5. **歌曲列表模拟** - 实际使用场景演示
|
|
||||||
|
|
||||||
## 🎯 集成状态
|
|
||||||
|
|
||||||
### 已集成页面
|
|
||||||
|
|
||||||
- ✅ **本地音乐页面** (`src/renderer/src/views/music/local.vue`)
|
|
||||||
- 歌曲右键菜单
|
|
||||||
- 播放、收藏、添加到歌单等功能
|
|
||||||
- 多级歌单选择
|
|
||||||
|
|
||||||
### 菜单功能
|
|
||||||
|
|
||||||
- ✅ 播放歌曲
|
|
||||||
- ✅ 下一首播放
|
|
||||||
- ✅ 收藏歌曲
|
|
||||||
- ✅ 添加到歌单(支持子菜单)
|
|
||||||
- ✅ 导出歌曲
|
|
||||||
- ✅ 查看歌曲信息
|
|
||||||
- ✅ 删除歌曲
|
|
||||||
|
|
||||||
## 🚀 性能优化
|
|
||||||
|
|
||||||
### 渲染优化
|
|
||||||
|
|
||||||
- **Teleport 渲染** - 避免 z-index 冲突
|
|
||||||
- **按需渲染** - 只在显示时渲染菜单
|
|
||||||
- **事件委托** - 高效的事件处理
|
|
||||||
|
|
||||||
### 内存管理
|
|
||||||
|
|
||||||
- **自动清理** - 组件卸载时清理事件监听
|
|
||||||
- **防抖处理** - 避免频繁的位置计算
|
|
||||||
- **缓存优化** - 计算结果缓存
|
|
||||||
|
|
||||||
## 🔮 扩展性
|
|
||||||
|
|
||||||
### 自定义组件
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 支持自定义图标组件
|
|
||||||
createMenuItem('custom', '自定义', {
|
|
||||||
icon: CustomIconComponent,
|
|
||||||
onClick: () => {}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主题扩展
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* 自定义主题变量 */
|
|
||||||
:root {
|
|
||||||
--context-menu-bg: #ffffff;
|
|
||||||
--context-menu-border: #e5e5e5;
|
|
||||||
--context-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 浏览器兼容性
|
|
||||||
|
|
||||||
- ✅ **Chrome** 88+
|
|
||||||
- ✅ **Firefox** 85+
|
|
||||||
- ✅ **Safari** 14+
|
|
||||||
- ✅ **Edge** 88+
|
|
||||||
- ✅ **Electron** (项目环境)
|
|
||||||
|
|
||||||
## 🎉 总结
|
|
||||||
|
|
||||||
这个自定义右键菜单组件完全满足了项目需求:
|
|
||||||
|
|
||||||
1. **功能完整** - 支持所有要求的特性
|
|
||||||
2. **性能优秀** - 流畅的动画和交互
|
|
||||||
3. **样式现代** - 符合当前设计趋势
|
|
||||||
4. **易于使用** - 简洁的 API 设计
|
|
||||||
5. **高度可定制** - 灵活的配置选项
|
|
||||||
6. **无障碍友好** - 支持各种用户需求
|
|
||||||
|
|
||||||
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
|
||||||
@@ -41,6 +41,10 @@ export default defineConfig({
|
|||||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '鸣谢名单',
|
||||||
|
link: '/guide/sponsorship'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ module.exports = {
|
|||||||
> - kg 酷狗音乐 |
|
> - kg 酷狗音乐 |
|
||||||
> - mg 咪咕音乐 |
|
> - mg 咪咕音乐 |
|
||||||
> - kw 酷我音乐
|
> - kw 酷我音乐
|
||||||
>
|
|
||||||
> - 导出
|
> - 导出
|
||||||
>
|
>
|
||||||
> ```javascript
|
> ```javascript
|
||||||
|
|||||||
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8
docs/guide/sponsorship.md
Normal file
8
docs/guide/sponsorship.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 赞助名单
|
||||||
|
|
||||||
|
## 鸣谢
|
||||||
|
|
||||||
|
| 昵称 | 赞助金额 |
|
||||||
|
| :------------------------: | :------: |
|
||||||
|
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
||||||
|
| **群友**:🍀 | 5 |
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
|
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
|
||||||
- [ ] 导航上面这几个按钮可以稍微优化一下
|
- [ ] 导航上面这几个按钮可以稍微优化一下
|
||||||
|
- [x] 支持在线导入插件
|
||||||
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
|
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
|
||||||
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
|
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
|
||||||
- [x] 点击搜索框的 源图标实现快速切换
|
- [x] 点击搜索框的 源图标实现快速切换
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## 日志
|
## 日志
|
||||||
|
|
||||||
- ###### 2025-9-22 (v1.3.7)
|
- ###### 2025-9-26 (v1.3.8)
|
||||||
|
1. 写入歌曲tag信息
|
||||||
|
2. 歌曲下载 选择音质
|
||||||
|
3. 歌单 头部自动压缩
|
||||||
|
|
||||||
|
- ###### 2025-9-25 (v1.3.7)
|
||||||
1. 歌单
|
1. 歌单
|
||||||
- 新增右键移除歌曲
|
- 新增右键移除歌曲
|
||||||
- local 页歌单右键操作
|
- local 页歌单右键操作
|
||||||
@@ -11,7 +15,6 @@
|
|||||||
2. debug:右键菜单二级菜单位置决策
|
2. debug:右键菜单二级菜单位置决策
|
||||||
|
|
||||||
- ###### 2025-9-22 (v1.3.6)
|
- ###### 2025-9-22 (v1.3.6)
|
||||||
|
|
||||||
1. 歌单列表可以右键操作
|
1. 歌单列表可以右键操作
|
||||||
- 播放
|
- 播放
|
||||||
- 下载
|
- 下载
|
||||||
@@ -21,7 +24,6 @@
|
|||||||
3. 搜索页切换源重新加载
|
3. 搜索页切换源重新加载
|
||||||
|
|
||||||
- ###### 2025-9-22 (v1.3.5)
|
- ###### 2025-9-22 (v1.3.5)
|
||||||
|
|
||||||
1. 软件启动位置 宽高记忆 限制软件最大宽高
|
1. 软件启动位置 宽高记忆 限制软件最大宽高
|
||||||
2. debug: 修复歌曲音质支持短缺问题
|
2. debug: 修复歌曲音质支持短缺问题
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ files:
|
|||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
|
- node_modules/ffmpeg-static/**
|
||||||
win:
|
win:
|
||||||
executableName: ceru-music
|
executableName: ceru-music
|
||||||
icon: 'resources/icons/icon.ico'
|
icon: 'resources/icons/icon.ico'
|
||||||
@@ -20,18 +21,16 @@ win:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- ia32
|
- ia32
|
||||||
# 简化版本信息设置,避免rcedit错误
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- ia32
|
||||||
fileAssociations:
|
fileAssociations:
|
||||||
- ext: cerumusic
|
- ext: cerumusic
|
||||||
name: CeruMusic File
|
name: CeruMusic File
|
||||||
description: CeruMusic playlist file
|
description: CeruMusic playlist file
|
||||||
# 如果有证书文件,取消注释以下配置
|
|
||||||
# certificateFile: path/to/certificate.p12
|
|
||||||
# certificatePassword: your-password
|
|
||||||
# 或者使用证书存储
|
|
||||||
# certificateSubjectName: "Your Company Name"
|
|
||||||
nsis:
|
nsis:
|
||||||
artifactName: ${name}-${version}-${arch}-setup.${ext}
|
artifactName: ${name}-${version}-win-${arch}-setup.${ext}
|
||||||
shortcutName: ${productName}
|
shortcutName: ${productName}
|
||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
@@ -43,26 +42,43 @@ nsis:
|
|||||||
mac:
|
mac:
|
||||||
icon: 'resources/icons/icon.icns'
|
icon: 'resources/icons/icon.icns'
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
|
target:
|
||||||
|
- target: dmg
|
||||||
|
arch:
|
||||||
|
- universal
|
||||||
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- universal
|
||||||
extendInfo:
|
extendInfo:
|
||||||
- NSDocumentsFolderUsageDescription: 需要访问文档文件夹来保存和打开您创建的文件。
|
- NSDocumentsFolderUsageDescription: 需要访问文档文件夹来保存和打开您创建的文件。
|
||||||
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的内容。
|
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的歌曲。
|
||||||
notarize: false
|
notarize: false
|
||||||
dmg:
|
dmg:
|
||||||
artifactName: ${name}-${version}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
title: ${productName}
|
title: ${productName}
|
||||||
linux:
|
linux:
|
||||||
icon: 'resources/icons'
|
icon: 'resources/icons'
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- target: AppImage
|
||||||
- snap
|
arch:
|
||||||
- deb
|
- x64
|
||||||
|
- target: snap
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- target: deb
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
maintainer: electronjs.org
|
maintainer: electronjs.org
|
||||||
category: Utility
|
category: Utility
|
||||||
appImage:
|
appImage:
|
||||||
artifactName: ${name}-${version}.${ext}
|
artifactName: ${name}-${version}-linux-${arch}.${ext}
|
||||||
|
snap:
|
||||||
|
artifactName: ${name}-${version}-linux-${arch}.${ext}
|
||||||
|
deb:
|
||||||
|
artifactName: ${name}-${version}-linux-${arch}.${ext}
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
publish:
|
publish:
|
||||||
provider: generic
|
provider: generic
|
||||||
url: https://update.ceru.shiqianjiang.cn
|
url: https://update.ceru.shiqianjiang.cn
|
||||||
electronDownload:
|
electronDownload:
|
||||||
mirror: https://npmmirror.com/mirrors/electron/
|
mirror: https://npmmirror.com/mirrors/electron/
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// electron.vite.config.ts
|
|
||||||
import { resolve } from 'path'
|
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
|
||||||
import AutoImport from 'unplugin-auto-import/vite'
|
|
||||||
import Components from 'unplugin-vue-components/vite'
|
|
||||||
import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
|
|
||||||
import wasm from 'vite-plugin-wasm'
|
|
||||||
import topLevelAwait from 'vite-plugin-top-level-await'
|
|
||||||
var electron_vite_config_default = defineConfig({
|
|
||||||
main: {
|
|
||||||
plugins: [externalizeDepsPlugin()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@common': resolve('src/common')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
preload: {
|
|
||||||
plugins: [externalizeDepsPlugin()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@common': resolve('src/common')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderer: {
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
vueDevTools(),
|
|
||||||
wasm(),
|
|
||||||
topLevelAwait(),
|
|
||||||
AutoImport({
|
|
||||||
resolvers: [
|
|
||||||
TDesignResolver({
|
|
||||||
library: 'vue-next'
|
|
||||||
})
|
|
||||||
],
|
|
||||||
dts: true
|
|
||||||
}),
|
|
||||||
Components({
|
|
||||||
resolvers: [
|
|
||||||
TDesignResolver({
|
|
||||||
library: 'vue-next'
|
|
||||||
})
|
|
||||||
],
|
|
||||||
dts: true
|
|
||||||
})
|
|
||||||
],
|
|
||||||
base: './',
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@renderer': resolve('src/renderer/src'),
|
|
||||||
'@assets': resolve('src/renderer/src/assets'),
|
|
||||||
'@components': resolve('src/renderer/src/components'),
|
|
||||||
'@services': resolve('src/renderer/src/services'),
|
|
||||||
'@types': resolve('src/renderer/src/types'),
|
|
||||||
'@store': resolve('src/renderer/src/store'),
|
|
||||||
'@common': resolve('src/common')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
export { electron_vite_config_default as default }
|
|
||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.3.7",
|
"version": "1.3.11",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -18,9 +18,12 @@
|
|||||||
"onlybuild": "electron-vite build && electron-builder --win --x64",
|
"onlybuild": "electron-vite build && electron-builder --win --x64",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "yarn run build && electron-builder --dir",
|
"build:unpack": "yarn run build && electron-builder --dir",
|
||||||
"build:win": "yarn run build && electron-builder --win --config --publish never",
|
"build:win": "yarn run build && electron-builder --win --x64 --config --publish never",
|
||||||
"build:win32": "yarn run build && electron-builder --win --ia32 --config --publish never",
|
"build:win32": "yarn run build && electron-builder --win --ia32 --config --publish never",
|
||||||
"build:mac": "yarn run build && electron-builder --mac --config --publish never",
|
"build:mac": "yarn run build && electron-builder --mac --config --publish never",
|
||||||
|
"build:mac:intel": "yarn run build && electron-builder --mac --x64 --config --publish never",
|
||||||
|
"build:mac:arm64": "yarn run build && electron-builder --mac --arm64 --config --publish never",
|
||||||
|
"build:mac:universal": "yarn run build && electron-builder --mac --universal --config --publish never",
|
||||||
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
|
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
|
||||||
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
|
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
|
||||||
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
|
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
|
||||||
@@ -44,6 +47,7 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/needle": "^3.3.0",
|
"@types/needle": "^3.3.0",
|
||||||
"NeteaseCloudMusicApi": "^4.27.0",
|
"NeteaseCloudMusicApi": "^4.27.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
@@ -53,6 +57,8 @@
|
|||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"hpagent": "^1.2.0",
|
"hpagent": "^1.2.0",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
@@ -63,7 +69,9 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"needle": "^3.3.1",
|
"needle": "^3.3.1",
|
||||||
"node-fetch": "2",
|
"node-fetch": "2",
|
||||||
|
"node-id3": "^0.2.9",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"tdesign-icons-vue-next": "^0.4.1",
|
||||||
"tdesign-vue-next": "^1.15.2",
|
"tdesign-vue-next": "^1.15.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"zlib": "^1.0.5"
|
"zlib": "^1.0.5"
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.downloadAndAddPlugin(url, type)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error downloading and adding plugin:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
|
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
return await pluginService.addPlugin(pluginCode, pluginName)
|
return await pluginService.addPlugin(pluginCode, pluginName)
|
||||||
|
|||||||
@@ -19,9 +19,416 @@ import axios from 'axios'
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { configManager } from '../ConfigManager'
|
import { configManager } from '../ConfigManager'
|
||||||
|
import NodeID3 from 'node-id3'
|
||||||
|
import ffmpegStatic from 'ffmpeg-static'
|
||||||
|
import ffmpeg from 'fluent-ffmpeg'
|
||||||
|
|
||||||
const fileLock: Record<string, boolean> = {}
|
const fileLock: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换LRC格式
|
||||||
|
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
|
||||||
|
* @param lrcContent 原始LRC内容
|
||||||
|
* @returns 转换后的LRC内容
|
||||||
|
*/
|
||||||
|
function convertLrcFormat(lrcContent: string): string {
|
||||||
|
if (!lrcContent) return ''
|
||||||
|
|
||||||
|
const lines = lrcContent.split('\n')
|
||||||
|
const convertedLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 跳过空行
|
||||||
|
if (!line.trim()) {
|
||||||
|
convertedLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||||
|
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
|
||||||
|
if (newFormatMatch) {
|
||||||
|
const [, startTimeMs, , content] = newFormatMatch
|
||||||
|
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
|
||||||
|
convertedLines.push(convertedLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||||
|
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
|
||||||
|
if (oldFormatMatch) {
|
||||||
|
const [, timestamp, content] = oldFormatMatch
|
||||||
|
|
||||||
|
// 如果内容中没有位置信息,直接返回原行
|
||||||
|
if (!content.includes('(') || !content.includes(')')) {
|
||||||
|
convertedLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertedLine = convertOldFormat(timestamp, content)
|
||||||
|
convertedLines.push(convertedLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他行直接保留
|
||||||
|
convertedLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
|
||||||
|
* @param timeMs 毫秒时间戳
|
||||||
|
* @returns 格式化的时间字符串
|
||||||
|
*/
|
||||||
|
function formatTimestamp(timeMs: number): string {
|
||||||
|
const minutes = Math.floor(timeMs / 60000)
|
||||||
|
const seconds = Math.floor((timeMs % 60000) / 1000)
|
||||||
|
const milliseconds = timeMs % 1000
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||||
|
*/
|
||||||
|
function convertNewFormat(baseTimeMs: number, content: string): string {
|
||||||
|
const baseTimestamp = formatTimestamp(baseTimeMs)
|
||||||
|
let convertedContent = `<${baseTimestamp}>`
|
||||||
|
|
||||||
|
// 匹配模式:(开始时间,字符持续时间,0)字符
|
||||||
|
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
|
||||||
|
let match
|
||||||
|
let isFirstChar = true
|
||||||
|
|
||||||
|
while ((match = charPattern.exec(content)) !== null) {
|
||||||
|
const [, charStartMs, , , char] = match
|
||||||
|
const charTimeMs = parseInt(charStartMs)
|
||||||
|
const charTimestamp = formatTimestamp(charTimeMs)
|
||||||
|
|
||||||
|
if (isFirstChar) {
|
||||||
|
// 第一个字符直接添加
|
||||||
|
convertedContent += char.trim()
|
||||||
|
isFirstChar = false
|
||||||
|
} else {
|
||||||
|
convertedContent += `<${charTimestamp}>${char.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${baseTimestamp}]${convertedContent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||||
|
*/
|
||||||
|
function convertOldFormat(timestamp: string, content: string): string {
|
||||||
|
// 解析基础时间戳(毫秒)
|
||||||
|
const [minutes, seconds] = timestamp.split(':')
|
||||||
|
const [sec, ms] = seconds.split('.')
|
||||||
|
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
|
||||||
|
|
||||||
|
let convertedContent = `<${timestamp}>`
|
||||||
|
|
||||||
|
// 匹配所有字符(偏移,持续时间)的模式
|
||||||
|
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
|
||||||
|
let match
|
||||||
|
let lastIndex = 0
|
||||||
|
let isFirstChar = true
|
||||||
|
|
||||||
|
while ((match = charPattern.exec(content)) !== null) {
|
||||||
|
const [fullMatch, char, offsetMs, _durationMs] = match
|
||||||
|
const charTimeMs = baseTimeMs + parseInt(offsetMs)
|
||||||
|
const charTimestamp = formatTimestamp(charTimeMs)
|
||||||
|
|
||||||
|
// 添加匹配前的普通文本
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const beforeText = content.substring(lastIndex, match.index)
|
||||||
|
if (beforeText.trim()) {
|
||||||
|
convertedContent += beforeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加带时间戳的字符
|
||||||
|
if (isFirstChar) {
|
||||||
|
// 第一个字符直接添加,不需要额外的时间戳
|
||||||
|
convertedContent += char
|
||||||
|
isFirstChar = false
|
||||||
|
} else {
|
||||||
|
convertedContent += `<${charTimestamp}>${char}`
|
||||||
|
}
|
||||||
|
lastIndex = match.index + fullMatch.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余的普通文本
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
const remainingText = content.substring(lastIndex)
|
||||||
|
if (remainingText.trim()) {
|
||||||
|
convertedContent += remainingText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${timestamp}]${convertedContent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入音频标签的辅助函数
|
||||||
|
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
try {
|
||||||
|
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
|
||||||
|
|
||||||
|
// 获取文件扩展名来判断格式
|
||||||
|
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
|
||||||
|
console.log('文件格式:', fileExtension)
|
||||||
|
|
||||||
|
// 根据文件格式选择不同的标签写入方法
|
||||||
|
if (fileExtension === 'mp3') {
|
||||||
|
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
|
||||||
|
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
|
||||||
|
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
} else {
|
||||||
|
console.warn('不支持的音频格式:', fileExtension)
|
||||||
|
// 尝试使用 NodeID3 作为后备方案
|
||||||
|
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入音频标签时发生错误:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP3 格式标签写入
|
||||||
|
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
const tags: any = {}
|
||||||
|
|
||||||
|
// 写入基础信息
|
||||||
|
if (tagWriteOptions.basicInfo) {
|
||||||
|
tags.title = songInfo.name || ''
|
||||||
|
tags.artist = songInfo.singer || ''
|
||||||
|
tags.album = songInfo.albumName || ''
|
||||||
|
tags.year = songInfo.year || ''
|
||||||
|
tags.genre = songInfo.genre || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入歌词
|
||||||
|
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||||
|
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||||
|
tags.unsynchronisedLyrics = {
|
||||||
|
language: 'chi',
|
||||||
|
shortText: 'Lyrics',
|
||||||
|
text: convertedLrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入封面
|
||||||
|
if (tagWriteOptions.cover && songInfo.img) {
|
||||||
|
try {
|
||||||
|
const coverResponse = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: songInfo.img,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coverResponse.data) {
|
||||||
|
tags.image = {
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
type: {
|
||||||
|
id: 3,
|
||||||
|
name: 'front cover'
|
||||||
|
},
|
||||||
|
description: 'Cover',
|
||||||
|
imageBuffer: Buffer.from(coverResponse.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (coverError) {
|
||||||
|
console.warn('获取封面失败:', coverError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入标签到文件
|
||||||
|
if (Object.keys(tags).length > 0) {
|
||||||
|
const success = NodeID3.write(tags, filePath)
|
||||||
|
if (success) {
|
||||||
|
console.log('MP3音频标签写入成功:', filePath)
|
||||||
|
} else {
|
||||||
|
console.warn('MP3音频标签写入失败:', filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
|
||||||
|
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||||
|
try {
|
||||||
|
console.log('开始写入 FLAC 标签:', filePath)
|
||||||
|
|
||||||
|
// 准备新的标签数据
|
||||||
|
const newTags: any = {}
|
||||||
|
|
||||||
|
// 写入基础信息
|
||||||
|
if (tagWriteOptions.basicInfo) {
|
||||||
|
if (songInfo.name) newTags.TITLE = songInfo.name
|
||||||
|
if (songInfo.singer) newTags.ARTIST = songInfo.singer
|
||||||
|
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
|
||||||
|
if (songInfo.year) newTags.DATE = songInfo.year.toString()
|
||||||
|
if (songInfo.genre) newTags.GENRE = songInfo.genre
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入歌词
|
||||||
|
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||||
|
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||||
|
newTags.LYRICS = convertedLrc
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('准备写入的标签:', newTags)
|
||||||
|
|
||||||
|
// 使用 ffmpeg-static 写入 FLAC 标签
|
||||||
|
if (path.extname(filePath).toLowerCase() === '.flac') {
|
||||||
|
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
|
||||||
|
} else {
|
||||||
|
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入 Vorbis Comment 标签失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 fluent-ffmpeg 写入 FLAC 标签
|
||||||
|
async function writeFLACTagsWithFFmpeg(
|
||||||
|
filePath: string,
|
||||||
|
tags: any,
|
||||||
|
songInfo: any,
|
||||||
|
tagWriteOptions: any
|
||||||
|
) {
|
||||||
|
let tempOutputPath: string | null = null
|
||||||
|
let tempCoverPath: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!ffmpegStatic) {
|
||||||
|
throw new Error('ffmpeg-static 不可用')
|
||||||
|
}
|
||||||
|
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
|
||||||
|
|
||||||
|
// 创建临时输出文件
|
||||||
|
tempOutputPath = filePath + '.temp.flac'
|
||||||
|
|
||||||
|
// 创建 fluent-ffmpeg 实例
|
||||||
|
let command = ffmpeg(filePath)
|
||||||
|
.audioCodec('copy') // 复制音频编解码器,不重新编码
|
||||||
|
.output(tempOutputPath)
|
||||||
|
|
||||||
|
// 添加元数据标签
|
||||||
|
for (const [key, value] of Object.entries(tags)) {
|
||||||
|
if (value) {
|
||||||
|
// fluent-ffmpeg 会自动处理特殊字符转义
|
||||||
|
command = command.outputOptions(['-metadata', `${key}=${value}`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理封面
|
||||||
|
if (tagWriteOptions.cover && songInfo.img) {
|
||||||
|
try {
|
||||||
|
console.log('开始下载封面:', songInfo.img)
|
||||||
|
const coverResponse = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: songInfo.img,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coverResponse.data) {
|
||||||
|
// 保存临时封面文件
|
||||||
|
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
|
||||||
|
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
|
||||||
|
|
||||||
|
// 添加封面作为输入
|
||||||
|
command = command.input(tempCoverPath).outputOptions([
|
||||||
|
'-map',
|
||||||
|
'0:a', // 映射原始文件的音频流
|
||||||
|
'-map',
|
||||||
|
'1:v', // 映射封面的视频流
|
||||||
|
'-c:v',
|
||||||
|
'copy', // 复制视频编解码器
|
||||||
|
'-disposition:v:0',
|
||||||
|
'attached_pic' // 设置为附加图片
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('封面已添加到命令中')
|
||||||
|
}
|
||||||
|
} catch (coverError) {
|
||||||
|
console.warn(
|
||||||
|
'下载封面失败,跳过封面写入:',
|
||||||
|
coverError instanceof Error ? coverError.message : coverError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 ffmpeg 命令
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
command
|
||||||
|
.on('start', () => {
|
||||||
|
console.log('执行 ffmpeg 命令')
|
||||||
|
})
|
||||||
|
.on('progress', (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log('处理进度:', Math.round(progress.percent) + '%')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
console.log('ffmpeg 处理完成')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.on('error', (err, _, stderr) => {
|
||||||
|
console.error('ffmpeg 错误:', err.message)
|
||||||
|
if (stderr) {
|
||||||
|
console.error('ffmpeg stderr:', stderr)
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查临时文件是否创建成功
|
||||||
|
if (!fs.existsSync(tempOutputPath)) {
|
||||||
|
throw new Error('ffmpeg 未能创建输出文件')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换原文件
|
||||||
|
await fsPromise.rename(tempOutputPath, filePath)
|
||||||
|
tempOutputPath = null // 标记已处理,避免重复清理
|
||||||
|
|
||||||
|
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 清理所有临时文件
|
||||||
|
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
for (const tempFile of filesToClean) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempFile)) {
|
||||||
|
await fsPromise.unlink(tempFile)
|
||||||
|
console.log('已清理临时文件:', tempFile)
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(
|
||||||
|
'清理临时文件失败:',
|
||||||
|
tempFile,
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : cleanupError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4/M4A 格式标签写入
|
||||||
|
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
|
||||||
|
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
|
||||||
|
// 可以使用 ffmpeg 或其他工具实现
|
||||||
|
}
|
||||||
|
|
||||||
function main(source: string) {
|
function main(source: string) {
|
||||||
const Api = musicSdk[source]
|
const Api = musicSdk[source]
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +498,12 @@ function main(source: string) {
|
|||||||
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
|
async downloadSingleSong({
|
||||||
|
pluginId,
|
||||||
|
songInfo,
|
||||||
|
quality,
|
||||||
|
tagWriteOptions
|
||||||
|
}: DownloadSingleSongArgs) {
|
||||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||||
|
|
||||||
@@ -167,6 +579,16 @@ function main(source: string) {
|
|||||||
delete fileLock[songPath]
|
delete fileLock[songPath]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 写入标签信息
|
||||||
|
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||||
|
try {
|
||||||
|
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('写入音频标签失败:', error)
|
||||||
|
throw ffmpegStatic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: '下载成功',
|
message: '下载成功',
|
||||||
path: songPath
|
path: songPath
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
|
|||||||
info: PlaylistInfo
|
info: PlaylistInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagWriteOptions {
|
||||||
|
basicInfo?: boolean
|
||||||
|
cover?: boolean
|
||||||
|
lyrics?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
||||||
path?: string
|
path?: string
|
||||||
|
tagWriteOptions?: TagWriteOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fsPromise from 'fs/promises'
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { dialog } from 'electron'
|
import { dialog } from 'electron'
|
||||||
import { getAppDirPath } from '../../utils/path'
|
import { getAppDirPath } from '../../utils/path'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
import CeruMusicPluginHost from './manager/CeruMusicPluginHost'
|
import CeruMusicPluginHost from './manager/CeruMusicPluginHost'
|
||||||
import convertEventDrivenPlugin from './manager/converter-event-driven'
|
import convertEventDrivenPlugin from './manager/converter-event-driven'
|
||||||
@@ -223,6 +224,60 @@ const pluginService = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async downloadAndAddPlugin(url: string, type: 'lx' | 'cr') {
|
||||||
|
try {
|
||||||
|
// 验证URL
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
throw new Error('无效的URL地址')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
let pluginCode = await this.downloadFile(url)
|
||||||
|
|
||||||
|
// 生成临时文件名
|
||||||
|
const fileName = `downloaded_${Date.now()}.js`
|
||||||
|
if (type == 'lx') {
|
||||||
|
pluginCode = convertEventDrivenPlugin(pluginCode)
|
||||||
|
}
|
||||||
|
// 调用现有的添加插件方法
|
||||||
|
return await this.addPlugin(pluginCode, fileName)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('下载并添加插件失败:', error)
|
||||||
|
return { error: error.message || '下载插件失败' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadFile(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
timeout: 30000, // 30秒超时
|
||||||
|
responseType: 'text',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'CeruMusic/1.0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`下载失败: HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data
|
||||||
|
if (!data || !data.trim()) {
|
||||||
|
throw new Error('下载的文件内容为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
throw new Error(`下载失败: HTTP ${error.response.status}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error('网络错误: 无法连接到服务器')
|
||||||
|
} else {
|
||||||
|
throw new Error(`下载错误: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async getPluginLog(pluginId: string) {
|
async getPluginLog(pluginId: string) {
|
||||||
return await getLog(pluginId)
|
return await getLog(pluginId)
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/preload/index.d.ts
vendored
1
src/preload/index.d.ts
vendored
@@ -68,6 +68,7 @@ interface CustomAPI {
|
|||||||
// 插件管理API
|
// 插件管理API
|
||||||
plugins: {
|
plugins: {
|
||||||
selectAndAddPlugin: (type: 'lx' | 'cr') => Promise<any>
|
selectAndAddPlugin: (type: 'lx' | 'cr') => Promise<any>
|
||||||
|
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') => Promise<any>
|
||||||
uninstallPlugin(pluginId: string): ApiResult | PromiseLike<ApiResult>
|
uninstallPlugin(pluginId: string): ApiResult | PromiseLike<ApiResult>
|
||||||
addPlugin: (pluginCode: string, pluginName: string) => Promise<any>
|
addPlugin: (pluginCode: string, pluginName: string) => Promise<any>
|
||||||
getPluginById: (id: string) => Promise<any>
|
getPluginById: (id: string) => Promise<any>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const api = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
selectAndAddPlugin: (type: 'lx' | 'cr') =>
|
selectAndAddPlugin: (type: 'lx' | 'cr') =>
|
||||||
ipcRenderer.invoke('service-plugin-selectAndAddPlugin', type),
|
ipcRenderer.invoke('service-plugin-selectAndAddPlugin', type),
|
||||||
|
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') =>
|
||||||
|
ipcRenderer.invoke('service-plugin-downloadAndAddPlugin', url, type),
|
||||||
addPlugin: (pluginCode: string, pluginName: string) =>
|
addPlugin: (pluginCode: string, pluginName: string) =>
|
||||||
ipcRenderer.invoke('service-plugin-addPlugin', pluginCode, pluginName),
|
ipcRenderer.invoke('service-plugin-addPlugin', pluginCode, pluginName),
|
||||||
getPluginById: (id: string) => ipcRenderer.invoke('service-plugin-getPluginById', id),
|
getPluginById: (id: string) => ipcRenderer.invoke('service-plugin-getPluginById', id),
|
||||||
|
|||||||
2
src/renderer/auto-imports.d.ts
vendored
2
src/renderer/auto-imports.d.ts
vendored
@@ -6,5 +6,5 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
|
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/renderer/components.d.ts
vendored
24
src/renderer/components.d.ts
vendored
@@ -23,35 +23,13 @@ declare module 'vue' {
|
|||||||
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
|
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
|
||||||
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
|
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
|
||||||
PluginNoticeDialog: typeof import('./src/components/PluginNoticeDialog.vue')['default']
|
PluginNoticeDialog: typeof import('./src/components/PluginNoticeDialog.vue')['default']
|
||||||
|
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
||||||
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
||||||
TAlert: typeof import('tdesign-vue-next')['Alert']
|
|
||||||
TAside: typeof import('tdesign-vue-next')['Aside']
|
|
||||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
|
||||||
TButton: typeof import('tdesign-vue-next')['Button']
|
|
||||||
TCard: typeof import('tdesign-vue-next')['Card']
|
|
||||||
TContent: typeof import('tdesign-vue-next')['Content']
|
|
||||||
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
|
||||||
TDivider: typeof import('tdesign-vue-next')['Divider']
|
|
||||||
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
|
|
||||||
TForm: typeof import('tdesign-vue-next')['Form']
|
|
||||||
TFormItem: typeof import('tdesign-vue-next')['FormItem']
|
|
||||||
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
|
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
|
||||||
TIcon: typeof import('tdesign-vue-next')['Icon']
|
|
||||||
TImage: typeof import('tdesign-vue-next')['Image']
|
|
||||||
TInput: typeof import('tdesign-vue-next')['Input']
|
|
||||||
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
||||||
TLayout: typeof import('tdesign-vue-next')['Layout']
|
|
||||||
TLoading: typeof import('tdesign-vue-next')['Loading']
|
|
||||||
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
|
|
||||||
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
|
|
||||||
TSlider: typeof import('tdesign-vue-next')['Slider']
|
|
||||||
TSwitch: typeof import('tdesign-vue-next')['Switch']
|
|
||||||
TTag: typeof import('tdesign-vue-next')['Tag']
|
|
||||||
TTextarea: typeof import('tdesign-vue-next')['Textarea']
|
|
||||||
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
|
|
||||||
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']
|
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']
|
||||||
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
|
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
|
||||||
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']
|
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌曲信息 -->
|
<!-- 歌曲信息 -->
|
||||||
<div class="col-title" @dblclick="handleAddToPlaylist(song)">
|
<div class="col-title" @click="handleSongClick(song)">
|
||||||
<div v-if="song.img" class="song-cover">
|
<div v-if="song.img" class="song-cover">
|
||||||
<img :src="song.img" loading="lazy" alt="封面" />
|
<img :src="song.img" loading="lazy" alt="封面" />
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +175,11 @@ const scrollTop = ref(0)
|
|||||||
const visibleStartIndex = ref(0)
|
const visibleStartIndex = ref(0)
|
||||||
const visibleEndIndex = ref(0)
|
const visibleEndIndex = ref(0)
|
||||||
|
|
||||||
|
// 点击防抖相关状态
|
||||||
|
let clickTimer: NodeJS.Timeout | null = null
|
||||||
|
let lastClickTime = 0
|
||||||
|
const doubleClickDelay = 300 // 300ms 内的第二次点击视为双击
|
||||||
|
|
||||||
// 右键菜单相关状态
|
// 右键菜单相关状态
|
||||||
const contextMenuVisible = ref(false)
|
const contextMenuVisible = ref(false)
|
||||||
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
|
||||||
@@ -209,21 +214,9 @@ const visibleItems = computed(() => {
|
|||||||
return props.songs.slice(visibleStartIndex.value, visibleEndIndex.value)
|
return props.songs.slice(visibleStartIndex.value, visibleEndIndex.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否为当前歌曲
|
|
||||||
const isCurrentSong = (song: Song) => {
|
|
||||||
return (
|
|
||||||
props.currentSong &&
|
|
||||||
(song.id === props.currentSong.id || song.songmid === props.currentSong.songmid)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理播放
|
// 处理播放
|
||||||
const handlePlay = (song: Song) => {
|
const handlePlay = (song: Song) => {
|
||||||
if (isCurrentSong(song) && props.isPlaying) {
|
emit('play', song)
|
||||||
emit('pause')
|
|
||||||
} else {
|
|
||||||
emit('play', song)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理添加到播放列表
|
// 处理添加到播放列表
|
||||||
@@ -231,6 +224,31 @@ const handleAddToPlaylist = (song: Song) => {
|
|||||||
emit('addToPlaylist', song)
|
emit('addToPlaylist', song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理歌曲点击事件
|
||||||
|
const handleSongClick = (song: Song) => {
|
||||||
|
const currentTime = Date.now()
|
||||||
|
const timeDiff = currentTime - lastClickTime
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (clickTimer) {
|
||||||
|
clearTimeout(clickTimer)
|
||||||
|
clickTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeDiff < doubleClickDelay && timeDiff > 0) {
|
||||||
|
// 双击:立即执行播放操作
|
||||||
|
handlePlay(song)
|
||||||
|
lastClickTime = 0 // 重置时间,防止三击
|
||||||
|
} else {
|
||||||
|
// 单击:延迟执行添加到播放列表
|
||||||
|
lastClickTime = currentTime
|
||||||
|
clickTimer = setTimeout(() => {
|
||||||
|
handleAddToPlaylist(song)
|
||||||
|
clickTimer = null
|
||||||
|
}, doubleClickDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化时长
|
// 格式化时长
|
||||||
const formatDuration = (duration: string | number) => {
|
const formatDuration = (duration: string | number) => {
|
||||||
if (!duration) return '--:--'
|
if (!duration) return '--:--'
|
||||||
@@ -326,10 +344,10 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
// 添加分隔线
|
|
||||||
baseItems.push(createSeparator())
|
|
||||||
// 如果是本地歌单,添加"移出本地歌单"选项
|
// 如果是本地歌单,添加"移出本地歌单"选项
|
||||||
if (props.isLocalPlaylist) {
|
if (props.isLocalPlaylist) {
|
||||||
|
// 添加分隔线
|
||||||
|
baseItems.push(createSeparator())
|
||||||
baseItems.push(
|
baseItems.push(
|
||||||
createMenuItem('removeFromLocalPlaylist', '移出当前歌单', {
|
createMenuItem('removeFromLocalPlaylist', '移出当前歌单', {
|
||||||
icon: DeleteIcon,
|
icon: DeleteIcon,
|
||||||
|
|||||||
@@ -394,8 +394,10 @@ const lightMainColor = computed(() => {
|
|||||||
.fullscreen-btn,
|
.fullscreen-btn,
|
||||||
.putawayscreen-btn {
|
.putawayscreen-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 40px;
|
|
||||||
left: 40px;
|
-webkit-app-region: no-drag;
|
||||||
|
top: 25px;
|
||||||
|
left: 30px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
@@ -442,7 +444,7 @@ const lightMainColor = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.putawayscreen-btn {
|
.putawayscreen-btn {
|
||||||
left: 100px;
|
left: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-play {
|
.full-play {
|
||||||
@@ -468,8 +470,8 @@ const lightMainColor = computed(() => {
|
|||||||
.top {
|
.top {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: calc(100% - 200px);
|
width: calc(100% - 200px);
|
||||||
margin-left: 200px;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
right: 0;
|
||||||
padding: 30px 30px;
|
padding: 30px 30px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -691,8 +693,8 @@ const lightMainColor = computed(() => {
|
|||||||
|
|
||||||
// bottom: max(2vw, 29px);
|
// bottom: max(2vw, 29px);
|
||||||
|
|
||||||
height: 200%;
|
height: 100%;
|
||||||
transform: translateY(-25%);
|
// transform: translateY(-25%);
|
||||||
|
|
||||||
* [class^='lyricMainLine'] {
|
* [class^='lyricMainLine'] {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
|||||||
@@ -1011,7 +1011,7 @@ watch(showFullPlay, (val) => {
|
|||||||
/* 进度条样式 */
|
/* 进度条样式 */
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// padding-top: 2px;
|
// padding-top: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1019,7 +1019,7 @@ watch(showFullPlay, (val) => {
|
|||||||
|
|
||||||
&:has(.progress-handle.dragging, *:hover) {
|
&:has(.progress-handle.dragging, *:hover) {
|
||||||
// margin-bottom: 0;
|
// margin-bottom: 0;
|
||||||
height: 4px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { ref, computed, nextTick, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onUnmounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
|
import { MessagePlugin, Popconfirm } from 'tdesign-vue-next'
|
||||||
|
import { LocationIcon, DeleteIcon } from 'tdesign-icons-vue-next'
|
||||||
import type { SongList } from '@renderer/types/audio'
|
import type { SongList } from '@renderer/types/audio'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -413,6 +415,33 @@ onUnmounted(() => {
|
|||||||
stopAutoScroll()
|
stopAutoScroll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 清空播放列表
|
||||||
|
const handleClearPlaylist = () => {
|
||||||
|
if (list.value.length === 0) {
|
||||||
|
MessagePlugin.warning('播放列表已为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localUserStore.clearList()
|
||||||
|
MessagePlugin.success('播放列表已清空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定位到当前播放歌曲
|
||||||
|
const handleLocateCurrentSong = () => {
|
||||||
|
if (!props.currentSongId) {
|
||||||
|
MessagePlugin.info('当前没有正在播放的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSongExists = list.value.some((song) => song.songmid === props.currentSongId)
|
||||||
|
if (!currentSongExists) {
|
||||||
|
MessagePlugin.warning('当前播放的歌曲不在播放列表中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToCurrentSong()
|
||||||
|
}
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToCurrentSong
|
scrollToCurrentSong
|
||||||
@@ -484,6 +513,35 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作按钮 -->
|
||||||
|
<div v-if="list.length > 0" class="playlist-footer">
|
||||||
|
<button
|
||||||
|
class="playlist-action-btn locate-btn"
|
||||||
|
:disabled="!currentSongId"
|
||||||
|
@click="handleLocateCurrentSong"
|
||||||
|
>
|
||||||
|
<LocationIcon size="16" />
|
||||||
|
<span>定位当前播放</span>
|
||||||
|
</button>
|
||||||
|
<Popconfirm
|
||||||
|
content="确定要清空播放列表吗?此操作不可撤销。"
|
||||||
|
:confirm-btn="{ content: '确认清空', theme: 'danger' }"
|
||||||
|
cancel-btn="取消"
|
||||||
|
placement="top"
|
||||||
|
theme="warning"
|
||||||
|
:popup-props="{
|
||||||
|
zIndex: 9999,
|
||||||
|
overlayStyle: { zIndex: 9998 }
|
||||||
|
}"
|
||||||
|
@confirm="handleClearPlaylist"
|
||||||
|
>
|
||||||
|
<button class="playlist-action-btn clear-btn">
|
||||||
|
<DeleteIcon size="16" />
|
||||||
|
<span>清空播放列表</span>
|
||||||
|
</button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
@@ -509,6 +567,7 @@ defineExpose({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #333;
|
color: #333;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
overflow: hidden;
|
||||||
/* 初始位置 */
|
/* 初始位置 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +598,7 @@ defineExpose({
|
|||||||
|
|
||||||
/* 全屏模式下的滚动条样式 - 只显示滑块 */
|
/* 全屏模式下的滚动条样式 - 只显示滑块 */
|
||||||
.playlist-container .playlist-content {
|
.playlist-container .playlist-content {
|
||||||
|
scrollbar-arrow-color: transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(91, 91, 91, 0.3) transparent;
|
scrollbar-color: rgba(91, 91, 91, 0.3) transparent;
|
||||||
}
|
}
|
||||||
@@ -834,6 +894,153 @@ defineExpose({
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 播放列表底部操作按钮 */
|
||||||
|
.playlist-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
// background: rgba(255, 255, 255, 0.3);
|
||||||
|
// backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container.full-screen-mode .playlist-footer {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
// background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn {
|
||||||
|
background: rgba(35, 115, 206, 0.1);
|
||||||
|
color: #2373ce;
|
||||||
|
border: 1px solid rgba(35, 115, 206, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(35, 115, 206, 0.15);
|
||||||
|
border-color: rgba(35, 115, 206, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #999;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: rgba(229, 72, 77, 0.1);
|
||||||
|
color: #e5484d;
|
||||||
|
border: 1px solid rgba(229, 72, 77, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
background: rgba(229, 72, 77, 0.15);
|
||||||
|
border-color: rgba(229, 72, 77, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /* 全屏模式下的按钮样式 */
|
||||||
|
// .playlist-container.full-screen-mode .locate-btn {
|
||||||
|
// background: rgba(255, 255, 255, 0.1);
|
||||||
|
// color: #87ceeb;
|
||||||
|
// border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode .locate-btn:hover:not(:disabled) {
|
||||||
|
// background: rgba(255, 255, 255, 0.15);
|
||||||
|
// border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode .locate-btn:disabled {
|
||||||
|
// color: #666;
|
||||||
|
// background: rgba(255, 255, 255, 0.05);
|
||||||
|
// border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode .clear-btn {
|
||||||
|
// background: rgba(255, 255, 255, 0.1);
|
||||||
|
// color: #ff6b6b;
|
||||||
|
// border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode .clear-btn:hover {
|
||||||
|
// background: rgba(255, 255, 255, 0.15);
|
||||||
|
// border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /* Popconfirm 样式适配 */
|
||||||
|
// .playlist-container :deep(.t-popup__content) {
|
||||||
|
// background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
// backdrop-filter: blur(20px) !important;
|
||||||
|
// border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
// box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
// border-radius: 8px !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-popup__content) {
|
||||||
|
// background: rgba(0, 0, 0, 0.85) !important;
|
||||||
|
// backdrop-filter: blur(20px) !important;
|
||||||
|
// border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||||
|
// box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-popconfirm__content) {
|
||||||
|
// color: #fff !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-button--theme-default) {
|
||||||
|
// background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
// border-color: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
// color: #fff !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-button--theme-default:hover) {
|
||||||
|
// background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
// border-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-button--theme-danger) {
|
||||||
|
// background: rgba(229, 72, 77, 0.8) !important;
|
||||||
|
// border-color: rgba(229, 72, 77, 0.9) !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container.full-screen-mode :deep(.t-button--theme-danger:hover) {
|
||||||
|
// background: rgba(229, 72, 77, 0.9) !important;
|
||||||
|
// border-color: rgba(229, 72, 77, 1) !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /* 普通模式下的按钮样式优化 */
|
||||||
|
// .playlist-container :deep(.t-button--theme-danger) {
|
||||||
|
// background: rgba(229, 72, 77, 0.1) !important;
|
||||||
|
// border-color: rgba(229, 72, 77, 0.3) !important;
|
||||||
|
// color: #e5484d !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .playlist-container :deep(.t-button--theme-danger:hover) {
|
||||||
|
// background: rgba(229, 72, 77, 0.15) !important;
|
||||||
|
// border-color: rgba(229, 72, 77, 0.4) !important;
|
||||||
|
// }
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.playlist-container {
|
.playlist-container {
|
||||||
@@ -841,5 +1048,22 @@ defineExpose({
|
|||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-footer {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-action-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /* 移动端 Popconfirm 适配 */
|
||||||
|
// :deep(.playlist-popconfirm .t-popup__content),
|
||||||
|
// :deep(.playlist-popconfirm-fullscreen .t-popup__content) {
|
||||||
|
// max-width: 280px;
|
||||||
|
// font-size: 14px;
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<TitleBarControls title="插件管理" :show-back="true" class="header"></TitleBarControls>
|
<!-- <TitleBarControls title="插件管理" :show-back="true" class="header"></TitleBarControls> -->
|
||||||
<div class="plugins-container">
|
<div class="plugins-container">
|
||||||
<h2>插件管理</h2>
|
<div class="plugin-actions-hearder">
|
||||||
|
<h2>插件管理</h2>
|
||||||
|
|
||||||
<div class="plugin-actions">
|
<div class="plugin-actions">
|
||||||
<t-button theme="primary" @click="plugTypeDialog = true">
|
<t-button theme="primary" @click="plugTypeDialog = true">
|
||||||
<template #icon><t-icon name="add" /></template> 添加插件
|
<template #icon><t-icon name="add" /></template> 添加插件
|
||||||
</t-button>
|
</t-button>
|
||||||
<t-dialog
|
<t-dialog
|
||||||
:visible="plugTypeDialog"
|
:visible="plugTypeDialog"
|
||||||
:close-btn="true"
|
:close-btn="true"
|
||||||
confirm-btn="确定"
|
confirm-btn="下一步"
|
||||||
cancel-btn="取消"
|
cancel-btn="取消"
|
||||||
:on-confirm="addPlug"
|
:on-confirm="showImportMethodDialog"
|
||||||
:on-close="() => (plugTypeDialog = false)"
|
:on-close="() => (plugTypeDialog = false)"
|
||||||
>
|
>
|
||||||
<template #header>请选择你的插件类别</template>
|
<template #header>请选择你的插件类别</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<t-radio-group v-model="type" variant="primary-filled" default-value="cr">
|
<t-radio-group v-model="type" variant="primary-filled" default-value="cr">
|
||||||
<t-radio-button value="cr">澜音插件</t-radio-button>
|
<t-radio-button value="cr">澜音插件</t-radio-button>
|
||||||
<t-radio-button value="lx">洛雪插件</t-radio-button>
|
<t-radio-button value="lx">洛雪插件</t-radio-button>
|
||||||
</t-radio-group>
|
</t-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</t-dialog>
|
</t-dialog>
|
||||||
<t-button theme="default" @click="refreshPlugins">
|
|
||||||
<template #icon><t-icon name="refresh" /></template> 刷新
|
<!-- 导入方式选择对话框 -->
|
||||||
</t-button>
|
<t-dialog
|
||||||
|
:visible="importMethodDialog"
|
||||||
|
:close-btn="true"
|
||||||
|
confirm-btn="确定"
|
||||||
|
cancel-btn="返回"
|
||||||
|
:on-confirm="handleImport"
|
||||||
|
:on-close="() => (importMethodDialog = false)"
|
||||||
|
:on-cancel="backToTypeSelection"
|
||||||
|
>
|
||||||
|
<template #header>选择导入方式</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="import-method-container">
|
||||||
|
<t-radio-group
|
||||||
|
v-model="importMethod"
|
||||||
|
variant="primary-filled"
|
||||||
|
default-value="local"
|
||||||
|
>
|
||||||
|
<t-radio-button value="local">本地导入</t-radio-button>
|
||||||
|
<t-radio-button value="online">在线导入</t-radio-button>
|
||||||
|
</t-radio-group>
|
||||||
|
|
||||||
|
<div v-if="importMethod === 'online'" class="online-input-container">
|
||||||
|
<t-input
|
||||||
|
v-model="onlineUrl"
|
||||||
|
placeholder="请输入插件下载地址"
|
||||||
|
size="large"
|
||||||
|
style="margin-top: 15px"
|
||||||
|
/>
|
||||||
|
<p class="hint-text">支持 HTTP/HTTPS 链接,插件文件应为 .js 或 .zip 格式</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="local-hint-container">
|
||||||
|
<p class="hint-text">将从本地文件选择插件文件进行导入</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-dialog>
|
||||||
|
<t-button theme="default" @click="refreshPlugins">
|
||||||
|
<template #icon><t-icon name="refresh" /></template> 刷新
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" class="loading">
|
||||||
@@ -179,7 +220,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
@@ -215,7 +255,10 @@ const plugins = ref<Plugin[]>([])
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const plugTypeDialog = ref(false)
|
const plugTypeDialog = ref(false)
|
||||||
|
const importMethodDialog = ref(false)
|
||||||
const type = ref<'lx' | 'cr'>('cr')
|
const type = ref<'lx' | 'cr'>('cr')
|
||||||
|
const importMethod = ref<'local' | 'online'>('local')
|
||||||
|
const onlineUrl = ref('')
|
||||||
|
|
||||||
// 日志相关状态
|
// 日志相关状态
|
||||||
const logDialogVisible = ref(false)
|
const logDialogVisible = ref(false)
|
||||||
@@ -327,14 +370,52 @@ async function getPlugins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPlug() {
|
// 显示导入方式选择对话框
|
||||||
try {
|
function showImportMethodDialog() {
|
||||||
// 调用主进程的文件选择和添加插件API
|
plugTypeDialog.value = false
|
||||||
plugTypeDialog.value = false
|
importMethodDialog.value = true
|
||||||
console.log(type.value)
|
}
|
||||||
const result = (await window.api.plugins.selectAndAddPlugin(type.value)) as ApiResult
|
|
||||||
|
|
||||||
// 检查用户是否取消了文件选择
|
// 返回到插件类型选择
|
||||||
|
function backToTypeSelection() {
|
||||||
|
importMethodDialog.value = false
|
||||||
|
plugTypeDialog.value = true
|
||||||
|
onlineUrl.value = '' // 清空在线地址
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理导入操作
|
||||||
|
async function handleImport() {
|
||||||
|
try {
|
||||||
|
importMethodDialog.value = false
|
||||||
|
let result: ApiResult
|
||||||
|
|
||||||
|
if (importMethod.value === 'local') {
|
||||||
|
// 本地导入:调用文件选择API
|
||||||
|
result = (await window.api.plugins.selectAndAddPlugin(type.value)) as ApiResult
|
||||||
|
} else {
|
||||||
|
// 在线导入:调用在线下载API
|
||||||
|
if (!onlineUrl.value.trim()) {
|
||||||
|
MessagePlugin.warning('请输入插件下载地址')
|
||||||
|
importMethodDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证URL格式
|
||||||
|
try {
|
||||||
|
new URL(onlineUrl.value)
|
||||||
|
} catch {
|
||||||
|
MessagePlugin.warning('请输入有效的URL地址')
|
||||||
|
importMethodDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result = (await window.api.plugins.downloadAndAddPlugin(
|
||||||
|
onlineUrl.value,
|
||||||
|
type.value
|
||||||
|
)) as ApiResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否取消了操作
|
||||||
if (result && result.canceled) {
|
if (result && result.canceled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -353,6 +434,9 @@ async function addPlug() {
|
|||||||
MessagePlugin.success('插件安装成功!')
|
MessagePlugin.success('插件安装成功!')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
onlineUrl.value = ''
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('安装插件失败:', err)
|
console.error('安装插件失败:', err)
|
||||||
MessagePlugin.error(`安装插件失败: ${err.message || '未知错误'}`)
|
MessagePlugin.error(`安装插件失败: ${err.message || '未知错误'}`)
|
||||||
@@ -514,6 +598,9 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
@@ -540,7 +627,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.plugin-actions {
|
.plugin-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,7 +700,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--color-background-soft, #f8f9fa);
|
background-color: #fefefe;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
@@ -1013,6 +1100,26 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 导入方式选择样式 */
|
||||||
|
.import-method-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-input-container {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-hint-container {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
:deep(.log-dialog) {
|
:deep(.log-dialog) {
|
||||||
@@ -46,12 +46,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
transitionOut: 'animate__fadeOut'
|
transitionOut: 'animate__fadeOut'
|
||||||
},
|
},
|
||||||
component: () => import('@renderer/views/settings/index.vue')
|
component: () => import('@renderer/views/settings/index.vue')
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/plugins',
|
|
||||||
name: 'plugins',
|
|
||||||
component: () => import('@renderer/views/settings/plugins.vue')
|
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// path: '/plugins',
|
||||||
|
// name: 'plugins',
|
||||||
|
// component: () => import('@renderer/views/settings/plugins.vue')
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
function setAnimate(routerObj: RouteRecordRaw[]) {
|
function setAnimate(routerObj: RouteRecordRaw[]) {
|
||||||
for (let i = 0; i < routerObj.length; i++) {
|
for (let i = 0; i < routerObj.length; i++) {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface TagWriteOptions {
|
||||||
|
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
|
||||||
|
cover: boolean // 封面
|
||||||
|
lyrics: boolean // 普通歌词
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
showFloatBall: boolean
|
showFloatBall: boolean
|
||||||
directories?: {
|
directories?: {
|
||||||
cacheDir: string
|
cacheDir: string
|
||||||
downloadDir: string
|
downloadDir: string
|
||||||
}
|
}
|
||||||
|
tagWriteOptions?: TagWriteOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
// 默认设置
|
// 默认设置
|
||||||
return {
|
return {
|
||||||
showFloatBall: true
|
showFloatBall: true,
|
||||||
|
tagWriteOptions: {
|
||||||
|
basicInfo: true,
|
||||||
|
cover: true,
|
||||||
|
lyrics: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NotifyPlugin, MessagePlugin } from 'tdesign-vue-next'
|
import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { toRaw } from 'vue'
|
import { useSettingsStore } from '@renderer/store/Settings'
|
||||||
|
import { toRaw, h } from 'vue'
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
singer: string
|
singer: string
|
||||||
@@ -12,7 +13,7 @@ interface MusicItem {
|
|||||||
songmid: number
|
songmid: number
|
||||||
img: string
|
img: string
|
||||||
lrc: null | string
|
lrc: null | string
|
||||||
types: string[]
|
types: Array<{ type: string; size: string }>
|
||||||
_types: Record<string, any>
|
_types: Record<string, any>
|
||||||
typeUrl: Record<string, any>
|
typeUrl: Record<string, any>
|
||||||
}
|
}
|
||||||
@@ -29,15 +30,166 @@ const qualityMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
const qualityKey = Object.keys(qualityMap)
|
const qualityKey = Object.keys(qualityMap)
|
||||||
|
|
||||||
|
// 创建音质选择弹窗
|
||||||
|
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
|
|
||||||
|
// 获取歌曲支持的音质列表
|
||||||
|
const availableQualities = songInfo.types || []
|
||||||
|
|
||||||
|
// 检查用户设置的音质是否为特殊音质
|
||||||
|
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
|
||||||
|
|
||||||
|
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
|
||||||
|
const qualityOptions = [...availableQualities]
|
||||||
|
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
|
||||||
|
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
|
||||||
|
if (!hasSpecialQuality) {
|
||||||
|
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按音质优先级排序
|
||||||
|
qualityOptions.sort((a, b) => {
|
||||||
|
const aIndex = qualityKey.indexOf(a.type)
|
||||||
|
const bIndex = qualityKey.indexOf(b.type)
|
||||||
|
return bIndex - aIndex // 降序排列,高音质在前
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
header: '选择下载音质(可滚动)',
|
||||||
|
width: 400,
|
||||||
|
placement: 'center',
|
||||||
|
body: () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-selector'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-list',
|
||||||
|
style: {
|
||||||
|
maxHeight:
|
||||||
|
'max(calc(calc(70vh - 2 * var(--td-comp-paddingTB-xxl)) - 24px - 32px - 32px),100px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qualityOptions.map((quality) =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
key: quality.type,
|
||||||
|
class: 'quality-item',
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
margin: '8px 0',
|
||||||
|
border: '1px solid #e7e7e7',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||||
|
},
|
||||||
|
onClick: () => {
|
||||||
|
dialog.destroy()
|
||||||
|
resolve(quality.type)
|
||||||
|
},
|
||||||
|
onMouseenter: (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
target.style.backgroundColor = '#f0f9ff'
|
||||||
|
target.style.borderColor = '#1890ff'
|
||||||
|
},
|
||||||
|
onMouseleave: (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
target.style.backgroundColor =
|
||||||
|
quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||||
|
target.style.borderColor = '#e7e7e7'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', { class: 'quality-info' }, [
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: quality.type === userQuality ? '#1890ff' : '#333'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qualityMap[quality.type] || quality.type
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
marginTop: '2px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quality.type.toUpperCase()
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'quality-size',
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quality.size
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
confirmBtn: null,
|
||||||
|
cancelBtn: null,
|
||||||
|
footer: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const LocalUserDetail = LocalUserDetailStore()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
let quality = LocalUserDetail.userSource.quality as string
|
const userQuality = LocalUserDetail.userSource.quality as string
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
|
// 获取歌词
|
||||||
|
const { crlyric, lyric } = await window.api.music.requestSdk('getLyric', {
|
||||||
|
source: toRaw(songInfo.source),
|
||||||
|
songInfo: toRaw(songInfo) as any
|
||||||
|
})
|
||||||
|
console.log(songInfo)
|
||||||
|
songInfo.lrc = crlyric && songInfo.source !== 'tx' ? crlyric : lyric
|
||||||
|
|
||||||
|
// 显示音质选择弹窗
|
||||||
|
const selectedQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
|
||||||
|
// 如果用户取消选择,直接返回
|
||||||
|
if (!selectedQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let quality = selectedQuality
|
||||||
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
|
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
|
||||||
|
|
||||||
// 如果是特殊音质,先尝试获取对应链接
|
// 如果选择的是特殊音质,先尝试下载
|
||||||
if (isSpecialQuality) {
|
if (isSpecialQuality) {
|
||||||
try {
|
try {
|
||||||
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
|
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
|
||||||
@@ -47,13 +199,14 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||||
source: songInfo.source,
|
source: songInfo.source,
|
||||||
quality,
|
quality,
|
||||||
songInfo: toRaw(songInfo)
|
songInfo: toRaw(songInfo) as any,
|
||||||
|
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
|
||||||
})
|
})
|
||||||
|
|
||||||
;(await tip).close()
|
;(await tip).close()
|
||||||
|
|
||||||
// 如果成功获取特殊音质链接,处理结果并返回
|
// 如果成功获取特殊音质链接,处理结果并返回
|
||||||
if (specialResult && 'error' in specialResult && !specialResult.error) {
|
if (specialResult) {
|
||||||
if (!Object.hasOwn(specialResult, 'path')) {
|
if (!Object.hasOwn(specialResult, 'path')) {
|
||||||
MessagePlugin.info(specialResult.message)
|
MessagePlugin.info(specialResult.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -65,34 +218,48 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`下载${qualityMap[quality]}音质失败,回退到标准逻辑`)
|
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
|
||||||
// 如果获取特殊音质失败,继续执行原有逻辑
|
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||||
|
|
||||||
|
// 特殊音质下载失败,重新弹出选择框
|
||||||
|
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
if (!retryQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quality = retryQuality
|
||||||
} catch (specialError) {
|
} catch (specialError) {
|
||||||
console.log(`下载${qualityMap[quality]}音质出错,回退到标准逻辑:`, specialError)
|
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
|
||||||
// 特殊音质获取失败,继续执行原有逻辑
|
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||||
|
|
||||||
|
// 特殊音质下载出错,重新弹出选择框
|
||||||
|
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||||
|
if (!retryQuality) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quality = retryQuality
|
||||||
}
|
}
|
||||||
MessagePlugin.error('下载失败了,向下兼容音质')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有逻辑:检查歌曲支持的最高音质
|
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||||
if (
|
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
|
||||||
qualityKey.indexOf(quality) >
|
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
|
||||||
qualityKey.indexOf(
|
quality = songMaxQuality
|
||||||
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
|
||||||
)
|
|
||||||
) {
|
|
||||||
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
||||||
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
||||||
|
|
||||||
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
||||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||||
source: songInfo.source,
|
source: songInfo.source,
|
||||||
quality,
|
quality,
|
||||||
songInfo: toRaw(songInfo)
|
songInfo: toRaw(songInfo) as any,
|
||||||
|
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
|
||||||
})
|
})
|
||||||
|
|
||||||
;(await tip).close()
|
;(await tip).close()
|
||||||
|
|
||||||
if (!Object.hasOwn(result, 'path')) {
|
if (!Object.hasOwn(result, 'path')) {
|
||||||
MessagePlugin.info(result.message)
|
MessagePlugin.info(result.message)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, toRaw, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
@@ -190,7 +190,7 @@ const handlePause = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (song: MusicItem) => {
|
const handleDownload = (song: any) => {
|
||||||
downloadSingleSong(song)
|
downloadSingleSong(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +232,12 @@ const isLocalPlaylist = computed(() => {
|
|||||||
// 文件选择器引用
|
// 文件选择器引用
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// 滚动相关状态
|
||||||
|
const scrollY = ref(0)
|
||||||
|
const isHeaderCompact = ref(false)
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
const songListRef = ref<any>(null)
|
||||||
|
|
||||||
// 点击封面修改图片(仅本地歌单)
|
// 点击封面修改图片(仅本地歌单)
|
||||||
const handleCoverClick = () => {
|
const handleCoverClick = () => {
|
||||||
if (!isLocalPlaylist.value) return
|
if (!isLocalPlaylist.value) return
|
||||||
@@ -383,18 +389,54 @@ const handleShufflePlaylist = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 滚动事件处理
|
||||||
|
const handleScroll = (event?: Event) => {
|
||||||
|
let scrollTop = 0
|
||||||
|
|
||||||
|
if (event && event.target) {
|
||||||
|
scrollTop = (event.target as HTMLElement).scrollTop
|
||||||
|
} else if (scrollContainer.value) {
|
||||||
|
scrollTop = scrollContainer.value.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollY.value = scrollTop
|
||||||
|
// 当滚动超过100px时,启用紧凑模式
|
||||||
|
isHeaderCompact.value = scrollY.value > 100
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时获取数据
|
// 组件挂载时获取数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPlaylistSongs()
|
fetchPlaylistSongs()
|
||||||
|
|
||||||
|
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
|
||||||
|
setTimeout(() => {
|
||||||
|
// 查找 SongVirtualList 内部的虚拟滚动容器
|
||||||
|
const virtualListContainer = document.querySelector('.virtual-scroll-container')
|
||||||
|
|
||||||
|
if (virtualListContainer) {
|
||||||
|
scrollContainer.value = virtualListContainer as HTMLElement
|
||||||
|
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
console.log('滚动监听器已添加到:', virtualListContainer)
|
||||||
|
} else {
|
||||||
|
console.warn('未找到虚拟滚动容器')
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时清理事件监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (scrollContainer.value) {
|
||||||
|
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="list-container">
|
<div class="list-container">
|
||||||
<!-- 固定头部区域 -->
|
<!-- 固定头部区域 -->
|
||||||
<div class="fixed-header">
|
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
|
||||||
<!-- 歌单信息 -->
|
<!-- 歌单信息 -->
|
||||||
<div class="playlist-header">
|
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
|
||||||
<div
|
<div
|
||||||
class="playlist-cover"
|
class="playlist-cover"
|
||||||
:class="{ clickable: isLocalPlaylist }"
|
:class="{ clickable: isLocalPlaylist }"
|
||||||
@@ -421,11 +463,15 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="playlist-details">
|
<div class="playlist-details">
|
||||||
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
||||||
<p class="playlist-author">by {{ playlistInfo.author }}</p>
|
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
|
||||||
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
|
by {{ playlistInfo.author }}
|
||||||
|
</p>
|
||||||
|
<p class="playlist-stats" :class="{ hidden: isHeaderCompact }">
|
||||||
|
{{ playlistInfo.total || songs.length }} 首歌曲
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- 播放控制按钮 -->
|
<!-- 播放控制按钮 -->
|
||||||
<div class="playlist-actions">
|
<div class="playlist-actions" :class="{ compact: isHeaderCompact }">
|
||||||
<t-button
|
<t-button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
size="medium"
|
size="medium"
|
||||||
@@ -473,6 +519,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="song-list-wrapper">
|
<div v-else class="song-list-wrapper">
|
||||||
<SongVirtualList
|
<SongVirtualList
|
||||||
|
ref="songListRef"
|
||||||
:songs="songs"
|
:songs="songs"
|
||||||
:current-song="currentSong"
|
:current-song="currentSong"
|
||||||
:is-playing="isPlaying"
|
:is-playing="isPlaying"
|
||||||
@@ -486,6 +533,7 @@ onMounted(() => {
|
|||||||
@download="handleDownload"
|
@download="handleDownload"
|
||||||
@add-to-playlist="handleAddToPlaylist"
|
@add-to-playlist="handleAddToPlaylist"
|
||||||
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
|
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
|
||||||
|
@scroll="handleScroll"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,7 +543,7 @@ onMounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.list-container {
|
.list-container {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #fafafa;
|
// background: #fafafa;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -564,7 +612,17 @@ onMounted(() => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact .playlist-cover {
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px !important;
|
||||||
|
}
|
||||||
.playlist-cover {
|
.playlist-cover {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
@@ -572,6 +630,7 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -628,39 +687,85 @@ onMounted(() => {
|
|||||||
|
|
||||||
.playlist-details {
|
.playlist-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.playlist-title {
|
.playlist-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-header.compact & {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-author {
|
.playlist-author {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-stats {
|
.playlist-stats {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-actions {
|
.playlist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.play-btn,
|
.play-btn,
|
||||||
.shuffle-btn {
|
.shuffle-btn {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-actions.compact & {
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.play-icon,
|
.play-icon,
|
||||||
.shuffle-icon {
|
.shuffle-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.playlist-actions.compact & {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, onMounted, computed, toRaw } from 'vue'
|
import { ref, onMounted, computed, toRaw } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { Edit2Icon, ListIcon, PlayCircleIcon, DeleteIcon } from 'tdesign-icons-vue-next'
|
import { Edit2Icon, PlayCircleIcon, DeleteIcon, ViewListIcon } from 'tdesign-icons-vue-next'
|
||||||
import songListAPI from '@renderer/api/songList'
|
import songListAPI from '@renderer/api/songList'
|
||||||
import type { SongList, Songs } from '@common/types/songList'
|
import type { SongList, Songs } from '@common/types/songList'
|
||||||
import defaultCover from '/default-cover.png'
|
import defaultCover from '/default-cover.png'
|
||||||
@@ -856,7 +856,7 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
createMenuItem('view', '查看详情', {
|
createMenuItem('view', '查看详情', {
|
||||||
icon: ListIcon,
|
icon: ViewListIcon,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (contextMenuPlaylist.value) {
|
if (contextMenuPlaylist.value) {
|
||||||
viewPlaylist(contextMenuPlaylist.value)
|
viewPlaylist(contextMenuPlaylist.value)
|
||||||
@@ -923,7 +923,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="page">
|
||||||
<div class="local-container">
|
<div class="local-container">
|
||||||
<!-- 页面标题和操作 -->
|
<!-- 页面标题和操作 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -1035,7 +1035,11 @@ onMounted(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
@click="viewPlaylist(playlist)"
|
@click="viewPlaylist(playlist)"
|
||||||
>
|
>
|
||||||
<ListIcon />
|
<view-list-icon
|
||||||
|
:fill-color="'transparent'"
|
||||||
|
:stroke-color="'#000000'"
|
||||||
|
:stroke-width="1.5"
|
||||||
|
/>
|
||||||
</t-button>
|
</t-button>
|
||||||
</t-tooltip>
|
</t-tooltip>
|
||||||
<t-tooltip content="编辑歌单">
|
<t-tooltip content="编辑歌单">
|
||||||
@@ -1415,6 +1419,10 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
// height: 100%;
|
||||||
|
}
|
||||||
.local-container {
|
.local-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const handlePause = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (song: MusicItem) => {
|
const handleDownload = (song: any) => {
|
||||||
downloadSingleSong(song)
|
downloadSingleSong(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +217,6 @@ const handleScroll = (event: Event) => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.search-container {
|
.search-container {
|
||||||
background: #fafafa;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -14,16 +14,20 @@ import {
|
|||||||
SaveIcon
|
SaveIcon
|
||||||
} from 'tdesign-icons-vue-next'
|
} from 'tdesign-icons-vue-next'
|
||||||
import fonts from '@renderer/assets/icon_font/icons'
|
import fonts from '@renderer/assets/icon_font/icons'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import DirectorySettings from '@renderer/components/Settings/DirectorySettings.vue'
|
import DirectorySettings from '@renderer/components/Settings/DirectorySettings.vue'
|
||||||
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
|
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
|
||||||
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
|
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
|
||||||
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
|
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
|
||||||
import Versions from '@renderer/components/Versions.vue'
|
import Versions from '@renderer/components/Versions.vue'
|
||||||
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
|
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
|
||||||
|
import { useSettingsStore } from '@renderer/store/Settings'
|
||||||
const Store = LocalUserDetailStore()
|
const Store = LocalUserDetailStore()
|
||||||
const { userInfo } = storeToRefs(Store)
|
const { userInfo } = storeToRefs(Store)
|
||||||
|
|
||||||
|
// 设置存储
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const { settings } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
// 当前选择的设置分类
|
// 当前选择的设置分类
|
||||||
const activeCategory = ref<string>('appearance')
|
const activeCategory = ref<string>('appearance')
|
||||||
// 应用版本号
|
// 应用版本号
|
||||||
@@ -175,9 +179,8 @@ const clearAPIKey = (): void => {
|
|||||||
console.log('DeepSeek API Key 已清空')
|
console.log('DeepSeek API Key 已清空')
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const goPlugin = () => {
|
const goPlugin = () => {
|
||||||
router.push('/plugins')
|
switchCategory('plugins')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音乐源和音质配置相关
|
// 音乐源和音质配置相关
|
||||||
@@ -308,6 +311,30 @@ const getCurrentSourceName = () => {
|
|||||||
const openLink = (url: string) => {
|
const openLink = (url: string) => {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签写入选项
|
||||||
|
const tagWriteOptions = ref({
|
||||||
|
basicInfo: settings.value.tagWriteOptions?.basicInfo ?? true,
|
||||||
|
cover: settings.value.tagWriteOptions?.cover ?? true,
|
||||||
|
lyrics: settings.value.tagWriteOptions?.lyrics ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新标签写入选项
|
||||||
|
const updateTagWriteOptions = () => {
|
||||||
|
settingsStore.updateSettings({
|
||||||
|
tagWriteOptions: { ...tagWriteOptions.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签选项状态描述
|
||||||
|
const getTagOptionsStatus = () => {
|
||||||
|
const enabled: string[] = []
|
||||||
|
if (tagWriteOptions.value.basicInfo) enabled.push('基础信息')
|
||||||
|
if (tagWriteOptions.value.cover) enabled.push('封面')
|
||||||
|
if (tagWriteOptions.value.lyrics) enabled.push('歌词')
|
||||||
|
|
||||||
|
return enabled.length > 0 ? enabled.join('、') : '未选择任何选项'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -468,14 +495,15 @@ const openLink = (url: string) => {
|
|||||||
|
|
||||||
<!-- 插件管理 -->
|
<!-- 插件管理 -->
|
||||||
<div v-else-if="activeCategory === 'plugins'" key="plugins" class="settings-section">
|
<div v-else-if="activeCategory === 'plugins'" key="plugins" class="settings-section">
|
||||||
<div class="setting-group">
|
<!-- <div class="setting-group">
|
||||||
<h3>插件管理</h3>
|
<h3>插件管理</h3>
|
||||||
<p>管理和配置应用插件,扩展音乐播放器功能</p>
|
<p>管理和配置应用插件,扩展音乐播放器功能</p>
|
||||||
<t-button theme="primary" @click="goPlugin">
|
<t-button theme="primary" @click="goPlugin">
|
||||||
<TreeRoundDotIcon style="margin-right: 0.5em" />
|
<TreeRoundDotIcon style="margin-right: 0.5em" />
|
||||||
打开插件管理
|
打开插件管理
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div> -->
|
||||||
|
<plugins />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 音乐源配置 -->
|
<!-- 音乐源配置 -->
|
||||||
@@ -582,6 +610,44 @@ const openLink = (url: string) => {
|
|||||||
<div style="margin-top: 20px" class="setting-group">
|
<div style="margin-top: 20px" class="setting-group">
|
||||||
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
|
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签写入设置 -->
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>下载标签写入设置</h3>
|
||||||
|
<p>选择下载歌曲时要写入的标签信息</p>
|
||||||
|
|
||||||
|
<div class="tag-options">
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.basicInfo" @change="updateTagWriteOptions">
|
||||||
|
基础信息
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">包括歌曲标题、艺术家、专辑名称等基本信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.cover" @change="updateTagWriteOptions">
|
||||||
|
封面
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">将专辑封面嵌入到音频文件中</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-option">
|
||||||
|
<t-checkbox v-model="tagWriteOptions.lyrics" @change="updateTagWriteOptions">
|
||||||
|
普通歌词
|
||||||
|
</t-checkbox>
|
||||||
|
<p class="option-desc">将歌词信息写入到音频文件的标签中</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-options-status">
|
||||||
|
<div class="status-summary">
|
||||||
|
<span class="status-label">当前配置:</span>
|
||||||
|
<span class="status-value">
|
||||||
|
{{ getTagOptionsStatus() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 关于页面 -->
|
<!-- 关于页面 -->
|
||||||
@@ -1783,6 +1849,53 @@ const openLink = (url: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签写入设置样式
|
||||||
|
.tag-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.tag-option {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
margin: 0.5rem 0 0 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-options-status {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.status-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 响应式适配
|
// 响应式适配
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#features">功能特色</a>
|
<a href="#features">功能特色</a>
|
||||||
<a href="#download">下载</a>
|
<a href="#download">下载</a>
|
||||||
<a href="./CeruUse.html" target="_blank">文档</a>
|
<a href="https://ceru.docs.shiqianjiang.cn/" target="_blank">文档</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -11,163 +11,52 @@ function scrollToFeatures() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alist API configuration
|
// GitHub repository configuration
|
||||||
const ALIST_BASE_URL = 'https://alist.shiqianjiang.cn'
|
|
||||||
const ALIST_USERNAME = 'ceruupdate'
|
|
||||||
const ALIST_PASSWORD = '123456'
|
|
||||||
|
|
||||||
// GitHub repository configuration (for fallback)
|
|
||||||
const GITHUB_REPO = 'timeshiftsauce/CeruMusic'
|
const GITHUB_REPO = 'timeshiftsauce/CeruMusic'
|
||||||
|
const GITHUB_PROXY = 'https://gh-proxy.com/'
|
||||||
const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
|
const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
|
||||||
|
const GITHUB_RELEASES_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases`
|
||||||
|
|
||||||
// Cache for release data
|
// Cache for release data
|
||||||
let releaseData = null
|
let releaseData = null
|
||||||
let releaseDataTimestamp = null
|
let releaseDataTimestamp = null
|
||||||
let alistToken = null
|
let allReleasesData = null
|
||||||
|
let allReleasesTimestamp = null
|
||||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
// Alist authentication
|
// Get all releases from GitHub API
|
||||||
async function getAlistToken() {
|
async function getAllReleases() {
|
||||||
if (alistToken) {
|
// Check cache first
|
||||||
return alistToken
|
const now = Date.now()
|
||||||
|
if (allReleasesData && allReleasesTimestamp && now - allReleasesTimestamp < CACHE_DURATION) {
|
||||||
|
return allReleasesData
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${ALIST_BASE_URL}/api/auth/login`, {
|
const response = await fetch(GITHUB_RELEASES_API_URL)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
if (!response.ok) {
|
||||||
'Content-Type': 'application/json'
|
throw new Error(`GitHub API error: ${response.status}`)
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
|
||||||
username: ALIST_USERNAME,
|
|
||||||
password: ALIST_PASSWORD
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.code === 200) {
|
// Filter and sort releases by version
|
||||||
alistToken = data.data.token
|
const releases = data
|
||||||
return alistToken
|
.filter(release => !release.draft && !release.prerelease)
|
||||||
} else {
|
.sort((a, b) => compareVersions(b.tag_name, a.tag_name))
|
||||||
throw new Error(`Alist authentication failed: ${data.message}`)
|
|
||||||
}
|
// Cache the data
|
||||||
|
allReleasesData = releases
|
||||||
|
allReleasesTimestamp = now
|
||||||
|
|
||||||
|
return releases
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Alist authentication error:', error)
|
console.error('Failed to fetch releases data:', error)
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available versions from Alist
|
|
||||||
async function getAlistVersions() {
|
|
||||||
try {
|
|
||||||
const token = await getAlistToken()
|
|
||||||
|
|
||||||
const response = await fetch(`${ALIST_BASE_URL}/api/fs/list`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: '/',
|
|
||||||
password: '',
|
|
||||||
page: 1,
|
|
||||||
per_page: 100,
|
|
||||||
refresh: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data.code === 200) {
|
|
||||||
// Filter directories that look like version numbers
|
|
||||||
const versions = data.data.content
|
|
||||||
.filter((item) => item.is_dir && /^v?\d+\.\d+\.\d+/.test(item.name))
|
|
||||||
.sort((a, b) => b.name.localeCompare(a.name)) // Sort by version desc
|
|
||||||
|
|
||||||
return versions
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to get versions: ${data.message}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get Alist versions:', error)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files in a specific version directory
|
|
||||||
async function getAlistVersionFiles(version) {
|
|
||||||
try {
|
|
||||||
const token = await getAlistToken()
|
|
||||||
|
|
||||||
const response = await fetch(`${ALIST_BASE_URL}/api/fs/list`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: `/${version}`,
|
|
||||||
password: '',
|
|
||||||
page: 1,
|
|
||||||
per_page: 100,
|
|
||||||
refresh: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data.code === 200) {
|
|
||||||
return data.data.content.filter((item) => !item.is_dir)
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to get version files: ${data.message}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get version files:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get direct download URL from Alist
|
|
||||||
async function getAlistDownloadUrl(version, fileName) {
|
|
||||||
try {
|
|
||||||
const token = await getAlistToken()
|
|
||||||
const filePath = `/${version}/${fileName}`
|
|
||||||
|
|
||||||
const response = await fetch(`${ALIST_BASE_URL}/api/fs/get`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: filePath
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data.code === 200) {
|
|
||||||
const fileInfo = data.data
|
|
||||||
|
|
||||||
// Try different URL formats
|
|
||||||
if (fileInfo.raw_url) {
|
|
||||||
return fileInfo.raw_url
|
|
||||||
} else if (fileInfo.sign) {
|
|
||||||
return `${ALIST_BASE_URL}/d${filePath}?sign=${fileInfo.sign}`
|
|
||||||
} else {
|
|
||||||
return `${ALIST_BASE_URL}/d${filePath}`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to get download URL: ${data.message}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get Alist download URL:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download functionality
|
// Download functionality
|
||||||
async function downloadApp(platform) {
|
async function downloadApp(platform) {
|
||||||
const button = event.target
|
const button = event.target
|
||||||
@@ -183,55 +72,51 @@ async function downloadApp(platform) {
|
|||||||
button.disabled = true
|
button.disabled = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try Alist first
|
// Detect user's architecture for better matching
|
||||||
const versions = await getAlistVersions()
|
const userArch = detectArchitecture()
|
||||||
|
|
||||||
if (versions.length > 0) {
|
// Get latest release from GitHub
|
||||||
const latestVersion = versions[0]
|
const release = await getLatestRelease()
|
||||||
const files = await getAlistVersionFiles(latestVersion.name)
|
|
||||||
|
|
||||||
// Find the appropriate file for the platform
|
if (!release) {
|
||||||
const fileName = findFileForPlatform(files, platform)
|
throw new Error('无法获取最新版本信息')
|
||||||
|
|
||||||
if (fileName) {
|
|
||||||
const downloadUrl = await getAlistDownloadUrl(latestVersion.name, fileName)
|
|
||||||
|
|
||||||
// Show success notification
|
|
||||||
showNotification(
|
|
||||||
`正在下载 ${getPlatformName(platform)} 版本 ${latestVersion.name}...`,
|
|
||||||
'success'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Start download
|
|
||||||
window.open(downloadUrl, '_blank')
|
|
||||||
|
|
||||||
// Track download
|
|
||||||
trackDownload(platform, latestVersion.name)
|
|
||||||
|
|
||||||
return // Success, exit function
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to GitHub if Alist fails
|
const downloadUrl = findDownloadAsset(release.assets, platform, userArch)
|
||||||
console.log('Alist download failed, trying GitHub fallback...')
|
|
||||||
await downloadFromGitHub(platform)
|
if (!downloadUrl) {
|
||||||
|
throw new Error(`暂无 ${getPlatformName(platform)} 版本下载`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the asset to get architecture info
|
||||||
|
const asset = release.assets.find((a) => a.browser_download_url === downloadUrl)
|
||||||
|
const archInfo = asset ? getArchitectureInfo(asset.name) : ''
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
showNotification(
|
||||||
|
`正在下载 ${getPlatformName(platform)} ${archInfo} 版本 ${release.tag_name}...`,
|
||||||
|
'success'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use proxy for download if it's a GitHub URL
|
||||||
|
const finalDownloadUrl = downloadUrl.includes('github.com') ?
|
||||||
|
`${GITHUB_PROXY}${downloadUrl}` : downloadUrl
|
||||||
|
|
||||||
|
// Start download
|
||||||
|
window.open(finalDownloadUrl, '_blank')
|
||||||
|
|
||||||
|
// Track download
|
||||||
|
trackDownload(platform, release.tag_name, asset ? asset.name : '')
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download error:', error)
|
console.error('Download error:', error)
|
||||||
|
showNotification(`下载失败: ${error.message}`, 'error')
|
||||||
|
|
||||||
// Try GitHub fallback
|
// Fallback to GitHub releases page
|
||||||
try {
|
setTimeout(() => {
|
||||||
console.log('Trying GitHub fallback...')
|
showNotification('正在跳转到GitHub下载页面...', 'info')
|
||||||
await downloadFromGitHub(platform)
|
window.open(`https://github.com/${GITHUB_REPO}/releases/latest`, '_blank')
|
||||||
} catch (fallbackError) {
|
}, 2000)
|
||||||
console.error('GitHub fallback also failed:', fallbackError)
|
|
||||||
showNotification(`下载失败: ${error.message}`, 'error')
|
|
||||||
|
|
||||||
// Final fallback to GitHub releases page
|
|
||||||
setTimeout(() => {
|
|
||||||
showNotification('正在跳转到GitHub下载页面...', 'info')
|
|
||||||
window.open(`https://github.com/${GITHUB_REPO}/releases/latest`, '_blank')
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
// Restore button state
|
// Restore button state
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -241,29 +126,7 @@ async function downloadApp(platform) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHub fallback function
|
|
||||||
async function downloadFromGitHub(platform) {
|
|
||||||
const release = await getLatestRelease()
|
|
||||||
|
|
||||||
if (!release) {
|
|
||||||
throw new Error('无法获取最新版本信息')
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadUrl = findDownloadAsset(release.assets, platform)
|
|
||||||
|
|
||||||
if (!downloadUrl) {
|
|
||||||
throw new Error(`暂无 ${getPlatformName(platform)} 版本下载`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success notification
|
|
||||||
showNotification(`正在下载 ${getPlatformName(platform)} 版本 v${release.tag_name}...`, 'success')
|
|
||||||
|
|
||||||
// Start download
|
|
||||||
window.open(downloadUrl, '_blank')
|
|
||||||
|
|
||||||
// Track download
|
|
||||||
trackDownload(platform, release.tag_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get latest release from GitHub API
|
// Get latest release from GitHub API
|
||||||
async function getLatestRelease() {
|
async function getLatestRelease() {
|
||||||
@@ -293,80 +156,7 @@ async function getLatestRelease() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find appropriate file for platform from Alist files
|
|
||||||
function findFileForPlatform(files, platform) {
|
|
||||||
if (!files || !Array.isArray(files)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out unwanted files (yml, yaml, txt, md, etc.)
|
|
||||||
const filteredFiles = files.filter((file) => {
|
|
||||||
const name = file.name.toLowerCase()
|
|
||||||
return (
|
|
||||||
!name.endsWith('.yml') &&
|
|
||||||
!name.endsWith('.yaml') &&
|
|
||||||
!name.endsWith('.txt') &&
|
|
||||||
!name.endsWith('.md') &&
|
|
||||||
!name.endsWith('.json') &&
|
|
||||||
!name.includes('latest') &&
|
|
||||||
!name.includes('blockmap')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Define file patterns for each platform (ordered by priority)
|
|
||||||
const patterns = {
|
|
||||||
windows: [
|
|
||||||
/ceru-music.*setup\\.exe$/i,
|
|
||||||
/\\.exe$/i,
|
|
||||||
/windows.*\\.zip$/i,
|
|
||||||
/win32.*\\.zip$/i,
|
|
||||||
/win.*x64.*\\.zip$/i
|
|
||||||
],
|
|
||||||
macos: [
|
|
||||||
/ceru-music.*\\.dmg$/i,
|
|
||||||
/\\.dmg$/i,
|
|
||||||
/darwin.*\\.zip$/i,
|
|
||||||
/macos.*\\.zip$/i,
|
|
||||||
/mac.*\\.zip$/i,
|
|
||||||
/osx.*\\.zip$/i
|
|
||||||
],
|
|
||||||
linux: [
|
|
||||||
/ceru-music.*amd64\\.deb$/i,
|
|
||||||
/\\.deb$/i,
|
|
||||||
/\\.AppImage$/i,
|
|
||||||
/linux.*\\.zip$/i,
|
|
||||||
/linux.*\\.tar\\.gz$/i,
|
|
||||||
/\\.rpm$/i
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformPatterns = patterns[platform] || []
|
|
||||||
|
|
||||||
// Try to find exact match
|
|
||||||
for (const pattern of platformPatterns) {
|
|
||||||
const file = filteredFiles.find((file) => pattern.test(file.name))
|
|
||||||
if (file) {
|
|
||||||
return file.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look for any file that might match the platform
|
|
||||||
const fallbackPatterns = {
|
|
||||||
windows: /win|exe/i,
|
|
||||||
macos: /mac|darwin|dmg/i,
|
|
||||||
linux: /linux|appimage|deb|rpm/i
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackPattern = fallbackPatterns[platform]
|
|
||||||
if (fallbackPattern) {
|
|
||||||
const file = filteredFiles.find((file) => fallbackPattern.test(file.name))
|
|
||||||
if (file) {
|
|
||||||
return file.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find appropriate download asset based on platform
|
// Find appropriate download asset based on platform
|
||||||
function findDownloadAsset(assets, platform) {
|
function findDownloadAsset(assets, platform) {
|
||||||
@@ -391,21 +181,33 @@ function findDownloadAsset(assets, platform) {
|
|||||||
// Define file patterns for each platform (ordered by priority)
|
// Define file patterns for each platform (ordered by priority)
|
||||||
const patterns = {
|
const patterns = {
|
||||||
windows: [
|
windows: [
|
||||||
|
/ceru-music.*win.*x64.*setup\.exe$/i,
|
||||||
|
/ceru-music.*win.*ia32.*setup\.exe$/i,
|
||||||
/ceru-music.*setup\.exe$/i,
|
/ceru-music.*setup\.exe$/i,
|
||||||
/\.exe$/i,
|
/\.exe$/i,
|
||||||
|
/ceru-music.*win.*x64.*\.zip$/i,
|
||||||
|
/ceru-music.*win.*ia32.*\.zip$/i,
|
||||||
/windows.*\.zip$/i,
|
/windows.*\.zip$/i,
|
||||||
/win32.*\.zip$/i,
|
/win32.*\.zip$/i,
|
||||||
/win.*x64.*\.zip$/i
|
/win.*x64.*\.zip$/i
|
||||||
],
|
],
|
||||||
macos: [
|
macos: [
|
||||||
|
/ceru-music.*universal\.dmg$/i,
|
||||||
|
/ceru-music.*arm64\.dmg$/i,
|
||||||
|
/ceru-music.*x64\.dmg$/i,
|
||||||
/ceru-music.*\.dmg$/i,
|
/ceru-music.*\.dmg$/i,
|
||||||
/\.dmg$/i,
|
/\.dmg$/i,
|
||||||
|
/ceru-music.*universal\.zip$/i,
|
||||||
|
/ceru-music.*arm64\.zip$/i,
|
||||||
|
/ceru-music.*x64\.zip$/i,
|
||||||
/darwin.*\.zip$/i,
|
/darwin.*\.zip$/i,
|
||||||
/macos.*\.zip$/i,
|
/macos.*\.zip$/i,
|
||||||
/mac.*\.zip$/i,
|
/mac.*\.zip$/i,
|
||||||
/osx.*\.zip$/i
|
/osx.*\.zip$/i
|
||||||
],
|
],
|
||||||
linux: [
|
linux: [
|
||||||
|
/ceru-music.*linux.*x64\.deb$/i,
|
||||||
|
/ceru-music.*linux.*x64\.AppImage$/i,
|
||||||
/ceru-music.*amd64\.deb$/i,
|
/ceru-music.*amd64\.deb$/i,
|
||||||
/\.deb$/i,
|
/\.deb$/i,
|
||||||
/\.AppImage$/i,
|
/\.AppImage$/i,
|
||||||
@@ -604,7 +406,7 @@ function setupAnimations() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect user's operating system
|
// Auto-detect user's operating system and architecture
|
||||||
function detectOS() {
|
function detectOS() {
|
||||||
const userAgent = navigator.userAgent.toLowerCase()
|
const userAgent = navigator.userAgent.toLowerCase()
|
||||||
if (userAgent.includes('win')) return 'windows'
|
if (userAgent.includes('win')) return 'windows'
|
||||||
@@ -613,9 +415,52 @@ function detectOS() {
|
|||||||
return 'windows' // default
|
return 'windows' // default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect user's architecture
|
||||||
|
function detectArchitecture() {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase()
|
||||||
|
const platform = navigator.platform.toLowerCase()
|
||||||
|
|
||||||
|
// For macOS, detect Apple Silicon vs Intel
|
||||||
|
if (userAgent.includes('mac')) {
|
||||||
|
// Check for Apple Silicon indicators
|
||||||
|
if (userAgent.includes('arm') || platform.includes('arm')) {
|
||||||
|
return 'arm64'
|
||||||
|
}
|
||||||
|
// Default to universal for macOS (works on both Intel and Apple Silicon)
|
||||||
|
return 'universal'
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Windows, detect 32-bit vs 64-bit
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
if (userAgent.includes('wow64') || userAgent.includes('win64') || userAgent.includes('x64')) {
|
||||||
|
return 'x64'
|
||||||
|
}
|
||||||
|
return 'ia32'
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Linux, assume 64-bit
|
||||||
|
if (userAgent.includes('linux')) {
|
||||||
|
return 'x64'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'x64' // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get architecture display name
|
||||||
|
function getArchitectureName(arch) {
|
||||||
|
const names = {
|
||||||
|
x64: '64位',
|
||||||
|
ia32: '32位',
|
||||||
|
arm64: 'Apple Silicon',
|
||||||
|
universal: 'Universal (Intel + Apple Silicon)'
|
||||||
|
}
|
||||||
|
return names[arch] || arch
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight user's OS download option
|
// Highlight user's OS download option
|
||||||
function highlightUserOS() {
|
function highlightUserOS() {
|
||||||
const userOS = detectOS()
|
const userOS = detectOS()
|
||||||
|
const userArch = detectArchitecture()
|
||||||
const downloadCards = document.querySelectorAll('.download-card')
|
const downloadCards = document.querySelectorAll('.download-card')
|
||||||
|
|
||||||
downloadCards.forEach((card, index) => {
|
downloadCards.forEach((card, index) => {
|
||||||
@@ -624,10 +469,11 @@ function highlightUserOS() {
|
|||||||
card.style.border = '2px solid var(--primary-color)'
|
card.style.border = '2px solid var(--primary-color)'
|
||||||
card.style.transform = 'scale(1.02)'
|
card.style.transform = 'scale(1.02)'
|
||||||
|
|
||||||
// Add "推荐" badge
|
// Add "推荐" badge with architecture info
|
||||||
const badge = document.createElement('div')
|
const badge = document.createElement('div')
|
||||||
badge.className = 'recommended-badge'
|
badge.className = 'recommended-badge'
|
||||||
badge.textContent = '推荐'
|
const archName = getArchitectureName(userArch)
|
||||||
|
badge.textContent = `推荐 (${archName})`
|
||||||
badge.style.cssText = `
|
badge.style.cssText = `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -10px;
|
||||||
@@ -638,9 +484,22 @@ function highlightUserOS() {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
`
|
`
|
||||||
card.style.position = 'relative'
|
card.style.position = 'relative'
|
||||||
card.appendChild(badge)
|
card.appendChild(badge)
|
||||||
|
|
||||||
|
// Add architecture info to the card description
|
||||||
|
const description = card.querySelector('p')
|
||||||
|
if (description && userOS === 'macos') {
|
||||||
|
if (userArch === 'arm64') {
|
||||||
|
description.innerHTML +=
|
||||||
|
'<br><small style="color: var(--text-muted);">检测到 Apple Silicon Mac,推荐 Universal 版本</small>'
|
||||||
|
} else if (userArch === 'universal') {
|
||||||
|
description.innerHTML +=
|
||||||
|
'<br><small style="color: var(--text-muted);">Universal 版本兼容 Intel 和 Apple Silicon Mac</small>'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -741,36 +600,7 @@ window.addEventListener('error', (e) => {
|
|||||||
// Update version information on page
|
// Update version information on page
|
||||||
async function updateVersionInfo() {
|
async function updateVersionInfo() {
|
||||||
try {
|
try {
|
||||||
// Try to get version info from Alist first
|
// Get latest release from GitHub
|
||||||
const versions = await getAlistVersions()
|
|
||||||
|
|
||||||
if (versions.length > 0) {
|
|
||||||
const latestVersion = versions[0]
|
|
||||||
const versionElement = document.querySelector('.version')
|
|
||||||
const versionInfoElement = document.querySelector('.version-info p')
|
|
||||||
|
|
||||||
if (versionElement) {
|
|
||||||
versionElement.textContent = latestVersion.name
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionInfoElement) {
|
|
||||||
const modifyDate = new Date(latestVersion.modified)
|
|
||||||
const formattedDate = modifyDate.toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long'
|
|
||||||
})
|
|
||||||
versionInfoElement.innerHTML = `当前版本: <span class="version">${latestVersion.name}</span> | 更新时间: ${formattedDate}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update download button text with file info from Alist
|
|
||||||
const files = await getAlistVersionFiles(latestVersion.name)
|
|
||||||
updateDownloadButtonsWithAlistFiles(files)
|
|
||||||
|
|
||||||
return // Success, exit function
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to GitHub if Alist fails
|
|
||||||
console.log('Alist version info failed, trying GitHub fallback...')
|
|
||||||
const release = await getLatestRelease()
|
const release = await getLatestRelease()
|
||||||
if (release) {
|
if (release) {
|
||||||
const versionElement = document.querySelector('.version')
|
const versionElement = document.querySelector('.version')
|
||||||
@@ -784,9 +614,14 @@ async function updateVersionInfo() {
|
|||||||
const publishDate = new Date(release.published_at)
|
const publishDate = new Date(release.published_at)
|
||||||
const formattedDate = publishDate.toLocaleDateString('zh-CN', {
|
const formattedDate = publishDate.toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long'
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
})
|
})
|
||||||
versionInfoElement.innerHTML = `当前版本: <span class="version">${release.tag_name}</span> | 更新时间: ${formattedDate}`
|
const formattedTime = publishDate.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
versionInfoElement.innerHTML = `当前版本: <span class="version">${release.tag_name}</span> | 更新时间: ${formattedDate} ${formattedTime}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update download button text with file sizes if available
|
// Update download button text with file sizes if available
|
||||||
@@ -797,30 +632,100 @@ async function updateVersionInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update download buttons with Alist file information
|
|
||||||
function updateDownloadButtonsWithAlistFiles(files) {
|
|
||||||
if (!files || !Array.isArray(files)) return
|
|
||||||
|
|
||||||
const downloadCards = document.querySelectorAll('.download-card')
|
|
||||||
const platforms = ['windows', 'macos', 'linux']
|
|
||||||
|
|
||||||
downloadCards.forEach((card, index) => {
|
// Find asset for platform (helper function)
|
||||||
const platform = platforms[index]
|
function findAssetForPlatform(assets, platform) {
|
||||||
const fileName = findFileForPlatform(files, platform)
|
const userArch = detectArchitecture()
|
||||||
|
|
||||||
if (fileName) {
|
// Filter out unwanted files
|
||||||
const file = files.find((f) => f.name === fileName)
|
const filteredAssets = assets.filter((asset) => {
|
||||||
const button = card.querySelector('.btn-download')
|
const name = asset.name.toLowerCase()
|
||||||
const sizeText = formatFileSize(file.size)
|
return (
|
||||||
const originalText = button.innerHTML
|
!name.endsWith('.yml') &&
|
||||||
|
!name.endsWith('.yaml') &&
|
||||||
// Add file size info
|
!name.endsWith('.txt') &&
|
||||||
button.innerHTML = originalText.replace(
|
!name.endsWith('.md') &&
|
||||||
/下载 \..*?$/,
|
!name.endsWith('.json') &&
|
||||||
`下载 .${getFileExtension(fileName)} (${sizeText})`
|
!name.includes('latest') &&
|
||||||
)
|
!name.includes('blockmap')
|
||||||
}
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Define architecture-specific patterns for each platform
|
||||||
|
const archPatterns = {
|
||||||
|
windows: {
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*x64.*setup\.exe$/i,
|
||||||
|
/ceru-music.*win.*x64.*setup\.exe$/i,
|
||||||
|
/ceru-music.*x64.*\.zip$/i,
|
||||||
|
/ceru-music.*win.*x64.*\.zip$/i
|
||||||
|
],
|
||||||
|
ia32: [
|
||||||
|
/ceru-music.*ia32.*setup\.exe$/i,
|
||||||
|
/ceru-music.*win.*ia32.*setup\.exe$/i,
|
||||||
|
/ceru-music.*ia32.*\.zip$/i,
|
||||||
|
/ceru-music.*win.*ia32.*\.zip$/i
|
||||||
|
],
|
||||||
|
fallback: [/ceru-music.*setup\.exe$/i, /\.exe$/i, /windows.*\.zip$/i, /win.*\.zip$/i]
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
universal: [/ceru-music.*universal\.dmg$/i, /ceru-music.*universal\.zip$/i],
|
||||||
|
arm64: [
|
||||||
|
/ceru-music.*arm64\.dmg$/i,
|
||||||
|
/ceru-music.*arm64\.zip$/i,
|
||||||
|
/ceru-music.*universal\.dmg$/i,
|
||||||
|
/ceru-music.*universal\.zip$/i
|
||||||
|
],
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*x64\.dmg$/i,
|
||||||
|
/ceru-music.*x64\.zip$/i,
|
||||||
|
/ceru-music.*universal\.dmg$/i,
|
||||||
|
/ceru-music.*universal\.zip$/i
|
||||||
|
],
|
||||||
|
fallback: [
|
||||||
|
/ceru-music.*\.dmg$/i,
|
||||||
|
/\.dmg$/i,
|
||||||
|
/darwin.*\.zip$/i,
|
||||||
|
/macos.*\.zip$/i,
|
||||||
|
/mac.*\.zip$/i
|
||||||
|
]
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*linux.*x64\.AppImage$/i,
|
||||||
|
/ceru-music.*linux.*x64\.deb$/i,
|
||||||
|
/ceru-music.*x64\.AppImage$/i,
|
||||||
|
/ceru-music.*x64\.deb$/i
|
||||||
|
],
|
||||||
|
fallback: [
|
||||||
|
/ceru-music.*\.AppImage$/i,
|
||||||
|
/ceru-music.*\.deb$/i,
|
||||||
|
/\.AppImage$/i,
|
||||||
|
/\.deb$/i,
|
||||||
|
/linux.*\.zip$/i
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformArchPatterns = archPatterns[platform]
|
||||||
|
if (!platformArchPatterns) return null
|
||||||
|
|
||||||
|
// Try architecture-specific patterns first
|
||||||
|
const archSpecificPatterns = platformArchPatterns[userArch] || []
|
||||||
|
for (const pattern of archSpecificPatterns) {
|
||||||
|
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
|
||||||
|
if (asset) return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fallback patterns
|
||||||
|
const fallbackPatterns = platformArchPatterns.fallback || []
|
||||||
|
for (const pattern of fallbackPatterns) {
|
||||||
|
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
|
||||||
|
if (asset) return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update download buttons with asset information
|
// Update download buttons with asset information
|
||||||
@@ -848,19 +753,116 @@ function updateDownloadButtonsWithAssets(assets) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to find asset for platform
|
// Find appropriate download asset based on platform and architecture
|
||||||
function findAssetForPlatform(assets, platform) {
|
function findDownloadAsset(assets, platform, userArch = null) {
|
||||||
const patterns = {
|
if (!assets || !Array.isArray(assets)) {
|
||||||
windows: [/\.exe$/i, /windows.*\.zip$/i, /win32.*\.zip$/i],
|
return null
|
||||||
macos: [/\.dmg$/i, /darwin.*\.zip$/i, /macos.*\.zip$/i],
|
|
||||||
linux: [/\.AppImage$/i, /linux.*\.zip$/i, /\.deb$/i]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformPatterns = patterns[platform] || []
|
if (!userArch) {
|
||||||
|
userArch = detectArchitecture()
|
||||||
|
}
|
||||||
|
|
||||||
for (const pattern of platformPatterns) {
|
// Filter out unwanted files
|
||||||
const asset = assets.find((asset) => pattern.test(asset.name))
|
const filteredAssets = assets.filter((asset) => {
|
||||||
if (asset) return asset
|
const name = asset.name.toLowerCase()
|
||||||
|
return (
|
||||||
|
!name.endsWith('.yml') &&
|
||||||
|
!name.endsWith('.yaml') &&
|
||||||
|
!name.endsWith('.txt') &&
|
||||||
|
!name.endsWith('.md') &&
|
||||||
|
!name.endsWith('.json') &&
|
||||||
|
!name.includes('latest') &&
|
||||||
|
!name.includes('blockmap')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define architecture-specific patterns for each platform
|
||||||
|
const archPatterns = {
|
||||||
|
windows: {
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*x64.*setup\.exe$/i,
|
||||||
|
/ceru-music.*win.*x64.*setup\.exe$/i,
|
||||||
|
/ceru-music.*x64.*\.zip$/i,
|
||||||
|
/ceru-music.*win.*x64.*\.zip$/i
|
||||||
|
],
|
||||||
|
ia32: [
|
||||||
|
/ceru-music.*ia32.*setup\.exe$/i,
|
||||||
|
/ceru-music.*win.*ia32.*setup\.exe$/i,
|
||||||
|
/ceru-music.*ia32.*\.zip$/i,
|
||||||
|
/ceru-music.*win.*ia32.*\.zip$/i
|
||||||
|
],
|
||||||
|
fallback: [/ceru-music.*setup\.exe$/i, /\.exe$/i, /windows.*\.zip$/i, /win.*\.zip$/i]
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
universal: [/ceru-music.*universal\.dmg$/i, /ceru-music.*universal\.zip$/i],
|
||||||
|
arm64: [
|
||||||
|
/ceru-music.*arm64\.dmg$/i,
|
||||||
|
/ceru-music.*arm64\.zip$/i,
|
||||||
|
/ceru-music.*universal\.dmg$/i,
|
||||||
|
/ceru-music.*universal\.zip$/i
|
||||||
|
],
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*x64\.dmg$/i,
|
||||||
|
/ceru-music.*x64\.zip$/i,
|
||||||
|
/ceru-music.*universal\.dmg$/i,
|
||||||
|
/ceru-music.*universal\.zip$/i
|
||||||
|
],
|
||||||
|
fallback: [
|
||||||
|
/ceru-music.*\.dmg$/i,
|
||||||
|
/\.dmg$/i,
|
||||||
|
/darwin.*\.zip$/i,
|
||||||
|
/macos.*\.zip$/i,
|
||||||
|
/mac.*\.zip$/i
|
||||||
|
]
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
x64: [
|
||||||
|
/ceru-music.*linux.*x64\.AppImage$/i,
|
||||||
|
/ceru-music.*linux.*x64\.deb$/i,
|
||||||
|
/ceru-music.*x64\.AppImage$/i,
|
||||||
|
/ceru-music.*x64\.deb$/i
|
||||||
|
],
|
||||||
|
fallback: [
|
||||||
|
/ceru-music.*\.AppImage$/i,
|
||||||
|
/ceru-music.*\.deb$/i,
|
||||||
|
/\.AppImage$/i,
|
||||||
|
/\.deb$/i,
|
||||||
|
/linux.*\.zip$/i
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformArchPatterns = archPatterns[platform]
|
||||||
|
if (!platformArchPatterns) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try architecture-specific patterns first
|
||||||
|
const archSpecificPatterns = platformArchPatterns[userArch] || []
|
||||||
|
for (const pattern of archSpecificPatterns) {
|
||||||
|
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
|
||||||
|
if (asset) return asset.browser_download_url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fallback patterns
|
||||||
|
const fallbackPatterns = platformArchPatterns.fallback || []
|
||||||
|
for (const pattern of fallbackPatterns) {
|
||||||
|
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
|
||||||
|
if (asset) return asset.browser_download_url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: look for any asset that might match the platform
|
||||||
|
const finalFallbackPatterns = {
|
||||||
|
windows: /win|exe/i,
|
||||||
|
macos: /mac|darwin|dmg/i,
|
||||||
|
linux: /linux|appimage|deb|rpm/i
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPattern = finalFallbackPatterns[platform]
|
||||||
|
if (finalPattern) {
|
||||||
|
const asset = filteredAssets.find((asset) => finalPattern.test(asset.name))
|
||||||
|
if (asset) return asset.browser_download_url
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -882,19 +884,60 @@ function formatFileSize(bytes) {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get architecture information from filename
|
||||||
|
function getArchitectureInfo(filename) {
|
||||||
|
if (!filename) return ''
|
||||||
|
|
||||||
|
const name = filename.toLowerCase()
|
||||||
|
|
||||||
|
if (name.includes('universal')) return '(Universal)'
|
||||||
|
if (name.includes('arm64')) return '(Apple Silicon)'
|
||||||
|
if (name.includes('x64')) return '(64位)'
|
||||||
|
if (name.includes('ia32')) return '(32位)'
|
||||||
|
if (name.includes('win') && name.includes('x64')) return '(64位)'
|
||||||
|
if (name.includes('win') && name.includes('ia32')) return '(32位)'
|
||||||
|
if (name.includes('linux') && name.includes('x64')) return '(64位)'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// Analytics tracking (placeholder)
|
// Analytics tracking (placeholder)
|
||||||
function trackDownload(platform, version) {
|
function trackDownload(platform, version, filename = '') {
|
||||||
// Add your analytics tracking code here
|
// Add your analytics tracking code here
|
||||||
console.log(`Download tracked: ${platform} v${version}`)
|
const archInfo = getArchitectureInfo(filename)
|
||||||
|
|
||||||
// Example: Google Analytics
|
// Example: Google Analytics
|
||||||
// gtag('event', 'download', {
|
// gtag('event', 'download', {
|
||||||
// 'event_category': 'software',
|
// 'event_category': 'software',
|
||||||
// 'event_label': platform,
|
// 'event_label': `${platform}_${archInfo}`,
|
||||||
// 'value': version
|
// 'value': version
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version comparison function to handle complex version numbers like v1.3.10, v1.3.3.1
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
// Remove 'v' prefix if present
|
||||||
|
const versionA = a.replace(/^v/, '')
|
||||||
|
const versionB = b.replace(/^v/, '')
|
||||||
|
|
||||||
|
// Split version numbers into parts
|
||||||
|
const partsA = versionA.split('.').map(num => parseInt(num, 10))
|
||||||
|
const partsB = versionB.split('.').map(num => parseInt(num, 10))
|
||||||
|
|
||||||
|
// Compare each part
|
||||||
|
const maxLength = Math.max(partsA.length, partsB.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
const partA = partsA[i] || 0
|
||||||
|
const partB = partsB[i] || 0
|
||||||
|
|
||||||
|
if (partA > partB) return 1
|
||||||
|
if (partA < partB) return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// Add GitHub link functionality
|
// Add GitHub link functionality
|
||||||
function addGitHubLinks() {
|
function addGitHubLinks() {
|
||||||
// Add GitHub link to footer if not exists
|
// Add GitHub link to footer if not exists
|
||||||
|
|||||||
Reference in New Issue
Block a user