Compare commits
79 Commits
59f71148af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 187ce573a4 | |||
| 5d3596de29 | |||
| 9153d85992 | |||
| 2073caf383 | |||
| f17bc62310 | |||
| 3dd85a7624 | |||
| b6e0fe0546 | |||
| 960afd04be | |||
| 968116ce18 | |||
| 3742ed5062 | |||
| 8f1248c959 | |||
| 7af71074d2 | |||
| 9618cb9521 | |||
| b50f69a65b | |||
| 2d773ae946 | |||
| 4554246c69 | |||
| 0f88a9dc14 | |||
| 9979ec8237 | |||
| 408397304c | |||
| fda79f2f8d | |||
| 7c953406e0 | |||
| a1b1006af0 | |||
| 33149c6a74 | |||
| dee717a575 | |||
| 1e50334ac7 | |||
| a3840e2bae | |||
| f4346f4c79 | |||
| 9f5473e14d | |||
| f39100e9eb | |||
| 1d36734f79 | |||
| 91a3259e27 | |||
| ecb85f0146 | |||
| 6bea735aef | |||
| d9b102879f | |||
| 1198be4f11 | |||
| 7792eeac2e | |||
| 1a8b5f4977 | |||
| 95694057ec | |||
| 938c497ca3 | |||
| a078e37e2c | |||
| 405b144e66 | |||
| 761884f23a | |||
| 537e280fdd | |||
| 15258f28fd | |||
| fa818a020f | |||
| c15a10c098 | |||
| 51f1aaba55 | |||
| 0c0189bcef | |||
| ee98eb0266 | |||
| d722728ee0 | |||
| 5ba9e6591a | |||
| 7e95ab69be | |||
| 7c6448733d | |||
| 2b1024ca24 | |||
| a62f525840 | |||
| 97220761cf | |||
| 7282e876f4 | |||
| 6b22713854 | |||
| 0d960aa8d5 | |||
| e066efb373 | |||
| b0b3eb3326 | |||
| 4a50886a68 | |||
| f9222b699d | |||
| 030a1f1c85 | |||
| 3f31278131 | |||
| 33fc4f768c | |||
| 8e3e4e610c | |||
| 03b52cd6e2 | |||
| 8726af556a | |||
| 0ab784024c | |||
| ad2df12957 | |||
| a407045527 | |||
| 38723165a0 | |||
| 042b8ba6f8 | |||
| eb801cfbfd | |||
| 0cfec3dd82 | |||
| 167f081ee6 | |||
| c28368f783 | |||
| bc46024499 |
@@ -1,4 +1,2 @@
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API = http://127.0.0.1:30488
|
||||
# 音乐破解接口地址 web端
|
||||
VITE_API_MUSIC = ***
|
||||
VITE_API = http://127.0.0.1:30488
|
||||
@@ -0,0 +1,83 @@
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
# 检查 PR 标题是否符合 Conventional Commits 规范
|
||||
pr-title:
|
||||
name: PR Title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install commitlint
|
||||
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
|
||||
|
||||
- name: Validate PR title
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: echo "$PR_TITLE" | npx commitlint
|
||||
|
||||
# 检查所有提交信息是否符合 Conventional Commits 规范
|
||||
commit-messages:
|
||||
name: Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install commitlint
|
||||
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
|
||||
|
||||
- name: Validate commit messages
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
|
||||
# 运行 lint 和类型检查
|
||||
code-quality:
|
||||
name: Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# lint:i18n 脚本用 bun 跑(package.json: "bun scripts/check_i18n.ts"),
|
||||
# CI 默认环境没有 bun 会报 sh: bun: not found
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
|
||||
|
||||
# tsconfig.web.json 显式 require src/renderer/{auto-imports,components}.d.ts
|
||||
# 这两个文件由 unplugin-auto-import / unplugin-vue-components 在 vite 启动时
|
||||
# 生成,且被 .gitignore 排除(58922dc 维护者主动设置)。CI 直接跑 typecheck
|
||||
# 拿不到 d.ts 会报 TS2688,先跑一次 build 触发 unplugin 生成
|
||||
- name: Build (generates auto-import / components d.ts)
|
||||
run: npm run build
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: I18n check
|
||||
run: npm run lint:i18n
|
||||
@@ -46,5 +46,8 @@ AGENTS.md
|
||||
.auto-imports.d.ts
|
||||
.components.d.ts
|
||||
|
||||
# TypeScript 增量编译缓存
|
||||
*.tsbuildinfo
|
||||
|
||||
src/renderer/auto-imports.d.ts
|
||||
src/renderer/components.d.ts
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
@@ -16,7 +16,5 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 989 B |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
['feat', 'fix', 'perf', 'refactor', 'docs', 'style', 'test', 'build', 'ci', 'chore', 'revert']
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'type-empty': [2, 'never']
|
||||
}
|
||||
};
|
||||
@@ -35,6 +35,17 @@ export default defineConfig({
|
||||
})
|
||||
],
|
||||
publicDir: resolve('resources'),
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 全部代码打到 entry chunk,避免 Vite 默认按共享依赖拆分时
|
||||
// 与 store/index.ts 的 `export *` 形成 chunk 间循环引用,
|
||||
// 触发生产构建里的 TDZ(dev 不分包不会暴露此问题)。
|
||||
// Electron 桌面端本地加载,无 CDN/首屏体积顾虑,单 chunk 合算。
|
||||
manualChunks: () => 'index'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 2389
|
||||
|
||||
@@ -11,7 +11,7 @@ import globals from 'globals';
|
||||
export default [
|
||||
// 忽略文件配置
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**', 'out/**', '.gitignore']
|
||||
ignores: ['node_modules/**', '**/dist/**', 'out/**', '.gitignore']
|
||||
},
|
||||
|
||||
// 基础 JavaScript 配置
|
||||
@@ -55,7 +55,8 @@ export default [
|
||||
defineEmits: 'readonly',
|
||||
// TypeScript 全局类型
|
||||
NodeJS: 'readonly',
|
||||
ScrollBehavior: 'readonly'
|
||||
ScrollBehavior: 'readonly',
|
||||
ScrollToOptions: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
@@ -148,7 +149,8 @@ export default [
|
||||
useMessage: 'readonly',
|
||||
// TypeScript 全局类型
|
||||
NodeJS: 'readonly',
|
||||
ScrollBehavior: 'readonly'
|
||||
ScrollBehavior: 'readonly',
|
||||
ScrollToOptions: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dev:web": "vite dev",
|
||||
"build": "electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"fix-sandbox": "node scripts/fix-sandbox.js",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml",
|
||||
@@ -36,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@httptoolkit/dbus-native": "^0.1.5",
|
||||
"@unblockneteasemusic/server": "^0.27.10",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -49,6 +51,7 @@
|
||||
"form-data": "^4.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"mpris-service": "^2.1.2",
|
||||
"music-metadata": "^11.10.3",
|
||||
"netease-cloud-music-api-alger": "^4.30.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
@@ -58,6 +61,8 @@
|
||||
"vue-i18n": "^11.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@@ -148,7 +153,6 @@
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "AlgerMusicPlayer needs access to the microphone for audio visualization.",
|
||||
"NSCameraUsageDescription": "Application requests access to the device's camera.",
|
||||
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
|
||||
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
|
||||
@@ -176,7 +180,7 @@
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "resources/icon.png",
|
||||
"icon": "build/icons",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
@@ -222,5 +226,9 @@
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"jsbi": "^4.3.2",
|
||||
"x11": "^2.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 172 KiB |
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"name": "Alger Music PWA",
|
||||
"name": "Alger Music Player",
|
||||
"short_name": "AlgerMusic",
|
||||
"description": "AlgerMusicPlayer 音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon.png",
|
||||
"src": "/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// 最小 Service Worker:仅满足 PWA 可安装性要求(需存在 fetch 处理器)。
|
||||
// 不做任何缓存拦截,所有请求保持浏览器默认网络行为。
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', () => {
|
||||
// 特意留空:不拦截,默认走网络
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 修复 Linux 下 Electron sandbox 权限问题
|
||||
* chrome-sandbox 需要 root 拥有且权限为 4755
|
||||
*
|
||||
* 注意:此脚本需要 sudo 权限,仅在 CI 环境或手动执行时使用
|
||||
* 用法:sudo npm run fix-sandbox
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const sandboxPath = path.resolve(__dirname, '../node_modules/electron/dist/chrome-sandbox');
|
||||
if (fs.existsSync(sandboxPath)) {
|
||||
execSync(`sudo chown root:root ${sandboxPath}`);
|
||||
execSync(`sudo chmod 4755 ${sandboxPath}`);
|
||||
console.log('[fix-sandbox] chrome-sandbox permissions fixed');
|
||||
} else {
|
||||
console.log('[fix-sandbox] chrome-sandbox not found, skipping');
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,9 @@ export default {
|
||||
operationFailed: 'Operation Failed',
|
||||
songsAlreadyInPlaylist: 'Songs already in playlist',
|
||||
locateCurrent: 'Locate current song',
|
||||
scrollToTop: 'Scroll to top',
|
||||
compactLayout: 'Compact layout',
|
||||
normalLayout: 'Normal layout',
|
||||
historyRecommend: 'Daily History',
|
||||
fetchDatesFailed: 'Failed to fetch dates',
|
||||
fetchSongsFailed: 'Failed to fetch songs',
|
||||
|
||||
@@ -20,7 +20,21 @@ export default {
|
||||
downloading: 'Downloading',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
unknown: 'Unknown'
|
||||
unknown: 'Unknown',
|
||||
queued: 'Queued',
|
||||
paused: 'Paused',
|
||||
cancelled: 'Cancelled'
|
||||
},
|
||||
action: {
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
cancel: 'Cancel',
|
||||
cancelAll: 'Cancel All',
|
||||
retrying: 'Re-resolving URL...'
|
||||
},
|
||||
batch: {
|
||||
complete: 'Download complete: {success}/{total} songs succeeded',
|
||||
allComplete: 'All downloads complete'
|
||||
},
|
||||
artist: {
|
||||
unknown: 'Unknown Artist'
|
||||
@@ -44,6 +58,14 @@ export default {
|
||||
success: 'Download records cleared',
|
||||
failed: 'Failed to clear download records'
|
||||
},
|
||||
save: {
|
||||
title: 'Save Settings',
|
||||
message: 'Current download settings are not saved. Do you want to save the changes?',
|
||||
confirm: 'Save',
|
||||
cancel: 'Cancel',
|
||||
discard: 'Discard',
|
||||
saveSuccess: 'Download settings saved'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} download completed',
|
||||
downloadFailed: '{filename} download failed: {error}'
|
||||
@@ -78,6 +100,8 @@ export default {
|
||||
dragToArrange: 'Sort or use arrow buttons to arrange:',
|
||||
formatVariables: 'Available variables',
|
||||
preview: 'Preview:',
|
||||
concurrency: 'Max Concurrent',
|
||||
concurrencyDesc: 'Maximum number of simultaneous downloads (1-5)',
|
||||
saveSuccess: 'Download settings saved',
|
||||
presets: {
|
||||
songArtist: 'Song - Artist',
|
||||
@@ -89,5 +113,10 @@ export default {
|
||||
artistName: 'Artist name',
|
||||
albumName: 'Album name'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
incomplete: 'File download incomplete',
|
||||
urlExpired: 'URL expired, re-resolving',
|
||||
resumeFailed: 'Resume failed'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,5 +9,7 @@ export default {
|
||||
emptyState: 'No local music found. Please select a folder to scan.',
|
||||
fileNotFound: 'File not found or has been moved',
|
||||
rescan: 'Rescan',
|
||||
songCount: '{count} songs'
|
||||
songCount: '{count} songs',
|
||||
removeFromLibrary: 'Remove from Library',
|
||||
removedFromLibrary: 'Removed from library (file not deleted)'
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
parseFailedPlayNext: 'Song parsing failed, playing next',
|
||||
consecutiveFailsError:
|
||||
'Playback error, possibly due to network issues or invalid source. Please switch playlist or try again later',
|
||||
playListEnded: 'Reached the end of the playlist',
|
||||
autoResumed: 'Playback resumed automatically',
|
||||
resumeFailed: 'Failed to resume playback, please try manually',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
loop: 'Loop',
|
||||
@@ -56,6 +59,7 @@ export default {
|
||||
eq: 'Equalizer',
|
||||
playList: 'Play List',
|
||||
reparse: 'Reparse',
|
||||
download: 'Download',
|
||||
miniPlayBar: 'Mini Play Bar',
|
||||
playMode: {
|
||||
sequence: 'Sequence',
|
||||
|
||||
@@ -400,6 +400,7 @@ export default {
|
||||
themeColor: {
|
||||
title: 'Lyric Theme Color',
|
||||
presetColors: 'Preset Colors',
|
||||
reset: 'Reset to Default',
|
||||
customColor: 'Custom Color',
|
||||
preview: 'Preview',
|
||||
previewText: 'Lyric Effect',
|
||||
|
||||
@@ -13,6 +13,8 @@ export default {
|
||||
},
|
||||
message: {
|
||||
downloading: 'Downloading, please wait...',
|
||||
addToPlaylistNeedLogin:
|
||||
'Please log in with Cookie or QR code to add songs to a playlist (not available for UID login)',
|
||||
downloadFailed: 'Download failed',
|
||||
downloadQueued: 'Added to download queue',
|
||||
addedToNextPlay: 'Added to play next',
|
||||
|
||||
@@ -223,6 +223,9 @@ export default {
|
||||
addToPlaylistSuccess: 'プレイリストに追加しました',
|
||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
||||
locateCurrent: '再生中の曲を表示',
|
||||
scrollToTop: 'トップに戻る',
|
||||
compactLayout: 'コンパクト表示',
|
||||
normalLayout: '通常表示',
|
||||
historyRecommend: '履歴の日次推薦',
|
||||
fetchDatesFailed: '日付リストの取得に失敗しました',
|
||||
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
||||
|
||||
@@ -20,7 +20,21 @@ export default {
|
||||
downloading: 'ダウンロード中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
unknown: '不明'
|
||||
unknown: '不明',
|
||||
queued: 'キュー中',
|
||||
paused: '一時停止',
|
||||
cancelled: 'キャンセル済み'
|
||||
},
|
||||
action: {
|
||||
pause: '一時停止',
|
||||
resume: '再開',
|
||||
cancel: 'キャンセル',
|
||||
cancelAll: 'すべてキャンセル',
|
||||
retrying: 'URL再取得中...'
|
||||
},
|
||||
batch: {
|
||||
complete: 'ダウンロード完了:{success}/{total}曲成功',
|
||||
allComplete: '全てのダウンロードが完了'
|
||||
},
|
||||
artist: {
|
||||
unknown: '不明なアーティスト'
|
||||
@@ -44,6 +58,14 @@ export default {
|
||||
success: 'ダウンロード記録をクリアしました',
|
||||
failed: 'ダウンロード記録のクリアに失敗しました'
|
||||
},
|
||||
save: {
|
||||
title: '設定を保存',
|
||||
message: '現在のダウンロード設定が保存されていません。変更を保存しますか?',
|
||||
confirm: '保存',
|
||||
cancel: 'キャンセル',
|
||||
discard: '破棄',
|
||||
saveSuccess: 'ダウンロード設定を保存しました'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename}のダウンロードが完了しました',
|
||||
downloadFailed: '{filename}のダウンロードに失敗しました: {error}'
|
||||
@@ -78,6 +100,8 @@ export default {
|
||||
dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:',
|
||||
formatVariables: '使用可能な変数',
|
||||
preview: 'プレビュー効果:',
|
||||
concurrency: '最大同時ダウンロード数',
|
||||
concurrencyDesc: '同時にダウンロードする最大曲数(1-5)',
|
||||
saveSuccess: 'ダウンロード設定を保存しました',
|
||||
presets: {
|
||||
songArtist: '楽曲名 - アーティスト名',
|
||||
@@ -89,5 +113,10 @@ export default {
|
||||
artistName: 'アーティスト名',
|
||||
albumName: 'アルバム名'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
incomplete: 'ファイルのダウンロードが不完全です',
|
||||
urlExpired: 'URLの有効期限が切れました。再取得中',
|
||||
resumeFailed: '再開に失敗しました'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,5 +9,7 @@ export default {
|
||||
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
|
||||
fileNotFound: 'ファイルが見つからないか、移動されました',
|
||||
rescan: '再スキャン',
|
||||
songCount: '{count} 曲'
|
||||
songCount: '{count} 曲',
|
||||
removeFromLibrary: 'ライブラリから削除',
|
||||
removedFromLibrary: 'ライブラリから削除しました(ファイルは削除されません)'
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します',
|
||||
consecutiveFailsError:
|
||||
'再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください',
|
||||
playListEnded: 'プレイリストの最後に到達しました',
|
||||
autoResumed: '自動的に再生を再開しました',
|
||||
resumeFailed: '再生の再開に失敗しました。手動でお試しください',
|
||||
playMode: {
|
||||
sequence: '順次再生',
|
||||
loop: 'リピート再生',
|
||||
@@ -56,6 +59,7 @@ export default {
|
||||
eq: 'イコライザー',
|
||||
playList: 'プレイリスト',
|
||||
reparse: '再解析',
|
||||
download: 'ダウンロード',
|
||||
playMode: {
|
||||
sequence: '順次再生',
|
||||
loop: 'ループ再生',
|
||||
|
||||
@@ -128,26 +128,26 @@ export default {
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源選択',
|
||||
lxMusic: '落雪音源',
|
||||
lxMusic: '洛雪音源',
|
||||
customApi: 'カスタムAPI'
|
||||
},
|
||||
scripts: {
|
||||
title: 'インポート済みのスクリプト',
|
||||
importLocal: 'ローカルインポート',
|
||||
importOnline: 'オンラインインポート',
|
||||
urlPlaceholder: '落雪音源スクリプトのURLを入力',
|
||||
urlPlaceholder: '洛雪音源スクリプトのURLを入力',
|
||||
importBtn: 'インポート',
|
||||
empty: 'インポート済みの落雪音源はありません',
|
||||
notConfigured: '未設定(落雪音源タブで設定してください)',
|
||||
empty: 'インポート済みの洛雪音源はありません',
|
||||
notConfigured: '未設定(洛雪音源タブで設定してください)',
|
||||
importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します',
|
||||
noScriptWarning: '先に落雪音源スクリプトをインポートしてください',
|
||||
noSelectionWarning: '先に落雪音源を選択してください',
|
||||
noScriptWarning: '先に洛雪音源スクリプトをインポートしてください',
|
||||
noSelectionWarning: '先に洛雪音源を選択してください',
|
||||
notFound: '音源が存在しません',
|
||||
switched: '音源を切り替えました: {name}',
|
||||
deleted: '音源を削除しました: {name}',
|
||||
enterUrl: 'スクリプトURLを入力してください',
|
||||
invalidUrl: '無効なURL形式',
|
||||
invalidScript: '無効な落雪音源スクリプトです(globalThis.lxが見つかりません)',
|
||||
invalidScript: '無効な洛雪音源スクリプトです(globalThis.lxが見つかりません)',
|
||||
nameRequired: '名前を空にすることはできません',
|
||||
renameSuccess: '名前を変更しました'
|
||||
}
|
||||
@@ -399,6 +399,7 @@ export default {
|
||||
themeColor: {
|
||||
title: '歌詞テーマカラー',
|
||||
presetColors: 'プリセットカラー',
|
||||
reset: 'デフォルトに戻す',
|
||||
customColor: 'カスタムカラー',
|
||||
preview: 'プレビュー効果',
|
||||
previewText: '歌詞効果',
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
export default {
|
||||
menu: {
|
||||
play: '再生',
|
||||
playNext: '次に再生',
|
||||
download: '楽曲をダウンロード',
|
||||
downloadLyric: '歌詞をダウンロード',
|
||||
addToPlaylist: 'プレイリストに追加',
|
||||
favorite: 'いいね',
|
||||
unfavorite: 'いいね解除',
|
||||
removeFromPlaylist: 'プレイリストから削除',
|
||||
dislike: '嫌い',
|
||||
undislike: '嫌い解除'
|
||||
},
|
||||
message: {
|
||||
downloading: 'ダウンロード中です。しばらくお待ちください...',
|
||||
downloadFailed: 'ダウンロードに失敗しました',
|
||||
downloadQueued: 'ダウンロードキューに追加しました',
|
||||
addedToNextPlay: '次の再生に追加しました',
|
||||
getUrlFailed:
|
||||
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
|
||||
noLyric: 'この楽曲には歌詞がありません',
|
||||
lyricDownloaded: '歌詞のダウンロードが完了しました',
|
||||
lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
|
||||
},
|
||||
dialog: {
|
||||
dislike: {
|
||||
title: 'お知らせ!',
|
||||
content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。',
|
||||
positiveText: '嫌い',
|
||||
negativeText: 'キャンセル'
|
||||
}
|
||||
}
|
||||
};
|
||||
export default {
|
||||
menu: {
|
||||
play: '再生',
|
||||
playNext: '次に再生',
|
||||
download: '楽曲をダウンロード',
|
||||
downloadLyric: '歌詞をダウンロード',
|
||||
addToPlaylist: 'プレイリストに追加',
|
||||
favorite: 'いいね',
|
||||
unfavorite: 'いいね解除',
|
||||
removeFromPlaylist: 'プレイリストから削除',
|
||||
dislike: '嫌い',
|
||||
undislike: '嫌い解除'
|
||||
},
|
||||
message: {
|
||||
downloading: 'ダウンロード中です。しばらくお待ちください...',
|
||||
addToPlaylistNeedLogin:
|
||||
'プレイリストに追加するには Cookie または QR コードでログインしてください(UID ログインでは利用できません)',
|
||||
downloadFailed: 'ダウンロードに失敗しました',
|
||||
downloadQueued: 'ダウンロードキューに追加しました',
|
||||
addedToNextPlay: '次の再生に追加しました',
|
||||
getUrlFailed:
|
||||
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
|
||||
noLyric: 'この楽曲には歌詞がありません',
|
||||
lyricDownloaded: '歌詞のダウンロードが完了しました',
|
||||
lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
|
||||
},
|
||||
dialog: {
|
||||
dislike: {
|
||||
title: 'お知らせ!',
|
||||
content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。',
|
||||
positiveText: '嫌い',
|
||||
negativeText: 'キャンセル'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,6 +222,9 @@ export default {
|
||||
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
||||
locateCurrent: '현재 재생 곡 찾기',
|
||||
scrollToTop: '맨 위로',
|
||||
compactLayout: '간결한 레이아웃',
|
||||
normalLayout: '일반 레이아웃',
|
||||
historyRecommend: '일일 기록 권장',
|
||||
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
||||
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
||||
|
||||
@@ -20,7 +20,21 @@ export default {
|
||||
downloading: '다운로드 중',
|
||||
completed: '완료',
|
||||
failed: '실패',
|
||||
unknown: '알 수 없음'
|
||||
unknown: '알 수 없음',
|
||||
queued: '대기 중',
|
||||
paused: '일시 정지',
|
||||
cancelled: '취소됨'
|
||||
},
|
||||
action: {
|
||||
pause: '일시 정지',
|
||||
resume: '재개',
|
||||
cancel: '취소',
|
||||
cancelAll: '모두 취소',
|
||||
retrying: 'URL 재획득 중...'
|
||||
},
|
||||
batch: {
|
||||
complete: '다운로드 완료: {success}/{total}곡 성공',
|
||||
allComplete: '모든 다운로드 완료'
|
||||
},
|
||||
artist: {
|
||||
unknown: '알 수 없는 가수'
|
||||
@@ -44,6 +58,14 @@ export default {
|
||||
success: '다운로드 기록이 지워졌습니다',
|
||||
failed: '다운로드 기록 삭제에 실패했습니다'
|
||||
},
|
||||
save: {
|
||||
title: '설정 저장',
|
||||
message: '현재 다운로드 설정이 저장되지 않았습니다. 변경 사항을 저장하시겠습니까?',
|
||||
confirm: '저장',
|
||||
cancel: '취소',
|
||||
discard: '포기',
|
||||
saveSuccess: '다운로드 설정이 저장됨'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} 다운로드 완료',
|
||||
downloadFailed: '{filename} 다운로드 실패: {error}'
|
||||
@@ -78,6 +100,8 @@ export default {
|
||||
dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:',
|
||||
formatVariables: '사용 가능한 변수',
|
||||
preview: '미리보기 효과:',
|
||||
concurrency: '최대 동시 다운로드',
|
||||
concurrencyDesc: '동시에 다운로드할 최대 곡 수 (1-5)',
|
||||
saveSuccess: '다운로드 설정이 저장됨',
|
||||
presets: {
|
||||
songArtist: '곡명 - 가수명',
|
||||
@@ -89,5 +113,10 @@ export default {
|
||||
artistName: '가수명',
|
||||
albumName: '앨범명'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
incomplete: '파일 다운로드가 불완전합니다',
|
||||
urlExpired: 'URL이 만료되었습니다. 재획득 중',
|
||||
resumeFailed: '재개 실패'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,5 +9,7 @@ export default {
|
||||
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
|
||||
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
|
||||
rescan: '다시 스캔',
|
||||
songCount: '{count}곡'
|
||||
songCount: '{count}곡',
|
||||
removeFromLibrary: '라이브러리에서 제거',
|
||||
removedFromLibrary: '라이브러리에서 제거했습니다 (파일은 삭제되지 않음)'
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생',
|
||||
consecutiveFailsError:
|
||||
'재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요',
|
||||
playListEnded: '재생 목록의 마지막 곡에 도달했습니다',
|
||||
autoResumed: '자동으로 재생이 재개되었습니다',
|
||||
resumeFailed: '재생 재개에 실패했습니다. 수동으로 시도해 주세요',
|
||||
playMode: {
|
||||
sequence: '순차 재생',
|
||||
loop: '한 곡 반복',
|
||||
@@ -56,6 +59,7 @@ export default {
|
||||
eq: '이퀄라이저',
|
||||
playList: '재생 목록',
|
||||
reparse: '재분석',
|
||||
download: '다운로드',
|
||||
playMode: {
|
||||
sequence: '순차 재생',
|
||||
loop: '반복 재생',
|
||||
|
||||
@@ -400,6 +400,7 @@ export default {
|
||||
themeColor: {
|
||||
title: '가사 테마 색상',
|
||||
presetColors: '미리 설정된 색상',
|
||||
reset: '기본값으로 복원',
|
||||
customColor: '사용자 정의 색상',
|
||||
preview: '미리보기 효과',
|
||||
previewText: '가사 효과',
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
export default {
|
||||
menu: {
|
||||
play: '재생',
|
||||
playNext: '다음에 재생',
|
||||
download: '곡 다운로드',
|
||||
downloadLyric: '가사 다운로드',
|
||||
addToPlaylist: '플레이리스트에 추가',
|
||||
favorite: '좋아요',
|
||||
unfavorite: '좋아요 취소',
|
||||
removeFromPlaylist: '플레이리스트에서 삭제',
|
||||
dislike: '싫어요',
|
||||
undislike: '싫어요 취소'
|
||||
},
|
||||
message: {
|
||||
downloading: '다운로드 중입니다. 잠시 기다려주세요...',
|
||||
downloadFailed: '다운로드 실패',
|
||||
downloadQueued: '다운로드 대기열에 추가됨',
|
||||
addedToNextPlay: '다음 재생에 추가됨',
|
||||
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
|
||||
noLyric: '이 곡에는 가사가 없습니다',
|
||||
lyricDownloaded: '가사 다운로드 완료',
|
||||
lyricDownloadFailed: '가사 다운로드 실패'
|
||||
},
|
||||
dialog: {
|
||||
dislike: {
|
||||
title: '알림!',
|
||||
content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.',
|
||||
positiveText: '싫어요',
|
||||
negativeText: '취소'
|
||||
}
|
||||
}
|
||||
};
|
||||
export default {
|
||||
menu: {
|
||||
play: '재생',
|
||||
playNext: '다음에 재생',
|
||||
download: '곡 다운로드',
|
||||
downloadLyric: '가사 다운로드',
|
||||
addToPlaylist: '플레이리스트에 추가',
|
||||
favorite: '좋아요',
|
||||
unfavorite: '좋아요 취소',
|
||||
removeFromPlaylist: '플레이리스트에서 삭제',
|
||||
dislike: '싫어요',
|
||||
undislike: '싫어요 취소'
|
||||
},
|
||||
message: {
|
||||
downloading: '다운로드 중입니다. 잠시 기다려주세요...',
|
||||
addToPlaylistNeedLogin:
|
||||
'플레이리스트에 추가하려면 Cookie 또는 QR 코드로 로그인하세요 (UID 로그인은 사용 불가)',
|
||||
downloadFailed: '다운로드 실패',
|
||||
downloadQueued: '다운로드 대기열에 추가됨',
|
||||
addedToNextPlay: '다음 재생에 추가됨',
|
||||
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
|
||||
noLyric: '이 곡에는 가사가 없습니다',
|
||||
lyricDownloaded: '가사 다운로드 완료',
|
||||
lyricDownloadFailed: '가사 다운로드 실패'
|
||||
},
|
||||
dialog: {
|
||||
dislike: {
|
||||
title: '알림!',
|
||||
content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.',
|
||||
positiveText: '싫어요',
|
||||
negativeText: '취소'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,6 +216,9 @@ export default {
|
||||
addToPlaylistSuccess: '添加到播放列表成功',
|
||||
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
||||
locateCurrent: '定位当前播放',
|
||||
scrollToTop: '回到顶部',
|
||||
compactLayout: '紧凑布局',
|
||||
normalLayout: '常规布局',
|
||||
historyRecommend: '历史日推',
|
||||
fetchDatesFailed: '获取日期列表失败',
|
||||
fetchSongsFailed: '获取歌曲列表失败',
|
||||
|
||||
@@ -20,7 +20,21 @@ export default {
|
||||
downloading: '下载中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
unknown: '未知'
|
||||
unknown: '未知',
|
||||
queued: '排队中',
|
||||
paused: '已暂停',
|
||||
cancelled: '已取消'
|
||||
},
|
||||
action: {
|
||||
pause: '暂停',
|
||||
resume: '恢复',
|
||||
cancel: '取消',
|
||||
cancelAll: '取消全部',
|
||||
retrying: '重新获取链接...'
|
||||
},
|
||||
batch: {
|
||||
complete: '下载完成:成功 {success}/{total} 首',
|
||||
allComplete: '全部下载完成'
|
||||
},
|
||||
artist: {
|
||||
unknown: '未知歌手'
|
||||
@@ -43,6 +57,14 @@ export default {
|
||||
success: '下载记录已清空',
|
||||
failed: '清空下载记录失败'
|
||||
},
|
||||
save: {
|
||||
title: '保存设置',
|
||||
message: '当前下载设置未保存,是否保存更改?',
|
||||
confirm: '保存',
|
||||
cancel: '取消',
|
||||
discard: '放弃',
|
||||
saveSuccess: '下载设置已保存'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} 下载完成',
|
||||
downloadFailed: '{filename} 下载失败: {error}'
|
||||
@@ -77,6 +99,8 @@ export default {
|
||||
dragToArrange: '拖动排序或使用箭头按钮调整顺序:',
|
||||
formatVariables: '可用变量',
|
||||
preview: '预览效果:',
|
||||
concurrency: '最大并发数',
|
||||
concurrencyDesc: '同时下载的最大歌曲数量(1-5)',
|
||||
saveSuccess: '下载设置已保存',
|
||||
presets: {
|
||||
songArtist: '歌曲名 - 歌手名',
|
||||
@@ -88,5 +112,10 @@ export default {
|
||||
artistName: '歌手名',
|
||||
albumName: '专辑名'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
incomplete: '文件下载不完整',
|
||||
urlExpired: '下载链接已过期,正在重新获取',
|
||||
resumeFailed: '恢复下载失败'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,5 +9,7 @@ export default {
|
||||
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
|
||||
fileNotFound: '文件不存在或已被移动',
|
||||
rescan: '重新扫描',
|
||||
songCount: '{count} 首歌曲'
|
||||
songCount: '{count} 首歌曲',
|
||||
removeFromLibrary: '从本地列表移除',
|
||||
removedFromLibrary: '已从本地列表移除(不删除文件)'
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ export default {
|
||||
playFailed: '当前歌曲播放失败,播放下一首',
|
||||
parseFailedPlayNext: '歌曲解析失败,播放下一首',
|
||||
consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试',
|
||||
playListEnded: '已播放到列表最后一首',
|
||||
autoResumed: '已自动恢复播放',
|
||||
resumeFailed: '恢复播放失败,请手动点击播放',
|
||||
playMode: {
|
||||
sequence: '顺序播放',
|
||||
loop: '单曲循环',
|
||||
@@ -55,6 +58,7 @@ export default {
|
||||
eq: '均衡器',
|
||||
playList: '播放列表',
|
||||
reparse: '重新解析',
|
||||
download: '下载',
|
||||
playMode: {
|
||||
sequence: '顺序播放',
|
||||
loop: '循环播放',
|
||||
|
||||
@@ -128,26 +128,26 @@ export default {
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源选择',
|
||||
lxMusic: '落雪音源',
|
||||
lxMusic: '洛雪音源',
|
||||
customApi: '自定义API'
|
||||
},
|
||||
scripts: {
|
||||
title: '已导入的音源脚本',
|
||||
importLocal: '本地导入',
|
||||
importOnline: '在线导入',
|
||||
urlPlaceholder: '输入落雪音源脚本 URL',
|
||||
urlPlaceholder: '输入洛雪音源脚本 URL',
|
||||
importBtn: '导入',
|
||||
empty: '暂无已导入的落雪音源',
|
||||
notConfigured: '未配置 (请去落雪音源Tab配置)',
|
||||
empty: '暂无已导入的洛雪音源',
|
||||
notConfigured: '未配置 (请去洛雪音源Tab配置)',
|
||||
importHint: '导入兼容的自定义 API 插件以扩展音源',
|
||||
noScriptWarning: '请先导入落雪音源脚本',
|
||||
noSelectionWarning: '请先选择一个落雪音源',
|
||||
noScriptWarning: '请先导入洛雪音源脚本',
|
||||
noSelectionWarning: '请先选择一个洛雪音源',
|
||||
notFound: '音源不存在',
|
||||
switched: '已切换到音源: {name}',
|
||||
deleted: '已删除音源: {name}',
|
||||
enterUrl: '请输入脚本 URL',
|
||||
invalidUrl: '无效的 URL 格式',
|
||||
invalidScript: '无效的落雪音源脚本,未找到 globalThis.lx 相关代码',
|
||||
invalidScript: '无效的洛雪音源脚本,未找到 globalThis.lx 相关代码',
|
||||
nameRequired: '名称不能为空',
|
||||
renameSuccess: '重命名成功'
|
||||
}
|
||||
@@ -396,6 +396,7 @@ export default {
|
||||
themeColor: {
|
||||
title: '歌词主题色',
|
||||
presetColors: '预设颜色',
|
||||
reset: '恢复默认',
|
||||
customColor: '自定义颜色',
|
||||
preview: '预览效果',
|
||||
previewText: '歌词效果',
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
},
|
||||
message: {
|
||||
downloading: '正在下载中,请稍候...',
|
||||
addToPlaylistNeedLogin: '请使用 Cookie 或扫码登录后再添加到歌单(UID 登录无法使用此功能)',
|
||||
downloadFailed: '下载失败',
|
||||
downloadQueued: '已加入下载队列',
|
||||
addedToNextPlay: '已添加到下一首播放',
|
||||
|
||||
@@ -216,6 +216,9 @@ export default {
|
||||
addToPlaylistSuccess: '新增至播放清單成功',
|
||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
||||
locateCurrent: '定位當前播放',
|
||||
scrollToTop: '回到頂部',
|
||||
compactLayout: '緊湊佈局',
|
||||
normalLayout: '常規佈局',
|
||||
historyRecommend: '歷史日推',
|
||||
fetchDatesFailed: '獲取日期列表失敗',
|
||||
fetchSongsFailed: '獲取歌曲列表失敗',
|
||||
|
||||
@@ -20,7 +20,21 @@ export default {
|
||||
downloading: '下載中',
|
||||
completed: '已完成',
|
||||
failed: '失敗',
|
||||
unknown: '未知'
|
||||
unknown: '未知',
|
||||
queued: '排隊中',
|
||||
paused: '已暫停',
|
||||
cancelled: '已取消'
|
||||
},
|
||||
action: {
|
||||
pause: '暫停',
|
||||
resume: '恢復',
|
||||
cancel: '取消',
|
||||
cancelAll: '取消全部',
|
||||
retrying: '重新獲取連結...'
|
||||
},
|
||||
batch: {
|
||||
complete: '下載完成:成功 {success}/{total} 首',
|
||||
allComplete: '全部下載完成'
|
||||
},
|
||||
artist: {
|
||||
unknown: '未知歌手'
|
||||
@@ -43,6 +57,14 @@ export default {
|
||||
success: '下載記錄已清空',
|
||||
failed: '清空下載記錄失敗'
|
||||
},
|
||||
save: {
|
||||
title: '儲存設定',
|
||||
message: '目前下載設定尚未儲存,是否儲存變更?',
|
||||
confirm: '儲存',
|
||||
cancel: '取消',
|
||||
discard: '放棄',
|
||||
saveSuccess: '下載設定已儲存'
|
||||
},
|
||||
message: {
|
||||
downloadComplete: '{filename} 下載完成',
|
||||
downloadFailed: '{filename} 下載失敗: {error}'
|
||||
@@ -77,6 +99,8 @@ export default {
|
||||
dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:',
|
||||
formatVariables: '可用變數',
|
||||
preview: '預覽效果:',
|
||||
concurrency: '最大並發數',
|
||||
concurrencyDesc: '同時下載的最大歌曲數量(1-5)',
|
||||
saveSuccess: '下載設定已儲存',
|
||||
presets: {
|
||||
songArtist: '歌曲名 - 歌手名',
|
||||
@@ -88,5 +112,10 @@ export default {
|
||||
artistName: '歌手名',
|
||||
albumName: '專輯名'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
incomplete: '檔案下載不完整',
|
||||
urlExpired: '下載連結已過期,正在重新獲取',
|
||||
resumeFailed: '恢復下載失敗'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,5 +9,7 @@ export default {
|
||||
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
|
||||
fileNotFound: '檔案不存在或已被移動',
|
||||
rescan: '重新掃描',
|
||||
songCount: '{count} 首歌曲'
|
||||
songCount: '{count} 首歌曲',
|
||||
removeFromLibrary: '從本機清單移除',
|
||||
removedFromLibrary: '已從本機清單移除(不刪除檔案)'
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ export default {
|
||||
playFailed: '目前歌曲播放失敗,播放下一首',
|
||||
parseFailedPlayNext: '歌曲解析失敗,播放下一首',
|
||||
consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試',
|
||||
playListEnded: '已播放到列表最後一首',
|
||||
autoResumed: '已自動恢復播放',
|
||||
resumeFailed: '恢復播放失敗,請手動點擊播放',
|
||||
playMode: {
|
||||
sequence: '順序播放',
|
||||
loop: '單曲循環',
|
||||
@@ -55,6 +58,7 @@ export default {
|
||||
eq: '等化器',
|
||||
playList: '播放清單',
|
||||
reparse: '重新解析',
|
||||
download: '下載',
|
||||
playMode: {
|
||||
sequence: '順序播放',
|
||||
loop: '循環播放',
|
||||
|
||||
@@ -124,26 +124,26 @@ export default {
|
||||
lxMusic: {
|
||||
tabs: {
|
||||
sources: '音源選擇',
|
||||
lxMusic: '落雪音源',
|
||||
lxMusic: '洛雪音源',
|
||||
customApi: '自訂API'
|
||||
},
|
||||
scripts: {
|
||||
title: '已匯入的音源腳本',
|
||||
importLocal: '本機匯入',
|
||||
importOnline: '線上匯入',
|
||||
urlPlaceholder: '輸入落雪音源腳本 URL',
|
||||
urlPlaceholder: '輸入洛雪音源腳本 URL',
|
||||
importBtn: '匯入',
|
||||
empty: '暫無已匯入的落雪音源',
|
||||
notConfigured: '未設定 (請至落雪音源分頁設定)',
|
||||
empty: '暫無已匯入的洛雪音源',
|
||||
notConfigured: '未設定 (請至洛雪音源分頁設定)',
|
||||
importHint: '匯入相容的自訂 API 外掛以擴充音源',
|
||||
noScriptWarning: '請先匯入落雪音源腳本',
|
||||
noSelectionWarning: '請先選擇一個落雪音源',
|
||||
noScriptWarning: '請先匯入洛雪音源腳本',
|
||||
noSelectionWarning: '請先選擇一個洛雪音源',
|
||||
notFound: '音源不存在',
|
||||
switched: '已切換到音源: {name}',
|
||||
deleted: '已刪除音源: {name}',
|
||||
enterUrl: '請輸入腳本 URL',
|
||||
invalidUrl: '無效的 URL 格式',
|
||||
invalidScript: '無效的落雪音源腳本,未找到 globalThis.lx 相關程式碼',
|
||||
invalidScript: '無效的洛雪音源腳本,未找到 globalThis.lx 相關程式碼',
|
||||
nameRequired: '名稱不能為空',
|
||||
renameSuccess: '重新命名成功'
|
||||
}
|
||||
@@ -387,6 +387,7 @@ export default {
|
||||
themeColor: {
|
||||
title: '歌詞主題色',
|
||||
presetColors: '預設顏色',
|
||||
reset: '恢復預設',
|
||||
customColor: '自訂顏色',
|
||||
preview: '預覽效果',
|
||||
previewText: '歌詞效果',
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
},
|
||||
message: {
|
||||
downloading: '正在下載中,請稍候...',
|
||||
addToPlaylistNeedLogin: '請使用 Cookie 或掃碼登入後再新增至播放清單(UID 登入無法使用此功能)',
|
||||
downloadFailed: '下載失敗',
|
||||
downloadQueued: '已加入下載佇列',
|
||||
addedToNextPlay: '已新增至下一首播放',
|
||||
|
||||
@@ -18,9 +18,13 @@ const mainI18n = {
|
||||
},
|
||||
t(key: string) {
|
||||
const keys = key.split('.');
|
||||
let current: any = messages[this.currentLocale];
|
||||
// 未知/非法 locale 时回退默认语言,避免 messages[locale] 为 undefined 导致崩溃
|
||||
let current: any = messages[this.currentLocale] ?? messages[DEFAULT_LANGUAGE as Language];
|
||||
if (current == null) {
|
||||
return key;
|
||||
}
|
||||
for (const k of keys) {
|
||||
if (current[k] === undefined) {
|
||||
if (current == null || current[k] === undefined) {
|
||||
// 如果找不到翻译,返回键名
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||
import { app, ipcMain, nativeImage, session } from 'electron';
|
||||
import { app, dialog, ipcMain, nativeImage, protocol, session } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
// 全局兜底(#714):Windows 上 config.json 等文件可能被杀毒/云同步软件短暂锁定,
|
||||
// electron-store 读写撞锁会抛 EBUSY 等未捕获异常,Electron 默认弹出致命错误框。
|
||||
// 对带 path 的文件系统锁类错误仅记录日志;其余异常保留报错弹窗以免掩盖真 bug。
|
||||
const FILE_LOCK_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN', 'EMFILE', 'ENFILE']);
|
||||
process.on('uncaughtException', (error: NodeJS.ErrnoException) => {
|
||||
if (error?.code && FILE_LOCK_ERROR_CODES.has(error.code) && typeof error.path === 'string') {
|
||||
console.error('[main] 文件被占用/锁定,已忽略本次读写:', error.message);
|
||||
return;
|
||||
}
|
||||
console.error('[main] 未捕获异常:', error);
|
||||
dialog.showErrorBox(
|
||||
'A JavaScript error occurred in the main process',
|
||||
error?.stack || String(error)
|
||||
);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[main] 未处理的 Promise 拒绝:', reason);
|
||||
});
|
||||
|
||||
// 必须在 app.whenReady() 之前注册自定义协议为特权协议,
|
||||
// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成
|
||||
// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'local',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
bypassCSP: true,
|
||||
corsEnabled: true
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
import type { Language } from '../i18n/main';
|
||||
import i18n from '../i18n/main';
|
||||
import { loadLyricWindow } from './lyric';
|
||||
import { initializeCacheManager } from './modules/cache';
|
||||
import { initializeConfig } from './modules/config';
|
||||
import { initializeDownloadManager, setDownloadManagerWindow } from './modules/downloadManager';
|
||||
import { initializeFileManager } from './modules/fileManager';
|
||||
import { initializeFonts } from './modules/fonts';
|
||||
import { initializeLocalMusicScanner } from './modules/localMusicScanner';
|
||||
import { initializeLoginWindow } from './modules/loginWindow';
|
||||
import { initLxMusicHttp } from './modules/lxMusicHttp';
|
||||
import { initializeMpris, updateMprisCurrentSong, updateMprisPlayState } from './modules/mpris';
|
||||
import { initializeOtherApi } from './modules/otherApi';
|
||||
import { initializeRemoteControl } from './modules/remoteControl';
|
||||
import { initializeShortcuts } from './modules/shortcuts';
|
||||
@@ -42,6 +80,8 @@ function initialize(configStore: any) {
|
||||
|
||||
// 初始化文件管理
|
||||
initializeFileManager();
|
||||
// 初始化下载管理
|
||||
initializeDownloadManager();
|
||||
// 初始化歌词缓存管理
|
||||
initializeCacheManager();
|
||||
// 初始化其他 API (搜索建议等)
|
||||
@@ -58,6 +98,9 @@ function initialize(configStore: any) {
|
||||
// 创建主窗口
|
||||
mainWindow = createMainWindow(icon);
|
||||
|
||||
// 设置下载管理器窗口引用
|
||||
setDownloadManagerWindow(mainWindow);
|
||||
|
||||
// 初始化托盘
|
||||
initializeTray(iconPath, mainWindow);
|
||||
|
||||
@@ -76,6 +119,9 @@ function initialize(configStore: any) {
|
||||
// 初始化远程控制服务
|
||||
initializeRemoteControl(mainWindow);
|
||||
|
||||
// 初始化 MPRIS 服务 (Linux)
|
||||
initializeMpris(mainWindow);
|
||||
|
||||
// 初始化更新处理程序
|
||||
setupUpdateHandlers(mainWindow);
|
||||
}
|
||||
@@ -86,6 +132,11 @@ const isSingleInstance = app.requestSingleInstanceLock();
|
||||
if (!isSingleInstance) {
|
||||
app.quit();
|
||||
} else {
|
||||
// 禁用 Chromium 内置的 MediaSession MPRIS 服务,避免重复显示
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
|
||||
}
|
||||
|
||||
// 在应用准备就绪前初始化GPU加速设置
|
||||
// 必须在 app.ready 之前调用 disableHardwareAcceleration
|
||||
try {
|
||||
@@ -127,15 +178,18 @@ if (!isSingleInstance) {
|
||||
// 初始化窗口大小管理器
|
||||
initWindowSizeManager();
|
||||
|
||||
// 设置媒体设备权限 - 允许枚举音频输出设备
|
||||
// 媒体设备权限:应用没有任何录音功能,麦克风/摄像头采集一律拒绝,
|
||||
// 防止依赖库静默调用 getUserMedia 触发系统麦克风授权弹窗(#147/#246/#440/#639 防御性加固)。
|
||||
// 输出设备切换走 speaker-selection / enumerateDevices,不受影响
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
if (permission === ('media' as any) || permission === ('audioCapture' as any)) {
|
||||
callback(true);
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
callback(true);
|
||||
});
|
||||
|
||||
// 保持放行:enumerateDevices 依赖它返回真实设备名(不访问麦克风硬件、不触发系统授权)
|
||||
session.defaultSession.setPermissionCheckHandler(() => {
|
||||
return true;
|
||||
});
|
||||
@@ -165,11 +219,13 @@ if (!isSingleInstance) {
|
||||
// 监听播放状态变化
|
||||
ipcMain.on('update-play-state', (_, playing: boolean) => {
|
||||
updatePlayState(playing);
|
||||
updateMprisPlayState(playing);
|
||||
});
|
||||
|
||||
// 监听当前歌曲变化
|
||||
ipcMain.on('update-current-song', (_, song: any) => {
|
||||
updateCurrentSong(song);
|
||||
updateMprisCurrentSong(song);
|
||||
});
|
||||
|
||||
// 所有窗口关闭时的处理
|
||||
|
||||
@@ -1,15 +1,92 @@
|
||||
import { BrowserWindow, IpcMain, screen } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import path, { join } from 'path';
|
||||
|
||||
const store = new Store();
|
||||
import { getSharedStore } from './modules/config';
|
||||
|
||||
const store = getSharedStore();
|
||||
let lyricWindow: BrowserWindow | null = null;
|
||||
|
||||
// 歌词窗口 bounds 防抖保存:拖动/缩放时高频触发,
|
||||
// 直接写盘会加剧 config.json 文件争用(#714 EBUSY)
|
||||
let lyricBoundsSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const saveLyricWindowBounds = (bounds: Record<string, number>) => {
|
||||
if (lyricBoundsSaveTimer) {
|
||||
clearTimeout(lyricBoundsSaveTimer);
|
||||
}
|
||||
lyricBoundsSaveTimer = setTimeout(() => {
|
||||
lyricBoundsSaveTimer = null;
|
||||
try {
|
||||
store.set('lyricWindowBounds', bounds);
|
||||
} catch (error) {
|
||||
console.error('保存歌词窗口位置失败:', error);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 跟踪拖动状态
|
||||
let isDragging = false;
|
||||
|
||||
// 添加窗口大小变化防护
|
||||
let originalSize = { width: 0, height: 0 };
|
||||
// 鼠标位置轮询仅在"锁定 + 可见"时启用,解锁态下 DOM 事件已足够
|
||||
let mousePresenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastMouseInside: boolean | null = null;
|
||||
let isLyricLocked = false;
|
||||
let isLyricWindowVisible = false;
|
||||
|
||||
const isPointInsideWindow = (
|
||||
point: { x: number; y: number },
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) => {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x < bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y < bounds.y + bounds.height
|
||||
);
|
||||
};
|
||||
|
||||
const stopMousePresenceTracking = () => {
|
||||
if (mousePresenceTimer) {
|
||||
clearInterval(mousePresenceTimer);
|
||||
mousePresenceTimer = null;
|
||||
}
|
||||
lastMouseInside = null;
|
||||
};
|
||||
|
||||
const emitMousePresence = () => {
|
||||
if (!lyricWindow || lyricWindow.isDestroyed()) return;
|
||||
|
||||
const mousePoint = screen.getCursorScreenPoint();
|
||||
const bounds = lyricWindow.getBounds();
|
||||
const isInside = isPointInsideWindow(mousePoint, bounds);
|
||||
|
||||
if (isInside === lastMouseInside) return;
|
||||
|
||||
lastMouseInside = isInside;
|
||||
lyricWindow.webContents.send('lyric-mouse-presence', isInside);
|
||||
};
|
||||
|
||||
const startMousePresenceTracking = () => {
|
||||
if (mousePresenceTimer) return;
|
||||
|
||||
emitMousePresence();
|
||||
mousePresenceTimer = setInterval(() => {
|
||||
if (!lyricWindow || lyricWindow.isDestroyed()) {
|
||||
stopMousePresenceTracking();
|
||||
return;
|
||||
}
|
||||
emitMousePresence();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const syncMousePresenceTracking = () => {
|
||||
if (isLyricLocked && isLyricWindowVisible && lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
startMousePresenceTracking();
|
||||
} else {
|
||||
stopMousePresenceTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const createWin = () => {
|
||||
console.log('Creating lyric window');
|
||||
@@ -102,12 +179,32 @@ const createWin = () => {
|
||||
|
||||
// 监听窗口关闭事件
|
||||
lyricWindow.on('closed', () => {
|
||||
stopMousePresenceTracking();
|
||||
isLyricLocked = false;
|
||||
isLyricWindowVisible = false;
|
||||
if (lyricWindow) {
|
||||
lyricWindow.destroy();
|
||||
lyricWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
lyricWindow.on('show', () => {
|
||||
isLyricWindowVisible = true;
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('hide', () => {
|
||||
isLyricWindowVisible = false;
|
||||
stopMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('minimize', () => {
|
||||
isLyricWindowVisible = false;
|
||||
stopMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('restore', () => {
|
||||
isLyricWindowVisible = true;
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
|
||||
// 监听窗口大小变化事件,保存新的尺寸
|
||||
lyricWindow.on('resize', () => {
|
||||
// 如果正在拖动,忽略大小调整事件
|
||||
@@ -117,8 +214,8 @@ const createWin = () => {
|
||||
const [width, height] = lyricWindow.getSize();
|
||||
const [x, y] = lyricWindow.getPosition();
|
||||
|
||||
// 保存窗口位置和大小
|
||||
store.set('lyricWindowBounds', { x, y, width, height });
|
||||
// 保存窗口位置和大小(防抖)
|
||||
saveLyricWindowBounds({ x, y, width, height });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -205,6 +302,17 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('set-lyric-lock-state', (_, isLocked: boolean) => {
|
||||
isLyricLocked = isLocked;
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
// 锁定时禁用 resize,避免鼠标移到边缘仍显示调整光标
|
||||
lyricWindow.setResizable(!isLocked);
|
||||
// 设置初始穿透状态,后续 polling 会按实际位置纠正
|
||||
lyricWindow.setIgnoreMouseEvents(isLocked, { forward: true });
|
||||
}
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
|
||||
// 处理鼠标事件
|
||||
ipcMain.on('mouseenter-lyric', () => {
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
@@ -267,7 +375,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
false
|
||||
);
|
||||
|
||||
// 更新存储的位置
|
||||
// 更新存储的位置(防抖,拖动结束后统一落盘)
|
||||
const windowBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
@@ -275,7 +383,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
height: windowHeight,
|
||||
displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理
|
||||
};
|
||||
store.set('lyricWindowBounds', windowBounds);
|
||||
saveLyricWindowBounds(windowBounds);
|
||||
} catch (error) {
|
||||
console.error('Error during window drag:', error);
|
||||
// 出错时尝试使用更简单的方法
|
||||
|
||||
@@ -6,6 +6,7 @@ import Store from 'electron-store';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { filePathToLocalUrl } from '../../shared/localUrl';
|
||||
import { getStore } from './config';
|
||||
|
||||
type CacheCleanupPolicy = 'lru' | 'fifo';
|
||||
@@ -412,7 +413,9 @@ class DiskCacheManager {
|
||||
cleanupPolicy
|
||||
};
|
||||
|
||||
this.saveConfig(normalizedConfig);
|
||||
// 注意:getCacheConfig 是纯读取,处于播放/下载/歌词等多个热路径。
|
||||
// 此处不再落盘(electron-store.set 每次整文件写 config.json,会造成写放大与文件争用),
|
||||
// 持久化交由 updateCacheConfig / setCacheDirectory 等真正的写操作完成。
|
||||
return normalizedConfig;
|
||||
}
|
||||
|
||||
@@ -535,8 +538,7 @@ class DiskCacheManager {
|
||||
}
|
||||
|
||||
private toLocalUrl(filePath: string): string {
|
||||
const normalized = path.normalize(filePath).replace(/\\/g, '/');
|
||||
return `local:///${encodeURIComponent(normalized)}`;
|
||||
return filePathToLocalUrl(path.normalize(filePath));
|
||||
}
|
||||
|
||||
private isRemoteAudioUrl(url: string): boolean {
|
||||
|
||||
@@ -38,41 +38,63 @@ interface StoreType {
|
||||
shortcuts: ShortcutsConfig;
|
||||
}
|
||||
|
||||
let store: Store<StoreType>;
|
||||
// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。
|
||||
// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁
|
||||
// 叠加触发 EBUSY 未捕获异常(#714)
|
||||
const store = new Store<StoreType>({
|
||||
name: 'config',
|
||||
defaults: {
|
||||
set: set as SetConfig,
|
||||
shortcuts: createDefaultShortcuts()
|
||||
}
|
||||
});
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化配置管理
|
||||
* 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听)
|
||||
*/
|
||||
export function initializeConfig() {
|
||||
store = new Store<StoreType>({
|
||||
name: 'config',
|
||||
defaults: {
|
||||
set: set as SetConfig,
|
||||
shortcuts: createDefaultShortcuts()
|
||||
}
|
||||
});
|
||||
if (initialized) {
|
||||
return store;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
|
||||
store.get('set.diskCacheDir') ||
|
||||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
|
||||
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
|
||||
store.set('set.diskCacheMaxSizeMB', 4096);
|
||||
}
|
||||
if (!store.get('set.diskCacheCleanupPolicy')) {
|
||||
store.set('set.diskCacheCleanupPolicy', 'lru');
|
||||
}
|
||||
if (store.get('set.enableDiskCache') === undefined) {
|
||||
store.set('set.enableDiskCache', true);
|
||||
try {
|
||||
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
|
||||
store.get('set.diskCacheDir') ||
|
||||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
|
||||
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
|
||||
store.set('set.diskCacheMaxSizeMB', 4096);
|
||||
}
|
||||
if (!store.get('set.diskCacheCleanupPolicy')) {
|
||||
store.set('set.diskCacheCleanupPolicy', 'lru');
|
||||
}
|
||||
if (store.get('set.enableDiskCache') === undefined) {
|
||||
store.set('set.enableDiskCache', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[config] 初始化默认配置失败:', error);
|
||||
}
|
||||
|
||||
// 定义ipcRenderer监听事件
|
||||
ipcMain.on('set-store-value', (_, key, value) => {
|
||||
store.set(key, value);
|
||||
try {
|
||||
store.set(key, value);
|
||||
} catch (error) {
|
||||
// config.json 可能被杀毒/云同步短暂锁定,丢一次写入无害,避免主进程崩溃
|
||||
console.error(`[config] 写入配置失败 key=${key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('get-store-value', (_, key) => {
|
||||
const value = store.get(key);
|
||||
_.returnValue = value || '';
|
||||
ipcMain.on('get-store-value', (event, key) => {
|
||||
try {
|
||||
const value = store.get(key);
|
||||
event.returnValue = value || '';
|
||||
} catch (error) {
|
||||
console.error(`[config] 读取配置失败 key=${key}:`, error);
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// GPU加速设置更新处理
|
||||
@@ -98,3 +120,11 @@ export function initializeConfig() {
|
||||
export function getStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供主进程其他模块共享同一个 config.json Store 实例
|
||||
* (不要再 new Store(),多实例并发读写是 #714 EBUSY 崩溃的诱因之一)
|
||||
*/
|
||||
export function getSharedStore(): Store<Record<string, unknown>> {
|
||||
return store as unknown as Store<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { machineIdSync } from 'node-machine-id';
|
||||
import os from 'os';
|
||||
|
||||
const store = new Store();
|
||||
import { getSharedStore } from './config';
|
||||
|
||||
const store = getSharedStore();
|
||||
|
||||
/**
|
||||
* 获取设备唯一标识符
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import { app, dialog, ipcMain, nativeImage, Notification, protocol, shell } from 'electron';
|
||||
import { app, dialog, ipcMain, protocol, shell } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { FlacTagMap, writeFlacTags } from 'flac-tagger';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as mm from 'music-metadata';
|
||||
import * as NodeID3 from 'node-id3';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { getStore } from './config';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||
const downloadQueue: { url: string; filename: string; songInfo: any; type?: string }[] = [];
|
||||
let activeDownloads = 0;
|
||||
|
||||
// 创建一个store实例用于存储下载历史
|
||||
const downloadStore = new Store({
|
||||
name: 'downloads',
|
||||
defaults: {
|
||||
history: []
|
||||
}
|
||||
});
|
||||
|
||||
// 创建一个store实例用于存储音频缓存
|
||||
const audioCacheStore = new Store({
|
||||
name: 'audioCache',
|
||||
@@ -33,39 +14,89 @@ const audioCacheStore = new Store({
|
||||
}
|
||||
});
|
||||
|
||||
// 保存已发送通知的文件,避免重复通知
|
||||
const sentNotifications = new Map();
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Electron net.fetch(file://) 在当前版本不会回 206,audio seek 需要主进程自己处理 Range
|
||||
function buildLocalFileResponse(
|
||||
filePath: string,
|
||||
total: number,
|
||||
rangeHeader: string | null
|
||||
): Response {
|
||||
const range416 = () =>
|
||||
new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
|
||||
|
||||
let start = 0;
|
||||
let end = total - 1;
|
||||
let partial = false;
|
||||
|
||||
if (rangeHeader) {
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
||||
if (!m || (!m[1] && !m[2])) return range416();
|
||||
if (m[1]) {
|
||||
start = parseInt(m[1], 10);
|
||||
if (m[2]) end = Math.min(parseInt(m[2], 10), end);
|
||||
} else {
|
||||
start = Math.max(0, total - parseInt(m[2], 10));
|
||||
}
|
||||
if (start > end || start >= total) return range416();
|
||||
partial = true;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream,
|
||||
{
|
||||
status: partial ? 206 : 200,
|
||||
headers: {
|
||||
'Content-Length': String(end - start + 1),
|
||||
'Accept-Ranges': 'bytes',
|
||||
...(partial && { 'Content-Range': `bytes ${start}-${end}/${total}` })
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化文件管理相关的IPC监听
|
||||
*/
|
||||
export function initializeFileManager() {
|
||||
// 注册本地文件协议
|
||||
protocol.registerFileProtocol('local', (request, callback) => {
|
||||
// Electron 25+ 起 registerFileProtocol 已弃用,改用 protocol.handle,并配合 main/index.ts
|
||||
// 中的 registerSchemesAsPrivileged,让 audio 元素能从 http(s) 页面跨协议加载本地文件
|
||||
protocol.handle('local', async (request) => {
|
||||
try {
|
||||
const url = request.url;
|
||||
// local://C:/Users/xxx.mp3
|
||||
let filePath = decodeURIComponent(url.replace('local:///', ''));
|
||||
// local:///<absolute-path>
|
||||
let filePath = decodeURIComponent(request.url.replace(/^local:\/\/\/?/, ''));
|
||||
|
||||
// 兼容 local:///C:/Users/xxx.mp3 这种情况
|
||||
// Windows: 协议解析后可能是 /C:/...,去掉前导斜杠
|
||||
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
|
||||
// 还原为系统路径格式
|
||||
filePath = path.normalize(filePath);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('File not found:', filePath);
|
||||
callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND
|
||||
return;
|
||||
// macOS/Linux 上去掉前导斜杠后会丢失绝对路径标识,这里补回
|
||||
if (process.platform !== 'win32' && !filePath.startsWith('/')) {
|
||||
filePath = '/' + filePath;
|
||||
}
|
||||
|
||||
callback({ path: filePath });
|
||||
filePath = path.normalize(filePath);
|
||||
|
||||
const stat = await fs.promises.stat(filePath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
console.error('File not found:', filePath);
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return buildLocalFileResponse(filePath, stat.size, request.headers.get('range'));
|
||||
} catch (error) {
|
||||
console.error('Error handling local protocol:', error);
|
||||
callback({ error: -2 }); // net::FAILED
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,122 +161,6 @@ export function initializeFileManager() {
|
||||
return app.getPath('downloads');
|
||||
});
|
||||
|
||||
// 获取存储的配置值
|
||||
ipcMain.handle('get-store-value', (_, key) => {
|
||||
const store = new Store();
|
||||
return store.get(key);
|
||||
});
|
||||
|
||||
// 设置存储的配置值
|
||||
ipcMain.on('set-store-value', (_, key, value) => {
|
||||
const store = new Store();
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
// 下载音乐处理
|
||||
ipcMain.on('download-music', handleDownloadRequest);
|
||||
|
||||
// 检查文件是否已下载
|
||||
ipcMain.handle('check-music-downloaded', (_, filename: string) => {
|
||||
const store = new Store();
|
||||
const downloadPath = (store.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
const filePath = path.join(downloadPath, `${filename}.mp3`);
|
||||
return fs.existsSync(filePath);
|
||||
});
|
||||
|
||||
// 删除已下载的音乐
|
||||
ipcMain.handle('delete-downloaded-music', async (_, filePath: string) => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
// 先删除文件
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
|
||||
// 删除对应的歌曲信息
|
||||
const store = new Store();
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
delete songInfos[filePath];
|
||||
store.set('downloadedSongs', songInfos);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取已下载音乐列表
|
||||
ipcMain.handle('get-downloaded-music', async () => {
|
||||
try {
|
||||
const store = new Store();
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
|
||||
// 异步处理文件存在性检查
|
||||
const entriesArray = Object.entries(songInfos);
|
||||
const validEntriesPromises = await Promise.all(
|
||||
entriesArray.map(async ([path, info]) => {
|
||||
try {
|
||||
const exists = await fs.promises
|
||||
.access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return exists ? info : null;
|
||||
} catch (error) {
|
||||
console.error('Error checking file existence:', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 过滤有效的歌曲并排序
|
||||
const validSongs = validEntriesPromises
|
||||
.filter((song) => song !== null)
|
||||
.sort((a, b) => (b.downloadTime || 0) - (a.downloadTime || 0));
|
||||
|
||||
// 更新存储,移除不存在的文件记录
|
||||
const newSongInfos = validSongs.reduce((acc, song) => {
|
||||
if (song && song.path) {
|
||||
acc[song.path] = song;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
store.set('downloadedSongs', newSongInfos);
|
||||
|
||||
return validSongs;
|
||||
} catch (error) {
|
||||
console.error('Error getting downloaded music:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// 检查歌曲是否已下载并返回本地路径
|
||||
ipcMain.handle('check-song-downloaded', (_, songId: number) => {
|
||||
const store = new Store();
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
|
||||
// 通过ID查找已下载的歌曲
|
||||
for (const [path, info] of Object.entries(songInfos)) {
|
||||
if (info.id === songId && fs.existsSync(path)) {
|
||||
return {
|
||||
isDownloaded: true,
|
||||
localPath: `local://${path}`,
|
||||
songInfo: info
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDownloaded: false,
|
||||
localPath: '',
|
||||
songInfo: null
|
||||
};
|
||||
});
|
||||
|
||||
// 保存歌词文件
|
||||
ipcMain.handle(
|
||||
'save-lyric-file',
|
||||
@@ -273,18 +188,6 @@ export function initializeFileManager() {
|
||||
}
|
||||
);
|
||||
|
||||
// 添加清除下载历史的处理函数
|
||||
ipcMain.on('clear-downloads-history', () => {
|
||||
downloadStore.set('history', []);
|
||||
});
|
||||
|
||||
// 添加清除已下载音乐记录的处理函数
|
||||
ipcMain.handle('clear-downloaded-music', () => {
|
||||
const store = new Store();
|
||||
store.set('downloadedSongs', {});
|
||||
return true;
|
||||
});
|
||||
|
||||
// 添加清除音频缓存的处理函数
|
||||
ipcMain.on('clear-audio-cache', () => {
|
||||
audioCacheStore.set('cache', {});
|
||||
@@ -378,613 +281,3 @@ export function initializeFileManager() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下载请求
|
||||
*/
|
||||
function handleDownloadRequest(
|
||||
event: Electron.IpcMainEvent,
|
||||
{
|
||||
url,
|
||||
filename,
|
||||
songInfo,
|
||||
type
|
||||
}: { url: string; filename: string; songInfo?: any; type?: string }
|
||||
) {
|
||||
// 检查是否已经在队列中或正在下载
|
||||
if (downloadQueue.some((item) => item.filename === filename)) {
|
||||
event.reply('music-download-error', {
|
||||
filename,
|
||||
error: '该歌曲已在下载队列中'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已下载
|
||||
const store = new Store();
|
||||
const songInfos = store.get('downloadedSongs', {}) as Record<string, any>;
|
||||
|
||||
// 检查是否已下载(通过ID)
|
||||
const isDownloaded =
|
||||
songInfo?.id && Object.values(songInfos).some((info: any) => info.id === songInfo.id);
|
||||
|
||||
if (isDownloaded) {
|
||||
event.reply('music-download-error', {
|
||||
filename,
|
||||
error: '该歌曲已下载'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到下载队列
|
||||
downloadQueue.push({ url, filename, songInfo, type });
|
||||
event.reply('music-download-queued', {
|
||||
filename,
|
||||
songInfo
|
||||
});
|
||||
|
||||
// 尝试开始下载
|
||||
processDownloadQueue(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下载队列
|
||||
*/
|
||||
async function processDownloadQueue(event: Electron.IpcMainEvent) {
|
||||
if (activeDownloads >= MAX_CONCURRENT_DOWNLOADS || downloadQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { url, filename, songInfo, type } = downloadQueue.shift()!;
|
||||
activeDownloads++;
|
||||
|
||||
try {
|
||||
await downloadMusic(event, { url, filename, songInfo, type });
|
||||
} finally {
|
||||
activeDownloads--;
|
||||
processDownloadQueue(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// 替换 Windows 和 Unix 系统中的非法字符
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // 替换特殊字符为下划线
|
||||
.replace(/\s+/g, ' ') // 将多个空格替换为单个空格
|
||||
.trim(); // 移除首尾空格
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载音乐和歌词
|
||||
*/
|
||||
async function downloadMusic(
|
||||
event: Electron.IpcMainEvent,
|
||||
{
|
||||
url,
|
||||
filename,
|
||||
songInfo,
|
||||
type = 'mp3'
|
||||
}: { url: string; filename: string; songInfo: any; type?: string }
|
||||
) {
|
||||
let finalFilePath = '';
|
||||
let writer: fs.WriteStream | null = null;
|
||||
let tempFilePath = '';
|
||||
|
||||
try {
|
||||
// 使用配置Store来获取设置
|
||||
const configStore = getStore();
|
||||
const downloadPath =
|
||||
(configStore.get('set.downloadPath') as string) || app.getPath('downloads');
|
||||
const apiPort = configStore.get('set.musicApiPort') || 30488;
|
||||
|
||||
// 获取文件名格式设置
|
||||
const nameFormat =
|
||||
(configStore.get('set.downloadNameFormat') as string) || '{songName} - {artistName}';
|
||||
|
||||
// 根据格式创建文件名
|
||||
let formattedFilename = filename;
|
||||
if (songInfo) {
|
||||
// 准备替换变量
|
||||
const artistName = songInfo.ar?.map((a: any) => a.name).join('、') || '未知艺术家';
|
||||
const songName = songInfo.name || filename;
|
||||
const albumName = songInfo.al?.name || '未知专辑';
|
||||
|
||||
// 应用自定义格式
|
||||
formattedFilename = nameFormat
|
||||
.replace(/\{songName\}/g, songName)
|
||||
.replace(/\{artistName\}/g, artistName)
|
||||
.replace(/\{albumName\}/g, albumName);
|
||||
}
|
||||
|
||||
// 清理文件名中的非法字符
|
||||
const sanitizedFilename = sanitizeFilename(formattedFilename);
|
||||
|
||||
// 创建临时文件路径 (在系统临时目录中创建)
|
||||
const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
|
||||
|
||||
// 先获取文件大小
|
||||
const headResponse = await axios.head(url);
|
||||
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
|
||||
|
||||
// 开始下载到临时文件
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000, // 30秒超时
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
writer = fs.createWriteStream(tempFilePath);
|
||||
let downloadedSize = 0;
|
||||
|
||||
// 使用 data 事件来跟踪下载进度
|
||||
response.data.on('data', (chunk: Buffer) => {
|
||||
downloadedSize += chunk.length;
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
event.reply('music-download-progress', {
|
||||
filename,
|
||||
progress,
|
||||
loaded: downloadedSize,
|
||||
total: totalSize,
|
||||
path: tempFilePath,
|
||||
status: progress === 100 ? 'completed' : 'downloading',
|
||||
songInfo: songInfo || {
|
||||
name: filename,
|
||||
ar: [{ name: '本地音乐' }],
|
||||
picUrl: '/images/default_cover.png'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 等待下载完成
|
||||
await new Promise((resolve, reject) => {
|
||||
writer!.on('finish', () => resolve(undefined));
|
||||
writer!.on('error', (error) => reject(error));
|
||||
response.data.pipe(writer!);
|
||||
});
|
||||
|
||||
// 验证文件是否完整下载
|
||||
const stats = fs.statSync(tempFilePath);
|
||||
if (stats.size !== totalSize) {
|
||||
throw new Error('文件下载不完整');
|
||||
}
|
||||
|
||||
// 检测文件类型
|
||||
let fileExtension = '';
|
||||
|
||||
try {
|
||||
// 首先尝试使用file-type库检测
|
||||
const fileType = await fileTypeFromFile(tempFilePath);
|
||||
if (fileType && fileType.ext) {
|
||||
fileExtension = `.${fileType.ext}`;
|
||||
console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`);
|
||||
} else {
|
||||
// 如果file-type无法识别,尝试使用music-metadata
|
||||
const metadata = await mm.parseFile(tempFilePath);
|
||||
if (metadata && metadata.format) {
|
||||
// 根据format.container或codec判断扩展名
|
||||
const formatInfo = metadata.format;
|
||||
const container = formatInfo.container || '';
|
||||
const codec = formatInfo.codec || '';
|
||||
|
||||
// 音频格式映射表
|
||||
const formatMap = {
|
||||
mp3: ['MPEG', 'MP3', 'mp3'],
|
||||
aac: ['AAC'],
|
||||
flac: ['FLAC'],
|
||||
ogg: ['Ogg', 'Vorbis'],
|
||||
wav: ['WAV', 'PCM'],
|
||||
m4a: ['M4A', 'MP4']
|
||||
};
|
||||
|
||||
// 查找匹配的格式
|
||||
const format = Object.entries(formatMap).find(([_, keywords]) =>
|
||||
keywords.some((keyword) => container.includes(keyword) || codec.includes(keyword))
|
||||
);
|
||||
|
||||
// 设置文件扩展名,如果没找到则默认为mp3
|
||||
fileExtension = format ? `.${format[0]}` : '.mp3';
|
||||
|
||||
console.log(
|
||||
`music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}`
|
||||
);
|
||||
} else {
|
||||
// 两种方法都失败,使用传入的type或默认mp3
|
||||
fileExtension = type ? `.${type}` : '.mp3';
|
||||
console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('检测文件类型失败:', err);
|
||||
// 检测失败,使用传入的type或默认mp3
|
||||
fileExtension = type ? `.${type}` : '.mp3';
|
||||
}
|
||||
|
||||
// 使用检测到的文件扩展名创建最终文件路径
|
||||
const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`);
|
||||
|
||||
// 检查文件是否已存在,如果存在则添加序号
|
||||
finalFilePath = filePath;
|
||||
let counter = 1;
|
||||
while (fs.existsSync(finalFilePath)) {
|
||||
const ext = path.extname(filePath);
|
||||
const nameWithoutExt = filePath.slice(0, -ext.length);
|
||||
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// 将临时文件移动到最终位置
|
||||
fs.copyFileSync(tempFilePath, finalFilePath);
|
||||
fs.unlinkSync(tempFilePath); // 删除临时文件
|
||||
|
||||
// 下载歌词
|
||||
let lyricData = null;
|
||||
let lyricsContent = '';
|
||||
try {
|
||||
if (songInfo?.id) {
|
||||
// 下载歌词,使用配置的端口
|
||||
const lyricsResponse = await axios.get(
|
||||
`http://localhost:${apiPort}/lyric?id=${songInfo.id}`
|
||||
);
|
||||
if (lyricsResponse.data && (lyricsResponse.data.lrc || lyricsResponse.data.tlyric)) {
|
||||
lyricData = lyricsResponse.data;
|
||||
|
||||
// 处理歌词内容
|
||||
if (lyricsResponse.data.lrc && lyricsResponse.data.lrc.lyric) {
|
||||
lyricsContent = lyricsResponse.data.lrc.lyric;
|
||||
|
||||
// 如果有翻译歌词,合并到主歌词中
|
||||
if (lyricsResponse.data.tlyric && lyricsResponse.data.tlyric.lyric) {
|
||||
// 解析原歌词和翻译
|
||||
const originalLyrics = parseLyrics(lyricsResponse.data.lrc.lyric);
|
||||
const translatedLyrics = parseLyrics(lyricsResponse.data.tlyric.lyric);
|
||||
|
||||
// 合并歌词
|
||||
const mergedLyrics = mergeLyrics(originalLyrics, translatedLyrics);
|
||||
lyricsContent = mergedLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('歌词已准备好,将写入元数据');
|
||||
}
|
||||
}
|
||||
} catch (lyricError) {
|
||||
console.error('下载歌词失败:', lyricError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
// 下载封面
|
||||
let coverImageBuffer: Buffer | null = null;
|
||||
try {
|
||||
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
|
||||
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
|
||||
if (picUrl && picUrl !== '/images/default_cover.png') {
|
||||
// 处理 base64 Data URL(本地音乐扫描提取的封面)
|
||||
if (picUrl.startsWith('data:')) {
|
||||
const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
coverImageBuffer = Buffer.from(base64Match[1], 'base64');
|
||||
console.log('从 base64 Data URL 提取封面');
|
||||
}
|
||||
} else {
|
||||
const coverResponse = await axios({
|
||||
url: picUrl.replace('http://', 'https://'),
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const originalCoverBuffer = Buffer.from(coverResponse.data);
|
||||
const TWO_MB = 2 * 1024 * 1024;
|
||||
// 检查图片大小是否超过2MB
|
||||
if (originalCoverBuffer.length > TWO_MB) {
|
||||
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
|
||||
try {
|
||||
// 使用 Electron nativeImage 进行压缩
|
||||
const image = nativeImage.createFromBuffer(originalCoverBuffer);
|
||||
const size = image.getSize();
|
||||
|
||||
// 计算新尺寸,保持宽高比,最大1600px
|
||||
const maxSize = 1600;
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
|
||||
if (size.width > maxSize || size.height > maxSize) {
|
||||
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
|
||||
newWidth = Math.round(size.width * ratio);
|
||||
newHeight = Math.round(size.height * ratio);
|
||||
}
|
||||
|
||||
// 调整大小并转换为 JPEG 格式(质量 80)
|
||||
const resizedImage = image.resize({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
quality: 'good'
|
||||
});
|
||||
coverImageBuffer = resizedImage.toJPEG(80);
|
||||
|
||||
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
|
||||
} catch (compressionError) {
|
||||
console.error('封面图压缩失败,将使用原图:', compressionError);
|
||||
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
|
||||
}
|
||||
} else {
|
||||
// 如果图片不大于2MB,直接使用原图
|
||||
coverImageBuffer = originalCoverBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('封面已准备好,将写入元数据');
|
||||
}
|
||||
}
|
||||
} catch (coverError) {
|
||||
console.error('下载封面失败:', coverError);
|
||||
// 继续处理,不影响音乐下载
|
||||
}
|
||||
|
||||
const fileFormat = fileExtension.toLowerCase();
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') || '未知艺术家';
|
||||
|
||||
// 根据文件类型处理元数据
|
||||
if (['.mp3'].includes(fileFormat)) {
|
||||
// 对MP3文件使用NodeID3处理ID3标签
|
||||
try {
|
||||
// 在写入ID3标签前,先移除可能存在的旧标签
|
||||
NodeID3.removeTags(finalFilePath);
|
||||
|
||||
const tags = {
|
||||
title: songInfo?.name,
|
||||
artist: artistNames,
|
||||
TPE1: artistNames,
|
||||
TPE2: artistNames,
|
||||
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||
APIC: {
|
||||
// 专辑封面
|
||||
imageBuffer: coverImageBuffer,
|
||||
type: {
|
||||
id: 3,
|
||||
name: 'front cover'
|
||||
},
|
||||
description: 'Album cover',
|
||||
mime: 'image/jpeg'
|
||||
},
|
||||
USLT: {
|
||||
// 歌词
|
||||
language: 'chi',
|
||||
description: 'Lyrics',
|
||||
text: lyricsContent || ''
|
||||
},
|
||||
trackNumber: songInfo?.no || undefined,
|
||||
year: songInfo?.publishTime
|
||||
? new Date(songInfo.publishTime).getFullYear().toString()
|
||||
: undefined
|
||||
};
|
||||
|
||||
const success = NodeID3.write(tags, finalFilePath);
|
||||
if (!success) {
|
||||
console.error('Failed to write ID3 tags');
|
||||
} else {
|
||||
console.log('ID3 tags written successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error writing ID3 tags:', err);
|
||||
}
|
||||
} else if (['.flac'].includes(fileFormat)) {
|
||||
try {
|
||||
const tagMap: FlacTagMap = {
|
||||
TITLE: songInfo?.name,
|
||||
ARTIST: artistNames,
|
||||
ALBUM: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
|
||||
LYRICS: lyricsContent || '',
|
||||
TRACKNUMBER: songInfo?.no ? String(songInfo.no) : '',
|
||||
DATE: songInfo?.publishTime ? new Date(songInfo.publishTime).getFullYear().toString() : ''
|
||||
};
|
||||
|
||||
await writeFlacTags(
|
||||
{
|
||||
tagMap,
|
||||
picture: coverImageBuffer
|
||||
? {
|
||||
buffer: coverImageBuffer,
|
||||
mime: 'image/jpeg'
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
finalFilePath
|
||||
);
|
||||
console.log('FLAC tags written successfully');
|
||||
} catch (err) {
|
||||
console.error('Error writing FLAC tags:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了单独保存歌词文件,将歌词保存为 .lrc 文件
|
||||
if (lyricsContent && configStore.get('set.downloadSaveLyric')) {
|
||||
try {
|
||||
const lrcFilePath = finalFilePath.replace(/\.[^.]+$/, '.lrc');
|
||||
await fs.promises.writeFile(lrcFilePath, lyricsContent, 'utf-8');
|
||||
console.log('歌词文件已保存:', lrcFilePath);
|
||||
} catch (lrcError) {
|
||||
console.error('保存歌词文件失败:', lrcError);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存下载信息
|
||||
try {
|
||||
const songInfos = configStore.get('downloadedSongs', {}) as Record<string, any>;
|
||||
const defaultInfo = {
|
||||
name: filename,
|
||||
ar: [{ name: '本地音乐' }],
|
||||
picUrl: '/images/default_cover.png'
|
||||
};
|
||||
|
||||
const newSongInfo = {
|
||||
id: songInfo?.id || 0,
|
||||
name: songInfo?.name || filename,
|
||||
filename,
|
||||
picUrl: songInfo?.picUrl || songInfo?.al?.picUrl || defaultInfo.picUrl,
|
||||
ar: songInfo?.ar || defaultInfo.ar,
|
||||
al: songInfo?.al || {
|
||||
picUrl: songInfo?.picUrl || defaultInfo.picUrl,
|
||||
name: songInfo?.name || filename
|
||||
},
|
||||
size: totalSize,
|
||||
path: finalFilePath,
|
||||
downloadTime: Date.now(),
|
||||
type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
|
||||
lyric: lyricData
|
||||
};
|
||||
|
||||
// 保存到下载记录
|
||||
songInfos[finalFilePath] = newSongInfo;
|
||||
configStore.set('downloadedSongs', songInfos);
|
||||
|
||||
// 添加到下载历史
|
||||
const history = downloadStore.get('history', []) as any[];
|
||||
history.unshift(newSongInfo);
|
||||
downloadStore.set('history', history);
|
||||
|
||||
// 避免重复发送通知
|
||||
const notificationId = `download-${finalFilePath}`;
|
||||
if (!sentNotifications.has(notificationId)) {
|
||||
sentNotifications.set(notificationId, true);
|
||||
|
||||
// 发送桌面通知
|
||||
try {
|
||||
const artistNames =
|
||||
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('、') ||
|
||||
'未知艺术家';
|
||||
const notification = new Notification({
|
||||
title: '下载完成',
|
||||
body: `${songInfo?.name || filename} - ${artistNames}`,
|
||||
silent: false
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
shell.showItemInFolder(finalFilePath);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
|
||||
// 60秒后清理通知记录,释放内存
|
||||
setTimeout(() => {
|
||||
sentNotifications.delete(notificationId);
|
||||
}, 60000);
|
||||
} catch (notifyError) {
|
||||
console.error('发送通知失败:', notifyError);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送下载完成事件,确保只发送一次
|
||||
event.reply('music-download-complete', {
|
||||
success: true,
|
||||
path: finalFilePath,
|
||||
filename,
|
||||
size: totalSize,
|
||||
songInfo: newSongInfo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving download info:', error);
|
||||
throw new Error('保存下载信息失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Download error:', error);
|
||||
|
||||
// 清理未完成的下载
|
||||
if (writer) {
|
||||
writer.end();
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
if (tempFilePath && fs.existsSync(tempFilePath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete temporary file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理未完成的最终文件
|
||||
if (finalFilePath && fs.existsSync(finalFilePath)) {
|
||||
try {
|
||||
fs.unlinkSync(finalFilePath);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete incomplete download:', e);
|
||||
}
|
||||
}
|
||||
|
||||
event.reply('music-download-complete', {
|
||||
success: false,
|
||||
error: error.message || '下载失败',
|
||||
filename
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数 - 解析歌词文本成时间戳和内容的映射
|
||||
function parseLyrics(lyricsText: string): Map<string, string> {
|
||||
const lyricMap = new Map<string, string>();
|
||||
const lines = lyricsText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配时间标签,形如 [00:00.000]
|
||||
const timeTagMatches = line.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g);
|
||||
if (!timeTagMatches) continue;
|
||||
|
||||
// 提取歌词内容(去除时间标签)
|
||||
const content = line.replace(/\[\d{2}:\d{2}(\.\d{1,3})?\]/g, '').trim();
|
||||
if (!content) continue;
|
||||
|
||||
// 将每个时间标签与歌词内容关联
|
||||
for (const timeTag of timeTagMatches) {
|
||||
lyricMap.set(timeTag, content);
|
||||
}
|
||||
}
|
||||
|
||||
return lyricMap;
|
||||
}
|
||||
|
||||
// 辅助函数 - 合并原文歌词和翻译歌词
|
||||
function mergeLyrics(
|
||||
originalLyrics: Map<string, string>,
|
||||
translatedLyrics: Map<string, string>
|
||||
): string {
|
||||
const mergedLines: string[] = [];
|
||||
|
||||
// 对每个时间戳,组合原始歌词和翻译
|
||||
for (const [timeTag, originalContent] of originalLyrics.entries()) {
|
||||
const translatedContent = translatedLyrics.get(timeTag);
|
||||
|
||||
// 添加原始歌词行
|
||||
mergedLines.push(`${timeTag}${originalContent}`);
|
||||
|
||||
// 如果有翻译,添加翻译行(时间戳相同,这样可以和原歌词同步显示)
|
||||
if (translatedContent) {
|
||||
mergedLines.push(`${timeTag}${translatedContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间顺序排序
|
||||
mergedLines.sort((a, b) => {
|
||||
const timeA = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
const timeB = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
|
||||
return timeA.localeCompare(timeB);
|
||||
});
|
||||
|
||||
return mergedLines.join('\n');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 本地音乐扫描模块
|
||||
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import * as crypto from 'crypto';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as mm from 'music-metadata';
|
||||
import * as os from 'os';
|
||||
@@ -10,7 +11,30 @@ import * as path from 'path';
|
||||
/** 支持的音频文件格式 */
|
||||
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
|
||||
const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length));
|
||||
const MAX_COVER_BYTES = 1024 * 1024;
|
||||
const MAX_COVER_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** 封面缓存目录:userData/AudioCovers/<hash>.<ext> */
|
||||
const COVER_DIR_NAME = 'AudioCovers';
|
||||
let cachedCoverDir: string | null = null;
|
||||
|
||||
function getCoverDir(): string {
|
||||
if (cachedCoverDir) return cachedCoverDir;
|
||||
const dir = path.join(app.getPath('userData'), COVER_DIR_NAME);
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('创建封面目录失败:', error);
|
||||
}
|
||||
cachedCoverDir = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** 从 mime 类型推断文件扩展名 */
|
||||
function extFromMime(mime: string | undefined): string {
|
||||
const sub = mime?.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
|
||||
if (!sub) return 'bin';
|
||||
return sub === 'jpeg' ? 'jpg' : sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主进程返回的原始音乐元数据
|
||||
@@ -27,8 +51,8 @@ type LocalMusicMeta = {
|
||||
album: string;
|
||||
/** 时长(毫秒) */
|
||||
duration: number;
|
||||
/** base64 Data URL 格式的封面图片,无封面时为 null */
|
||||
cover: string | null;
|
||||
/** 封面图片缓存文件绝对路径,无封面时为 null */
|
||||
coverPath: string | null;
|
||||
/** LRC 格式歌词文本,无歌词时为 null */
|
||||
lyrics: string | null;
|
||||
/** 文件大小(字节) */
|
||||
@@ -66,23 +90,37 @@ function extractTitleFromFilename(filePath: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将封面图片数据转换为 base64 Data URL
|
||||
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径
|
||||
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
|
||||
* @param picture music-metadata 解析出的封面图片对象
|
||||
* @returns base64 Data URL 字符串,转换失败返回 null
|
||||
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名
|
||||
* @returns 封面文件绝对路径,无封面或写入失败返回 null
|
||||
*/
|
||||
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
|
||||
async function extractCoverToFile(
|
||||
picture: mm.IPicture | undefined,
|
||||
sourceFilePath: string
|
||||
): Promise<string | null> {
|
||||
if (!picture) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (picture.data.length > MAX_COVER_BYTES) {
|
||||
console.warn(
|
||||
`封面超过大小上限被跳过: ${sourceFilePath} (${picture.data.length} bytes > ${MAX_COVER_BYTES})`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const mime = picture.format ?? 'image/jpeg';
|
||||
const base64 = Buffer.from(picture.data).toString('base64');
|
||||
return `data:${mime};base64,${base64}`;
|
||||
const ext = extFromMime(picture.format);
|
||||
const hash = crypto.createHash('sha256').update(sourceFilePath).digest('hex');
|
||||
const coverFile = path.join(getCoverDir(), `${hash}.${ext}`);
|
||||
|
||||
// 直接覆盖写:本函数只在文件 mtime 变更时被调用(见 scanFolders 的 parseTargets),
|
||||
// 频率本就受守门;按 size 跳过会在"用户替换内嵌封面、新旧字节数恰好相等"时留旧图,
|
||||
// 单张封面几十~几百 KB,覆盖代价可忽略。
|
||||
await fs.promises.writeFile(coverFile, Buffer.from(picture.data));
|
||||
return coverFile;
|
||||
} catch (error) {
|
||||
console.error('封面提取失败:', error);
|
||||
console.error('封面落盘失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +272,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
|
||||
artist: '未知艺术家',
|
||||
album: '未知专辑',
|
||||
duration: 0,
|
||||
cover: null,
|
||||
coverPath: null,
|
||||
lyrics: null,
|
||||
fileSize,
|
||||
modifiedTime
|
||||
@@ -250,7 +288,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
|
||||
artist: common.artist || fallback.artist,
|
||||
album: common.album || fallback.album,
|
||||
duration: format.duration ? Math.round(format.duration * 1000) : 0,
|
||||
cover: extractCoverAsDataUrl(common.picture?.[0]),
|
||||
coverPath: await extractCoverToFile(common.picture?.[0], filePath),
|
||||
lyrics: extractLyrics(common.lyrics),
|
||||
fileSize,
|
||||
modifiedTime
|
||||
|
||||
@@ -6,7 +6,6 @@ import i18n from '../../i18n/main';
|
||||
let loginWindow: BrowserWindow | null = null;
|
||||
|
||||
const loginUrl = 'https://music.163.com/#/login/';
|
||||
const loginTitle = i18n.global.t('login.qrTitle');
|
||||
|
||||
/**
|
||||
* 打开登录窗口获取Cookie
|
||||
@@ -29,7 +28,8 @@ const openLoginWindow = async (mainWin: BrowserWindow) => {
|
||||
|
||||
loginWindow = new BrowserWindow({
|
||||
parent: mainWin,
|
||||
title: loginTitle,
|
||||
// 在打开窗口时求值,确保跟随当前语言(模块加载时 i18n locale 可能尚未设置)
|
||||
title: i18n.global.t('login.qrTitle'),
|
||||
width: 1280,
|
||||
height: 800,
|
||||
center: true,
|
||||
|
||||
@@ -42,6 +42,8 @@ export const initLxMusicHttp = () => {
|
||||
// 保存取消控制器
|
||||
abortControllers.set(requestId, controller);
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
|
||||
|
||||
@@ -77,13 +79,14 @@ export const initLxMusicHttp = () => {
|
||||
|
||||
// 设置超时
|
||||
const timeout = options.timeout || 30000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutId = setTimeout(() => {
|
||||
console.warn(`[LxMusicHttp] 请求超时: ${url}`);
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
|
||||
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
|
||||
|
||||
@@ -122,7 +125,10 @@ export const initLxMusicHttp = () => {
|
||||
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
// 清理取消控制器
|
||||
// 清理超时定时器(fetch 出错时前面来不及 clear)与取消控制器
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
abortControllers.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import Player from 'mpris-service';
|
||||
|
||||
let dbusModule: any;
|
||||
try {
|
||||
dbusModule = require('@httptoolkit/dbus-native');
|
||||
} catch {
|
||||
// dbus-native 不可用(非 Linux 环境)
|
||||
}
|
||||
|
||||
interface SongInfo {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
picUrl?: string;
|
||||
ar?: Array<{ name: string }>;
|
||||
artists?: Array<{ name: string }>;
|
||||
al?: { name: string };
|
||||
album?: { name: string };
|
||||
duration?: number;
|
||||
dt?: number;
|
||||
song?: {
|
||||
artists?: Array<{ name: string }>;
|
||||
album?: { name: string };
|
||||
duration?: number;
|
||||
picUrl?: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let mprisPlayer: Player | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentPosition = 0;
|
||||
let trayLyricIface: any = null;
|
||||
let trayLyricBus: any = null;
|
||||
|
||||
// 保存 IPC 处理函数引用,用于清理
|
||||
let onPositionUpdate: ((event: any, position: number) => void) | null = null;
|
||||
let onTrayLyricUpdate: ((event: any, lrcObj: string) => void) | null = null;
|
||||
|
||||
export function initializeMpris(mainWindowRef: BrowserWindow) {
|
||||
if (process.platform !== 'linux') return;
|
||||
|
||||
if (mprisPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow = mainWindowRef;
|
||||
|
||||
try {
|
||||
mprisPlayer = Player({
|
||||
name: 'AlgerMusicPlayer',
|
||||
identity: 'Alger Music Player',
|
||||
supportedUriSchemes: ['file', 'http', 'https'],
|
||||
supportedMimeTypes: [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/flac',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/aac',
|
||||
'audio/m4a'
|
||||
],
|
||||
supportedInterfaces: ['player']
|
||||
});
|
||||
|
||||
mprisPlayer.on('quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
mprisPlayer.on('raise', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('next', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'nextPlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('previous', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'prevPlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('pause', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-pause');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('play', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-play');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('playpause', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'togglePlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('stop', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-pause');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.getPosition = (): number => {
|
||||
return currentPosition;
|
||||
};
|
||||
|
||||
mprisPlayer.on('seek', (offset: number) => {
|
||||
if (mainWindow) {
|
||||
const newPosition = Math.max(0, currentPosition + offset / 1000000);
|
||||
mainWindow.webContents.send('mpris-seek', newPosition);
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('position', (event: { trackId: string; position: number }) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-set-position', event.position / 1000000);
|
||||
}
|
||||
});
|
||||
|
||||
onPositionUpdate = (_, position: number) => {
|
||||
currentPosition = position * 1000 * 1000;
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.seeked(position * 1000 * 1000);
|
||||
mprisPlayer.getPosition = () => position * 1000 * 1000;
|
||||
mprisPlayer.position = position * 1000 * 1000;
|
||||
}
|
||||
};
|
||||
ipcMain.on('mpris-position-update', onPositionUpdate);
|
||||
|
||||
onTrayLyricUpdate = (_, lrcObj: string) => {
|
||||
sendTrayLyric(lrcObj);
|
||||
};
|
||||
ipcMain.on('tray-lyric-update', onTrayLyricUpdate);
|
||||
|
||||
initTrayLyric();
|
||||
|
||||
console.log('[MPRIS] Service initialized');
|
||||
} catch (error) {
|
||||
console.error('[MPRIS] Failed to initialize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMprisPlayState(playing: boolean) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
mprisPlayer.playbackStatus = playing ? 'Playing' : 'Paused';
|
||||
}
|
||||
|
||||
export function updateMprisCurrentSong(song: SongInfo | null) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
|
||||
if (!song) {
|
||||
mprisPlayer.metadata = {};
|
||||
mprisPlayer.playbackStatus = 'Stopped';
|
||||
return;
|
||||
}
|
||||
|
||||
const artists =
|
||||
song.ar?.map((a) => a.name).join(', ') ||
|
||||
song.artists?.map((a) => a.name).join(', ') ||
|
||||
song.song?.artists?.map((a) => a.name).join(', ') ||
|
||||
'';
|
||||
const album = song.al?.name || song.album?.name || song.song?.album?.name || '';
|
||||
const duration = song.duration || song.dt || song.song?.duration || 0;
|
||||
|
||||
mprisPlayer.metadata = {
|
||||
'mpris:trackid': mprisPlayer.objectPath(`track/${song.id || 0}`),
|
||||
'mpris:length': duration * 1000,
|
||||
'mpris:artUrl': song.picUrl || '',
|
||||
'xesam:title': song.name || '',
|
||||
'xesam:album': album,
|
||||
'xesam:artist': artists ? [artists] : []
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMprisPosition(position: number) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
mprisPlayer.seeked(position * 1000000);
|
||||
}
|
||||
|
||||
export function destroyMpris() {
|
||||
if (onPositionUpdate) {
|
||||
ipcMain.removeListener('mpris-position-update', onPositionUpdate);
|
||||
onPositionUpdate = null;
|
||||
}
|
||||
if (onTrayLyricUpdate) {
|
||||
ipcMain.removeListener('tray-lyric-update', onTrayLyricUpdate);
|
||||
onTrayLyricUpdate = null;
|
||||
}
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.quit();
|
||||
mprisPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initTrayLyric() {
|
||||
if (process.platform !== 'linux' || !dbusModule) return;
|
||||
|
||||
const serviceName = 'org.gnome.Shell.TrayLyric';
|
||||
|
||||
try {
|
||||
const sessionBus = dbusModule.sessionBus({});
|
||||
trayLyricBus = sessionBus;
|
||||
|
||||
const dbusPath = '/org/freedesktop/DBus';
|
||||
const dbusInterface = 'org.freedesktop.DBus';
|
||||
|
||||
sessionBus.invoke(
|
||||
{
|
||||
path: dbusPath,
|
||||
interface: dbusInterface,
|
||||
member: 'GetNameOwner',
|
||||
destination: 'org.freedesktop.DBus',
|
||||
signature: 's',
|
||||
body: [serviceName]
|
||||
},
|
||||
(err: any, result: any) => {
|
||||
if (err || !result) {
|
||||
console.log('[TrayLyric] Service not running');
|
||||
} else {
|
||||
onServiceAvailable();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TrayLyric] Failed to init:', err);
|
||||
}
|
||||
|
||||
function onServiceAvailable() {
|
||||
if (!trayLyricBus) return;
|
||||
const path = '/' + serviceName.replace(/\./g, '/');
|
||||
trayLyricBus.getService(serviceName).getInterface(path, serviceName, (err: any, iface: any) => {
|
||||
if (err) {
|
||||
console.error('[TrayLyric] Failed to get service interface:', err);
|
||||
return;
|
||||
}
|
||||
trayLyricIface = iface;
|
||||
console.log('[TrayLyric] Service interface ready');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendTrayLyric(lrcObj: string) {
|
||||
if (!trayLyricIface || !trayLyricBus) return;
|
||||
|
||||
trayLyricBus.invoke(
|
||||
{
|
||||
path: '/org/gnome/Shell/TrayLyric',
|
||||
interface: 'org.gnome.Shell.TrayLyric',
|
||||
member: 'UpdateLyric',
|
||||
destination: 'org.gnome.Shell.TrayLyric',
|
||||
signature: 's',
|
||||
body: [lrcObj]
|
||||
},
|
||||
(err: any, _result: any) => {
|
||||
if (err) {
|
||||
console.error('[TrayLyric] Failed to invoke UpdateLyric:', err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { app, BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import type Store from 'electron-store';
|
||||
|
||||
const store = new Store();
|
||||
import { getSharedStore } from './config';
|
||||
|
||||
const store = getSharedStore();
|
||||
|
||||
// 默认窗口尺寸
|
||||
export const DEFAULT_MAIN_WIDTH = 1200;
|
||||
@@ -38,16 +40,40 @@ export interface WindowState {
|
||||
* 负责保存、恢复和维护窗口大小状态
|
||||
*/
|
||||
class WindowSizeManager {
|
||||
private store: Store;
|
||||
private store: Store<Record<string, unknown>>;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private savedState: WindowState | null = null;
|
||||
private isInitialized: boolean = false;
|
||||
private saveStateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.store = store;
|
||||
// 初始化时不做与screen相关的操作,等app ready后再初始化
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖保存窗口状态:move/resize 在拖动时每秒触发几十次,
|
||||
* 直接写 config.json 是 #714 EBUSY 争用的最大来源
|
||||
*/
|
||||
private scheduleSaveWindowState(win: BrowserWindow): void {
|
||||
if (this.saveStateDebounceTimer) {
|
||||
clearTimeout(this.saveStateDebounceTimer);
|
||||
}
|
||||
this.saveStateDebounceTimer = setTimeout(() => {
|
||||
this.saveStateDebounceTimer = null;
|
||||
if (!win.isDestroyed() && !win.isMinimized()) {
|
||||
this.saveWindowState(win);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private flushScheduledSave(): void {
|
||||
if (this.saveStateDebounceTimer) {
|
||||
clearTimeout(this.saveStateDebounceTimer);
|
||||
this.saveStateDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化窗口大小管理器
|
||||
* 必须在app ready后调用
|
||||
@@ -117,17 +143,17 @@ class WindowSizeManager {
|
||||
* 设置事件监听器
|
||||
*/
|
||||
private setupEventListeners(win: BrowserWindow): void {
|
||||
// 监听窗口大小调整事件
|
||||
// 监听窗口大小调整事件(防抖,拖动结束后统一落盘)
|
||||
win.on('resize', () => {
|
||||
if (!win.isDestroyed() && !win.isMinimized()) {
|
||||
this.saveWindowState(win);
|
||||
this.scheduleSaveWindowState(win);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听窗口移动事件
|
||||
// 监听窗口移动事件(防抖,拖动结束后统一落盘)
|
||||
win.on('move', () => {
|
||||
if (!win.isDestroyed() && !win.isMinimized()) {
|
||||
this.saveWindowState(win);
|
||||
this.scheduleSaveWindowState(win);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,8 +171,9 @@ class WindowSizeManager {
|
||||
}
|
||||
});
|
||||
|
||||
// 监听窗口关闭事件,确保保存最终状态
|
||||
// 监听窗口关闭事件,确保保存最终状态(取消挂起的防抖,立即落盘)
|
||||
win.on('close', () => {
|
||||
this.flushScheduledSave();
|
||||
if (!win.isDestroyed()) {
|
||||
this.saveWindowState(win);
|
||||
}
|
||||
@@ -354,8 +381,12 @@ class WindowSizeManager {
|
||||
}
|
||||
|
||||
// 检查是否是mini模式窗口(根据窗口大小判断)
|
||||
// 注意展开播放列表后的 mini 窗口是 340x400,也不能当普通窗口持久化,
|
||||
// 否则污染 windowState 导致下次启动主窗口尺寸异常(#242)
|
||||
const [currentWidth, currentHeight] = win.getSize();
|
||||
const isMiniMode = currentWidth === DEFAULT_MINI_WIDTH && currentHeight === DEFAULT_MINI_HEIGHT;
|
||||
const isMiniMode =
|
||||
currentWidth === DEFAULT_MINI_WIDTH &&
|
||||
(currentHeight === DEFAULT_MINI_HEIGHT || currentHeight === DEFAULT_MINI_EXPANDED_HEIGHT);
|
||||
|
||||
const isMaximized = win.isMaximized();
|
||||
let state: WindowState;
|
||||
@@ -409,9 +440,13 @@ class WindowSizeManager {
|
||||
return state;
|
||||
}
|
||||
|
||||
// 保存状态到存储
|
||||
this.store.set(WINDOW_STATE_KEY, state);
|
||||
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
|
||||
// 保存状态到存储(config.json 可能被外部程序短暂锁定,失败时丢弃本次写入即可)
|
||||
try {
|
||||
this.store.set(WINDOW_STATE_KEY, state);
|
||||
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
|
||||
} catch (error) {
|
||||
console.error('保存窗口状态失败:', error);
|
||||
}
|
||||
|
||||
// 更新内部状态
|
||||
this.savedState = state;
|
||||
@@ -424,7 +459,13 @@ class WindowSizeManager {
|
||||
* 获取保存的窗口状态
|
||||
*/
|
||||
getWindowState(): WindowState | null {
|
||||
const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
|
||||
let state: WindowState | undefined;
|
||||
try {
|
||||
state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
|
||||
} catch (error) {
|
||||
console.error('读取窗口状态失败:', error);
|
||||
return this.savedState;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
console.log('未找到保存的窗口状态,将使用默认值');
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
session,
|
||||
shell
|
||||
} from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { join } from 'path';
|
||||
|
||||
import { getSharedStore } from './config';
|
||||
import {
|
||||
applyContentZoom,
|
||||
applyInitialState,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
WindowState
|
||||
} from './window-size';
|
||||
|
||||
const store = new Store();
|
||||
const store = getSharedStore();
|
||||
|
||||
// 保存主窗口引用,以便在 activate 事件中使用
|
||||
let mainWindowInstance: BrowserWindow | null = null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { getSharedStore } from './modules/config';
|
||||
import { type Platform, unblockMusic } from './unblockMusic';
|
||||
|
||||
// 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件
|
||||
@@ -12,7 +12,7 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
|
||||
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
|
||||
}
|
||||
|
||||
const store = new Store();
|
||||
const store = getSharedStore();
|
||||
|
||||
// 设置音乐解析的处理程序
|
||||
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
declare module 'mpris-service' {
|
||||
interface PlayerOptions {
|
||||
name: string;
|
||||
identity: string;
|
||||
supportedUriSchemes?: string[];
|
||||
supportedMimeTypes?: string[];
|
||||
supportedInterfaces?: string[];
|
||||
}
|
||||
|
||||
interface Player {
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
playbackStatus: string;
|
||||
metadata: Record<string, any>;
|
||||
position: number;
|
||||
getPosition: () => number;
|
||||
seeked(position: number): void;
|
||||
objectPath(path: string): string;
|
||||
quit(): void;
|
||||
}
|
||||
|
||||
function Player(options: PlayerOptions): Player;
|
||||
export = Player;
|
||||
}
|
||||
@@ -44,6 +44,25 @@ interface API {
|
||||
parseLocalMusicMetadata: (
|
||||
filePaths: string[]
|
||||
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
|
||||
// Download manager
|
||||
downloadAdd: (task: any) => Promise<string>;
|
||||
downloadAddBatch: (tasks: any) => Promise<{ batchId: string; taskIds: string[] }>;
|
||||
downloadPause: (taskId: string) => Promise<void>;
|
||||
downloadResume: (taskId: string) => Promise<void>;
|
||||
downloadCancel: (taskId: string) => Promise<void>;
|
||||
downloadCancelAll: () => Promise<void>;
|
||||
downloadGetQueue: () => Promise<any[]>;
|
||||
downloadSetConcurrency: (n: number) => void;
|
||||
downloadGetCompleted: () => Promise<any[]>;
|
||||
downloadDeleteCompleted: (filePath: string) => Promise<boolean>;
|
||||
downloadClearCompleted: () => Promise<boolean>;
|
||||
getEmbeddedLyrics: (filePath: string) => Promise<string | null>;
|
||||
downloadProvideUrl: (taskId: string, url: string) => Promise<void>;
|
||||
onDownloadProgress: (cb: (data: any) => void) => void;
|
||||
onDownloadStateChange: (cb: (data: any) => void) => void;
|
||||
onDownloadBatchComplete: (cb: (data: any) => void) => void;
|
||||
onDownloadRequestUrl: (cb: (data: any) => void) => void;
|
||||
removeDownloadListeners: () => void;
|
||||
}
|
||||
|
||||
// 自定义IPC渲染进程通信接口
|
||||
|
||||
@@ -82,7 +82,43 @@ const api = {
|
||||
scanLocalMusicWithStats: (folderPath: string) =>
|
||||
ipcRenderer.invoke('scan-local-music-with-stats', folderPath),
|
||||
parseLocalMusicMetadata: (filePaths: string[]) =>
|
||||
ipcRenderer.invoke('parse-local-music-metadata', filePaths)
|
||||
ipcRenderer.invoke('parse-local-music-metadata', filePaths),
|
||||
|
||||
// Download manager
|
||||
downloadAdd: (task: any) => ipcRenderer.invoke('download:add', task),
|
||||
downloadAddBatch: (tasks: any) => ipcRenderer.invoke('download:add-batch', tasks),
|
||||
downloadPause: (taskId: string) => ipcRenderer.invoke('download:pause', taskId),
|
||||
downloadResume: (taskId: string) => ipcRenderer.invoke('download:resume', taskId),
|
||||
downloadCancel: (taskId: string) => ipcRenderer.invoke('download:cancel', taskId),
|
||||
downloadCancelAll: () => ipcRenderer.invoke('download:cancel-all'),
|
||||
downloadGetQueue: () => ipcRenderer.invoke('download:get-queue'),
|
||||
downloadSetConcurrency: (n: number) => ipcRenderer.send('download:set-concurrency', n),
|
||||
downloadGetCompleted: () => ipcRenderer.invoke('download:get-completed'),
|
||||
downloadDeleteCompleted: (filePath: string) =>
|
||||
ipcRenderer.invoke('download:delete-completed', filePath),
|
||||
downloadClearCompleted: () => ipcRenderer.invoke('download:clear-completed'),
|
||||
getEmbeddedLyrics: (filePath: string) =>
|
||||
ipcRenderer.invoke('download:get-embedded-lyrics', filePath),
|
||||
downloadProvideUrl: (taskId: string, url: string) =>
|
||||
ipcRenderer.invoke('download:provide-url', { taskId, url }),
|
||||
onDownloadProgress: (cb: (data: any) => void) => {
|
||||
ipcRenderer.on('download:progress', (_event: any, data: any) => cb(data));
|
||||
},
|
||||
onDownloadStateChange: (cb: (data: any) => void) => {
|
||||
ipcRenderer.on('download:state-change', (_event: any, data: any) => cb(data));
|
||||
},
|
||||
onDownloadBatchComplete: (cb: (data: any) => void) => {
|
||||
ipcRenderer.on('download:batch-complete', (_event: any, data: any) => cb(data));
|
||||
},
|
||||
onDownloadRequestUrl: (cb: (data: any) => void) => {
|
||||
ipcRenderer.on('download:request-url', (_event: any, data: any) => cb(data));
|
||||
},
|
||||
removeDownloadListeners: () => {
|
||||
ipcRenderer.removeAllListeners('download:progress');
|
||||
ipcRenderer.removeAllListeners('download:state-change');
|
||||
ipcRenderer.removeAllListeners('download:batch-complete');
|
||||
ipcRenderer.removeAllListeners('download:request-url');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||
|
||||
@@ -100,11 +100,18 @@ if (isElectron) {
|
||||
window.api.onLanguageChanged(handleSetLanguage);
|
||||
window.electron.ipcRenderer.on('mini-mode', (_, value) => {
|
||||
settingsStore.setMiniMode(value);
|
||||
// 切换迷你/主界面时复位全屏播放页状态:
|
||||
// musicFull 残留为 true 会让返回主界面后第一次点击歌曲信息被"取反"关闭,
|
||||
// 表现为全屏页打不开(#242)
|
||||
playerStore.setMusicFull(false);
|
||||
if (value) {
|
||||
// 存储当前路由
|
||||
localStorage.setItem('currentRoute', router.currentRoute.value.path);
|
||||
router.push('/mini');
|
||||
} else {
|
||||
// 清理迷你模式下设置的 body 样式
|
||||
document.body.style.height = '';
|
||||
document.body.style.overflow = '';
|
||||
// 恢复当前路由
|
||||
const currentRoute = localStorage.getItem('currentRoute');
|
||||
if (currentRoute) {
|
||||
@@ -128,18 +135,23 @@ onMounted(async () => {
|
||||
|
||||
// 检查网络状态,离线时自动跳转到本地音乐页面
|
||||
if (!navigator.onLine) {
|
||||
console.log('检测到无网络连接,跳转到本地音乐页面');
|
||||
router.push('/local-music');
|
||||
}
|
||||
|
||||
// 监听网络状态变化,断网时跳转到本地音乐页面
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('网络连接断开,跳转到本地音乐页面');
|
||||
const handleOffline = () => {
|
||||
router.push('/local-music');
|
||||
};
|
||||
window.addEventListener('offline', handleOffline);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
});
|
||||
|
||||
// 初始化 MusicHook,注入 playerStore
|
||||
initMusicHook(playerStore);
|
||||
// 设置 URL 过期自动续播处理器
|
||||
const { setupUrlExpiredHandler } = await import('@/services/playbackController');
|
||||
setupUrlExpiredHandler();
|
||||
// 初始化播放状态
|
||||
await playerStore.initializePlayState();
|
||||
|
||||
|
||||
@@ -56,17 +56,18 @@ export const parseFromGDMusic = async (
|
||||
}
|
||||
|
||||
const songName = data.name || '';
|
||||
let artistNames = '';
|
||||
let artistList: string[] = [];
|
||||
|
||||
// 处理不同的艺术家字段结构
|
||||
if (data.artists && Array.isArray(data.artists)) {
|
||||
artistNames = data.artists.map((artist) => artist.name).join(' ');
|
||||
artistList = data.artists.map((artist) => artist?.name).filter(Boolean);
|
||||
} else if (data.ar && Array.isArray(data.ar)) {
|
||||
artistNames = data.ar.map((artist) => artist.name).join(' ');
|
||||
} else if (data.artist) {
|
||||
artistNames = typeof data.artist === 'string' ? data.artist : '';
|
||||
artistList = data.ar.map((artist) => artist?.name).filter(Boolean);
|
||||
} else if (data.artist && typeof data.artist === 'string') {
|
||||
artistList = [data.artist];
|
||||
}
|
||||
|
||||
const artistNames = artistList.join(' ');
|
||||
const searchQuery = `${songName} ${artistNames}`.trim();
|
||||
|
||||
if (!searchQuery || searchQuery.length < 2) {
|
||||
@@ -82,7 +83,12 @@ export const parseFromGDMusic = async (
|
||||
// 依次尝试所有音源
|
||||
for (const source of allSources) {
|
||||
try {
|
||||
const result = await searchAndGetUrl(source, searchQuery, quality);
|
||||
const result = await searchAndGetUrl(
|
||||
source,
|
||||
searchQuery,
|
||||
{ name: songName, artists: artistList },
|
||||
quality
|
||||
);
|
||||
if (result) {
|
||||
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
|
||||
// 返回符合原API格式的数据
|
||||
@@ -132,35 +138,124 @@ interface GDMusicUrlResult {
|
||||
source: string;
|
||||
}
|
||||
|
||||
type GDSearchItem = {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
artist?: unknown;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type ExpectedSong = {
|
||||
name: string;
|
||||
artists: string[];
|
||||
};
|
||||
|
||||
const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
|
||||
|
||||
/**
|
||||
* 归一化文本用于匹配:去掉括号备注(Live/翻唱/伴奏等)、空白与常见标点,转小写
|
||||
*/
|
||||
const normalizeText = (text: string): string => {
|
||||
const stripped = text
|
||||
.toLowerCase()
|
||||
.replace(/[((【[].*?[))】\]]/g, '')
|
||||
.replace(/[\s\-—_·・'"‘’“”!!??.,,。&&+]/g, '');
|
||||
// 整个歌名都在括号里时退化为仅去标点,避免归一化成空串
|
||||
return stripped || text.toLowerCase().replace(/[\s\-—_·・'"‘’“”!!??.,,。&&+]/g, '');
|
||||
};
|
||||
|
||||
const getCandidateArtistText = (artist: unknown): string => {
|
||||
if (Array.isArray(artist)) {
|
||||
return artist
|
||||
.map((item) => (typeof item === 'string' ? item : (item as any)?.name || ''))
|
||||
.join(' ');
|
||||
}
|
||||
return typeof artist === 'string' ? artist : '';
|
||||
};
|
||||
|
||||
const isNameMatched = (expectedName: string, candidateName: string): boolean => {
|
||||
const expected = normalizeText(expectedName);
|
||||
const candidate = normalizeText(candidateName);
|
||||
if (!expected || !candidate) return false;
|
||||
return expected === candidate || candidate.includes(expected) || expected.includes(candidate);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从候选中挑选与原曲匹配的结果。
|
||||
* 此前无条件取第一条搜索结果,版权下架歌曲(如周杰伦)会解析出翻唱/同名歌,
|
||||
* 即"货不对版"(#704)。校验策略:歌名必须匹配;候选带歌手信息时歌手也必须匹配,
|
||||
* 宁可解析失败也不返回错误的歌。
|
||||
*/
|
||||
const pickBestCandidate = (
|
||||
candidates: GDSearchItem[],
|
||||
expected: ExpectedSong
|
||||
): GDSearchItem | null => {
|
||||
let best: GDSearchItem | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const item of candidates) {
|
||||
if (!item || !item.id) continue;
|
||||
if (!isNameMatched(expected.name, item.name || '')) continue;
|
||||
|
||||
const candidateArtist = normalizeText(getCandidateArtistText(item.artist));
|
||||
let score: number;
|
||||
if (expected.artists.length === 0) {
|
||||
// 原曲无歌手信息,歌名匹配即可
|
||||
score = 2;
|
||||
} else if (!candidateArtist) {
|
||||
// 候选缺少歌手信息:保留为低优先级候选
|
||||
score = 1;
|
||||
} else {
|
||||
const artistMatched = expected.artists.some((name) => {
|
||||
const normalized = normalizeText(name);
|
||||
return (
|
||||
!!normalized &&
|
||||
(candidateArtist.includes(normalized) || normalized.includes(candidateArtist))
|
||||
);
|
||||
});
|
||||
// 有歌手信息但对不上 → 拒绝,这正是"货不对版"的来源
|
||||
if (!artistMatched) continue;
|
||||
score = 3;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
best = item;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在指定音源搜索歌曲并获取URL
|
||||
* @param source 音源
|
||||
* @param searchQuery 搜索关键词
|
||||
* @param expected 原曲信息(用于校验搜索结果,避免货不对版)
|
||||
* @param quality 音质
|
||||
* @returns 音乐URL结果
|
||||
*/
|
||||
async function searchAndGetUrl(
|
||||
source: MusicSourceType,
|
||||
searchQuery: string,
|
||||
expected: ExpectedSong,
|
||||
quality: string
|
||||
): Promise<GDMusicUrlResult | null> {
|
||||
// 1. 搜索歌曲
|
||||
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
|
||||
// 1. 搜索歌曲(取前5条做校验,而不是盲取第一条)
|
||||
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=5&pages=1`;
|
||||
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
|
||||
|
||||
const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
|
||||
|
||||
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
|
||||
const firstResult = searchResponse.data[0];
|
||||
if (!firstResult || !firstResult.id) {
|
||||
console.log(`GD音乐台 ${source} 搜索结果无效`);
|
||||
const matchedResult = pickBestCandidate(searchResponse.data as GDSearchItem[], expected);
|
||||
if (!matchedResult) {
|
||||
console.log(`GD音乐台 ${source} 搜索结果与原曲不匹配,已拒绝(避免货不对版)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackId = firstResult.id;
|
||||
const trackSource = firstResult.source || source;
|
||||
const trackId = matchedResult.id;
|
||||
const trackSource = matchedResult.source || source;
|
||||
|
||||
// 2. 获取歌曲URL
|
||||
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;
|
||||
|
||||
@@ -16,68 +16,72 @@ import { CacheManager } from './musicParser';
|
||||
/**
|
||||
* 解析可能是 API 端点的 URL,获取真实音频 URL
|
||||
* 一些音源脚本返回的是 API 端点,需要额外请求才能获取真实音频 URL
|
||||
*
|
||||
* 通过主进程请求验证(绕过渲染进程 CORS)。端点确认失效时返回 null,
|
||||
* 让解析策略正确失败并进入失败缓存,而不是把不可播放的 URL 当成功缓存
|
||||
* (否则会导致:坏 URL 进缓存 → audio 元素 Format error → 连续跳歌,
|
||||
* 且后续策略如 unblockMusic 永远没有机会接手)
|
||||
*/
|
||||
const resolveAudioUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
|
||||
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
|
||||
const resolveAudioUrl = async (url: string): Promise<string | null> => {
|
||||
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
|
||||
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
|
||||
|
||||
if (!isApiEndpoint) {
|
||||
// 看起来像直接的音频 URL,直接返回
|
||||
if (!isApiEndpoint) {
|
||||
// 看起来像直接的音频 URL,直接返回
|
||||
return url;
|
||||
}
|
||||
|
||||
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
|
||||
|
||||
// 非 Electron 环境无法绕过 CORS 验证,保持乐观返回
|
||||
if (typeof window.api?.lxMusicHttpRequest !== 'function') {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestId = `lx_resolve_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const response = await window.api.lxMusicHttpRequest({
|
||||
url,
|
||||
options: {
|
||||
method: 'GET',
|
||||
// 端点若直接返回音频流,用 Range 避免整段下载;返回 JSON 时 8KB 也足够
|
||||
headers: { Range: 'bytes=0-8191' },
|
||||
timeout: 15000
|
||||
},
|
||||
requestId
|
||||
});
|
||||
|
||||
const status = response?.statusCode ?? 0;
|
||||
const contentType = String(response?.headers?.['content-type'] || '');
|
||||
|
||||
if (status < 200 || status >= 400) {
|
||||
console.warn(`[LxMusicStrategy] API 端点返回 ${status},判定解析失败`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 端点直接返回音频流(或重定向到音频,主进程已自动跟随),
|
||||
// audio 元素可以直接播放原始 URL
|
||||
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
|
||||
console.log('[LxMusicStrategy] API 端点为音频流,直接使用原始 URL');
|
||||
return url;
|
||||
}
|
||||
|
||||
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
|
||||
|
||||
// 尝试获取真实 URL
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual' // 不自动跟随重定向
|
||||
});
|
||||
|
||||
// 检查是否是重定向
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
console.log('[LxMusicStrategy] API 返回重定向 URL:', location);
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 HEAD 请求没有重定向,尝试 GET 请求
|
||||
const getResponse = await fetch(url, {
|
||||
redirect: 'follow'
|
||||
});
|
||||
|
||||
// 检查 Content-Type
|
||||
const contentType = getResponse.headers.get('Content-Type') || '';
|
||||
|
||||
// 如果是音频类型,返回最终 URL
|
||||
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
|
||||
console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url);
|
||||
return getResponse.url;
|
||||
}
|
||||
|
||||
// 如果是 JSON,尝试解析
|
||||
if (contentType.includes('application/json') || contentType.includes('text/json')) {
|
||||
const json = await getResponse.json();
|
||||
console.log('[LxMusicStrategy] API 返回 JSON:', json);
|
||||
|
||||
// 尝试从 JSON 中提取 URL(常见字段)
|
||||
const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src;
|
||||
// JSON 响应:尝试提取常见字段中的音频 URL
|
||||
const body = response?.body;
|
||||
if (body && typeof body === 'object') {
|
||||
const audioUrl = body.url || body.data?.url || body.audio_url || body.link || body.src;
|
||||
if (audioUrl && typeof audioUrl === 'string') {
|
||||
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
|
||||
return audioUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都不是,返回原始 URL(可能直接可用)
|
||||
console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
|
||||
return url;
|
||||
// 2xx 但既不是音频也提取不到 URL(如 HTML 错误页),视为不可播放
|
||||
console.warn('[LxMusicStrategy] API 端点响应无法解析为音频,判定解析失败');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[LxMusicStrategy] URL 解析失败:', error);
|
||||
// 解析失败时返回原始 URL
|
||||
return url;
|
||||
console.error('[LxMusicStrategy] URL 解析请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils';
|
||||
import requestMusic from '@/utils/request_music';
|
||||
|
||||
import type { ParsedMusicResult } from './gdmusic';
|
||||
import { parseFromGDMusic } from './gdmusic';
|
||||
@@ -161,21 +160,11 @@ export class CacheManager {
|
||||
// 清除URL缓存
|
||||
await deleteData('music_url_cache', id);
|
||||
console.log(`清除歌曲 ${id} 的URL缓存`);
|
||||
|
||||
// 清除失败缓存 - 需要遍历所有策略
|
||||
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
|
||||
for (const strategy of strategies) {
|
||||
const cacheKey = `${id}_${strategy}`;
|
||||
try {
|
||||
await deleteData('music_failed_cache', cacheKey);
|
||||
} catch {
|
||||
// 忽略删除不存在缓存的错误
|
||||
}
|
||||
}
|
||||
console.log(`清除歌曲 ${id} 的失败缓存`);
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error);
|
||||
console.error('清除URL缓存失败:', error);
|
||||
}
|
||||
// 清除内存失败缓存(覆盖所有策略,含 lxMusic)
|
||||
CacheManager.clearFailedCache(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +227,19 @@ const getGDMusicAudio = async (id: number, data: SongResult): Promise<ParsedMusi
|
||||
const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
|
||||
const filteredSources = sources.filter((source) => source !== 'gdmusic');
|
||||
console.log(`使用unblockMusic解析,音源:`, filteredSources);
|
||||
return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
|
||||
// 整体超时兜底:unblock 全链路(IPC → match())本身无超时,底层请求挂起时会
|
||||
// 导致渲染进程无限等待、起播/切歌长时间无响应。超时解析为 null 以走降级,
|
||||
// 且不抛异常(避免触发 RetryHelper 的多次重试放大等待时间)。
|
||||
const UNBLOCK_TIMEOUT = 15000;
|
||||
return Promise.race([
|
||||
window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources)),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.warn(`unblockMusic 解析超时(${UNBLOCK_TIMEOUT}ms),放弃等待`);
|
||||
resolve(null);
|
||||
}, UNBLOCK_TIMEOUT);
|
||||
})
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -486,6 +487,18 @@ const getMusicConfig = (id: number, settingsStore?: any) => {
|
||||
return { musicSources, quality };
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造解析失败结果
|
||||
* (原远端后备接口 music_proxy 已停止服务,解析失败时不再回退到远端请求)
|
||||
*/
|
||||
const buildFailedResult = (message: string, code = 404): MusicParseResult => ({
|
||||
data: {
|
||||
code,
|
||||
message,
|
||||
data: undefined
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 音乐解析器主类
|
||||
*/
|
||||
@@ -500,10 +513,10 @@ export class MusicParser {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 非Electron环境直接使用API请求
|
||||
// 非Electron环境不支持本地解析
|
||||
if (!isElectron) {
|
||||
console.log('非Electron环境,使用API请求');
|
||||
return await requestMusic.get<any>('/music', { params: { id } });
|
||||
console.log('非Electron环境,不支持音乐解析');
|
||||
return buildFailedResult('当前环境不支持音乐解析');
|
||||
}
|
||||
|
||||
// 获取设置存储
|
||||
@@ -511,8 +524,8 @@ export class MusicParser {
|
||||
try {
|
||||
settingsStore = useSettingsStore();
|
||||
} catch (error) {
|
||||
console.error('无法获取设置存储,使用后备方案:', error);
|
||||
return await requestMusic.get<any>('/music', { params: { id } });
|
||||
console.error('无法获取设置存储:', error);
|
||||
return buildFailedResult('无法读取设置,音乐解析不可用');
|
||||
}
|
||||
|
||||
// 获取音源配置
|
||||
@@ -541,8 +554,8 @@ export class MusicParser {
|
||||
}
|
||||
|
||||
if (musicSources.length === 0) {
|
||||
console.warn('没有配置可用的音源,使用后备方案');
|
||||
return await requestMusic.get<any>('/music', { params: { id } });
|
||||
console.warn('没有配置可用的音源');
|
||||
return buildFailedResult('没有配置可用的音源');
|
||||
}
|
||||
|
||||
// 获取可用的解析策略
|
||||
@@ -552,8 +565,8 @@ export class MusicParser {
|
||||
);
|
||||
|
||||
if (availableStrategies.length === 0) {
|
||||
console.warn('没有可用的解析策略,使用后备方案');
|
||||
return await requestMusic.get<any>('/music', { params: { id } });
|
||||
console.warn('没有可用的解析策略');
|
||||
return buildFailedResult('没有可用的解析策略');
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -583,34 +596,13 @@ export class MusicParser {
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('所有解析策略都失败了,使用后备方案');
|
||||
console.warn('所有解析策略都失败了');
|
||||
} catch (error) {
|
||||
console.error('MusicParser.parseMusic 执行异常,使用后备方案:', error);
|
||||
console.error('MusicParser.parseMusic 执行异常:', error);
|
||||
}
|
||||
|
||||
// 后备方案:使用API请求
|
||||
try {
|
||||
console.log('使用后备方案:API请求');
|
||||
const result = await requestMusic.get<any>('/music', { params: { id } });
|
||||
|
||||
// 如果后备方案成功,也进行缓存
|
||||
if (result?.data?.data?.url) {
|
||||
console.log('后备方案成功,缓存结果');
|
||||
await CacheManager.setCachedMusicUrl(id, result, []);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (apiError) {
|
||||
console.error('API请求也失败了:', apiError);
|
||||
const endTime = performance.now();
|
||||
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||||
return {
|
||||
data: {
|
||||
code: 500,
|
||||
message: '所有解析方式都失败了',
|
||||
data: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
const endTime = performance.now();
|
||||
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||||
return buildFailedResult('所有解析方式都失败了', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ export const parseFromCustomApi = async (
|
||||
response = await axios.post(plugin.apiUrl, finalParams, { timeout });
|
||||
} else {
|
||||
// 默认为 GET
|
||||
const finalUrl = `${plugin.apiUrl}?${new URLSearchParams(finalParams).toString()}`;
|
||||
// apiUrl 本身可能已带查询串(如 xxx/api.php?type=url),需按情况选择 ? 或 &
|
||||
const separator = plugin.apiUrl.includes('?') ? '&' : '?';
|
||||
const finalUrl = `${plugin.apiUrl}${separator}${new URLSearchParams(finalParams).toString()}`;
|
||||
console.log('自定义API:发送 GET 请求到:', finalUrl);
|
||||
response = await axios.get(finalUrl, { timeout });
|
||||
}
|
||||
@@ -83,11 +85,20 @@ export const parseFromCustomApi = async (
|
||||
if (musicUrl && typeof musicUrl === 'string') {
|
||||
console.log('自定义API:成功获取URL!');
|
||||
// 5. 组装成应用所需的标准格式并返回
|
||||
// quality 是 'standard'/'higher'/'exhigh'/'lossless'/'hires' 等字符串,
|
||||
// 直接 parseInt 会得到 NaN,这里映射为对应码率(bps)
|
||||
const QUALITY_BITRATE: Record<string, number> = {
|
||||
standard: 128000,
|
||||
higher: 192000,
|
||||
exhigh: 320000,
|
||||
lossless: 999000,
|
||||
hires: 1900000
|
||||
};
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
url: musicUrl,
|
||||
br: parseInt(quality) * 1000,
|
||||
br: QUALITY_BITRATE[quality] ?? 320000,
|
||||
size: 0,
|
||||
md5: '',
|
||||
platform: plugin.name.toLowerCase().replace(/\s/g, ''),
|
||||
|
||||
@@ -18,3 +18,115 @@ body {
|
||||
.settings-slider .n-slider-mark {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
/* ==================== 桌面端 Message 样式 ==================== */
|
||||
|
||||
.n-message {
|
||||
border-radius: 20px !important;
|
||||
padding: 10px 18px !important;
|
||||
font-size: 13px !important;
|
||||
backdrop-filter: blur(16px) saturate(1.8) !important;
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.8) !important;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
|
||||
border: none !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
|
||||
/* 浅色模式 */
|
||||
.n-message {
|
||||
background: rgba(255, 255, 255, 0.72) !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
.dark .n-message {
|
||||
background: rgba(40, 40, 40, 0.75) !important;
|
||||
color: #e5e5e5 !important;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
/* 成功 */
|
||||
.n-message--success-type {
|
||||
background: rgba(34, 197, 94, 0.15) !important;
|
||||
color: #16a34a !important;
|
||||
}
|
||||
.n-message--success-type .n-message__icon {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
.dark .n-message--success-type {
|
||||
background: rgba(34, 197, 94, 0.18) !important;
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
.dark .n-message--success-type .n-message__icon {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
/* 错误 */
|
||||
.n-message--error-type {
|
||||
background: rgba(239, 68, 68, 0.12) !important;
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
.n-message--error-type .n-message__icon {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
.dark .n-message--error-type {
|
||||
background: rgba(239, 68, 68, 0.18) !important;
|
||||
color: #f87171 !important;
|
||||
}
|
||||
.dark .n-message--error-type .n-message__icon {
|
||||
color: #f87171 !important;
|
||||
}
|
||||
|
||||
/* 警告 */
|
||||
.n-message--warning-type {
|
||||
background: rgba(245, 158, 11, 0.12) !important;
|
||||
color: #d97706 !important;
|
||||
}
|
||||
.n-message--warning-type .n-message__icon {
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
.dark .n-message--warning-type {
|
||||
background: rgba(245, 158, 11, 0.18) !important;
|
||||
color: #fbbf24 !important;
|
||||
}
|
||||
.dark .n-message--warning-type .n-message__icon {
|
||||
color: #fbbf24 !important;
|
||||
}
|
||||
|
||||
/* 信息 */
|
||||
.n-message--info-type {
|
||||
background: rgba(59, 130, 246, 0.12) !important;
|
||||
color: #2563eb !important;
|
||||
}
|
||||
.n-message--info-type .n-message__icon {
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
.dark .n-message--info-type {
|
||||
background: rgba(59, 130, 246, 0.18) !important;
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
.dark .n-message--info-type .n-message__icon {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.n-message--loading-type {
|
||||
background: rgba(255, 255, 255, 0.72) !important;
|
||||
}
|
||||
.dark .n-message--loading-type {
|
||||
background: rgba(40, 40, 40, 0.75) !important;
|
||||
}
|
||||
|
||||
/* 图标统一大小 */
|
||||
.n-message__icon {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* 间距优化 */
|
||||
.n-message-wrapper {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="download-drawer-trigger">
|
||||
<div class="fixed left-6 bottom-24 z-[999]">
|
||||
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
|
||||
<n-button circle @click="navigateToDownloads">
|
||||
<n-button
|
||||
circle
|
||||
class="bg-white/80 dark:bg-gray-800/80 shadow-lg backdrop-blur-sm hover:bg-light dark:hover:bg-dark-200 text-gray-600 dark:text-gray-300 transition-all duration-300 w-10 h-10"
|
||||
@click="navigateToDownloads"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="iconfont ri-download-cloud-2-line"></i>
|
||||
<i class="iconfont ri-download-cloud-2-line text-xl"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-badge>
|
||||
@@ -11,102 +15,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useDownloadStore } from '@/store/modules/download';
|
||||
|
||||
const router = useRouter();
|
||||
const downloadList = ref<any[]>([]);
|
||||
const downloadStore = useDownloadStore();
|
||||
|
||||
// 计算下载中的任务数量
|
||||
const downloadingCount = computed(() => {
|
||||
return downloadList.value.filter((item) => item.status === 'downloading').length;
|
||||
});
|
||||
const downloadingCount = computed(() => downloadStore.downloadingCount);
|
||||
|
||||
// 导航到下载页面
|
||||
const navigateToDownloads = () => {
|
||||
router.push('/downloads');
|
||||
};
|
||||
|
||||
// 监听下载进度
|
||||
onMounted(() => {
|
||||
// 监听下载进度
|
||||
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
|
||||
// 如果进度为100%,将状态设置为已完成
|
||||
if (data.progress === 100) {
|
||||
data.status = 'completed';
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
Object.assign(existingItem, {
|
||||
...data,
|
||||
songInfo: data.songInfo || existingItem.songInfo
|
||||
});
|
||||
|
||||
// 如果下载完成,从列表中移除
|
||||
if (data.status === 'completed') {
|
||||
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||
}
|
||||
} else {
|
||||
downloadList.value.push({
|
||||
...data,
|
||||
songInfo: data.songInfo
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听下载完成
|
||||
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
||||
if (data.success) {
|
||||
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||
} else {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
if (existingItem) {
|
||||
Object.assign(existingItem, {
|
||||
status: 'error',
|
||||
error: data.error,
|
||||
progress: 0
|
||||
});
|
||||
setTimeout(() => {
|
||||
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听下载队列
|
||||
window.electron.ipcRenderer.on('music-download-queued', (_, data) => {
|
||||
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||
if (!existingItem) {
|
||||
downloadList.value.push({
|
||||
filename: data.filename,
|
||||
progress: 0,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
path: '',
|
||||
status: 'downloading',
|
||||
songInfo: data.songInfo
|
||||
});
|
||||
}
|
||||
});
|
||||
downloadStore.initListeners();
|
||||
downloadStore.loadPersistedQueue();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.download-drawer-trigger {
|
||||
@apply fixed left-6 bottom-24 z-[999];
|
||||
|
||||
.n-button {
|
||||
@apply bg-white/80 dark:bg-gray-800/80 shadow-lg backdrop-blur-sm;
|
||||
@apply hover:bg-light dark:hover:bg-dark-200;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply transition-all duration-300;
|
||||
@apply w-10 h-10;
|
||||
|
||||
.iconfont {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,6 +235,8 @@ const handleAddToPlaylist = async (playlist: any) => {
|
||||
if (res.status === 200) {
|
||||
message.success(t('comp.playlistDrawer.addSuccess'));
|
||||
emit('update:modelValue', false);
|
||||
// 刷新用户歌单列表,让用户页/详情页的歌曲总数(trackCount)及时更新(#508)
|
||||
store.initializePlaylist().catch(() => {});
|
||||
} else {
|
||||
throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed'));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<div class="w-full pb-32">
|
||||
<!-- Page Header (scrolls away) -->
|
||||
<div ref="headerRef" class="page-padding pt-6 pb-2">
|
||||
<h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
|
||||
<h1
|
||||
class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="text-neutral-500 dark:text-neutral-400">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dblclick.stop="playMusicEvent(item)"
|
||||
@dblclick.stop="handlePlay(item)"
|
||||
>
|
||||
<slot name="index"></slot>
|
||||
<slot name="select" v-if="selectable"></slot>
|
||||
@@ -22,7 +22,7 @@
|
||||
:is-dislike="isDislike"
|
||||
:can-remove="canRemove"
|
||||
@update:show="showDropdown = $event"
|
||||
@play="playMusicEvent(item)"
|
||||
@play="handlePlay(item)"
|
||||
@play-next="handlePlayNext"
|
||||
@download="downloadMusic(item)"
|
||||
@download-lyric="downloadLyric(item)"
|
||||
@@ -83,6 +83,12 @@ const imageLoad = async (event: Event) => {
|
||||
await handleImageLoad(target);
|
||||
};
|
||||
|
||||
// 双击和右键菜单"播放"统一入口:先通知父组件设置播放列表上下文,再触发播放
|
||||
const handlePlay = (song: SongResult) => {
|
||||
emits('play', song);
|
||||
playMusicEvent(song);
|
||||
};
|
||||
|
||||
// 切换选择状态
|
||||
const toggleSelect = () => {
|
||||
emits('select', props.item.id, !props.selected);
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
:class="{ 'text-green-500': isPlaying }"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span
|
||||
v-if="item.tns?.length || item.alia?.length"
|
||||
class="text-neutral-400 dark:text-neutral-500"
|
||||
>({{ item.tns?.[0] || item.alia?.[0] }})</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<div class="song-item-content-compact-artist">
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
:class="{ 'text-green-500': isPlaying }"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span
|
||||
v-if="item.tns?.length || item.alia?.length"
|
||||
class="text-neutral-400 dark:text-neutral-500 font-normal"
|
||||
>({{ item.tns?.[0] || item.alia?.[0] }})</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
<n-ellipsis
|
||||
class="artist-name text-xs md:text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
:class="{ 'text-green-500': isPlaying }"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span
|
||||
v-if="item.tns?.length || item.alia?.length"
|
||||
class="text-neutral-400 dark:text-neutral-500"
|
||||
>({{ item.tns?.[0] || item.alia?.[0] }})</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<div class="song-item-content-title">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
||||
{{ item.name }}
|
||||
<span
|
||||
v-if="item.tns?.length || item.alia?.length"
|
||||
class="text-neutral-400 dark:text-neutral-500"
|
||||
>({{ item.tns?.[0] || item.alia?.[0] }})</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<div class="song-item-content-name">
|
||||
|
||||
@@ -15,13 +15,15 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MenuOption } from 'naive-ui';
|
||||
import { NDropdown, NEllipsis, NImage } from 'naive-ui';
|
||||
import { createDiscreteApi, NDropdown, NEllipsis, NImage } from 'naive-ui';
|
||||
import { computed, h, inject } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { getImgUrl, isElectron } from '@/utils';
|
||||
import { hasPermission } from '@/utils/auth';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -50,6 +52,19 @@ const emits = defineEmits([
|
||||
|
||||
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 是否具有真实登录权限(Cookie/扫码登录)。
|
||||
// 用 userStore 的响应式状态而非直接读 localStorage:
|
||||
// 后者不具备响应性,登录/登出后菜单禁用状态不会刷新(#706)
|
||||
const hasRealAuth = computed(() => !!userStore.user && userStore.loginType !== 'uid');
|
||||
|
||||
// 本地歌曲:移除菜单项显示"从本地列表移除"而非"从歌单中删除"(#713)
|
||||
const isLocalSong = computed(
|
||||
() =>
|
||||
typeof props.item.playMusicUrl === 'string' && props.item.playMusicUrl.startsWith('local://')
|
||||
);
|
||||
|
||||
// 渲染歌曲预览
|
||||
const renderSongPreview = () => {
|
||||
return h(
|
||||
@@ -123,8 +138,6 @@ const renderSongPreview = () => {
|
||||
|
||||
// 下拉菜单选项
|
||||
const dropdownOptions = computed<MenuOption[]>(() => {
|
||||
const hasRealAuth = hasPermission(true);
|
||||
|
||||
const options: MenuOption[] = [
|
||||
{
|
||||
key: 'header',
|
||||
@@ -160,10 +173,10 @@ const dropdownOptions = computed<MenuOption[]>(() => {
|
||||
icon: () => h('i', { class: 'iconfont ri-file-text-line' })
|
||||
},
|
||||
{
|
||||
// 不做灰色禁用:点击时提示不可用原因,避免用户不知道"为什么用不了"(#706)
|
||||
label: t('songItem.menu.addToPlaylist'),
|
||||
key: 'addToPlaylist',
|
||||
icon: () => h('i', { class: 'iconfont ri-folder-add-line' }),
|
||||
disabled: !hasRealAuth
|
||||
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
|
||||
},
|
||||
{
|
||||
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
|
||||
@@ -191,7 +204,9 @@ const dropdownOptions = computed<MenuOption[]>(() => {
|
||||
key: 'd2'
|
||||
},
|
||||
{
|
||||
label: t('songItem.menu.removeFromPlaylist'),
|
||||
label: isLocalSong.value
|
||||
? t('localMusic.removeFromLibrary')
|
||||
: t('songItem.menu.removeFromPlaylist'),
|
||||
key: 'remove',
|
||||
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
|
||||
}
|
||||
@@ -216,6 +231,10 @@ const handleSelect = (key: string | number) => {
|
||||
emits('play-next');
|
||||
break;
|
||||
case 'addToPlaylist':
|
||||
if (!hasRealAuth.value) {
|
||||
message.warning(t('songItem.message.addToPlaylistNeedLogin'));
|
||||
break;
|
||||
}
|
||||
openPlaylistDrawer?.(props.item.id);
|
||||
break;
|
||||
case 'favorite':
|
||||
|
||||
@@ -37,11 +37,13 @@
|
||||
<template #content>
|
||||
<div class="song-item-content">
|
||||
<div class="song-item-content-title">
|
||||
<n-ellipsis
|
||||
class="text-ellipsis"
|
||||
line-clamp="1"
|
||||
:class="{ 'text-green-500': isPlaying }"
|
||||
>{{ item.name }}</n-ellipsis
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }"
|
||||
>{{ item.name }}
|
||||
<span
|
||||
v-if="item.tns?.length || item.alia?.length"
|
||||
class="text-neutral-400 dark:text-neutral-500"
|
||||
>({{ item.tns?.[0] || item.alia?.[0] }})</span
|
||||
></n-ellipsis
|
||||
>
|
||||
</div>
|
||||
<div class="song-item-content-name">
|
||||
|
||||
@@ -48,7 +48,8 @@ const { t } = useI18n();
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lyric-correction {
|
||||
@apply absolute right-0 bottom-4 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
|
||||
/* bottom 需越过全屏态下钉底的 PlayBar(h-20=80px, z-index:9999),否则被遮挡无法点击(#592) */
|
||||
@apply absolute right-0 bottom-24 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
|
||||
}
|
||||
|
||||
.lyric-correction-btn {
|
||||
|
||||
@@ -202,19 +202,18 @@ import {
|
||||
useLyricProgress
|
||||
} from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { useLyricBackground } from '@/hooks/useLyricBackground';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
|
||||
import { getImgUrl, isMobile } from '@/utils';
|
||||
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
|
||||
import { getTextColors } from '@/utils/linearColor';
|
||||
|
||||
const { t } = useI18n();
|
||||
// 定义 refs
|
||||
const lrcSider = ref<any>(null);
|
||||
const isMouse = ref(false);
|
||||
const currentBackground = ref('');
|
||||
const animationFrame = ref<number | null>(null);
|
||||
const isDark = ref(false);
|
||||
const { currentBackground, applyBackground } = useLyricBackground();
|
||||
|
||||
// 计算自定义背景样式
|
||||
const customBackgroundStyle = computed(() => {
|
||||
@@ -381,42 +380,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const setTextColors = (background: string) => {
|
||||
if (!background) {
|
||||
textColors.value = getTextColors();
|
||||
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新文字颜色
|
||||
textColors.value = getTextColors(background);
|
||||
isDark.value = textColors.value.active === '#000000';
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
'--hover-bg-color',
|
||||
getHoverBackgroundColor(isDark.value)
|
||||
);
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
|
||||
// 处理背景颜色动画
|
||||
if (currentBackground.value) {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
const result = animateGradient(currentBackground.value, background, (gradient) => {
|
||||
currentBackground.value = gradient;
|
||||
});
|
||||
if (typeof result === 'number') {
|
||||
animationFrame.value = result;
|
||||
}
|
||||
} else {
|
||||
currentBackground.value = background;
|
||||
}
|
||||
};
|
||||
|
||||
const targetBackground = computed(() => {
|
||||
if (config.value.useCustomBackground && customBackgroundStyle.value) {
|
||||
if (typeof customBackgroundStyle.value === 'string') {
|
||||
@@ -434,7 +397,7 @@ watch(
|
||||
targetBackground,
|
||||
(newBg) => {
|
||||
if (newBg) {
|
||||
setTextColors(newBg);
|
||||
applyBackground(newBg);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -523,13 +486,6 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理动画
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
});
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { navigateToArtist } = useArtist();
|
||||
@@ -626,9 +582,6 @@ onMounted(() => {
|
||||
|
||||
// 移除滚动监听和全屏状态监听
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
if (lrcSider.value?.$el) {
|
||||
lrcSider.value.$el.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
@@ -408,11 +408,13 @@ import {
|
||||
useLyricProgress
|
||||
} from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { useLyricBackground } from '@/hooks/useLyricBackground';
|
||||
import { usePlayMode } from '@/hooks/usePlayMode';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
|
||||
import { getImgUrl, secondToMinute } from '@/utils';
|
||||
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
|
||||
import { getTextColors } from '@/utils/linearColor';
|
||||
import { showBottomToast } from '@/utils/shortcutToast';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -757,7 +759,7 @@ const handleProgressBarClick = (e: MouseEvent) => {
|
||||
|
||||
console.log(`进度条点击: ${percentage.toFixed(2)}, 新时间: ${newTime.toFixed(2)}`);
|
||||
|
||||
sound.value.seek(newTime);
|
||||
audioService.seek(newTime);
|
||||
nowTime.value = newTime;
|
||||
};
|
||||
|
||||
@@ -817,7 +819,7 @@ const handleMouseUp = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 释放时跳转到指定位置
|
||||
sound.value.seek(nowTime.value);
|
||||
audioService.seek(nowTime.value);
|
||||
console.log(`鼠标释放,跳转到: ${nowTime.value.toFixed(2)}秒`);
|
||||
|
||||
isMouseDragging.value = false;
|
||||
@@ -871,14 +873,14 @@ const handleThumbTouchEnd = (e: TouchEvent) => {
|
||||
|
||||
// 拖动结束时执行seek操作
|
||||
console.log(`拖动结束,跳转到: ${nowTime.value.toFixed(2)}秒`);
|
||||
sound.value.seek(nowTime.value);
|
||||
audioService.seek(nowTime.value);
|
||||
isThumbDragging.value = false;
|
||||
};
|
||||
|
||||
// 背景相关
|
||||
const currentBackground = ref('');
|
||||
const animationFrame = ref<number | null>(null);
|
||||
const isDark = ref(false);
|
||||
// 背景相关(由 composable 管理)
|
||||
const { isDark, applyBackground } = useLyricBackground({
|
||||
writeBgColor: () => playerStore.playMusic.primaryColor || undefined
|
||||
});
|
||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||
|
||||
// 可见歌词计算
|
||||
@@ -936,49 +938,6 @@ const isVisible = computed({
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
// 设置文字颜色
|
||||
const setTextColors = (background: string) => {
|
||||
if (!background) {
|
||||
textColors.value = getTextColors();
|
||||
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
document.documentElement.style.setProperty('--bg-color', 'rgba(25, 25, 25, 1)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新文字颜色
|
||||
textColors.value = getTextColors(background);
|
||||
isDark.value = textColors.value.active === '#000000';
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
'--hover-bg-color',
|
||||
getHoverBackgroundColor(isDark.value)
|
||||
);
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
|
||||
// 解析背景颜色用于封面融合
|
||||
let bgColor = playerStore.playMusic.primaryColor || 'rgba(25, 25, 25, 1)';
|
||||
|
||||
document.documentElement.style.setProperty('--bg-color', bgColor);
|
||||
|
||||
// 处理背景颜色动画
|
||||
if (currentBackground.value) {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
const result = animateGradient(currentBackground.value, background, (gradient) => {
|
||||
currentBackground.value = gradient;
|
||||
});
|
||||
if (typeof result === 'number') {
|
||||
animationFrame.value = result;
|
||||
}
|
||||
} else {
|
||||
currentBackground.value = background;
|
||||
}
|
||||
};
|
||||
|
||||
const targetBackground = computed(() => {
|
||||
if (config.value.theme !== 'default') {
|
||||
return themeMusic[config.value.theme] || props.background;
|
||||
@@ -991,21 +950,24 @@ watch(
|
||||
targetBackground,
|
||||
(newBg) => {
|
||||
if (newBg) {
|
||||
setTextColors(newBg);
|
||||
applyBackground(newBg);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 组件卸载时清理动画
|
||||
// 组件卸载清理
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
if (autoScrollTimer.value) {
|
||||
clearTimeout(autoScrollTimer.value);
|
||||
}
|
||||
|
||||
// 清理睡眠定时器倒计时刷新 interval,避免组件卸载后残留、闭包持有 store 无法回收
|
||||
if (sleepTimerInterval) {
|
||||
clearInterval(sleepTimerInterval);
|
||||
sleepTimerInterval = null;
|
||||
}
|
||||
|
||||
// 清理鼠标事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
@@ -1112,7 +1074,7 @@ watch(isVisible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 播放器显示时,重新设置背景颜色
|
||||
if (targetBackground.value) {
|
||||
setTextColors(targetBackground.value);
|
||||
applyBackground(targetBackground.value);
|
||||
}
|
||||
} else {
|
||||
showFullLyrics.value = false;
|
||||
@@ -1128,7 +1090,7 @@ const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
||||
|
||||
// 修改 getLrcStyle 函数
|
||||
const getLrcStyle = (index: number) => {
|
||||
const colors = textColors.value || getTextColors;
|
||||
const colors = textColors.value || getTextColors();
|
||||
const originalStyle = originalLrcStyle(index);
|
||||
|
||||
if (index === nowIndex.value) {
|
||||
|
||||
@@ -7,8 +7,14 @@
|
||||
>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ t('settings.themeColor.title') }}</span>
|
||||
<div class="close-button" @click="handleClose">
|
||||
<i class="ri-close-line"></i>
|
||||
<div class="header-actions">
|
||||
<div class="reset-button" :title="t('settings.themeColor.reset')" @click="handleReset">
|
||||
<i class="ri-arrow-go-back-line"></i>
|
||||
<span>{{ t('settings.themeColor.reset') }}</span>
|
||||
</div>
|
||||
<div class="close-button" @click="handleClose">
|
||||
<i class="ri-close-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +117,7 @@ interface Props {
|
||||
interface Emits {
|
||||
(e: 'colorChange', _color: string): void;
|
||||
(e: 'close'): void;
|
||||
(e: 'reset'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -160,6 +167,12 @@ const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 恢复默认:关闭自定义主题色(#591 此前设置后无任何入口可关闭)
|
||||
const handleReset = () => {
|
||||
showColorPicker.value = false;
|
||||
emit('reset');
|
||||
};
|
||||
|
||||
const handlePresetColorSelect = (color: LyricThemeColor) => {
|
||||
const colorValue = getColorValue(color);
|
||||
const optimizedColor = optimizeColorForTheme(colorValue, props.theme);
|
||||
@@ -301,6 +314,35 @@ watch(
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
v-model:value="volumeSlider"
|
||||
:step="0.01"
|
||||
:tooltip="false"
|
||||
:disabled="isMuted"
|
||||
vertical
|
||||
@wheel.prevent="handleVolumeWheel"
|
||||
></n-slider>
|
||||
@@ -129,6 +130,9 @@ import { computed, provide, ref, useTemplateRef } from 'vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { useFavorite } from '@/hooks/useFavorite';
|
||||
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
|
||||
import { useVolumeControl } from '@/hooks/useVolumeControl';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore, useSettingsStore } from '@/store';
|
||||
import type { SongResult } from '@/types/music';
|
||||
@@ -138,6 +142,21 @@ const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { navigateToArtist } = useArtist();
|
||||
|
||||
// 播放控制
|
||||
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
|
||||
|
||||
// 音量控制(统一通过 playerStore 管理)
|
||||
const {
|
||||
isMuted,
|
||||
volumeSlider,
|
||||
volumeIcon: getVolumeIcon,
|
||||
mute,
|
||||
handleVolumeWheel
|
||||
} = useVolumeControl();
|
||||
|
||||
// 收藏
|
||||
const { isFavorite, toggleFavorite } = useFavorite();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
pureModeEnabled?: boolean;
|
||||
@@ -155,74 +174,19 @@ const handleClose = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 是否播放
|
||||
const play = computed(() => playerStore.play as boolean);
|
||||
// 播放列表
|
||||
const playList = computed(() => playerStore.playList as SongResult[]);
|
||||
|
||||
// 音量控制
|
||||
const audioVolume = ref(
|
||||
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
|
||||
);
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
audioService.setVolume(value / 100);
|
||||
audioVolume.value = value / 100;
|
||||
}
|
||||
});
|
||||
|
||||
// 音量图标
|
||||
const getVolumeIcon = computed(() => {
|
||||
if (audioVolume.value === 0) return 'ri-volume-mute-line';
|
||||
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
// 静音
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标滚轮调整音量
|
||||
const handleVolumeWheel = (e: WheelEvent) => {
|
||||
// 向上滚动增加音量,向下滚动减少音量
|
||||
const delta = e.deltaY < 0 ? 5 : -5;
|
||||
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
|
||||
volumeSlider.value = newValue;
|
||||
};
|
||||
|
||||
// 收藏相关
|
||||
const isFavorite = computed(() => {
|
||||
return playerStore.favoriteList.includes(playMusic.value.id);
|
||||
});
|
||||
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
let favoriteId = playMusic.value.id;
|
||||
|
||||
if (isFavorite.value) {
|
||||
playerStore.removeFromFavorite(favoriteId);
|
||||
} else {
|
||||
playerStore.addToFavorite(favoriteId);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放列表相关
|
||||
const palyListRef = useTemplateRef('palyListRef') as any;
|
||||
const isPlaylistOpen = ref(false);
|
||||
|
||||
// 提供 openPlaylistDrawer 给子组件
|
||||
// Mini 窗口(340px 宽)容不下 420px 的歌单抽屉:记录待添加歌曲并恢复主窗口,
|
||||
// AppLayout 重新挂载后接力打开抽屉(#504)
|
||||
provide('openPlaylistDrawer', (songId: number) => {
|
||||
console.log('打开歌单抽屉', songId);
|
||||
// 由于在迷你模式不处理这个功能,所以只记录日志
|
||||
localStorage.setItem('pendingAddToPlaylistSongId', String(songId));
|
||||
window.api.restore();
|
||||
});
|
||||
|
||||
// 切换播放列表显示/隐藏
|
||||
@@ -308,19 +272,6 @@ const handleProgressLeave = () => {
|
||||
isHovering.value = false;
|
||||
};
|
||||
|
||||
// 播放控制
|
||||
const handlePrev = () => playerStore.prevPlay();
|
||||
const handleNext = () => playerStore.nextPlay();
|
||||
|
||||
const playMusicEvent = async () => {
|
||||
try {
|
||||
playerStore.setPlay(playerStore.playMusic);
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到完整播放器
|
||||
const setMusicFull = () => {
|
||||
playerStore.setMusicFull(true);
|
||||
|
||||
@@ -62,10 +62,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { useSwipe } from '@vueuse/core';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { inject, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
|
||||
import { artistList, playMusic, textColors } from '@/hooks/MusicHook';
|
||||
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
import { getImgUrl, setAnimationClass } from '@/utils';
|
||||
@@ -75,24 +76,15 @@ const shouldShowMobileMenu = inject('shouldShowMobileMenu') as Ref<boolean>;
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 是否播放
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
// 播放控制
|
||||
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
|
||||
|
||||
// 背景颜色
|
||||
const background = ref('#000');
|
||||
|
||||
// 播放控制
|
||||
function handleNext() {
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
playerStore.prevPlay();
|
||||
}
|
||||
|
||||
// 全屏播放器
|
||||
const MusicFullRef = ref<any>(null);
|
||||
|
||||
// 设置musicFull
|
||||
const setMusicFull = () => {
|
||||
playerStore.setMusicFull(!playerStore.musicFull);
|
||||
if (playerStore.musicFull) {
|
||||
@@ -107,21 +99,10 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 打开播放列表抽屉
|
||||
const openPlayListDrawer = () => {
|
||||
playerStore.setPlayListDrawerVisible(true);
|
||||
};
|
||||
|
||||
// 播放暂停按钮事件
|
||||
const playMusicEvent = async () => {
|
||||
try {
|
||||
playerStore.setPlay(playMusic.value);
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
};
|
||||
|
||||
// 滑动切歌
|
||||
const playBarRef = ref<HTMLElement | null>(null);
|
||||
onMounted(() => {
|
||||
|
||||
@@ -99,8 +99,16 @@
|
||||
<i class="iconfont" :class="getVolumeIcon"></i>
|
||||
</div>
|
||||
<div class="volume-slider">
|
||||
<div class="volume-percentage">{{ Math.round(volumeSlider) }}%</div>
|
||||
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
|
||||
<div class="volume-percentage" :class="{ 'volume-percentage-disabled': isMuted }">
|
||||
{{ Math.round(volumeSlider) }}%
|
||||
</div>
|
||||
<n-slider
|
||||
v-model:value="volumeSlider"
|
||||
:step="0.01"
|
||||
:tooltip="false"
|
||||
:disabled="isMuted"
|
||||
vertical
|
||||
></n-slider>
|
||||
</div>
|
||||
</div>
|
||||
<n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999">
|
||||
@@ -143,6 +151,16 @@
|
||||
</template>
|
||||
{{ t('player.playBar.reparse') }}
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="playMusic?.id && isElectron" trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i
|
||||
class="iconfont ri-download-line"
|
||||
:class="{ 'disabled-icon': isDownloading }"
|
||||
@click="playMusic?.id && handleDownload()"
|
||||
/>
|
||||
</template>
|
||||
{{ isDownloading ? t('songItem.message.downloading') : t('player.playBar.download') }}
|
||||
</n-tooltip>
|
||||
|
||||
<!-- 高级控制菜单按钮(整合了 EQ、定时关闭、播放速度) -->
|
||||
<advanced-controls-popover />
|
||||
@@ -164,7 +182,6 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -182,7 +199,11 @@ import {
|
||||
textColors
|
||||
} from '@/hooks/MusicHook';
|
||||
import { useArtist } from '@/hooks/useArtist';
|
||||
import { useDownload } from '@/hooks/useDownload';
|
||||
import { useFavorite } from '@/hooks/useFavorite';
|
||||
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
|
||||
import { usePlayMode } from '@/hooks/usePlayMode';
|
||||
import { useVolumeControl } from '@/hooks/useVolumeControl';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { useSettingsStore } from '@/store/modules/settings';
|
||||
@@ -191,9 +212,35 @@ import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } fr
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
// 是否播放
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
|
||||
// 播放控制
|
||||
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
|
||||
|
||||
// 音量控制
|
||||
const {
|
||||
isMuted,
|
||||
volumeSlider,
|
||||
volumeIcon: getVolumeIcon,
|
||||
mute,
|
||||
handleVolumeWheel
|
||||
} = useVolumeControl();
|
||||
|
||||
// 收藏
|
||||
const { isFavorite, toggleFavorite } = useFavorite();
|
||||
|
||||
// 下载
|
||||
const { downloadMusic, isDownloading } = useDownload();
|
||||
const handleDownload = () => {
|
||||
if (!playMusic.value || isDownloading.value) return;
|
||||
downloadMusic(playMusic.value);
|
||||
};
|
||||
|
||||
// 播放模式
|
||||
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
|
||||
|
||||
// 播放速度控制
|
||||
const { playbackRate } = storeToRefs(playerStore);
|
||||
|
||||
// 背景颜色
|
||||
const background = ref('#000');
|
||||
|
||||
@@ -211,115 +258,41 @@ watch(
|
||||
const throttledSeek = useThrottleFn((value: number) => {
|
||||
audioService.seek(value);
|
||||
nowTime.value = value;
|
||||
}, 50); // 50ms 的节流延迟
|
||||
}, 50);
|
||||
|
||||
// 拖动时的临时值,避免频繁更新 nowTime 触发重渲染
|
||||
// 拖动时的临时值
|
||||
const dragValue = ref(0);
|
||||
|
||||
// 为滑块拖动添加状态跟踪
|
||||
const isDragging = ref(false);
|
||||
|
||||
// 修改 timeSlider 计算属性
|
||||
const timeSlider = computed({
|
||||
get: () => (isDragging.value ? dragValue.value : nowTime.value),
|
||||
set: (value) => {
|
||||
if (isDragging.value) {
|
||||
// 拖动中只更新临时值,不触发 nowTime 更新和 seek 操作
|
||||
dragValue.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击操作 (非拖动),可以直接 seek
|
||||
throttledSeek(value);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加滑块拖动开始和结束事件处理
|
||||
const handleSliderDragStart = () => {
|
||||
isDragging.value = true;
|
||||
// 初始化拖动值为当前时间
|
||||
dragValue.value = nowTime.value;
|
||||
};
|
||||
|
||||
const handleSliderDragEnd = () => {
|
||||
isDragging.value = false;
|
||||
|
||||
// 直接应用最终的拖动值
|
||||
audioService.seek(dragValue.value);
|
||||
nowTime.value = dragValue.value;
|
||||
};
|
||||
|
||||
// 格式化提示文本,根据拖动状态显示不同的时间
|
||||
const formatTooltip = (value: number) => {
|
||||
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
|
||||
};
|
||||
|
||||
// 音量条 - 使用 playerStore 的统一音量管理
|
||||
const getVolumeIcon = computed(() => {
|
||||
// 0 静音 ri-volume-mute-line 0.5 ri-volume-down-line 1 ri-volume-up-line
|
||||
if (playerStore.volume === 0) {
|
||||
return 'ri-volume-mute-line';
|
||||
}
|
||||
if (playerStore.volume <= 0.5) {
|
||||
return 'ri-volume-down-line';
|
||||
}
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => playerStore.volume * 100,
|
||||
set: (value) => {
|
||||
playerStore.setVolume(value / 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 静音
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标滚轮调整音量
|
||||
const handleVolumeWheel = (e: WheelEvent) => {
|
||||
// 向上滚动增加音量,向下滚动减少音量
|
||||
const delta = e.deltaY < 0 ? 5 : -5;
|
||||
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
|
||||
volumeSlider.value = newValue;
|
||||
};
|
||||
|
||||
// 播放模式
|
||||
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
|
||||
|
||||
// 播放速度控制
|
||||
const { playbackRate } = storeToRefs(playerStore);
|
||||
|
||||
function handleNext() {
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
playerStore.prevPlay();
|
||||
}
|
||||
|
||||
const MusicFullRef = ref<any>(null);
|
||||
const showSliderTooltip = ref(false);
|
||||
|
||||
// 播放暂停按钮事件
|
||||
const playMusicEvent = async () => {
|
||||
try {
|
||||
const result = await playerStore.setPlay({ ...playMusic.value });
|
||||
if (result) {
|
||||
playerStore.setPlayMusic(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新获取播放链接失败:', error);
|
||||
message.error(t('player.playFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const musicFullVisible = computed({
|
||||
get: () => playerStore.musicFull,
|
||||
set: (value) => {
|
||||
@@ -327,7 +300,6 @@ const musicFullVisible = computed({
|
||||
}
|
||||
});
|
||||
|
||||
// 设置musicFull
|
||||
const setMusicFull = () => {
|
||||
musicFullVisible.value = !musicFullVisible.value;
|
||||
playerStore.setMusicFull(musicFullVisible.value);
|
||||
@@ -336,24 +308,6 @@ const setMusicFull = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
if (!playMusic || !playMusic.value) return false;
|
||||
return playerStore.favoriteList.includes(playMusic.value.id);
|
||||
});
|
||||
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
console.log('playMusic.value', playMusic.value);
|
||||
e.stopPropagation();
|
||||
|
||||
let favoriteId = playMusic.value.id;
|
||||
|
||||
if (isFavorite.value) {
|
||||
playerStore.removeFromFavorite(favoriteId);
|
||||
} else {
|
||||
playerStore.addToFavorite(favoriteId);
|
||||
}
|
||||
};
|
||||
|
||||
const openLyricWindow = () => {
|
||||
openLyric();
|
||||
};
|
||||
@@ -365,7 +319,6 @@ const handleArtistClick = (id: number) => {
|
||||
navigateToArtist(id);
|
||||
};
|
||||
|
||||
// 打开播放列表抽屉
|
||||
const openPlayListDrawer = () => {
|
||||
playerStore.setPlayListDrawerVisible(true);
|
||||
};
|
||||
@@ -461,6 +414,10 @@ const openPlayListDrawer = () => {
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
@apply text-gray-800 dark:text-white;
|
||||
white-space: nowrap;
|
||||
|
||||
&.volume-percentage-disabled {
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ import { useI18n } from 'vue-i18n';
|
||||
import { CacheManager } from '@/api/musicParser';
|
||||
import { playMusic } from '@/hooks/MusicHook';
|
||||
import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner';
|
||||
import { reparseCurrentSong } from '@/services/playbackController';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { LxMusicScriptConfig } from '@/types/lxMusic';
|
||||
import type { Platform } from '@/types/music';
|
||||
import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig';
|
||||
@@ -119,7 +119,6 @@ type ReparseSourceItem = {
|
||||
lxScriptId?: string;
|
||||
};
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
@@ -253,7 +252,7 @@ const reparseWithLxScript = async (source: ReparseSourceItem) => {
|
||||
selectedSourceId.value = source.id;
|
||||
SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual');
|
||||
|
||||
const success = await playerStore.reparseCurrentSong('lxMusic', false);
|
||||
const success = await reparseCurrentSong('lxMusic', false);
|
||||
|
||||
if (success) {
|
||||
message.success(t('player.reparse.success'));
|
||||
@@ -283,7 +282,7 @@ const directReparseMusic = async (source: ReparseSourceItem) => {
|
||||
selectedSourceId.value = source.id;
|
||||
SongSourceConfigManager.setConfig(songId, [source.platform], 'manual');
|
||||
|
||||
const success = await playerStore.reparseCurrentSong(source.platform, false);
|
||||
const success = await reparseCurrentSong(source.platform, false);
|
||||
|
||||
if (success) {
|
||||
message.success(t('player.reparse.success'));
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
v-model:value="volumeSlider"
|
||||
:step="1"
|
||||
:tooltip="false"
|
||||
:disabled="isMuted"
|
||||
@wheel.prevent="handleVolumeWheel"
|
||||
></n-slider>
|
||||
</div>
|
||||
@@ -80,8 +81,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { allTime, nowTime, playMusic } from '@/hooks/MusicHook';
|
||||
import { allTime, nowTime } from '@/hooks/MusicHook';
|
||||
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
|
||||
import { usePlayMode } from '@/hooks/usePlayMode';
|
||||
import { useVolumeControl } from '@/hooks/useVolumeControl';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import { secondToMinute } from '@/utils';
|
||||
@@ -98,61 +101,20 @@ const props = withDefaults(
|
||||
const playerStore = usePlayerStore();
|
||||
const playBarRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 播放状态
|
||||
const play = computed(() => playerStore.isPlay);
|
||||
// 播放控制
|
||||
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
|
||||
|
||||
// 播放模式
|
||||
const { playMode, playModeIcon, togglePlayMode } = usePlayMode();
|
||||
|
||||
// 音量控制
|
||||
const audioVolume = ref(
|
||||
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
|
||||
);
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
audioService.setVolume(value / 100);
|
||||
audioVolume.value = value / 100;
|
||||
}
|
||||
});
|
||||
|
||||
// 音量图标
|
||||
const getVolumeIcon = computed(() => {
|
||||
if (audioVolume.value === 0) return 'ri-volume-mute-line';
|
||||
if (audioVolume.value <= 0.5) return 'ri-volume-down-line';
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
// 静音切换
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标滚轮调整音量
|
||||
const handleVolumeWheel = (e: WheelEvent) => {
|
||||
const delta = e.deltaY < 0 ? 5 : -5;
|
||||
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
|
||||
volumeSlider.value = newValue;
|
||||
};
|
||||
|
||||
// 播放控制
|
||||
const handlePrev = () => playerStore.prevPlay();
|
||||
const handleNext = () => playerStore.nextPlay();
|
||||
|
||||
const playMusicEvent = async () => {
|
||||
try {
|
||||
await playerStore.setPlay({ ...playMusic.value });
|
||||
} catch (error) {
|
||||
console.error('播放出错:', error);
|
||||
playerStore.nextPlay();
|
||||
}
|
||||
};
|
||||
// 音量控制(统一通过 playerStore 管理)
|
||||
const {
|
||||
isMuted,
|
||||
volumeSlider,
|
||||
volumeIcon: getVolumeIcon,
|
||||
mute,
|
||||
handleVolumeWheel
|
||||
} = useVolumeControl();
|
||||
|
||||
// 进度条控制
|
||||
const isDragging = ref(false);
|
||||
|
||||
@@ -23,11 +23,7 @@ const goToDetail = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group cursor-pointer animate-item"
|
||||
:style="{ animationDelay }"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<div class="group cursor-pointer animate-item" :style="{ animationDelay }" @click="goToDetail">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
|
||||
|
||||
@@ -2,39 +2,46 @@ import { createVNode, render, VNode } from 'vue';
|
||||
|
||||
import Loading from './index.vue';
|
||||
|
||||
const vnode: VNode = createVNode(Loading) as VNode;
|
||||
// 每个使用 v-loading 的元素独立持有一个 Loading 实例,
|
||||
// 避免此前"模块级单例 vnode"导致的多个 v-loading 争用同一实例、
|
||||
// spinner 只出现在最后挂载元素上的问题。
|
||||
const instanceMap = new WeakMap<HTMLElement, VNode>();
|
||||
|
||||
export const vLoading = {
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
|
||||
mounted: (el: HTMLElement) => {
|
||||
render(vnode, el);
|
||||
},
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
|
||||
updated: (el: HTMLElement, binding: any) => {
|
||||
if (binding.value) {
|
||||
vnode?.component?.exposed?.show();
|
||||
} else {
|
||||
vnode?.component?.exposed?.hide();
|
||||
}
|
||||
// 动态添加删除自定义class: loading-parent
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 绑定元素的父组件卸载后调用
|
||||
unmounted: () => {
|
||||
const setLoading = (el: HTMLElement, visible: boolean) => {
|
||||
const vnode = instanceMap.get(el);
|
||||
if (visible) {
|
||||
vnode?.component?.exposed?.show();
|
||||
} else {
|
||||
vnode?.component?.exposed?.hide();
|
||||
}
|
||||
};
|
||||
|
||||
export const vLoading = {
|
||||
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
|
||||
mounted: (el: HTMLElement, binding: any) => {
|
||||
const vnode = createVNode(Loading);
|
||||
render(vnode, el);
|
||||
instanceMap.set(el, vnode);
|
||||
setLoading(el, !!binding.value);
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 在绑定元素的父组件及他自己的所有子节点都更新后调用
|
||||
updated: (el: HTMLElement, binding: any) => {
|
||||
setLoading(el, !!binding.value);
|
||||
// 动态添加删除自定义class: loading-parent
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 绑定元素的父组件卸载后调用:真正卸载组件实例,释放资源
|
||||
unmounted: (el: HTMLElement) => {
|
||||
render(null, el);
|
||||
instanceMap.delete(el);
|
||||
}
|
||||
};
|
||||
|
||||
function formatterClass(el: HTMLElement, binding: any) {
|
||||
const classStr = el.getAttribute('class');
|
||||
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
|
||||
if (binding.value) {
|
||||
if (tagetClass === -1) {
|
||||
el.setAttribute('class', `${classStr} loading-parent`);
|
||||
}
|
||||
} else if (tagetClass > -1) {
|
||||
const classArray: Array<string> = classStr?.split('') as string[];
|
||||
classArray.splice(tagetClass - 1, tagetClass + 15);
|
||||
el.setAttribute('class', classArray?.join(''));
|
||||
el.classList.add('loading-parent');
|
||||
} else {
|
||||
el.classList.remove('loading-parent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import useIndexedDB from '@/hooks/IndexDBHook';
|
||||
@@ -45,7 +44,7 @@ export const nowTime = ref(0); // 当前播放时间
|
||||
export const allTime = ref(0); // 总播放时间
|
||||
export const nowIndex = ref(0); // 当前播放歌词
|
||||
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
|
||||
export const sound = ref<Howl | null>(audioService.getCurrentSound());
|
||||
export const sound = ref<HTMLAudioElement | null>(audioService.getCurrentSound());
|
||||
export const isLyricWindowOpen = ref(false); // 新增状态
|
||||
export const textColors = ref<any>(getTextColors());
|
||||
|
||||
@@ -53,6 +52,11 @@ export const textColors = ref<any>(getTextColors());
|
||||
export let playMusic: ComputedRef<SongResult>;
|
||||
export let artistList: ComputedRef<Artist[]>;
|
||||
|
||||
let lastIndex = -1;
|
||||
|
||||
// 缓存平台信息,避免每次歌词变化时同步 IPC 调用
|
||||
const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
|
||||
|
||||
export const musicDB = await useIndexedDB(
|
||||
'musicDB',
|
||||
[
|
||||
@@ -65,28 +69,28 @@ export const musicDB = await useIndexedDB(
|
||||
3
|
||||
);
|
||||
|
||||
// 键盘事件处理器,在初始化后设置
|
||||
const setupKeyboardListeners = () => {
|
||||
document.onkeyup = (e) => {
|
||||
// 检查事件目标是否是输入框元素
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
// 键盘事件处理器(提取为命名函数,防止重复注册)
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = getPlayerStore();
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
if (store.playMusic?.id) {
|
||||
void store.setPlay({ ...store.playMusic });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
const store = getPlayerStore();
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
if (store.playMusic?.id) {
|
||||
void store.setPlay({ ...store.playMusic });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
const setupKeyboardListeners = () => {
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
|
||||
let audioListenersInitialized = false;
|
||||
|
||||
@@ -136,7 +140,10 @@ const parseLyricsString = async (
|
||||
duration: line.duration
|
||||
});
|
||||
|
||||
lrcTimeArray.push(line.startTime);
|
||||
// yrcParser 的 startTime 是毫秒;lrcTimeArray 全链路(nowTime 对比、
|
||||
// setAudioTime seek)以秒为单位,必须换算,否则点击歌词会 seek 到
|
||||
// 远超时长的位置被钳到末尾、直接触发切歌
|
||||
lrcTimeArray.push(line.startTime / 1000);
|
||||
}
|
||||
return { lrcArray, lrcTimeArray, hasWordByWord };
|
||||
} catch (error) {
|
||||
@@ -145,77 +152,130 @@ const parseLyricsString = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 设置音乐相关的监听器
|
||||
// 解析当前 playMusic.lyric 写入 lrcArray, 供 watcher / openLyric / onLyricWindowReady 共用
|
||||
const ensureLyricsLoaded = async (force = false) => {
|
||||
const songId = playMusic.value?.id;
|
||||
if (!songId) {
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
nowIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
if (!force && lrcArray.value.length > 0) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const lyricData = playMusic.value.lyric;
|
||||
if (lyricData && typeof lyricData === 'string') {
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(lyricData);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
|
||||
const rawLrc = lyricData.lrcArray || [];
|
||||
lrcTimeArray.value = lyricData.lrcTimeArray || [];
|
||||
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
|
||||
try {
|
||||
let filePath = decodeURIComponent(playMusic.value.playMusicUrl.replace('local:///', ''));
|
||||
// 处理 Windows 路径:/C:/... → C:/...
|
||||
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
|
||||
if (embeddedLyrics) {
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(embeddedLyrics);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else if (typeof songId === 'number') {
|
||||
try {
|
||||
const { getMusicLrc } = await import('@/api/music');
|
||||
const res = await getMusicLrc(songId);
|
||||
if (res?.data?.lrc?.lyric) {
|
||||
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||
res.data.lrc.lyric
|
||||
);
|
||||
lrcArray.value = apiLrcArray;
|
||||
lrcTimeArray.value = apiTimeArray;
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.error('API lyrics fallback failed:', apiErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to extract embedded lyrics:', err);
|
||||
}
|
||||
} else if (typeof songId === 'number') {
|
||||
// 在线歌曲但 lyric 字段尚未加载, 主动调 API 兜底
|
||||
try {
|
||||
const { getMusicLrc } = await import('@/api/music');
|
||||
const res = await getMusicLrc(songId);
|
||||
if (res?.data?.lrc?.lyric) {
|
||||
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||
res.data.lrc.lyric
|
||||
);
|
||||
lrcArray.value = apiLrcArray;
|
||||
lrcTimeArray.value = apiTimeArray;
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.error('API lyrics fallback failed:', apiErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
setTimeout(() => sendLyricToWin(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const setupMusicWatchers = () => {
|
||||
const store = getPlayerStore();
|
||||
|
||||
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
||||
// 切歌时 id 变化, 强制重新解析
|
||||
watch(
|
||||
() => store.playMusic.id,
|
||||
async (newId, oldId) => {
|
||||
// 如果没有歌曲ID,清空歌词
|
||||
if (!newId) {
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
nowIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免相同ID的重复执行(但允许初始化时执行)
|
||||
if (newId === oldId && lrcArray.value.length > 0) return;
|
||||
|
||||
// 歌曲切换时重置歌词索引
|
||||
if (newId !== oldId) {
|
||||
nowIndex.value = 0;
|
||||
}
|
||||
|
||||
await nextTick(async () => {
|
||||
console.log('歌曲切换,更新歌词数据');
|
||||
|
||||
// 检查是否有原始歌词字符串需要解析
|
||||
const lyricData = playMusic.value.lyric;
|
||||
if (lyricData && typeof lyricData === 'string') {
|
||||
// 如果歌词是字符串格式,使用新的解析器
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(lyricData);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
|
||||
// 更新歌曲的歌词数据结构
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else {
|
||||
// 使用现有的歌词数据结构
|
||||
const rawLrc = lyricData?.lrcArray || [];
|
||||
lrcTimeArray.value = lyricData?.lrcTimeArray || [];
|
||||
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
}
|
||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
console.log('歌词窗口已打开,同步最新歌词数据');
|
||||
// 不管歌词数组是否为空,都发送最新数据
|
||||
sendLyricToWin();
|
||||
|
||||
// 再次延迟发送,确保歌词窗口已完全加载
|
||||
setTimeout(() => {
|
||||
sendLyricToWin();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
if (newId !== oldId) nowIndex.value = 0;
|
||||
await ensureLyricsLoaded(true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 同一首歌但 lyric 字段后到 (播放后异步加载元数据 / 重启 + autoPlay 关闭场景)
|
||||
watch(
|
||||
() => playMusic.value?.lyric,
|
||||
(newLyric) => {
|
||||
if (!playMusic.value?.id) return;
|
||||
// 完整歌词对象(含 yrc 逐字/翻译,时间单位为秒)后到时强制重新解析,
|
||||
// 替换掉先行的 API 兜底纯 lrc 歌词
|
||||
const isRichLyric =
|
||||
!!newLyric && typeof newLyric === 'object' && (newLyric.lrcArray?.length ?? 0) > 0;
|
||||
if (lrcArray.value.length === 0 || isRichLyric) {
|
||||
ensureLyricsLoaded(isRichLyric);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setupAudioListeners = () => {
|
||||
@@ -260,12 +320,7 @@ const setupAudioListeners = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof currentSound.seek !== 'function') {
|
||||
// seek 方法不可用,跳过本次更新,不清除 interval
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = currentSound.seek() as number;
|
||||
const currentTime = currentSound.currentTime;
|
||||
if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) {
|
||||
// 无效时间,跳过本次更新
|
||||
return;
|
||||
@@ -277,7 +332,7 @@ const setupAudioListeners = () => {
|
||||
}
|
||||
|
||||
nowTime.value = currentTime;
|
||||
allTime.value = currentSound.duration() as number;
|
||||
allTime.value = currentSound.duration;
|
||||
|
||||
// === 歌词索引更新 ===
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
@@ -288,6 +343,12 @@ const setupAudioListeners = () => {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
if (isElectron && lrcArray.value[nowIndex.value]) {
|
||||
if (lastIndex !== nowIndex.value) {
|
||||
sendTrayLyric(nowIndex.value);
|
||||
lastIndex = nowIndex.value;
|
||||
}
|
||||
}
|
||||
|
||||
// === 逐字歌词行内进度 ===
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
@@ -331,6 +392,15 @@ const setupAudioListeners = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === MPRIS 进度更新(每 ~1 秒)===
|
||||
if (isElectron && lyricThrottleCounter % 20 === 0) {
|
||||
try {
|
||||
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
|
||||
} catch {
|
||||
// 忽略发送失败
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('进度更新 interval 出错:', error);
|
||||
// 出错时不清除 interval,让下一次 tick 继续尝试
|
||||
@@ -349,7 +419,7 @@ const setupAudioListeners = () => {
|
||||
const store = getPlayerStore();
|
||||
if (store.play && !interval) {
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound && currentSound.playing()) {
|
||||
if (currentSound && !currentSound.paused) {
|
||||
console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复');
|
||||
startProgressInterval();
|
||||
}
|
||||
@@ -375,10 +445,15 @@ const setupAudioListeners = () => {
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
// 立即更新显示时间,不进行任何检查
|
||||
const currentTime = currentSound.seek() as number;
|
||||
const currentTime = currentSound.currentTime;
|
||||
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
|
||||
nowTime.value = currentTime;
|
||||
|
||||
// === MPRIS seek 时同步进度 ===
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
|
||||
}
|
||||
|
||||
// 检查是否需要更新歌词
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
@@ -400,10 +475,10 @@ const setupAudioListeners = () => {
|
||||
if (currentSound) {
|
||||
try {
|
||||
// 更新当前时间和总时长
|
||||
const currentTime = currentSound.seek() as number;
|
||||
const currentTime = currentSound.currentTime;
|
||||
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
|
||||
nowTime.value = currentTime;
|
||||
allTime.value = currentSound.duration() as number;
|
||||
allTime.value = currentSound.duration;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化时间和进度失败:', error);
|
||||
@@ -420,7 +495,10 @@ const setupAudioListeners = () => {
|
||||
if (isElectron) {
|
||||
window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
|
||||
}
|
||||
// 启动进度更新
|
||||
// 兜底: 重启后首次点播放时 lrcArray 仍为空则主动加载
|
||||
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||
ensureLyricsLoaded();
|
||||
}
|
||||
startProgressInterval();
|
||||
});
|
||||
|
||||
@@ -434,34 +512,25 @@ const setupAudioListeners = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const replayMusic = async (retryCount: number = 0) => {
|
||||
const replayMusic = async (retryCount = 0) => {
|
||||
const MAX_REPLAY_RETRIES = 3;
|
||||
try {
|
||||
// 如果当前有音频实例,先停止并销毁
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.stop();
|
||||
currentSound.unload();
|
||||
}
|
||||
sound.value = null;
|
||||
|
||||
// 重新播放当前歌曲
|
||||
if (getPlayerStore().playMusicUrl && playMusic.value) {
|
||||
const newSound = await audioService.play(getPlayerStore().playMusicUrl, playMusic.value);
|
||||
sound.value = newSound as Howl;
|
||||
await audioService.play(getPlayerStore().playMusicUrl, playMusic.value);
|
||||
sound.value = audioService.getCurrentSound();
|
||||
setupAudioListeners();
|
||||
} else {
|
||||
console.error('单曲循环:无可用 URL 或歌曲数据');
|
||||
getPlayerStore().nextPlay();
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
usePlaylistStore().nextPlayOnEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('单曲循环重播失败:', error);
|
||||
if (retryCount < MAX_REPLAY_RETRIES) {
|
||||
console.log(`单曲循环重试 ${retryCount + 1}/${MAX_REPLAY_RETRIES}`);
|
||||
setTimeout(() => replayMusic(retryCount + 1), 1000 * (retryCount + 1));
|
||||
} else {
|
||||
console.error('单曲循环重试次数用尽,切换下一首');
|
||||
getPlayerStore().nextPlay();
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
usePlaylistStore().nextPlayOnEnd();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -474,41 +543,12 @@ const setupAudioListeners = () => {
|
||||
if (getPlayerStore().playMode === 1) {
|
||||
// 单曲循环模式
|
||||
replayMusic();
|
||||
} else if (getPlayerStore().isFmPlaying) {
|
||||
// 私人FM模式:自动获取下一首
|
||||
try {
|
||||
const { getPersonalFM } = await import('@/api/home');
|
||||
const res = await getPersonalFM();
|
||||
const songs = res.data?.data;
|
||||
if (Array.isArray(songs) && songs.length > 0) {
|
||||
const song = songs[0];
|
||||
const fmSong = {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl,
|
||||
ar: song.artists || song.ar,
|
||||
al: song.al || song.album,
|
||||
source: 'netease' as const,
|
||||
song,
|
||||
...song,
|
||||
playLoading: false
|
||||
} as any;
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
playlistStore.setPlayList([fmSong], false, false);
|
||||
getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设
|
||||
await getPlayerStore().handlePlayMusic(fmSong, true);
|
||||
} else {
|
||||
getPlayerStore().setIsPlay(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FM自动播放下一首失败:', error);
|
||||
getPlayerStore().setIsPlay(false);
|
||||
}
|
||||
} else {
|
||||
// 顺序播放、列表循环、随机播放模式都使用统一的nextPlay方法
|
||||
getPlayerStore().nextPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他模式(FM/顺序/列表循环/随机):交给 playlist store 路由
|
||||
const { usePlaylistStore } = await import('@/store/modules/playlist');
|
||||
usePlaylistStore().nextPlayOnEnd();
|
||||
});
|
||||
|
||||
audioService.on('previoustrack', () => {
|
||||
@@ -529,8 +569,6 @@ export const play = () => {
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.play();
|
||||
// 在播放时也进行状态检测,防止URL已过期导致无声
|
||||
getPlayerStore().checkPlaybackState(getPlayerStore().playMusic);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,7 +577,7 @@ export const pause = () => {
|
||||
if (currentSound) {
|
||||
try {
|
||||
// 保存当前播放进度
|
||||
const currentTime = currentSound.seek() as number;
|
||||
const currentTime = currentSound.currentTime;
|
||||
if (getPlayerStore().playMusic && getPlayerStore().playMusic.id) {
|
||||
localStorage.setItem(
|
||||
'playProgress',
|
||||
@@ -692,7 +730,7 @@ export const setAudioTime = (index: number) => {
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
|
||||
currentSound.seek(lrcTimeArray.value[index]);
|
||||
audioService.seek(lrcTimeArray.value[index]);
|
||||
currentSound.play();
|
||||
};
|
||||
|
||||
@@ -775,6 +813,30 @@ export const sendLyricToWin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送歌词到系统托盘歌词(TrayLyric)
|
||||
const sendTrayLyric = (index: number) => {
|
||||
if (!isElectron || cachedPlatform !== 'linux') return;
|
||||
|
||||
try {
|
||||
const lyric = lrcArray.value[index];
|
||||
if (!lyric) return;
|
||||
|
||||
const currentTime = lrcTimeArray.value[index] || 0;
|
||||
const nextTime = lrcTimeArray.value[index + 1] || currentTime + 3;
|
||||
const duration = nextTime - currentTime;
|
||||
|
||||
const lrcObj = JSON.stringify({
|
||||
content: lyric.text || '',
|
||||
time: duration.toFixed(1),
|
||||
sender: 'AlgerMusicPlayer'
|
||||
});
|
||||
|
||||
window.electron.ipcRenderer.send('tray-lyric-update', lrcObj);
|
||||
} catch (error) {
|
||||
console.error('[TrayLyric] Failed to send:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 歌词同步定时器
|
||||
let lyricSyncInterval: any = null;
|
||||
|
||||
@@ -812,28 +874,20 @@ const stopLyricSync = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 修改openLyric函数,添加定时同步
|
||||
export const openLyric = () => {
|
||||
export const openLyric = async () => {
|
||||
if (!isElectron) return;
|
||||
|
||||
// 检查是否有播放中的歌曲
|
||||
if (!playMusic.value || !playMusic.value.id) {
|
||||
console.log('没有正在播放的歌曲,无法打开歌词窗口');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Opening lyric window with current song:', playMusic.value?.name);
|
||||
|
||||
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
||||
if (isLyricWindowOpen.value) {
|
||||
// 立即打开窗口
|
||||
window.api.openLyric();
|
||||
|
||||
// 确保有歌词数据,如果没有,则使用默认的"无歌词"提示
|
||||
// 先发"加载中"占位, 防止窗口启动期间显示"无歌词"
|
||||
if (!lrcArray.value || lrcArray.value.length === 0) {
|
||||
// 如果当前播放的歌曲有ID但没有歌词,则尝试加载歌词
|
||||
console.log('尝试加载歌词数据...');
|
||||
// 发送默认的"无歌词"数据
|
||||
const emptyLyricData = {
|
||||
type: 'empty',
|
||||
nowIndex: 0,
|
||||
@@ -847,12 +901,15 @@ export const openLyric = () => {
|
||||
playMusic: playMusic.value
|
||||
};
|
||||
window.api.sendLyric(JSON.stringify(emptyLyricData));
|
||||
|
||||
// 关键: 主动加载歌词, 不依赖 watcher
|
||||
// (重启场景下 playerCore.playMusic 整体替换可能未触发 lyric watcher)
|
||||
await ensureLyricsLoaded(true);
|
||||
} else {
|
||||
// 发送完整歌词数据
|
||||
sendLyricToWin();
|
||||
}
|
||||
|
||||
// 延迟重发一次,以防窗口加载略慢
|
||||
// 延迟重发, 防窗口加载慢丢消息
|
||||
setTimeout(() => {
|
||||
if (isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
@@ -974,11 +1031,13 @@ export const initAudioListeners = async () => {
|
||||
window.api.onLyricWindowClosed(() => {
|
||||
isLyricWindowOpen.value = false;
|
||||
});
|
||||
// 歌词窗口 Vue 加载完成后,发送完整歌词数据
|
||||
window.api.onLyricWindowReady(() => {
|
||||
if (isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
window.api.onLyricWindowReady(async () => {
|
||||
if (!isLyricWindowOpen.value) return;
|
||||
// 窗口加载完成时再兜底加载一次, 防止 openLyric 阶段 lyric 字段尚未到位
|
||||
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||
await ensureLyricsLoaded(true);
|
||||
}
|
||||
sendLyricToWin();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -995,50 +1054,27 @@ export const initAudioListeners = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听URL过期事件,自动重新获取URL并恢复播放
|
||||
audioService.on('url_expired', async (expiredTrack) => {
|
||||
if (!expiredTrack) return;
|
||||
|
||||
console.log('检测到URL过期事件,准备重新获取URL', expiredTrack.name);
|
||||
|
||||
try {
|
||||
// 使用 handlePlayMusic 重新播放,它会自动处理 URL 获取和状态跟踪
|
||||
// 我们将 isFirstPlay 设为 true 以强制获取新 URL
|
||||
const trackToPlay = {
|
||||
...expiredTrack,
|
||||
isFirstPlay: true,
|
||||
playMusicUrl: undefined
|
||||
};
|
||||
|
||||
await getPlayerStore().handlePlayMusic(trackToPlay, getPlayerStore().play);
|
||||
|
||||
message.success('已自动恢复播放');
|
||||
} catch (error) {
|
||||
console.error('处理URL过期事件失败:', error);
|
||||
message.error('恢复播放失败,请手动点击播放');
|
||||
}
|
||||
});
|
||||
|
||||
// 添加音频就绪事件监听器
|
||||
window.addEventListener('audio-ready', ((event: CustomEvent) => {
|
||||
// 音频就绪事件处理器(提取为命名函数,防止重复注册)
|
||||
const handleAudioReady = ((event: CustomEvent) => {
|
||||
try {
|
||||
const { sound: newSound } = event.detail;
|
||||
if (newSound) {
|
||||
// 更新本地 sound 引用
|
||||
sound.value = newSound as Howl;
|
||||
|
||||
// 设置音频监听器
|
||||
sound.value = audioService.getCurrentSound();
|
||||
setupAudioListeners();
|
||||
|
||||
// 获取当前播放位置并更新显示
|
||||
const currentPosition = newSound.seek() as number;
|
||||
if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) {
|
||||
nowTime.value = currentPosition;
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
const currentPosition = currentSound.currentTime;
|
||||
if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) {
|
||||
nowTime.value = currentPosition;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('音频就绪,已设置监听器并更新进度显示');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理音频就绪事件出错:', error);
|
||||
}
|
||||
}) as EventListener);
|
||||
}) as EventListener;
|
||||
|
||||
// 先移除再注册,防止重复
|
||||
window.removeEventListener('audio-ready', handleAudioReady);
|
||||
window.addEventListener('audio-ready', handleAudioReady);
|
||||
|
||||