19 Commits
v5.1.0 ... main

Author SHA1 Message Date
alger
b0b3eb3326 ci: 移除 PR 检查中已删除的 dev_electron 分支 2026-04-11 22:53:17 +08:00
alger
4a50886a68 ci: 添加 PR 提交规范检查和 commitlint
- 添加 commitlint 及 Conventional Commits 规范配置
- 添加 commit-msg husky hook 本地校验提交信息
- 添加 GitHub Actions PR 检查 workflow:
  - PR 标题符合 Conventional Commits
  - 所有 commit message 符合规范
  - ESLint + TypeScript 类型检查 + i18n 检查
2026-04-11 22:50:20 +08:00
Alger
f9222b699d Merge pull request #644 from algerkong/fix/mpris-review-643
fix(mpris): 修复 MPRIS 模块多项安全和性能问题
2026-04-11 22:44:38 +08:00
alger
030a1f1c85 fix(mpris): 修复 MPRIS 模块多项安全和性能问题
- 将 fix-sandbox.js 从 postinstall 移除,避免 npm install 时执行 sudo
- 修复 play/pause/stop 事件语义错误,不再全部映射到 togglePlay
- 缓存平台信息避免 sendSync 阻塞渲染进程
- 修复 cleanupAppShortcuts 中缺少 MPRIS 监听器清理导致的事件泄漏
- destroyMpris 中添加 IPC 监听器清理
- 清理冗余调试日志,安全加载 dbus-native 模块
- 添加 mpris-service 类型声明解决跨平台类型检查问题
2026-04-11 22:37:26 +08:00
stark81
3f31278131 fix-sandbox 2026-04-11 16:01:06 +08:00
alger
33fc4f768c 1. 实现linux下的mpris和gnome状态栏歌词功能 2026-04-11 15:45:14 +08:00
alger
8e3e4e610c fix(pwa): 修复 manifest.json 未被引用导致浏览器无法识别 PWA (#640)
在 index.html 中添加 manifest 引用,并补全 PWA 必需字段
2026-04-10 23:27:19 +08:00
alger
03b52cd6e2 fix(audio): 移除不必要的麦克风权限请求 (#639)
枚举音频输出设备时不再调用 getUserMedia,避免安全软件误报
2026-04-10 23:27:12 +08:00
4everWZ
8726af556a perf: 长列表渐进式渲染优化与播放栏遮挡修复 (#589)
- 新增 useProgressiveRender composable,提取手工虚拟化逻辑(renderLimit + placeholderHeight)
- FavoritePage/DownloadPage 使用 composable 实现渐进式渲染,避免大量 DOM 一次性渲染
- MusicListPage 初始加载扩大至 200 首,工具栏按钮添加 n-tooltip,新增回到顶部按钮
- 播放栏动态底部间距替代 PlayBottom 组件,修复播放时列表底部被遮挡
- 下载页无下载任务时自动切换到已下载 tab
- i18n: 添加 scrollToTop/compactLayout/normalLayout 翻译(5 种语言)

Inspired-By: https://github.com/algerkong/AlgerMusicPlayer/pull/589
2026-04-10 23:26:34 +08:00
Vanilla-puree
0ab784024c feat(download): 新增未保存下载设置时的确认对话框 (#507)
- feat(download): 关闭下载设置抽屉时检测未保存更改,提供取消/放弃/保存选项
- fix: 自动播放首次暂停无法暂停,移除不必要的 isFirstPlay 检查
- fix: 歌手详情路由添加 props key,修复跳转歌手详情不生效问题
- i18n: 添加 download.save.* 翻译(5 种语言)

Co-Authored-By: 心妄 <1661272893@qq.com>
2026-04-10 23:26:33 +08:00
alger
ad2df12957 fix(core): 修复事件监听器泄漏
- App.vue: offline 监听器添加 onUnmounted 清理,移除冗余 console.log
- MusicHook.ts: document.onkeyup 直接赋值改为 addEventListener + 防重复
- MusicHook.ts: audio-ready 监听器提取为命名函数,先移除再注册防堆叠
2026-04-10 23:26:33 +08:00
alger
a407045527 fix(player): 修复迷你模式恢复后歌词页面空白偏移
迷你播放栏的 togglePlaylist 设置 document.body.style.height='64px'
和 overflow='hidden',恢复主窗口时未清理,导致歌词 drawer 高度被限制。
在 mini-mode 事件处理中添加 body 样式重置。
2026-04-10 23:26:33 +08:00
alger
38723165a0 refactor(player): 提取播放栏共享逻辑为 composable
- 新增 useVolumeControl:统一音量管理(volumeSlider、mute、滚轮调节)
- 新增 useFavorite:收藏状态与切换
- 新增 usePlaybackControl:播放/暂停、上/下一首
- PlayBar、MiniPlayBar、SimplePlayBar、MobilePlayBar 使用新 composable
- 修复音量存储不一致:MiniPlayBar/SimplePlayBar 原先绕过 playerStore 直接操作 localStorage
2026-04-10 23:26:33 +08:00
alger
042b8ba6f8 fix(i18n): 补充 player.autoResumed/resumeFailed 翻译(5 种语言) 2026-03-29 13:20:45 +08:00
alger
eb801cfbfd style(ui): 桌面端 message 毛玻璃样式,本地音乐页面全页滚动优化
- message 提示适配项目设计:全圆角、backdrop-blur、半透明背景、深色/浅色模式
- 本地音乐页面:hero 缩小可滚出、action bar 吸顶、歌曲列表跟随全页滚动
- 顺序播放到最后一首:用户点下一首保持播放仅提示,自然播完才停止
- i18n 新增 playListEnded(5 种语言)
2026-03-29 13:18:56 +08:00
alger
0cfec3dd82 refactor(player): 重构播放控制系统,移除 Howler.js 改用原生 HTMLAudioElement
- 新建 playbackController.ts,使用 generation-based 取消替代 playbackRequestManager 状态机
- audioService 重写:单一持久 HTMLAudioElement + Web Audio API,createMediaElementSource 只调一次
- playerCore 瘦身为纯状态管理,移除 handlePlayMusic/playAudio/checkPlaybackState
- playlist next/prev 简化,区分用户手动切歌和歌曲自然播完
- MusicHook 适配 HTMLAudioElement API(.currentTime/.duration/.paused)
- preloadService 从 Howl 实例缓存改为 URL 可用性验证
- 所有 view/component 调用者迁移到 playbackController.playTrack()

修复:快速切歌竞态、seek 到未缓冲位置失败、重启后自动播放循环提示、EQ 重建崩溃
2026-03-29 13:18:05 +08:00
alger
167f081ee6 fix(download): 下载中列表封面使用缩略图加速加载 2026-03-27 23:06:38 +08:00
alger
c28368f783 fix(local-music): 扫描自动清理已删除文件,修复双滚动条
- scanFolders() 扫描时收集磁盘文件路径,完成后自动移除 IndexedDB 中已删除的条目
- 移除外层 n-scrollbar,改用 flex 布局,n-virtual-list 作为唯一滚动容器
2026-03-27 23:02:09 +08:00
alger
bc46024499 refactor(download): 重构下载系统,支持暂停/恢复/取消,修复歌词加载
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制
- 新建 Pinia useDownloadStore 作为渲染进程单一数据源
- 支持暂停/恢复/取消下载,支持断点续传(Range header)
- 批量下载全部完成后发送汇总系统通知,单首不重复通知
- 并发数可配置(1-5),队列持久化(重启后恢复)
- 修复下载列表不全、封面加载失败、通知重复等 bug
- 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback
- 删除 useDownloadStatus.ts,统一状态管理
- DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规
- 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
2026-03-27 23:02:08 +08:00
71 changed files with 4291 additions and 3957 deletions

71
.github/workflows/pr-check.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
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
- name: Install dependencies
run: npm install
- name: Lint
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
- name: Type check
run: npm run typecheck
- name: I18n check
run: npm run lint:i18n

1
.husky/commit-msg Executable file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit "$1"

View File

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

12
commitlint.config.js Normal file
View File

@@ -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']
}
};

21
fix-sandbox.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* 修复 Linux 下 Electron sandbox 权限问题
* chrome-sandbox 需要 root 拥有且权限为 4755
*
* 注意:此脚本需要 sudo 权限,仅在 CI 环境或手动执行时使用
* 用法sudo node fix-sandbox.js
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
if (process.platform === 'linux') {
const sandboxPath = path.resolve('./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');
}
}

View File

@@ -18,6 +18,7 @@
"dev:web": "vite dev", "dev:web": "vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"fix-sandbox": "node fix-sandbox.js",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win --publish never", "build:win": "npm run build && electron-builder --win --publish never",
"build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml", "build:mac": "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": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@httptoolkit/dbus-native": "^0.1.5",
"@unblockneteasemusic/server": "^0.27.10", "@unblockneteasemusic/server": "^0.27.10",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
@@ -49,6 +51,7 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"mpris-service": "^2.1.2",
"music-metadata": "^11.10.3", "music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.30.0", "netease-cloud-music-api-alger": "^4.30.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
@@ -58,6 +61,8 @@
"vue-i18n": "^11.2.2" "vue-i18n": "^11.2.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@electron-toolkit/eslint-config": "^2.1.0", "@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
@@ -148,7 +153,6 @@
"entitlements": "build/entitlements.mac.plist", "entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist",
"extendInfo": { "extendInfo": {
"NSMicrophoneUsageDescription": "AlgerMusicPlayer needs access to the microphone for audio visualization.",
"NSCameraUsageDescription": "Application requests access to the device's camera.", "NSCameraUsageDescription": "Application requests access to the device's camera.",
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.", "NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder." "NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
@@ -222,5 +226,9 @@
"electron", "electron",
"esbuild" "esbuild"
] ]
},
"optionalDependencies": {
"jsbi": "^4.3.2",
"x11": "^2.3.0"
} }
} }

View File

@@ -1,5 +1,11 @@
{ {
"name": "Alger Music PWA", "name": "Alger Music Player",
"short_name": "AlgerMusic",
"description": "AlgerMusicPlayer 音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [ "icons": [
{ {
"src": "./icon.png", "src": "./icon.png",

View File

@@ -223,6 +223,9 @@ export default {
operationFailed: 'Operation Failed', operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist', songsAlreadyInPlaylist: 'Songs already in playlist',
locateCurrent: 'Locate current song', locateCurrent: 'Locate current song',
scrollToTop: 'Scroll to top',
compactLayout: 'Compact layout',
normalLayout: 'Normal layout',
historyRecommend: 'Daily History', historyRecommend: 'Daily History',
fetchDatesFailed: 'Failed to fetch dates', fetchDatesFailed: 'Failed to fetch dates',
fetchSongsFailed: 'Failed to fetch songs', fetchSongsFailed: 'Failed to fetch songs',

View File

@@ -20,7 +20,21 @@ export default {
downloading: 'Downloading', downloading: 'Downloading',
completed: 'Completed', completed: 'Completed',
failed: 'Failed', 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: { artist: {
unknown: 'Unknown Artist' unknown: 'Unknown Artist'
@@ -44,6 +58,14 @@ export default {
success: 'Download records cleared', success: 'Download records cleared',
failed: 'Failed to clear download records' 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: { message: {
downloadComplete: '{filename} download completed', downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}' downloadFailed: '{filename} download failed: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: 'Sort or use arrow buttons to arrange:', dragToArrange: 'Sort or use arrow buttons to arrange:',
formatVariables: 'Available variables', formatVariables: 'Available variables',
preview: 'Preview:', preview: 'Preview:',
concurrency: 'Max Concurrent',
concurrencyDesc: 'Maximum number of simultaneous downloads (1-5)',
saveSuccess: 'Download settings saved', saveSuccess: 'Download settings saved',
presets: { presets: {
songArtist: 'Song - Artist', songArtist: 'Song - Artist',
@@ -89,5 +113,10 @@ export default {
artistName: 'Artist name', artistName: 'Artist name',
albumName: 'Album name' albumName: 'Album name'
} }
},
error: {
incomplete: 'File download incomplete',
urlExpired: 'URL expired, re-resolving',
resumeFailed: 'Resume failed'
} }
}; };

View File

@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: 'Song parsing failed, playing next', parseFailedPlayNext: 'Song parsing failed, playing next',
consecutiveFailsError: consecutiveFailsError:
'Playback error, possibly due to network issues or invalid source. Please switch playlist or try again later', '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: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
loop: 'Loop', loop: 'Loop',

View File

@@ -223,6 +223,9 @@ export default {
addToPlaylistSuccess: 'プレイリストに追加しました', addToPlaylistSuccess: 'プレイリストに追加しました',
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します', songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
locateCurrent: '再生中の曲を表示', locateCurrent: '再生中の曲を表示',
scrollToTop: 'トップに戻る',
compactLayout: 'コンパクト表示',
normalLayout: '通常表示',
historyRecommend: '履歴の日次推薦', historyRecommend: '履歴の日次推薦',
fetchDatesFailed: '日付リストの取得に失敗しました', fetchDatesFailed: '日付リストの取得に失敗しました',
fetchSongsFailed: '楽曲リストの取得に失敗しました', fetchSongsFailed: '楽曲リストの取得に失敗しました',

View File

@@ -20,7 +20,21 @@ export default {
downloading: 'ダウンロード中', downloading: 'ダウンロード中',
completed: '完了', completed: '完了',
failed: '失敗', failed: '失敗',
unknown: '不明' unknown: '不明',
queued: 'キュー中',
paused: '一時停止',
cancelled: 'キャンセル済み'
},
action: {
pause: '一時停止',
resume: '再開',
cancel: 'キャンセル',
cancelAll: 'すべてキャンセル',
retrying: 'URL再取得中...'
},
batch: {
complete: 'ダウンロード完了:{success}/{total}曲成功',
allComplete: '全てのダウンロードが完了'
}, },
artist: { artist: {
unknown: '不明なアーティスト' unknown: '不明なアーティスト'
@@ -44,6 +58,14 @@ export default {
success: 'ダウンロード記録をクリアしました', success: 'ダウンロード記録をクリアしました',
failed: 'ダウンロード記録のクリアに失敗しました' failed: 'ダウンロード記録のクリアに失敗しました'
}, },
save: {
title: '設定を保存',
message: '現在のダウンロード設定が保存されていません。変更を保存しますか?',
confirm: '保存',
cancel: 'キャンセル',
discard: '破棄',
saveSuccess: 'ダウンロード設定を保存しました'
},
message: { message: {
downloadComplete: '{filename}のダウンロードが完了しました', downloadComplete: '{filename}のダウンロードが完了しました',
downloadFailed: '{filename}のダウンロードに失敗しました: {error}' downloadFailed: '{filename}のダウンロードに失敗しました: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:', dragToArrange: 'ドラッグで並び替えまたは矢印ボタンで順序を調整:',
formatVariables: '使用可能な変数', formatVariables: '使用可能な変数',
preview: 'プレビュー効果:', preview: 'プレビュー効果:',
concurrency: '最大同時ダウンロード数',
concurrencyDesc: '同時にダウンロードする最大曲数1-5',
saveSuccess: 'ダウンロード設定を保存しました', saveSuccess: 'ダウンロード設定を保存しました',
presets: { presets: {
songArtist: '楽曲名 - アーティスト名', songArtist: '楽曲名 - アーティスト名',
@@ -89,5 +113,10 @@ export default {
artistName: 'アーティスト名', artistName: 'アーティスト名',
albumName: 'アルバム名' albumName: 'アルバム名'
} }
},
error: {
incomplete: 'ファイルのダウンロードが不完全です',
urlExpired: 'URLの有効期限が切れました。再取得中',
resumeFailed: '再開に失敗しました'
} }
}; };

View File

@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します', parseFailedPlayNext: '楽曲の解析に失敗しました。次の曲を再生します',
consecutiveFailsError: consecutiveFailsError:
'再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください', '再生エラーが発生しました。ネットワークの問題または無効な音源の可能性があります。プレイリストを切り替えるか、後でもう一度お試しください',
playListEnded: 'プレイリストの最後に到達しました',
autoResumed: '自動的に再生を再開しました',
resumeFailed: '再生の再開に失敗しました。手動でお試しください',
playMode: { playMode: {
sequence: '順次再生', sequence: '順次再生',
loop: 'リピート再生', loop: 'リピート再生',

View File

@@ -222,6 +222,9 @@ export default {
addToPlaylistSuccess: '재생 목록에 추가 성공', addToPlaylistSuccess: '재생 목록에 추가 성공',
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다', songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
locateCurrent: '현재 재생 곡 찾기', locateCurrent: '현재 재생 곡 찾기',
scrollToTop: '맨 위로',
compactLayout: '간결한 레이아웃',
normalLayout: '일반 레이아웃',
historyRecommend: '일일 기록 권장', historyRecommend: '일일 기록 권장',
fetchDatesFailed: '날짜를 가져오지 못했습니다', fetchDatesFailed: '날짜를 가져오지 못했습니다',
fetchSongsFailed: '곡을 가져오지 못했습니다', fetchSongsFailed: '곡을 가져오지 못했습니다',

View File

@@ -20,7 +20,21 @@ export default {
downloading: '다운로드 중', downloading: '다운로드 중',
completed: '완료', completed: '완료',
failed: '실패', failed: '실패',
unknown: '알 수 없음' unknown: '알 수 없음',
queued: '대기 중',
paused: '일시 정지',
cancelled: '취소됨'
},
action: {
pause: '일시 정지',
resume: '재개',
cancel: '취소',
cancelAll: '모두 취소',
retrying: 'URL 재획득 중...'
},
batch: {
complete: '다운로드 완료: {success}/{total}곡 성공',
allComplete: '모든 다운로드 완료'
}, },
artist: { artist: {
unknown: '알 수 없는 가수' unknown: '알 수 없는 가수'
@@ -44,6 +58,14 @@ export default {
success: '다운로드 기록이 지워졌습니다', success: '다운로드 기록이 지워졌습니다',
failed: '다운로드 기록 삭제에 실패했습니다' failed: '다운로드 기록 삭제에 실패했습니다'
}, },
save: {
title: '설정 저장',
message: '현재 다운로드 설정이 저장되지 않았습니다. 변경 사항을 저장하시겠습니까?',
confirm: '저장',
cancel: '취소',
discard: '포기',
saveSuccess: '다운로드 설정이 저장됨'
},
message: { message: {
downloadComplete: '{filename} 다운로드 완료', downloadComplete: '{filename} 다운로드 완료',
downloadFailed: '{filename} 다운로드 실패: {error}' downloadFailed: '{filename} 다운로드 실패: {error}'
@@ -78,6 +100,8 @@ export default {
dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:', dragToArrange: '드래그하여 정렬하거나 화살표 버튼을 사용하여 순서 조정:',
formatVariables: '사용 가능한 변수', formatVariables: '사용 가능한 변수',
preview: '미리보기 효과:', preview: '미리보기 효과:',
concurrency: '최대 동시 다운로드',
concurrencyDesc: '동시에 다운로드할 최대 곡 수 (1-5)',
saveSuccess: '다운로드 설정이 저장됨', saveSuccess: '다운로드 설정이 저장됨',
presets: { presets: {
songArtist: '곡명 - 가수명', songArtist: '곡명 - 가수명',
@@ -89,5 +113,10 @@ export default {
artistName: '가수명', artistName: '가수명',
albumName: '앨범명' albumName: '앨범명'
} }
},
error: {
incomplete: '파일 다운로드가 불완전합니다',
urlExpired: 'URL이 만료되었습니다. 재획득 중',
resumeFailed: '재개 실패'
} }
}; };

View File

@@ -17,6 +17,9 @@ export default {
parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생', parseFailedPlayNext: '곡 분석 실패, 다음 곡 재생',
consecutiveFailsError: consecutiveFailsError:
'재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요', '재생 오류가 발생했습니다. 네트워크 문제 또는 유효하지 않은 음원일 수 있습니다. 재생 목록을 변경하거나 나중에 다시 시도하세요',
playListEnded: '재생 목록의 마지막 곡에 도달했습니다',
autoResumed: '자동으로 재생이 재개되었습니다',
resumeFailed: '재생 재개에 실패했습니다. 수동으로 시도해 주세요',
playMode: { playMode: {
sequence: '순차 재생', sequence: '순차 재생',
loop: '한 곡 반복', loop: '한 곡 반복',

View File

@@ -216,6 +216,9 @@ export default {
addToPlaylistSuccess: '添加到播放列表成功', addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中', songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
locateCurrent: '定位当前播放', locateCurrent: '定位当前播放',
scrollToTop: '回到顶部',
compactLayout: '紧凑布局',
normalLayout: '常规布局',
historyRecommend: '历史日推', historyRecommend: '历史日推',
fetchDatesFailed: '获取日期列表失败', fetchDatesFailed: '获取日期列表失败',
fetchSongsFailed: '获取歌曲列表失败', fetchSongsFailed: '获取歌曲列表失败',

View File

@@ -20,7 +20,21 @@ export default {
downloading: '下载中', downloading: '下载中',
completed: '已完成', completed: '已完成',
failed: '失败', failed: '失败',
unknown: '未知' unknown: '未知',
queued: '排队中',
paused: '已暂停',
cancelled: '已取消'
},
action: {
pause: '暂停',
resume: '恢复',
cancel: '取消',
cancelAll: '取消全部',
retrying: '重新获取链接...'
},
batch: {
complete: '下载完成:成功 {success}/{total} 首',
allComplete: '全部下载完成'
}, },
artist: { artist: {
unknown: '未知歌手' unknown: '未知歌手'
@@ -43,6 +57,14 @@ export default {
success: '下载记录已清空', success: '下载记录已清空',
failed: '清空下载记录失败' failed: '清空下载记录失败'
}, },
save: {
title: '保存设置',
message: '当前下载设置未保存,是否保存更改?',
confirm: '保存',
cancel: '取消',
discard: '放弃',
saveSuccess: '下载设置已保存'
},
message: { message: {
downloadComplete: '{filename} 下载完成', downloadComplete: '{filename} 下载完成',
downloadFailed: '{filename} 下载失败: {error}' downloadFailed: '{filename} 下载失败: {error}'
@@ -77,6 +99,8 @@ export default {
dragToArrange: '拖动排序或使用箭头按钮调整顺序:', dragToArrange: '拖动排序或使用箭头按钮调整顺序:',
formatVariables: '可用变量', formatVariables: '可用变量',
preview: '预览效果:', preview: '预览效果:',
concurrency: '最大并发数',
concurrencyDesc: '同时下载的最大歌曲数量1-5',
saveSuccess: '下载设置已保存', saveSuccess: '下载设置已保存',
presets: { presets: {
songArtist: '歌曲名 - 歌手名', songArtist: '歌曲名 - 歌手名',
@@ -88,5 +112,10 @@ export default {
artistName: '歌手名', artistName: '歌手名',
albumName: '专辑名' albumName: '专辑名'
} }
},
error: {
incomplete: '文件下载不完整',
urlExpired: '下载链接已过期,正在重新获取',
resumeFailed: '恢复下载失败'
} }
}; };

View File

@@ -16,6 +16,9 @@ export default {
playFailed: '当前歌曲播放失败,播放下一首', playFailed: '当前歌曲播放失败,播放下一首',
parseFailedPlayNext: '歌曲解析失败,播放下一首', parseFailedPlayNext: '歌曲解析失败,播放下一首',
consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试', consecutiveFailsError: '播放遇到错误,可能是网络波动或解析源失效,请切换播放列表或稍后重试',
playListEnded: '已播放到列表最后一首',
autoResumed: '已自动恢复播放',
resumeFailed: '恢复播放失败,请手动点击播放',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
loop: '单曲循环', loop: '单曲循环',

View File

@@ -216,6 +216,9 @@ export default {
addToPlaylistSuccess: '新增至播放清單成功', addToPlaylistSuccess: '新增至播放清單成功',
songsAlreadyInPlaylist: '歌曲已存在於播放清單中', songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
locateCurrent: '定位當前播放', locateCurrent: '定位當前播放',
scrollToTop: '回到頂部',
compactLayout: '緊湊佈局',
normalLayout: '常規佈局',
historyRecommend: '歷史日推', historyRecommend: '歷史日推',
fetchDatesFailed: '獲取日期列表失敗', fetchDatesFailed: '獲取日期列表失敗',
fetchSongsFailed: '獲取歌曲列表失敗', fetchSongsFailed: '獲取歌曲列表失敗',

View File

@@ -20,7 +20,21 @@ export default {
downloading: '下載中', downloading: '下載中',
completed: '已完成', completed: '已完成',
failed: '失敗', failed: '失敗',
unknown: '未知' unknown: '未知',
queued: '排隊中',
paused: '已暫停',
cancelled: '已取消'
},
action: {
pause: '暫停',
resume: '恢復',
cancel: '取消',
cancelAll: '取消全部',
retrying: '重新獲取連結...'
},
batch: {
complete: '下載完成:成功 {success}/{total} 首',
allComplete: '全部下載完成'
}, },
artist: { artist: {
unknown: '未知歌手' unknown: '未知歌手'
@@ -43,6 +57,14 @@ export default {
success: '下載記錄已清空', success: '下載記錄已清空',
failed: '清空下載記錄失敗' failed: '清空下載記錄失敗'
}, },
save: {
title: '儲存設定',
message: '目前下載設定尚未儲存,是否儲存變更?',
confirm: '儲存',
cancel: '取消',
discard: '放棄',
saveSuccess: '下載設定已儲存'
},
message: { message: {
downloadComplete: '{filename} 下載完成', downloadComplete: '{filename} 下載完成',
downloadFailed: '{filename} 下載失敗: {error}' downloadFailed: '{filename} 下載失敗: {error}'
@@ -77,6 +99,8 @@ export default {
dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:', dragToArrange: '拖曳排序或使用箭頭按鈕調整順序:',
formatVariables: '可用變數', formatVariables: '可用變數',
preview: '預覽效果:', preview: '預覽效果:',
concurrency: '最大並發數',
concurrencyDesc: '同時下載的最大歌曲數量1-5',
saveSuccess: '下載設定已儲存', saveSuccess: '下載設定已儲存',
presets: { presets: {
songArtist: '歌曲名 - 歌手名', songArtist: '歌曲名 - 歌手名',
@@ -88,5 +112,10 @@ export default {
artistName: '歌手名', artistName: '歌手名',
albumName: '專輯名' albumName: '專輯名'
} }
},
error: {
incomplete: '檔案下載不完整',
urlExpired: '下載連結已過期,正在重新獲取',
resumeFailed: '恢復下載失敗'
} }
}; };

View File

@@ -16,6 +16,9 @@ export default {
playFailed: '目前歌曲播放失敗,播放下一首', playFailed: '目前歌曲播放失敗,播放下一首',
parseFailedPlayNext: '歌曲解析失敗,播放下一首', parseFailedPlayNext: '歌曲解析失敗,播放下一首',
consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試', consecutiveFailsError: '播放遇到錯誤,可能是網路波動或解析源失效,請切換播放清單或稍後重試',
playListEnded: '已播放到列表最後一首',
autoResumed: '已自動恢復播放',
resumeFailed: '恢復播放失敗,請手動點擊播放',
playMode: { playMode: {
sequence: '順序播放', sequence: '順序播放',
loop: '單曲循環', loop: '單曲循環',

View File

@@ -7,11 +7,13 @@ import i18n from '../i18n/main';
import { loadLyricWindow } from './lyric'; import { loadLyricWindow } from './lyric';
import { initializeCacheManager } from './modules/cache'; import { initializeCacheManager } from './modules/cache';
import { initializeConfig } from './modules/config'; import { initializeConfig } from './modules/config';
import { initializeDownloadManager, setDownloadManagerWindow } from './modules/downloadManager';
import { initializeFileManager } from './modules/fileManager'; import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts'; import { initializeFonts } from './modules/fonts';
import { initializeLocalMusicScanner } from './modules/localMusicScanner'; import { initializeLocalMusicScanner } from './modules/localMusicScanner';
import { initializeLoginWindow } from './modules/loginWindow'; import { initializeLoginWindow } from './modules/loginWindow';
import { initLxMusicHttp } from './modules/lxMusicHttp'; import { initLxMusicHttp } from './modules/lxMusicHttp';
import { initializeMpris, updateMprisCurrentSong, updateMprisPlayState } from './modules/mpris';
import { initializeOtherApi } from './modules/otherApi'; import { initializeOtherApi } from './modules/otherApi';
import { initializeRemoteControl } from './modules/remoteControl'; import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts } from './modules/shortcuts'; import { initializeShortcuts } from './modules/shortcuts';
@@ -42,6 +44,8 @@ function initialize(configStore: any) {
// 初始化文件管理 // 初始化文件管理
initializeFileManager(); initializeFileManager();
// 初始化下载管理
initializeDownloadManager();
// 初始化歌词缓存管理 // 初始化歌词缓存管理
initializeCacheManager(); initializeCacheManager();
// 初始化其他 API (搜索建议等) // 初始化其他 API (搜索建议等)
@@ -58,6 +62,9 @@ function initialize(configStore: any) {
// 创建主窗口 // 创建主窗口
mainWindow = createMainWindow(icon); mainWindow = createMainWindow(icon);
// 设置下载管理器窗口引用
setDownloadManagerWindow(mainWindow);
// 初始化托盘 // 初始化托盘
initializeTray(iconPath, mainWindow); initializeTray(iconPath, mainWindow);
@@ -76,6 +83,9 @@ function initialize(configStore: any) {
// 初始化远程控制服务 // 初始化远程控制服务
initializeRemoteControl(mainWindow); initializeRemoteControl(mainWindow);
// 初始化 MPRIS 服务 (Linux)
initializeMpris(mainWindow);
// 初始化更新处理程序 // 初始化更新处理程序
setupUpdateHandlers(mainWindow); setupUpdateHandlers(mainWindow);
} }
@@ -86,6 +96,11 @@ const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) { if (!isSingleInstance) {
app.quit(); app.quit();
} else { } else {
// 禁用 Chromium 内置的 MediaSession MPRIS 服务,避免重复显示
if (process.platform === 'linux') {
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
}
// 在应用准备就绪前初始化GPU加速设置 // 在应用准备就绪前初始化GPU加速设置
// 必须在 app.ready 之前调用 disableHardwareAcceleration // 必须在 app.ready 之前调用 disableHardwareAcceleration
try { try {
@@ -165,11 +180,13 @@ if (!isSingleInstance) {
// 监听播放状态变化 // 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => { ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing); updatePlayState(playing);
updateMprisPlayState(playing);
}); });
// 监听当前歌曲变化 // 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => { ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song); updateCurrentSong(song);
updateMprisCurrentSong(song);
}); });
// 所有窗口关闭时的处理 // 所有窗口关闭时的处理

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,10 @@
import axios from 'axios'; import { app, dialog, ipcMain, protocol, shell } from 'electron';
import { app, dialog, ipcMain, nativeImage, Notification, protocol, shell } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import { fileTypeFromFile } from 'file-type';
import { FlacTagMap, writeFlacTags } from 'flac-tagger';
import * as fs from 'fs'; 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 * as path from 'path';
import { getStore } from './config'; 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实例用于存储音频缓存 // 创建一个store实例用于存储音频缓存
const audioCacheStore = new Store({ const audioCacheStore = new Store({
name: 'audioCache', name: 'audioCache',
@@ -33,8 +13,15 @@ const audioCacheStore = new Store({
} }
}); });
// 保存已发送通知的文件,避免重复通知 /**
const sentNotifications = new Map(); * 清理文件名中的非法字符
*/
function sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
/** /**
* 初始化文件管理相关的IPC监听 * 初始化文件管理相关的IPC监听
@@ -130,122 +117,6 @@ export function initializeFileManager() {
return app.getPath('downloads'); 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( ipcMain.handle(
'save-lyric-file', 'save-lyric-file',
@@ -273,18 +144,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', () => { ipcMain.on('clear-audio-cache', () => {
audioCacheStore.set('cache', {}); audioCacheStore.set('cache', {});
@@ -378,613 +237,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');
}

270
src/main/modules/mpris.ts Normal file
View File

@@ -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);
}
}
);
}

23
src/main/types/mpris-service.d.ts vendored Normal file
View File

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

View File

@@ -44,6 +44,25 @@ interface API {
parseLocalMusicMetadata: ( parseLocalMusicMetadata: (
filePaths: string[] filePaths: string[]
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>; ) => 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渲染进程通信接口 // 自定义IPC渲染进程通信接口

View File

@@ -82,7 +82,43 @@ const api = {
scanLocalMusicWithStats: (folderPath: string) => scanLocalMusicWithStats: (folderPath: string) =>
ipcRenderer.invoke('scan-local-music-with-stats', folderPath), ipcRenderer.invoke('scan-local-music-with-stats', folderPath),
parseLocalMusicMetadata: (filePaths: string[]) => 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对象暴露给渲染进程 // 创建带类型的ipcRenderer对象暴露给渲染进程

View File

@@ -105,6 +105,9 @@ if (isElectron) {
localStorage.setItem('currentRoute', router.currentRoute.value.path); localStorage.setItem('currentRoute', router.currentRoute.value.path);
router.push('/mini'); router.push('/mini');
} else { } else {
// 清理迷你模式下设置的 body 样式
document.body.style.height = '';
document.body.style.overflow = '';
// 恢复当前路由 // 恢复当前路由
const currentRoute = localStorage.getItem('currentRoute'); const currentRoute = localStorage.getItem('currentRoute');
if (currentRoute) { if (currentRoute) {
@@ -128,18 +131,23 @@ onMounted(async () => {
// 检查网络状态,离线时自动跳转到本地音乐页面 // 检查网络状态,离线时自动跳转到本地音乐页面
if (!navigator.onLine) { if (!navigator.onLine) {
console.log('检测到无网络连接,跳转到本地音乐页面');
router.push('/local-music'); router.push('/local-music');
} }
// 监听网络状态变化,断网时跳转到本地音乐页面 // 监听网络状态变化,断网时跳转到本地音乐页面
window.addEventListener('offline', () => { const handleOffline = () => {
console.log('网络连接断开,跳转到本地音乐页面');
router.push('/local-music'); router.push('/local-music');
};
window.addEventListener('offline', handleOffline);
onUnmounted(() => {
window.removeEventListener('offline', handleOffline);
}); });
// 初始化 MusicHook注入 playerStore // 初始化 MusicHook注入 playerStore
initMusicHook(playerStore); initMusicHook(playerStore);
// 设置 URL 过期自动续播处理器
const { setupUrlExpiredHandler } = await import('@/services/playbackController');
setupUrlExpiredHandler();
// 初始化播放状态 // 初始化播放状态
await playerStore.initializePlayState(); await playerStore.initializePlayState();

View File

@@ -18,3 +18,115 @@ body {
.settings-slider .n-slider-mark { .settings-slider .n-slider-mark {
font-size: 10px !important; 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;
}

View File

@@ -1,9 +1,13 @@
<template> <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-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> <template #icon>
<i class="iconfont ri-download-cloud-2-line"></i> <i class="iconfont ri-download-cloud-2-line text-xl"></i>
</template> </template>
</n-button> </n-button>
</n-badge> </n-badge>
@@ -11,102 +15,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useDownloadStore } from '@/store/modules/download';
const router = useRouter(); const router = useRouter();
const downloadList = ref<any[]>([]); const downloadStore = useDownloadStore();
// 计算下载中的任务数量 const downloadingCount = computed(() => downloadStore.downloadingCount);
const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length;
});
// 导航到下载页面
const navigateToDownloads = () => { const navigateToDownloads = () => {
router.push('/downloads'); router.push('/downloads');
}; };
// 监听下载进度
onMounted(() => { onMounted(() => {
// 监听下载进度 downloadStore.initListeners();
window.electron.ipcRenderer.on('music-download-progress', (_, data) => { downloadStore.loadPersistedQueue();
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
});
}
});
}); });
</script> </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>

View File

@@ -409,6 +409,7 @@ import {
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { usePlayMode } from '@/hooks/usePlayMode'; import { usePlayMode } from '@/hooks/usePlayMode';
import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric'; import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, secondToMinute } from '@/utils'; import { getImgUrl, secondToMinute } from '@/utils';
@@ -757,7 +758,7 @@ const handleProgressBarClick = (e: MouseEvent) => {
console.log(`进度条点击: ${percentage.toFixed(2)}, 新时间: ${newTime.toFixed(2)}`); console.log(`进度条点击: ${percentage.toFixed(2)}, 新时间: ${newTime.toFixed(2)}`);
sound.value.seek(newTime); audioService.seek(newTime);
nowTime.value = newTime; nowTime.value = newTime;
}; };
@@ -817,7 +818,7 @@ const handleMouseUp = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
// 释放时跳转到指定位置 // 释放时跳转到指定位置
sound.value.seek(nowTime.value); audioService.seek(nowTime.value);
console.log(`鼠标释放,跳转到: ${nowTime.value.toFixed(2)}`); console.log(`鼠标释放,跳转到: ${nowTime.value.toFixed(2)}`);
isMouseDragging.value = false; isMouseDragging.value = false;
@@ -871,7 +872,7 @@ const handleThumbTouchEnd = (e: TouchEvent) => {
// 拖动结束时执行seek操作 // 拖动结束时执行seek操作
console.log(`拖动结束,跳转到: ${nowTime.value.toFixed(2)}`); console.log(`拖动结束,跳转到: ${nowTime.value.toFixed(2)}`);
sound.value.seek(nowTime.value); audioService.seek(nowTime.value);
isThumbDragging.value = false; isThumbDragging.value = false;
}; };

View File

@@ -129,6 +129,9 @@ import { computed, provide, ref, useTemplateRef } from 'vue';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook'; import { allTime, artistList, nowTime, playMusic } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; 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 { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store'; import { usePlayerStore, useSettingsStore } from '@/store';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
@@ -138,6 +141,15 @@ const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist(); const { navigateToArtist } = useArtist();
// 播放控制
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// 音量控制(统一通过 playerStore 管理)
const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
// 收藏
const { isFavorite, toggleFavorite } = useFavorite();
withDefaults( withDefaults(
defineProps<{ defineProps<{
pureModeEnabled?: boolean; pureModeEnabled?: boolean;
@@ -155,66 +167,9 @@ const handleClose = () => {
} }
}; };
// 是否播放
const play = computed(() => playerStore.play as boolean);
// 播放列表 // 播放列表
const playList = computed(() => playerStore.playList as SongResult[]); 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 palyListRef = useTemplateRef('palyListRef') as any;
const isPlaylistOpen = ref(false); const isPlaylistOpen = ref(false);
@@ -308,19 +263,6 @@ const handleProgressLeave = () => {
isHovering.value = false; 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 = () => { const setMusicFull = () => {
playerStore.setMusicFull(true); playerStore.setMusicFull(true);

View File

@@ -62,10 +62,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useSwipe } from '@vueuse/core'; import { useSwipe } from '@vueuse/core';
import type { Ref } from 'vue'; 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 MusicFullWrapper from '@/components/lyric/MusicFullWrapper.vue';
import { artistList, playMusic, textColors } from '@/hooks/MusicHook'; import { artistList, playMusic, textColors } from '@/hooks/MusicHook';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import { getImgUrl, setAnimationClass } from '@/utils'; import { getImgUrl, setAnimationClass } from '@/utils';
@@ -75,24 +76,15 @@ const shouldShowMobileMenu = inject('shouldShowMobileMenu') as Ref<boolean>;
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
// 是否播放 // 播放控制
const play = computed(() => playerStore.isPlay); const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// 背景颜色 // 背景颜色
const background = ref('#000'); const background = ref('#000');
// 播放控制
function handleNext() {
playerStore.nextPlay();
}
function handlePrev() {
playerStore.prevPlay();
}
// 全屏播放器 // 全屏播放器
const MusicFullRef = ref<any>(null); const MusicFullRef = ref<any>(null);
// 设置musicFull
const setMusicFull = () => { const setMusicFull = () => {
playerStore.setMusicFull(!playerStore.musicFull); playerStore.setMusicFull(!playerStore.musicFull);
if (playerStore.musicFull) { if (playerStore.musicFull) {
@@ -107,21 +99,10 @@ watch(
} }
); );
// 打开播放列表抽屉
const openPlayListDrawer = () => { const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true); playerStore.setPlayListDrawerVisible(true);
}; };
// 播放暂停按钮事件
const playMusicEvent = async () => {
try {
playerStore.setPlay(playMusic.value);
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
// 滑动切歌 // 滑动切歌
const playBarRef = ref<HTMLElement | null>(null); const playBarRef = ref<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {

View File

@@ -164,7 +164,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core'; import { useThrottleFn } from '@vueuse/core';
import { useMessage } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -182,7 +181,10 @@ import {
textColors textColors
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { useFavorite } from '@/hooks/useFavorite';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayMode } from '@/hooks/usePlayMode'; import { usePlayMode } from '@/hooks/usePlayMode';
import { useVolumeControl } from '@/hooks/useVolumeControl';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
@@ -191,9 +193,22 @@ import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } fr
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { t } = useI18n(); const { t } = useI18n();
const message = useMessage();
// 是否播放 // 播放控制
const play = computed(() => playerStore.isPlay); const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// 音量控制
const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
// 收藏
const { isFavorite, toggleFavorite } = useFavorite();
// 播放模式
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
// 播放速度控制
const { playbackRate } = storeToRefs(playerStore);
// 背景颜色 // 背景颜色
const background = ref('#000'); const background = ref('#000');
@@ -211,115 +226,41 @@ watch(
const throttledSeek = useThrottleFn((value: number) => { const throttledSeek = useThrottleFn((value: number) => {
audioService.seek(value); audioService.seek(value);
nowTime.value = value; nowTime.value = value;
}, 50); // 50ms 的节流延迟 }, 50);
// 拖动时的临时值,避免频繁更新 nowTime 触发重渲染 // 拖动时的临时值
const dragValue = ref(0); const dragValue = ref(0);
// 为滑块拖动添加状态跟踪
const isDragging = ref(false); const isDragging = ref(false);
// 修改 timeSlider 计算属性
const timeSlider = computed({ const timeSlider = computed({
get: () => (isDragging.value ? dragValue.value : nowTime.value), get: () => (isDragging.value ? dragValue.value : nowTime.value),
set: (value) => { set: (value) => {
if (isDragging.value) { if (isDragging.value) {
// 拖动中只更新临时值,不触发 nowTime 更新和 seek 操作
dragValue.value = value; dragValue.value = value;
return; return;
} }
// 点击操作 (非拖动),可以直接 seek
throttledSeek(value); throttledSeek(value);
} }
}); });
// 添加滑块拖动开始和结束事件处理
const handleSliderDragStart = () => { const handleSliderDragStart = () => {
isDragging.value = true; isDragging.value = true;
// 初始化拖动值为当前时间
dragValue.value = nowTime.value; dragValue.value = nowTime.value;
}; };
const handleSliderDragEnd = () => { const handleSliderDragEnd = () => {
isDragging.value = false; isDragging.value = false;
// 直接应用最终的拖动值
audioService.seek(dragValue.value); audioService.seek(dragValue.value);
nowTime.value = dragValue.value; nowTime.value = dragValue.value;
}; };
// 格式化提示文本,根据拖动状态显示不同的时间
const formatTooltip = (value: number) => { const formatTooltip = (value: number) => {
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`; 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 MusicFullRef = ref<any>(null);
const showSliderTooltip = ref(false); 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({ const musicFullVisible = computed({
get: () => playerStore.musicFull, get: () => playerStore.musicFull,
set: (value) => { set: (value) => {
@@ -327,7 +268,6 @@ const musicFullVisible = computed({
} }
}); });
// 设置musicFull
const setMusicFull = () => { const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value; musicFullVisible.value = !musicFullVisible.value;
playerStore.setMusicFull(musicFullVisible.value); playerStore.setMusicFull(musicFullVisible.value);
@@ -336,24 +276,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 = () => { const openLyricWindow = () => {
openLyric(); openLyric();
}; };
@@ -365,7 +287,6 @@ const handleArtistClick = (id: number) => {
navigateToArtist(id); navigateToArtist(id);
}; };
// 打开播放列表抽屉
const openPlayListDrawer = () => { const openPlayListDrawer = () => {
playerStore.setPlayListDrawerVisible(true); playerStore.setPlayListDrawerVisible(true);
}; };

View File

@@ -100,9 +100,9 @@ import { useI18n } from 'vue-i18n';
import { CacheManager } from '@/api/musicParser'; import { CacheManager } from '@/api/musicParser';
import { playMusic } from '@/hooks/MusicHook'; import { playMusic } from '@/hooks/MusicHook';
import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner'; import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner';
import { reparseCurrentSong } from '@/services/playbackController';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import { useSettingsStore } from '@/store'; import { useSettingsStore } from '@/store';
import { usePlayerStore } from '@/store/modules/player';
import type { LxMusicScriptConfig } from '@/types/lxMusic'; import type { LxMusicScriptConfig } from '@/types/lxMusic';
import type { Platform } from '@/types/music'; import type { Platform } from '@/types/music';
import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig'; import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig';
@@ -119,7 +119,6 @@ type ReparseSourceItem = {
lxScriptId?: string; lxScriptId?: string;
}; };
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { t } = useI18n(); const { t } = useI18n();
const message = useMessage(); const message = useMessage();
@@ -253,7 +252,7 @@ const reparseWithLxScript = async (source: ReparseSourceItem) => {
selectedSourceId.value = source.id; selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual'); SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual');
const success = await playerStore.reparseCurrentSong('lxMusic', false); const success = await reparseCurrentSong('lxMusic', false);
if (success) { if (success) {
message.success(t('player.reparse.success')); message.success(t('player.reparse.success'));
@@ -283,7 +282,7 @@ const directReparseMusic = async (source: ReparseSourceItem) => {
selectedSourceId.value = source.id; selectedSourceId.value = source.id;
SongSourceConfigManager.setConfig(songId, [source.platform], 'manual'); SongSourceConfigManager.setConfig(songId, [source.platform], 'manual');
const success = await playerStore.reparseCurrentSong(source.platform, false); const success = await reparseCurrentSong(source.platform, false);
if (success) { if (success) {
message.success(t('player.reparse.success')); message.success(t('player.reparse.success'));

View File

@@ -80,8 +80,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; 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 { usePlayMode } from '@/hooks/usePlayMode';
import { useVolumeControl } from '@/hooks/useVolumeControl';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { secondToMinute } from '@/utils'; import { secondToMinute } from '@/utils';
@@ -98,61 +100,14 @@ const props = withDefaults(
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const playBarRef = ref<HTMLElement | null>(null); const playBarRef = ref<HTMLElement | null>(null);
// 播放状态 // 播放控制
const play = computed(() => playerStore.isPlay); const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// 播放模式 // 播放模式
const { playMode, playModeIcon, togglePlayMode } = usePlayMode(); const { playMode, playModeIcon, togglePlayMode } = usePlayMode();
// 音量控制 // 音量控制(统一通过 playerStore 管理)
const audioVolume = ref( const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
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();
}
};
// 进度条控制 // 进度条控制
const isDragging = ref(false); const isDragging = ref(false);

View File

@@ -1,5 +1,4 @@
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue'; import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue';
import useIndexedDB from '@/hooks/IndexDBHook'; import useIndexedDB from '@/hooks/IndexDBHook';
@@ -45,7 +44,7 @@ export const nowTime = ref(0); // 当前播放时间
export const allTime = ref(0); // 总播放时间 export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词 export const nowIndex = ref(0); // 当前播放歌词
export const currentLrcProgress = 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 isLyricWindowOpen = ref(false); // 新增状态
export const textColors = ref<any>(getTextColors()); export const textColors = ref<any>(getTextColors());
@@ -53,6 +52,11 @@ export const textColors = ref<any>(getTextColors());
export let playMusic: ComputedRef<SongResult>; export let playMusic: ComputedRef<SongResult>;
export let artistList: ComputedRef<Artist[]>; export let artistList: ComputedRef<Artist[]>;
let lastIndex = -1;
// 缓存平台信息,避免每次歌词变化时同步 IPC 调用
const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
export const musicDB = await useIndexedDB( export const musicDB = await useIndexedDB(
'musicDB', 'musicDB',
[ [
@@ -65,28 +69,28 @@ export const musicDB = await useIndexedDB(
3 3
); );
// 键盘事件处理器,在初始化后设置 // 键盘事件处理器(提取为命名函数,防止重复注册)
const setupKeyboardListeners = () => { const handleKeyUp = (e: KeyboardEvent) => {
document.onkeyup = (e) => { const target = e.target as HTMLElement;
// 检查事件目标是否是输入框元素 if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
const target = e.target as HTMLElement; return;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { }
return;
}
const store = getPlayerStore(); const store = getPlayerStore();
switch (e.code) { switch (e.code) {
case 'Space': case 'Space':
if (store.playMusic?.id) { if (store.playMusic?.id) {
void store.setPlay({ ...store.playMusic }); void store.setPlay({ ...store.playMusic });
} }
break; break;
default: default:
} }
};
}; };
const { message } = createDiscreteApi(['message']); const setupKeyboardListeners = () => {
document.removeEventListener('keyup', handleKeyUp);
document.addEventListener('keyup', handleKeyUp);
};
let audioListenersInitialized = false; let audioListenersInitialized = false;
@@ -188,10 +192,10 @@ const setupMusicWatchers = () => {
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') { if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
playMusic.value.lyric.hasWordByWord = hasWordByWord; playMusic.value.lyric.hasWordByWord = hasWordByWord;
} }
} else { } else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
// 使用现有的歌词数据结构 // 使用现有的歌词数据结构
const rawLrc = lyricData?.lrcArray || []; const rawLrc = lyricData.lrcArray || [];
lrcTimeArray.value = lyricData?.lrcTimeArray || []; lrcTimeArray.value = lyricData.lrcTimeArray || [];
try { try {
const { translateLyrics } = await import('@/services/lyricTranslation'); const { translateLyrics } = await import('@/services/lyricTranslation');
@@ -200,6 +204,53 @@ const setupMusicWatchers = () => {
console.error('翻译歌词失败,使用原始歌词:', e); console.error('翻译歌词失败,使用原始歌词:', e);
lrcArray.value = rawLrc as any; lrcArray.value = rawLrc as any;
} }
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
// 从下载/本地文件的 ID3/FLAC 元数据中提取嵌入歌词
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 {
// 无嵌入歌词 — 若有数字 ID尝试 API 兜底
const songId = playMusic.value.id;
if (songId && 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 {
// 无歌词数据
lrcArray.value = [];
lrcTimeArray.value = [];
} }
// 当歌词数据更新时,如果歌词窗口打开,则发送数据 // 当歌词数据更新时,如果歌词窗口打开,则发送数据
if (isElectron && isLyricWindowOpen.value) { if (isElectron && isLyricWindowOpen.value) {
@@ -260,12 +311,7 @@ const setupAudioListeners = () => {
return; return;
} }
if (typeof currentSound.seek !== 'function') { const currentTime = currentSound.currentTime;
// seek 方法不可用,跳过本次更新,不清除 interval
return;
}
const currentTime = currentSound.seek() as number;
if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) { if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) {
// 无效时间,跳过本次更新 // 无效时间,跳过本次更新
return; return;
@@ -277,7 +323,7 @@ const setupAudioListeners = () => {
} }
nowTime.value = currentTime; nowTime.value = currentTime;
allTime.value = currentSound.duration() as number; allTime.value = currentSound.duration;
// === 歌词索引更新 === // === 歌词索引更新 ===
const newIndex = getLrcIndex(nowTime.value); const newIndex = getLrcIndex(nowTime.value);
@@ -288,6 +334,12 @@ const setupAudioListeners = () => {
sendLyricToWin(); sendLyricToWin();
} }
} }
if (isElectron && lrcArray.value[nowIndex.value]) {
if (lastIndex !== nowIndex.value) {
sendTrayLyric(nowIndex.value);
lastIndex = nowIndex.value;
}
}
// === 逐字歌词行内进度 === // === 逐字歌词行内进度 ===
const { start, end } = currentLrcTiming.value; const { start, end } = currentLrcTiming.value;
@@ -331,6 +383,15 @@ const setupAudioListeners = () => {
); );
} }
} }
// === MPRIS 进度更新(每 ~1 秒)===
if (isElectron && lyricThrottleCounter % 20 === 0) {
try {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
} catch {
// 忽略发送失败
}
}
} catch (error) { } catch (error) {
console.error('进度更新 interval 出错:', error); console.error('进度更新 interval 出错:', error);
// 出错时不清除 interval让下一次 tick 继续尝试 // 出错时不清除 interval让下一次 tick 继续尝试
@@ -349,7 +410,7 @@ const setupAudioListeners = () => {
const store = getPlayerStore(); const store = getPlayerStore();
if (store.play && !interval) { if (store.play && !interval) {
const currentSound = audioService.getCurrentSound(); const currentSound = audioService.getCurrentSound();
if (currentSound && currentSound.playing()) { if (currentSound && !currentSound.paused) {
console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复'); console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复');
startProgressInterval(); startProgressInterval();
} }
@@ -375,10 +436,15 @@ const setupAudioListeners = () => {
const currentSound = audioService.getCurrentSound(); const currentSound = audioService.getCurrentSound();
if (currentSound) { if (currentSound) {
// 立即更新显示时间,不进行任何检查 // 立即更新显示时间,不进行任何检查
const currentTime = currentSound.seek() as number; const currentTime = currentSound.currentTime;
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) { if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
nowTime.value = currentTime; nowTime.value = currentTime;
// === MPRIS seek 时同步进度 ===
if (isElectron) {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
}
// 检查是否需要更新歌词 // 检查是否需要更新歌词
const newIndex = getLrcIndex(nowTime.value); const newIndex = getLrcIndex(nowTime.value);
if (newIndex !== nowIndex.value) { if (newIndex !== nowIndex.value) {
@@ -400,10 +466,10 @@ const setupAudioListeners = () => {
if (currentSound) { if (currentSound) {
try { try {
// 更新当前时间和总时长 // 更新当前时间和总时长
const currentTime = currentSound.seek() as number; const currentTime = currentSound.currentTime;
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) { if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
nowTime.value = currentTime; nowTime.value = currentTime;
allTime.value = currentSound.duration() as number; allTime.value = currentSound.duration;
} }
} catch (error) { } catch (error) {
console.error('初始化时间和进度失败:', error); console.error('初始化时间和进度失败:', error);
@@ -434,34 +500,25 @@ const setupAudioListeners = () => {
} }
}); });
const replayMusic = async (retryCount: number = 0) => { const replayMusic = async (retryCount = 0) => {
const MAX_REPLAY_RETRIES = 3; const MAX_REPLAY_RETRIES = 3;
try { try {
// 如果当前有音频实例,先停止并销毁
const currentSound = audioService.getCurrentSound();
if (currentSound) {
currentSound.stop();
currentSound.unload();
}
sound.value = null;
// 重新播放当前歌曲
if (getPlayerStore().playMusicUrl && playMusic.value) { if (getPlayerStore().playMusicUrl && playMusic.value) {
const newSound = await audioService.play(getPlayerStore().playMusicUrl, playMusic.value); await audioService.play(getPlayerStore().playMusicUrl, playMusic.value);
sound.value = newSound as Howl; sound.value = audioService.getCurrentSound();
setupAudioListeners(); setupAudioListeners();
} else { } else {
console.error('单曲循环:无可用 URL 或歌曲数据'); console.error('单曲循环:无可用 URL 或歌曲数据');
getPlayerStore().nextPlay(); const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
} }
} catch (error) { } catch (error) {
console.error('单曲循环重播失败:', error); console.error('单曲循环重播失败:', error);
if (retryCount < MAX_REPLAY_RETRIES) { if (retryCount < MAX_REPLAY_RETRIES) {
console.log(`单曲循环重试 ${retryCount + 1}/${MAX_REPLAY_RETRIES}`);
setTimeout(() => replayMusic(retryCount + 1), 1000 * (retryCount + 1)); setTimeout(() => replayMusic(retryCount + 1), 1000 * (retryCount + 1));
} else { } else {
console.error('单曲循环重试次数用尽,切换下一首'); const { usePlaylistStore } = await import('@/store/modules/playlist');
getPlayerStore().nextPlay(); usePlaylistStore().nextPlayOnEnd();
} }
} }
}; };
@@ -497,7 +554,8 @@ const setupAudioListeners = () => {
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
playlistStore.setPlayList([fmSong], false, false); playlistStore.setPlayList([fmSong], false, false);
getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设 getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设
await getPlayerStore().handlePlayMusic(fmSong, true); const { playTrack } = await import('@/services/playbackController');
await playTrack(fmSong, true);
} else { } else {
getPlayerStore().setIsPlay(false); getPlayerStore().setIsPlay(false);
} }
@@ -506,8 +564,9 @@ const setupAudioListeners = () => {
getPlayerStore().setIsPlay(false); getPlayerStore().setIsPlay(false);
} }
} else { } else {
// 顺序播放、列表循环、随机播放模式都使用统一的nextPlay方法 // 顺序播放、列表循环、随机播放模式:歌曲自然结束
getPlayerStore().nextPlay(); const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
} }
}); });
@@ -529,8 +588,6 @@ export const play = () => {
const currentSound = audioService.getCurrentSound(); const currentSound = audioService.getCurrentSound();
if (currentSound) { if (currentSound) {
currentSound.play(); currentSound.play();
// 在播放时也进行状态检测防止URL已过期导致无声
getPlayerStore().checkPlaybackState(getPlayerStore().playMusic);
} }
}; };
@@ -539,7 +596,7 @@ export const pause = () => {
if (currentSound) { if (currentSound) {
try { try {
// 保存当前播放进度 // 保存当前播放进度
const currentTime = currentSound.seek() as number; const currentTime = currentSound.currentTime;
if (getPlayerStore().playMusic && getPlayerStore().playMusic.id) { if (getPlayerStore().playMusic && getPlayerStore().playMusic.id) {
localStorage.setItem( localStorage.setItem(
'playProgress', 'playProgress',
@@ -692,7 +749,7 @@ export const setAudioTime = (index: number) => {
const currentSound = sound.value; const currentSound = sound.value;
if (!currentSound) return; if (!currentSound) return;
currentSound.seek(lrcTimeArray.value[index]); audioService.seek(lrcTimeArray.value[index]);
currentSound.play(); currentSound.play();
}; };
@@ -775,6 +832,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; let lyricSyncInterval: any = null;
@@ -995,50 +1076,27 @@ export const initAudioListeners = async () => {
} }
}; };
// 监听URL过期事件自动重新获取URL并恢复播放 // 音频就绪事件处理器(提取为命名函数,防止重复注册)
audioService.on('url_expired', async (expiredTrack) => { const handleAudioReady = ((event: CustomEvent) => {
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) => {
try { try {
const { sound: newSound } = event.detail; const { sound: newSound } = event.detail;
if (newSound) { if (newSound) {
// 更新本地 sound 引用 sound.value = audioService.getCurrentSound();
sound.value = newSound as Howl;
// 设置音频监听器
setupAudioListeners(); setupAudioListeners();
// 获取当前播放位置并更新显示 const currentSound = audioService.getCurrentSound();
const currentPosition = newSound.seek() as number; if (currentSound) {
if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) { const currentPosition = currentSound.currentTime;
nowTime.value = currentPosition; if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) {
nowTime.value = currentPosition;
}
} }
console.log('音频就绪,已设置监听器并更新进度显示');
} }
} catch (error) { } catch (error) {
console.error('处理音频就绪事件出错:', error); console.error('处理音频就绪事件出错:', error);
} }
}) as EventListener); }) as EventListener;
// 先移除再注册,防止重复
window.removeEventListener('audio-ready', handleAudioReady);
window.addEventListener('audio-ready', handleAudioReady);

View File

@@ -1,160 +1,42 @@
import { cloneDeep } from 'lodash';
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { getMusicLrc } from '@/api/music'; import { getMusicLrc } from '@/api/music';
import { useDownloadStore } from '@/store/modules/download';
import { getSongUrl } from '@/store/modules/player'; import { getSongUrl } from '@/store/modules/player';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { isElectron } from '@/utils'; import { isElectron } from '@/utils';
import type { DownloadSongInfo } from '../../shared/download';
const ipcRenderer = isElectron ? window.electron.ipcRenderer : null; const ipcRenderer = isElectron ? window.electron.ipcRenderer : null;
// 全局下载管理(闭包模式) /**
const createDownloadManager = () => { * Map a SongResult to the minimal DownloadSongInfo shape required by the download store.
// 正在下载的文件集合 */
const activeDownloads = new Set<string>(); function toDownloadSongInfo(song: SongResult): DownloadSongInfo {
// 已经发送了通知的文件集合(避免重复通知)
const notifiedDownloads = new Set<string>();
// 事件监听器是否已初始化
let isInitialized = false;
// 监听器引用(用于清理)
let completeListener: ((event: any, data: any) => void) | null = null;
let errorListener: ((event: any, data: any) => void) | null = null;
return { return {
// 添加下载 id: song.id as number,
addDownload: (filename: string) => { name: song.name,
activeDownloads.add(filename); picUrl: song.picUrl ?? song.al?.picUrl ?? '',
}, ar: (song.ar || song.song?.artists || []).map((a: { name: string }) => ({ name: a.name })),
al: {
// 移除下载 name: song.al?.name ?? '',
removeDownload: (filename: string) => { picUrl: song.al?.picUrl ?? ''
activeDownloads.delete(filename);
// 延迟清理通知记录
setTimeout(() => {
notifiedDownloads.delete(filename);
}, 5000);
},
// 标记文件已通知
markNotified: (filename: string) => {
notifiedDownloads.add(filename);
},
// 检查文件是否已通知
isNotified: (filename: string) => {
return notifiedDownloads.has(filename);
},
// 清理所有下载
clearDownloads: () => {
activeDownloads.clear();
notifiedDownloads.clear();
},
// 初始化事件监听器
initEventListeners: (message: any, t: any) => {
if (isInitialized) return;
// 移除可能存在的旧监听器
if (completeListener) {
ipcRenderer?.removeListener('music-download-complete', completeListener);
}
if (errorListener) {
ipcRenderer?.removeListener('music-download-error', errorListener);
}
// 创建新的监听器
completeListener = (_event, data) => {
if (!data.filename || !activeDownloads.has(data.filename)) return;
// 如果该文件已经通知过,则跳过
if (notifiedDownloads.has(data.filename)) return;
// 标记为已通知
notifiedDownloads.add(data.filename);
// 从活动下载移除
activeDownloads.delete(data.filename);
};
errorListener = (_event, data) => {
if (!data.filename || !activeDownloads.has(data.filename)) return;
// 如果该文件已经通知过,则跳过
if (notifiedDownloads.has(data.filename)) return;
// 标记为已通知
notifiedDownloads.add(data.filename);
// 显示失败通知
message.error(
t('songItem.message.downloadFailed', {
filename: data.filename,
error: data.error || '未知错误'
})
);
// 从活动下载移除
activeDownloads.delete(data.filename);
};
// 添加监听器
ipcRenderer?.on('music-download-complete', completeListener);
ipcRenderer?.on('music-download-error', errorListener);
isInitialized = true;
},
// 清理事件监听器
cleanupEventListeners: () => {
if (!isInitialized) return;
if (completeListener) {
ipcRenderer?.removeListener('music-download-complete', completeListener);
completeListener = null;
}
if (errorListener) {
ipcRenderer?.removeListener('music-download-error', errorListener);
errorListener = null;
}
isInitialized = false;
},
// 获取活跃下载数量
getActiveDownloadCount: () => {
return activeDownloads.size;
},
// 检查是否有特定文件正在下载
hasDownload: (filename: string) => {
return activeDownloads.has(filename);
} }
}; };
}; }
// 创建单例下载管理器
const downloadManager = createDownloadManager();
export const useDownload = () => { export const useDownload = () => {
const { t } = useI18n(); const { t } = useI18n();
const message = useMessage(); const message = useMessage();
const downloadStore = useDownloadStore();
const isDownloading = ref(false); const isDownloading = ref(false);
// 初始化事件监听器
downloadManager.initEventListeners(message, t);
/** /**
* 下载单首音乐 * Download a single song.
* @param song 歌曲信息 * Resolves the URL in the renderer then delegates queuing to the download store.
* @returns Promise<void>
*/ */
const downloadMusic = async (song: SongResult) => { const downloadMusic = async (song: SongResult) => {
if (isDownloading.value) { if (isDownloading.value) {
@@ -165,55 +47,33 @@ export const useDownload = () => {
try { try {
isDownloading.value = true; isDownloading.value = true;
const musicUrl = (await getSongUrl(song.id as number, cloneDeep(song), true)) as any; const musicUrl = (await getSongUrl(song.id as number, song, true)) as any;
if (!musicUrl) { if (!musicUrl) {
throw new Error(t('songItem.message.getUrlFailed')); throw new Error(t('songItem.message.getUrlFailed'));
} }
// 构建文件名 const url = typeof musicUrl === 'string' ? musicUrl : musicUrl.url;
const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(','); const type = typeof musicUrl === 'string' ? '' : (musicUrl.type ?? '');
const filename = `${song.name} - ${artistNames}`; const songInfo = toDownloadSongInfo(song);
// 检查是否已在下载
if (downloadManager.hasDownload(filename)) {
isDownloading.value = false;
return;
}
// 添加到活动下载集合
downloadManager.addDownload(filename);
const songData = cloneDeep(song);
songData.ar = songData.ar || songData.song?.artists;
// 发送下载请求
ipcRenderer?.send('download-music', {
url: typeof musicUrl === 'string' ? musicUrl : musicUrl.url,
filename,
songInfo: {
...songData,
downloadTime: Date.now()
},
type: musicUrl.type
});
await downloadStore.addDownload(songInfo, url, type);
message.success(t('songItem.message.downloadQueued')); message.success(t('songItem.message.downloadQueued'));
// 简化的监听逻辑,基本通知由全局监听器处理
setTimeout(() => {
isDownloading.value = false;
}, 2000);
} catch (error: any) { } catch (error: any) {
console.error('Download error:', error); console.error('Download error:', error);
isDownloading.value = false;
message.error(error.message || t('songItem.message.downloadFailed')); message.error(error.message || t('songItem.message.downloadFailed'));
} finally {
isDownloading.value = false;
} }
}; };
/** /**
* 批量下载音乐 * Batch download multiple songs.
* @param songs 歌曲列表 *
* @returns Promise<void> * NOTE: This deviates slightly from the original spec (which envisioned JIT URL resolution in
* the main process via onDownloadRequestUrl). Instead we pre-resolve URLs here in batches of 5
* to avoid request storms against the local NeteaseCloudMusicApi service (> ~5 concurrent TLS
* connections can trigger 502s). The trade-off is acceptable: the renderer already has access to
* getSongUrl and this keeps the main process simpler.
*/ */
const batchDownloadMusic = async (songs: SongResult[]) => { const batchDownloadMusic = async (songs: SongResult[]) => {
if (isDownloading.value) { if (isDownloading.value) {
@@ -230,82 +90,46 @@ export const useDownload = () => {
isDownloading.value = true; isDownloading.value = true;
message.success(t('favorite.downloading')); message.success(t('favorite.downloading'));
let successCount = 0; const BATCH_SIZE = 5;
let failCount = 0; const resolvedItems: Array<{ songInfo: DownloadSongInfo; url: string; type: string }> = [];
const totalCount = songs.length;
// 下载进度追踪 // Resolve URLs in batches of 5 to avoid request storms
const trackProgress = () => { for (let i = 0; i < songs.length; i += BATCH_SIZE) {
if (successCount + failCount === totalCount) { const chunk = songs.slice(i, i + BATCH_SIZE);
isDownloading.value = false; const chunkResults = await Promise.all(
message.success(t('favorite.downloadSuccess')); chunk.map(async (song) => {
try {
const data = (await getSongUrl(song.id as number, song, true)) as any;
const url = typeof data === 'string' ? data : (data?.url ?? '');
const type = typeof data === 'string' ? '' : (data?.type ?? '');
if (!url) return null;
return { songInfo: toDownloadSongInfo(song), url, type };
} catch (error) {
console.error(`获取歌曲 ${song.name} 下载链接失败:`, error);
return null;
}
})
);
for (const item of chunkResults) {
if (item) resolvedItems.push(item);
} }
}; }
// 并行获取所有歌曲的下载链接 if (resolvedItems.length > 0) {
const downloadUrls = await Promise.all( await downloadStore.batchDownload(resolvedItems);
songs.map(async (song) => { }
try {
const data = (await getSongUrl(song.id, song, true)) as any;
return { song, ...data };
} catch (error) {
console.error(`获取歌曲 ${song.name} 下载链接失败:`, error);
failCount++;
return { song, url: null };
}
})
);
// 开始下载有效的链接
downloadUrls.forEach(({ song, url, type }) => {
if (!url) {
failCount++;
trackProgress();
return;
}
const songData = cloneDeep(song);
const filename = `${song.name} - ${(song.ar || song.song?.artists)?.map((a) => a.name).join(',')}`;
// 检查是否已在下载
if (downloadManager.hasDownload(filename)) {
failCount++;
trackProgress();
return;
}
// 添加到活动下载集合
downloadManager.addDownload(filename);
const songInfo = {
...songData,
ar: songData.ar || songData.song?.artists,
downloadTime: Date.now()
};
ipcRenderer?.send('download-music', {
url,
filename,
songInfo,
type
});
successCount++;
});
// 所有下载开始后,检查进度
trackProgress();
} catch (error) { } catch (error) {
console.error('下载失败:', error); console.error('下载失败:', error);
isDownloading.value = false;
message.destroyAll(); message.destroyAll();
message.error(t('favorite.downloadFailed')); message.error(t('favorite.downloadFailed'));
} finally {
isDownloading.value = false;
} }
}; };
/** /**
* 下载单首歌曲的歌词(.lrc 文件) * Download the lyric (.lrc) for a single song.
* @param song 歌曲信息 * This is independent of the download system and uses a direct IPC call.
*/ */
const downloadLyric = async (song: SongResult) => { const downloadLyric = async (song: SongResult) => {
try { try {
@@ -317,14 +141,15 @@ export const useDownload = () => {
return; return;
} }
// 构建 LRC 内容:保留原始歌词,如有翻译则合并 // Build LRC content: keep original lyrics, merge translation if available
let lrcContent = lyricData.lrc.lyric; let lrcContent = lyricData.lrc.lyric;
if (lyricData.tlyric?.lyric) { if (lyricData.tlyric?.lyric) {
lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric); lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric);
} }
// 构建文件名 const artistNames = (song.ar || song.song?.artists)
const artistNames = (song.ar || song.song?.artists)?.map((a) => a.name).join(','); ?.map((a: { name: string }) => a.name)
.join(',');
const filename = `${song.name} - ${artistNames}`; const filename = `${song.name} - ${artistNames}`;
const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent }); const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent });
@@ -349,7 +174,7 @@ export const useDownload = () => {
}; };
/** /**
* 将原文歌词和翻译歌词合并为一个 LRC 字符串 * Merge original LRC lyrics and translated LRC lyrics into a single LRC string.
*/ */
function mergeLrcWithTranslation(originalText: string, translationText: string): string { function mergeLrcWithTranslation(originalText: string, translationText: string): string {
const originalMap = parseLrcText(originalText); const originalMap = parseLrcText(originalText);
@@ -365,7 +190,7 @@ function mergeLrcWithTranslation(originalText: string, translationText: string):
} }
} }
// 按时间排序 // Sort by time tag
mergedLines.sort((a, b) => { mergedLines.sort((a, b) => {
const ta = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; const ta = a.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
const tb = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || ''; const tb = b.match(/\[\d{2}:\d{2}(\.\d{1,3})?\]/)?.[0] || '';
@@ -376,7 +201,7 @@ function mergeLrcWithTranslation(originalText: string, translationText: string):
} }
/** /**
* 解析 LRC 文本为 Map<timeTag, content> * Parse LRC text into a Map<timeTag, content>.
*/ */
function parseLrcText(text: string): Map<string, string> { function parseLrcText(text: string): Map<string, string> {
const map = new Map<string, string>(); const map = new Map<string, string>();

View File

@@ -1,94 +0,0 @@
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const downloadList = ref<any[]>([]);
const isInitialized = ref(false);
export const useDownloadStatus = () => {
const router = useRouter();
const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length;
});
const navigateToDownloads = () => {
router.push('/downloads');
};
const initDownloadListeners = () => {
if (isInitialized.value) return;
if (!window.electron?.ipcRenderer) return;
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
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
});
}
});
isInitialized.value = true;
};
onMounted(() => {
initDownloadListeners();
});
return {
downloadList,
downloadingCount,
navigateToDownloads
};
};

View File

@@ -0,0 +1,35 @@
import { computed } from 'vue';
import { playMusic } from '@/hooks/MusicHook';
import { usePlayerStore } from '@/store/modules/player';
/**
* 当前歌曲的收藏状态管理 composable
*/
export function useFavorite() {
const playerStore = usePlayerStore();
/** 当前歌曲是否已收藏 */
const isFavorite = computed(() => {
if (!playMusic?.value?.id) return false;
return playerStore.favoriteList.includes(playMusic.value.id);
});
/** 切换收藏状态 */
const toggleFavorite = (e?: Event) => {
e?.stopPropagation();
if (!playMusic?.value?.id) return;
const favoriteId = playMusic.value.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(favoriteId);
} else {
playerStore.addToFavorite(favoriteId);
}
};
return {
isFavorite,
toggleFavorite
};
}

View File

@@ -0,0 +1,41 @@
import { computed } from 'vue';
import { playMusic } from '@/hooks/MusicHook';
import { usePlayerStore } from '@/store/modules/player';
/**
* 播放控制 composable播放/暂停、上一首、下一首)
*/
export function usePlaybackControl() {
const playerStore = usePlayerStore();
/** 是否正在播放 */
const isPlaying = computed(() => playerStore.isPlay);
/** 播放/暂停切换 */
const playMusicEvent = async () => {
try {
await playerStore.setPlay({ ...playMusic.value });
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
}
};
/** 下一首 */
const handleNext = () => {
playerStore.nextPlay();
};
/** 上一首 */
const handlePrev = () => {
playerStore.prevPlay();
};
return {
isPlaying,
playMusicEvent,
handleNext,
handlePrev
};
}

View File

@@ -0,0 +1,96 @@
import { computed, type ComputedRef, type Ref,ref } from 'vue';
import { usePlayerStore } from '@/store';
import { isMobile } from '@/utils';
type ProgressiveRenderOptions = {
/** 全量数据列表 */
items: ComputedRef<any[]> | Ref<any[]>;
/** 每项估算高度px */
itemHeight: ComputedRef<number> | number;
/** 列表区域的 CSS 选择器,用于计算偏移 */
listSelector: string;
/** 初始渲染数量 */
initialCount?: number;
/** 滚动到底部时的回调(用于加载更多数据) */
onReachEnd?: () => void;
};
export const useProgressiveRender = (options: ProgressiveRenderOptions) => {
const { items, itemHeight, listSelector, initialCount = 40, onReachEnd } = options;
const playerStore = usePlayerStore();
const renderLimit = ref(initialCount);
const getItemHeight = () => (typeof itemHeight === 'number' ? itemHeight : itemHeight.value);
/** 截取到 renderLimit 的可渲染列表 */
const renderedItems = computed(() => {
const all = items.value;
return all.slice(0, renderLimit.value);
});
/** 未渲染项的占位高度,让滚动条反映真实总高度 */
const placeholderHeight = computed(() => {
const unrendered = items.value.length - renderedItems.value.length;
return Math.max(0, unrendered) * getItemHeight();
});
/** 是否正在播放(用于动态底部间距) */
const isPlaying = computed(() => !!playerStore.playMusicUrl);
/** 内容区底部 padding播放时留出播放栏空间 */
const contentPaddingBottom = computed(() =>
isPlaying.value && !isMobile.value ? '220px' : '80px'
);
/** 重置渲染限制 */
const resetRenderLimit = () => {
renderLimit.value = initialCount;
};
/** 扩展渲染限制到指定索引 */
const expandTo = (index: number) => {
renderLimit.value = Math.max(renderLimit.value, index);
};
/**
* 滚动事件处理函数,挂载到外层 n-scrollbar 的 @scroll
* 根据可视区域动态扩展 renderLimit
*/
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
const { scrollTop, clientHeight } = target;
const listSection = document.querySelector(listSelector) as HTMLElement;
const listStart = listSection?.offsetTop || 0;
const visibleBottom = scrollTop + clientHeight - listStart;
if (visibleBottom <= 0) return;
// 多渲染一屏作为缓冲
const bufferHeight = clientHeight;
const neededIndex = Math.ceil((visibleBottom + bufferHeight) / getItemHeight());
const allCount = items.value.length;
if (neededIndex > renderLimit.value) {
renderLimit.value = Math.min(neededIndex, allCount);
}
// 所有项都已渲染,通知外部加载更多数据
if (renderLimit.value >= allCount && onReachEnd) {
onReachEnd();
}
};
return {
renderLimit,
renderedItems,
placeholderHeight,
isPlaying,
contentPaddingBottom,
resetRenderLimit,
expandTo,
handleScroll
};
};

View File

@@ -0,0 +1,49 @@
import { computed } from 'vue';
import { usePlayerStore } from '@/store/modules/player';
/**
* 统一的音量控制 composable
* 通过 playerStore 管理音量,确保所有播放栏组件的音量状态一致
*/
export function useVolumeControl() {
const playerStore = usePlayerStore();
/** 音量滑块值 (0-100) */
const volumeSlider = computed({
get: () => playerStore.volume * 100,
set: (value: number) => {
playerStore.setVolume(value / 100);
}
});
/** 音量图标 class */
const volumeIcon = computed(() => {
if (playerStore.volume === 0) return 'ri-volume-mute-line';
if (playerStore.volume <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line';
});
/** 静音切换 (0 ↔ 30%) */
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
/** 鼠标滚轮调整音量 ±5% */
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;
};
return {
volumeSlider,
volumeIcon,
mute,
handleVolumeWheel
};
}

View File

@@ -25,6 +25,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="manifest" href="/manifest.json" />
<!-- 资源预加载 --> <!-- 资源预加载 -->
<link rel="preload" href="./assets/icon/iconfont.css" as="style" /> <link rel="preload" href="./assets/icon/iconfont.css" as="style" />

View File

@@ -221,8 +221,8 @@ import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png'; import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue'; import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const'; import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { useDownloadStatus } from '@/hooks/useDownloadStatus';
import { useZoom } from '@/hooks/useZoom'; import { useZoom } from '@/hooks/useZoom';
import { useDownloadStore } from '@/store/modules/download';
import { useIntelligenceModeStore } from '@/store/modules/intelligenceMode'; import { useIntelligenceModeStore } from '@/store/modules/intelligenceMode';
import { useNavTitleStore } from '@/store/modules/navTitle'; import { useNavTitleStore } from '@/store/modules/navTitle';
import { useSearchStore } from '@/store/modules/search'; import { useSearchStore } from '@/store/modules/search';
@@ -243,7 +243,11 @@ const userSetOptions = ref(USER_SET_OPTIONS);
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const intelligenceModeStore = useIntelligenceModeStore(); const intelligenceModeStore = useIntelligenceModeStore();
const { downloadingCount, navigateToDownloads } = useDownloadStatus(); const downloadStore = useDownloadStore();
const downloadingCount = computed(() => downloadStore.downloadingCount);
const navigateToDownloads = () => {
router.push('/downloads');
};
const showDownloadButton = computed( const showDownloadButton = computed(
() => () =>
isElectron && (settingsStore.setData?.alwaysShowDownloadButton || downloadingCount.value > 0) isElectron && (settingsStore.setData?.alwaysShowDownloadButton || downloadingCount.value > 0)

View File

@@ -53,7 +53,8 @@ const otherRouter = [
showInMenu: false, showInMenu: false,
back: true back: true
}, },
component: () => import('@/views/artist/detail.vue') component: () => import('@/views/artist/detail.vue'),
props: (route) => ({ key: route.params.id })
}, },
{ {
path: '/music-list/:id?', path: '/music-list/:id?',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,531 @@
/**
* 播放控制器
*
* 核心播放流程管理,使用 generation-based 取消模式替代原 playerCore.ts 中的控制流。
* 每次 playTrack() 调用递增 generation所有异步操作在 await 后检查 generation 是否过期。
*
* 导出playTrack, reparseCurrentSong, initializePlayState, setupUrlExpiredHandler, getCurrentGeneration
*/
import { cloneDeep } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import i18n from '@/../i18n/renderer';
import { getParsingMusicUrl } from '@/api/music';
import { loadLrc, useSongDetail } from '@/hooks/usePlayerHooks';
import { audioService } from '@/services/audioService';
import { playbackRequestManager } from '@/services/playbackRequestManager';
// preloadService 用于预加载下一首的 URL 验证triggerPreload 中使用)
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import type { Platform, SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
const { message } = createDiscreteApi(['message']);
// Generation counter for cancellation
let generation = 0;
/**
* 获取当前 generation用于外部检查
*/
export const getCurrentGeneration = (): number => generation;
// ==================== 懒加载 Store避免循环依赖 ====================
const getPlayerCoreStore = async () => {
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
return usePlayerCoreStore();
};
const getPlaylistStore = async () => {
const { usePlaylistStore } = await import('@/store/modules/playlist');
return usePlaylistStore();
};
const getPlayHistoryStore = async () => {
const { usePlayHistoryStore } = await import('@/store/modules/playHistory');
return usePlayHistoryStore();
};
const getSettingsStore = async () => {
const { useSettingsStore } = await import('@/store/modules/settings');
return useSettingsStore();
};
// ==================== 内部辅助函数 ====================
/**
* 加载元数据(歌词 + 背景色),并行执行
*/
const loadMetadata = async (
music: SongResult
): Promise<{
lyrics: SongResult['lyric'];
backgroundColor: string;
primaryColor: string;
}> => {
const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([
(async () => {
if (music.lyric && music.lyric.lrcTimeArray.length > 0) {
return music.lyric;
}
return await loadLrc(music.id);
})(),
(async () => {
if (music.backgroundColor && music.primaryColor) {
return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor };
}
return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30'));
})()
]);
return { lyrics, backgroundColor, primaryColor };
};
/**
* 加载并播放音频
*/
const loadAndPlayAudio = async (song: SongResult, shouldPlay: boolean): Promise<boolean> => {
if (!song.playMusicUrl) {
throw new Error('歌曲没有播放URL');
}
// 检查保存的进度
let initialPosition = 0;
const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}');
if (savedProgress.songId === song.id) {
initialPosition = savedProgress.progress;
console.log('[playbackController] 恢复播放进度:', initialPosition);
}
// 直接通过 audioService 播放(单一 audio 元素,换 src 即可)
console.log(`[playbackController] 开始播放: ${song.name}`);
await audioService.play(song.playMusicUrl, song, shouldPlay, initialPosition || 0);
// 发布音频就绪事件
window.dispatchEvent(
new CustomEvent('audio-ready', {
detail: { sound: audioService.getCurrentSound(), shouldPlay }
})
);
return true;
};
/**
* 触发预加载下一首/下下首歌曲
*/
const triggerPreload = async (song: SongResult): Promise<void> => {
try {
const playlistStore = await getPlaylistStore();
const list = playlistStore.playList;
if (Array.isArray(list) && list.length > 0) {
const idx = list.findIndex(
(item: SongResult) => item.id === song.id && item.source === song.source
);
if (idx !== -1) {
setTimeout(() => {
playlistStore.preloadNextSongs(idx);
}, 3000);
}
}
} catch (e) {
console.warn('预加载触发失败(可能是依赖未加载或循环依赖),已忽略:', e);
}
};
/**
* 更新文档标题
*/
const updateDocumentTitle = (music: SongResult): void => {
let title = music.name;
if (music.source === 'netease' && music?.song?.artists) {
title += ` - ${music.song.artists.reduce(
(prev: string, curr: any) => `${prev}${curr.name}/`,
''
)}`;
}
document.title = 'AlgerMusic - ' + title;
};
// ==================== 导出函数 ====================
/**
* 核心播放函数
*
* @param music 要播放的歌曲
* @param shouldPlay 是否立即播放(默认 true
* @returns 是否成功
*/
export const playTrack = async (
music: SongResult,
shouldPlay: boolean = true
): Promise<boolean> => {
// 1. 递增 generation创建 requestId
const gen = ++generation;
const requestId = playbackRequestManager.createRequest(music);
console.log(
`[playbackController] playTrack gen=${gen}, 歌曲: ${music.name}, requestId: ${requestId}`
);
// 如果是新歌曲,重置已尝试的音源
const playerCore = await getPlayerCoreStore();
if (music.id !== playerCore.playMusic.id) {
SongSourceConfigManager.clearTriedSources(music.id);
}
// 2. 停止当前音频
audioService.stop();
// 验证 & 激活请求
if (!playbackRequestManager.isRequestValid(requestId)) {
console.log(`[playbackController] 请求创建后即失效: ${requestId}`);
return false;
}
if (!playbackRequestManager.activateRequest(requestId)) {
console.log(`[playbackController] 无法激活请求: ${requestId}`);
return false;
}
// 3. 更新播放意图状态(不设置 playMusic等歌词加载完再设置以触发 watcher
playerCore.play = shouldPlay;
playerCore.isPlay = shouldPlay;
playerCore.userPlayIntent = shouldPlay;
// 4. 加载元数据(歌词 + 背景色)
try {
const { lyrics, backgroundColor, primaryColor } = await loadMetadata(music);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(加载元数据后),当前 gen=${generation}`);
return false;
}
music.lyric = lyrics;
music.backgroundColor = backgroundColor;
music.primaryColor = primaryColor;
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 加载元数据失败:', error);
// 元数据加载失败不阻塞播放,继续执行
}
// 5. 歌词已加载,现在设置 playMusic触发 MusicHook 的歌词 watcher
music.playLoading = true;
playerCore.playMusic = music;
updateDocumentTitle(music);
const originalMusic = { ...music };
// 5. 添加到播放历史
try {
const playHistoryStore = await getPlayHistoryStore();
if (music.isPodcast) {
if (music.program) {
playHistoryStore.addPodcast(music.program);
}
} else {
playHistoryStore.addMusic(music);
}
} catch (e) {
console.warn('[playbackController] 添加播放历史失败:', e);
}
// 6. 获取歌曲详情(解析 URL
try {
const { getSongDetail } = useSongDetail();
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(获取详情后),当前 gen=${generation}`);
return false;
}
updatedPlayMusic.lyric = music.lyric;
playerCore.playMusic = updatedPlayMusic;
playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 获取歌曲详情失败:', error);
message.error(i18n.global.t('player.playFailed'));
if (playerCore.playMusic) {
playerCore.playMusic.playLoading = false;
}
playbackRequestManager.failRequest(requestId);
return false;
}
// 7. 触发预加载下一首(异步,不阻塞)
triggerPreload(playerCore.playMusic);
// 8. 加载并播放音频
try {
const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(播放音频后),当前 gen=${generation}`);
audioService.stop();
return false;
}
if (success) {
// 9. 播放成功
playerCore.playMusic.playLoading = false;
playerCore.playMusic.isFirstPlay = false;
playbackRequestManager.completeRequest(requestId);
console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`);
return true;
} else {
playbackRequestManager.failRequest(requestId);
return false;
}
} catch (error) {
// 10. 播放失败
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`);
return false;
}
console.error('[playbackController] 播放音频失败:', error);
const errorMsg = error instanceof Error ? error.message : String(error);
// 操作锁错误:强制重置后重试一次
if (errorMsg.includes('操作锁激活')) {
try {
audioService.forceResetOperationLock();
console.log('[playbackController] 已强制重置操作锁');
} catch (e) {
console.error('[playbackController] 重置操作锁失败:', e);
}
}
message.error(i18n.global.t('player.playFailed'));
if (playerCore.playMusic) {
playerCore.playMusic.playLoading = false;
}
playerCore.setIsPlay(false);
playbackRequestManager.failRequest(requestId);
return false;
}
};
/**
* 使用指定音源重新解析当前歌曲
*
* @param sourcePlatform 目标音源平台
* @param isAuto 是否为自动切换
* @returns 是否成功
*/
export const reparseCurrentSong = async (
sourcePlatform: Platform,
isAuto: boolean = false
): Promise<boolean> => {
try {
const playerCore = await getPlayerCoreStore();
const currentSong = playerCore.playMusic;
if (!currentSong || !currentSong.id) {
console.warn('[playbackController] 没有有效的播放对象');
return false;
}
// 使用 SongSourceConfigManager 保存配置
SongSourceConfigManager.setConfig(currentSong.id, [sourcePlatform], isAuto ? 'auto' : 'manual');
const currentSound = audioService.getCurrentSound();
if (currentSound) {
currentSound.pause();
}
const numericId =
typeof currentSong.id === 'string' ? parseInt(currentSong.id, 10) : currentSong.id;
console.log(`[playbackController] 使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`);
const songData = cloneDeep(currentSong);
const res = await getParsingMusicUrl(numericId, songData);
if (res && res.data && res.data.data && res.data.data.url) {
const newUrl = res.data.data.url;
console.log(`[playbackController] 解析成功获取新URL: ${newUrl.substring(0, 50)}...`);
const updatedMusic: SongResult = {
...currentSong,
playMusicUrl: newUrl,
expiredAt: Date.now() + 1800000
};
await playTrack(updatedMusic, true);
// 更新播放列表中的歌曲信息
const playlistStore = await getPlaylistStore();
playlistStore.updateSong(updatedMusic);
return true;
} else {
console.warn(`[playbackController] 使用音源 ${sourcePlatform} 解析失败`);
return false;
}
} catch (error) {
console.error('[playbackController] 重新解析失败:', error);
return false;
}
};
/**
* 设置 URL 过期事件处理器
* 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放
*/
export const setupUrlExpiredHandler = (): void => {
audioService.on('url_expired', async (expiredTrack: SongResult) => {
if (!expiredTrack) return;
console.log('[playbackController] 检测到URL过期事件准备重新获取URL', expiredTrack.name);
const playerCore = await getPlayerCoreStore();
// 只在用户有播放意图或正在播放时处理
if (!playerCore.userPlayIntent && !playerCore.play) {
console.log('[playbackController] 用户无播放意图跳过URL过期处理');
return;
}
// 保存当前播放位置
const currentSound = audioService.getCurrentSound();
let seekPosition = 0;
if (currentSound) {
try {
seekPosition = currentSound.currentTime;
const duration = currentSound.duration;
if (duration > 0 && seekPosition > 0 && duration - seekPosition < 5) {
console.log('[playbackController] 歌曲接近末尾跳过URL过期处理');
return;
}
} catch {
// 静默忽略
}
}
try {
const trackToPlay: SongResult = {
...expiredTrack,
isFirstPlay: true,
playMusicUrl: undefined
};
const success = await playTrack(trackToPlay, true);
if (success) {
// 恢复播放位置
if (seekPosition > 0) {
// 延迟一小段时间确保音频已就绪
setTimeout(() => {
try {
audioService.seek(seekPosition);
} catch {
console.warn('[playbackController] 恢复播放位置失败');
}
}, 300);
}
message.success(i18n.global.t('player.autoResumed'));
} else {
// 检查歌曲是否仍然是当前歌曲
const currentPlayerCore = await getPlayerCoreStore();
if (currentPlayerCore.playMusic?.id === expiredTrack.id) {
message.error(i18n.global.t('player.resumeFailed'));
}
}
} catch (error) {
console.error('[playbackController] 处理URL过期事件失败:', error);
message.error(i18n.global.t('player.resumeFailed'));
}
});
};
/**
* 初始化播放状态
* 应用启动时恢复上次的播放状态
*/
export const initializePlayState = async (): Promise<void> => {
const playerCore = await getPlayerCoreStore();
const settingsStore = await getSettingsStore();
if (!playerCore.playMusic || Object.keys(playerCore.playMusic).length === 0) {
console.log('[playbackController] 没有保存的播放状态,跳过初始化');
// 设置播放速率
setTimeout(() => {
audioService.setPlaybackRate(playerCore.playbackRate);
}, 2000);
return;
}
try {
console.log('[playbackController] 恢复上次播放的音乐:', playerCore.playMusic.name);
const isPlaying = settingsStore.setData.autoPlay;
if (!isPlaying) {
// 自动播放禁用:仅加载元数据,不播放
console.log('[playbackController] 自动播放已禁用,仅加载元数据');
try {
const { lyrics, backgroundColor, primaryColor } = await loadMetadata(playerCore.playMusic);
playerCore.playMusic.lyric = lyrics;
playerCore.playMusic.backgroundColor = backgroundColor;
playerCore.playMusic.primaryColor = primaryColor;
} catch (e) {
console.warn('[playbackController] 加载元数据失败:', e);
}
playerCore.play = false;
playerCore.isPlay = false;
playerCore.userPlayIntent = false;
updateDocumentTitle(playerCore.playMusic);
// 恢复上次保存的播放进度仅UI显示
try {
const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}');
if (savedProgress.songId === playerCore.playMusic.id && savedProgress.progress > 0) {
const { nowTime, allTime } = await import('@/hooks/MusicHook');
nowTime.value = savedProgress.progress;
// 用歌曲时长设置 allTimedt 单位是毫秒)
if (playerCore.playMusic.dt) {
allTime.value = playerCore.playMusic.dt / 1000;
}
}
} catch (e) {
console.warn('[playbackController] 恢复播放进度失败:', e);
}
} else {
// 自动播放启用:调用 playTrack 恢复播放
// 本地音乐local:// 协议)不需要重新获取 URL保留原始路径
const isLocalMusic = playerCore.playMusic.playMusicUrl?.startsWith('local://');
await playTrack(
{
...playerCore.playMusic,
isFirstPlay: true,
playMusicUrl: isLocalMusic ? playerCore.playMusic.playMusicUrl : undefined
},
true
);
}
} catch (error) {
console.error('[playbackController] 恢复播放状态失败:', error);
playerCore.play = false;
playerCore.isPlay = false;
playerCore.playMusic = {} as SongResult;
playerCore.playMusicUrl = '';
}
// 延迟设置播放速率
setTimeout(() => {
audioService.setPlaybackRate(playerCore.playbackRate);
}, 2000);
};

View File

@@ -1,208 +1,51 @@
/** /**
* 播放请求管理 * 薄请求 ID 追踪
* 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件 * 用于 usePlayerHooks.ts 内部检查请求是否仍为最新。
* 实际的取消逻辑在 playbackController.ts 中generation ID
*/ */
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
/**
* 请求状态枚举
*/
export enum RequestStatus {
PENDING = 'pending',
ACTIVE = 'active',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
FAILED = 'failed'
}
/**
* 播放请求接口
*/
export interface PlaybackRequest {
id: string;
song: SongResult;
status: RequestStatus;
timestamp: number;
abortController?: AbortController;
}
/**
* 播放请求管理器类
*/
class PlaybackRequestManager { class PlaybackRequestManager {
private currentRequestId: string | null = null; private currentRequestId: string | null = null;
private requestMap: Map<string, PlaybackRequest> = new Map(); private counter = 0;
private requestCounter = 0;
/** /**
* 生成唯一的请求ID * 创建新请求,使之前的请求失效
*/
private generateRequestId(): string {
return `playback_${Date.now()}_${++this.requestCounter}`;
}
/**
* 创建新的播放请求
* @param song 要播放的歌曲
* @returns 新请求的ID
*/ */
createRequest(song: SongResult): string { createRequest(song: SongResult): string {
// 取消所有之前的请求 const requestId = `req_${Date.now()}_${++this.counter}`;
this.cancelAllRequests();
const requestId = this.generateRequestId();
const abortController = new AbortController();
const request: PlaybackRequest = {
id: requestId,
song,
status: RequestStatus.PENDING,
timestamp: Date.now(),
abortController
};
this.requestMap.set(requestId, request);
this.currentRequestId = requestId; this.currentRequestId = requestId;
console.log(`[RequestManager] 新请求: ${requestId}, 歌曲: ${song.name}`);
console.log(`[PlaybackRequestManager] 创建新请求: ${requestId}, 歌曲: ${song.name}`);
return requestId; return requestId;
} }
/** /**
* 激活请求(标记为正在处理) * 检查请求是否仍为当前请求
* @param requestId 请求ID
*/ */
activateRequest(requestId: string): boolean { isRequestValid(requestId: string): boolean {
const request = this.requestMap.get(requestId); return this.currentRequestId === requestId;
if (!request) {
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
return false;
}
if (request.status === RequestStatus.CANCELLED) {
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
return false;
}
request.status = RequestStatus.ACTIVE;
console.log(`[PlaybackRequestManager] 激活请求: ${requestId}`);
return true;
} }
/** /**
* 完成请求 * 激活请求(兼容旧调用,直接返回 isRequestValid 结果)
* @param requestId 请求ID */
activateRequest(requestId: string): boolean {
return this.isRequestValid(requestId);
}
/**
* 标记请求完成
*/ */
completeRequest(requestId: string): void { completeRequest(requestId: string): void {
const request = this.requestMap.get(requestId); console.log(`[RequestManager] 完成: ${requestId}`);
if (!request) {
return;
}
request.status = RequestStatus.COMPLETED;
console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`);
// 清理旧请求保留最近3个
this.cleanupOldRequests();
} }
/** /**
* 标记请求失败 * 标记请求失败
* @param requestId 请求ID
*/ */
failRequest(requestId: string): void { failRequest(requestId: string): void {
const request = this.requestMap.get(requestId); console.log(`[RequestManager] 失败: ${requestId}`);
if (!request) {
return;
}
request.status = RequestStatus.FAILED;
console.log(`[PlaybackRequestManager] 请求失败: ${requestId}`);
}
/**
* 取消指定请求
* @param requestId 请求ID
*/
cancelRequest(requestId: string): void {
const request = this.requestMap.get(requestId);
if (!request) {
return;
}
if (request.status === RequestStatus.CANCELLED) {
return;
}
// 取消AbortController
if (request.abortController && !request.abortController.signal.aborted) {
request.abortController.abort();
}
request.status = RequestStatus.CANCELLED;
console.log(`[PlaybackRequestManager] 取消请求: ${requestId}, 歌曲: ${request.song.name}`);
// 如果是当前请求清除当前请求ID
if (this.currentRequestId === requestId) {
this.currentRequestId = null;
}
}
/**
* 取消所有请求
*/
cancelAllRequests(): void {
console.log(`[PlaybackRequestManager] 取消所有请求,当前请求数: ${this.requestMap.size}`);
this.requestMap.forEach((request) => {
if (
request.status !== RequestStatus.COMPLETED &&
request.status !== RequestStatus.CANCELLED
) {
this.cancelRequest(request.id);
}
});
}
/**
* 检查请求是否仍然有效(是当前活动请求)
* @param requestId 请求ID
* @returns 是否有效
*/
isRequestValid(requestId: string): boolean {
// 检查是否是当前请求
if (this.currentRequestId !== requestId) {
console.warn(
`[PlaybackRequestManager] 请求已过期: ${requestId}, 当前请求: ${this.currentRequestId}`
);
return false;
}
const request = this.requestMap.get(requestId);
if (!request) {
console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`);
return false;
}
// 检查请求状态
if (request.status === RequestStatus.CANCELLED) {
console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`);
return false;
}
return true;
}
/**
* 检查请求是否应该中止(用于 AbortController
* @param requestId 请求ID
* @returns AbortSignal 或 undefined
*/
getAbortSignal(requestId: string): AbortSignal | undefined {
const request = this.requestMap.get(requestId);
return request?.abortController?.signal;
} }
/** /**
@@ -211,84 +54,6 @@ class PlaybackRequestManager {
getCurrentRequestId(): string | null { getCurrentRequestId(): string | null {
return this.currentRequestId; return this.currentRequestId;
} }
/**
* 获取请求信息
* @param requestId 请求ID
*/
getRequest(requestId: string): PlaybackRequest | undefined {
return this.requestMap.get(requestId);
}
/**
* 清理旧请求保留最近3个
*/
private cleanupOldRequests(): void {
if (this.requestMap.size <= 3) {
return;
}
// 按时间戳排序保留最新的3个
const sortedRequests = Array.from(this.requestMap.values()).sort(
(a, b) => b.timestamp - a.timestamp
);
const toKeep = new Set(sortedRequests.slice(0, 3).map((r) => r.id));
const toDelete: string[] = [];
this.requestMap.forEach((_, id) => {
if (!toKeep.has(id)) {
toDelete.push(id);
}
});
toDelete.forEach((id) => {
this.requestMap.delete(id);
});
if (toDelete.length > 0) {
console.log(`[PlaybackRequestManager] 清理了 ${toDelete.length} 个旧请求`);
}
}
/**
* 重置管理器(用于调试或特殊情况)
*/
reset(): void {
console.log('[PlaybackRequestManager] 重置管理器');
this.cancelAllRequests();
this.requestMap.clear();
this.currentRequestId = null;
this.requestCounter = 0;
}
/**
* 获取调试信息
*/
getDebugInfo(): {
currentRequestId: string | null;
totalRequests: number;
requestsByStatus: Record<string, number>;
} {
const requestsByStatus: Record<string, number> = {
[RequestStatus.PENDING]: 0,
[RequestStatus.ACTIVE]: 0,
[RequestStatus.COMPLETED]: 0,
[RequestStatus.CANCELLED]: 0,
[RequestStatus.FAILED]: 0
};
this.requestMap.forEach((request) => {
requestsByStatus[request.status]++;
});
return {
currentRequestId: this.currentRequestId,
totalRequests: this.requestMap.size,
requestsByStatus
};
}
} }
// 导出单例实例
export const playbackRequestManager = new PlaybackRequestManager(); export const playbackRequestManager = new PlaybackRequestManager();

View File

@@ -1,152 +1,150 @@
import { Howl } from 'howler';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
/**
* 预加载服务
*
* 新架构下 audioService 使用单一 HTMLAudioElement换歌改 src
* 不再需要预创建 Howl 实例。PreloadService 改为验证 URL 可用性并缓存元数据。
*/
class PreloadService { class PreloadService {
private loadingPromises: Map<string | number, Promise<Howl>> = new Map(); private validatedUrls: Map<string | number, string> = new Map();
private preloadedSounds: Map<string | number, Howl> = new Map(); private loadingPromises: Map<string | number, Promise<string>> = new Map();
/** /**
* 加载并验证音频 * 验证歌曲 URL 可用性
* 如果已经在加载中,返回现有的 Promise * 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果
* 如果已经加载完成,返回缓存的 Howl 实例
*/ */
public async load(song: SongResult): Promise<Howl> { public async load(song: SongResult): Promise<string> {
if (!song || !song.id) { if (!song || !song.id) {
throw new Error('无效的歌曲对象'); throw new Error('无效的歌曲对象');
} }
// 1. 检查是否有正在进行的加载 if (!song.playMusicUrl) {
throw new Error('歌曲没有 URL');
}
// 已验证过的 URL
if (this.validatedUrls.has(song.id)) {
console.log(`[PreloadService] 歌曲 ${song.name} URL 已验证,直接使用`);
return this.validatedUrls.get(song.id)!;
}
// 正在验证中
if (this.loadingPromises.has(song.id)) { if (this.loadingPromises.has(song.id)) {
console.log(`[PreloadService] 歌曲 ${song.name} 正在加载中,复用现有请求`); console.log(`[PreloadService] 歌曲 ${song.name} 正在验证中,复用现有请求`);
return this.loadingPromises.get(song.id)!; return this.loadingPromises.get(song.id)!;
} }
// 2. 检查是否有已完成的缓存 console.log(`[PreloadService] 开始验证歌曲: ${song.name}`);
if (this.preloadedSounds.has(song.id)) {
const sound = this.preloadedSounds.get(song.id)!;
if (sound.state() === 'loaded') {
console.log(`[PreloadService] 歌曲 ${song.name} 已预加载完成,直接使用`);
return sound;
} else {
// 如果缓存的音频状态不正常,清理并重新加载
this.preloadedSounds.delete(song.id);
}
}
// 3. 开始新的加载过程 const url = song.playMusicUrl;
const loadPromise = this._performLoad(song); const loadPromise = this._validate(url, song);
this.loadingPromises.set(song.id, loadPromise); this.loadingPromises.set(song.id, loadPromise);
try { try {
const sound = await loadPromise; const validatedUrl = await loadPromise;
this.preloadedSounds.set(song.id, sound); this.validatedUrls.set(song.id, validatedUrl);
return sound; return validatedUrl;
} finally { } finally {
this.loadingPromises.delete(song.id); this.loadingPromises.delete(song.id);
} }
} }
/** /**
* 执行实际的加载和验证逻辑 * 验证 URL 可用性(通过创建临时 Audio 元素检测是否可加载)
*/ */
private async _performLoad(song: SongResult): Promise<Howl> { private async _validate(url: string, song: SongResult): Promise<string> {
console.log(`[PreloadService] 开始加载歌曲: ${song.name}`); return new Promise<string>((resolve, reject) => {
const testAudio = new Audio();
testAudio.crossOrigin = 'anonymous';
testAudio.preload = 'metadata';
if (!song.playMusicUrl) { const cleanup = () => {
throw new Error('歌曲没有 URL'); testAudio.removeEventListener('loadedmetadata', onLoaded);
} testAudio.removeEventListener('error', onError);
testAudio.src = '';
testAudio.load();
};
// 创建初始音频实例 const onLoaded = () => {
const sound = await this._createSound(song.playMusicUrl); // 检查时长
const duration = testAudio.duration;
const expectedDuration = (song.dt || 0) / 1000;
// 检查时长 if (expectedDuration > 0 && duration > 0 && isFinite(duration)) {
const duration = sound.duration(); const durationDiff = Math.abs(duration - expectedDuration);
const expectedDuration = (song.dt || 0) / 1000; if (duration < expectedDuration * 0.5 && durationDiff > 10) {
console.warn(
`[PreloadService] 时长严重不足:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name}),可能是试听版`
);
window.dispatchEvent(
new CustomEvent('audio-duration-mismatch', {
detail: {
songId: song.id,
songName: song.name,
actualDuration: duration,
expectedDuration
}
})
);
}
}
if (expectedDuration > 0 && duration > 0) { cleanup();
const durationDiff = Math.abs(duration - expectedDuration); resolve(url);
// 如果实际时长远小于预期(可能是试听版),记录警告 };
if (duration < expectedDuration * 0.5 && durationDiff > 10) {
console.warn(
`[PreloadService] 时长严重不足:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name}),可能是试听版`
);
// 通过自定义事件通知上层,可用于后续自动切换音源
window.dispatchEvent(
new CustomEvent('audio-duration-mismatch', {
detail: {
songId: song.id,
songName: song.name,
actualDuration: duration,
expectedDuration
}
})
);
} else if (durationDiff > 5) {
console.warn(
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
);
}
}
return sound; const onError = () => {
} cleanup();
reject(new Error(`URL 验证失败: ${song.name}`));
};
private _createSound(url: string): Promise<Howl> { testAudio.addEventListener('loadedmetadata', onLoaded);
return new Promise((resolve, reject) => { testAudio.addEventListener('error', onError);
const sound = new Howl({ testAudio.src = url;
src: [url], testAudio.load();
html5: true,
preload: true, // 5秒超时
autoplay: false, setTimeout(() => {
onload: () => resolve(sound), cleanup();
onloaderror: (_, err) => reject(err) // 超时不算失败URL 可能是可用的只是服务器慢
}); resolve(url);
}, 5000);
}); });
} }
/** /**
* 取消特定歌曲的预加载(如果可能 * 消耗已验证的 URL从缓存移除
* 注意Promise 无法真正取消,但我们可以清理结果
*/ */
public cancel(songId: string | number) { public consume(songId: string | number): string | undefined {
if (this.preloadedSounds.has(songId)) { const url = this.validatedUrls.get(songId);
const sound = this.preloadedSounds.get(songId)!; if (url) {
sound.unload(); this.validatedUrls.delete(songId);
this.preloadedSounds.delete(songId); console.log(`[PreloadService] 消耗预验证的歌曲: ${songId}`);
} return url;
// loadingPromises 中的任务会继续执行,但因为 preloadedSounds 中没有记录,
// 下次请求时会重新加载(或者我们可以让 _performLoad 检查一个取消标记,但这增加了复杂性)
}
/**
* 获取已预加载的音频实例(如果存在)
*/
public getPreloadedSound(songId: string | number): Howl | undefined {
return this.preloadedSounds.get(songId);
}
/**
* 消耗(使用)已预加载的音频
* 从缓存中移除但不 unload由调用方管理生命周期
* @returns 预加载的 Howl 实例,如果没有则返回 undefined
*/
public consume(songId: string | number): Howl | undefined {
const sound = this.preloadedSounds.get(songId);
if (sound) {
this.preloadedSounds.delete(songId);
console.log(`[PreloadService] 消耗预加载的歌曲: ${songId}`);
return sound;
} }
return undefined; return undefined;
} }
/** /**
* 清理所有预加载资源 * 取消预加载
*/
public cancel(songId: string | number) {
this.validatedUrls.delete(songId);
}
/**
* 获取已验证的 URL
*/
public getPreloadedSound(songId: string | number): string | undefined {
return this.validatedUrls.get(songId);
}
/**
* 清理所有缓存
*/ */
public clearAll() { public clearAll() {
this.preloadedSounds.forEach((sound) => sound.unload()); this.validatedUrls.clear();
this.preloadedSounds.clear();
this.loadingPromises.clear(); this.loadingPromises.clear();
} }
} }

View File

@@ -15,6 +15,7 @@ pinia.use(({ store }) => {
}); });
// 导出所有 store // 导出所有 store
export * from './modules/download';
export * from './modules/favorite'; export * from './modules/favorite';
export * from './modules/intelligenceMode'; export * from './modules/intelligenceMode';
export * from './modules/localMusic'; export * from './modules/localMusic';

View File

@@ -0,0 +1,228 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { isElectron } from '@/utils';
import {
createDefaultDownloadSettings,
DOWNLOAD_TASK_STATE,
type DownloadSettings,
type DownloadTask
} from '../../../shared/download';
const DEFAULT_COVER = '/images/default_cover.png';
function validatePicUrl(url?: string): string {
if (!url || url === '' || url.startsWith('/')) return DEFAULT_COVER;
return url.replace(/^http:\/\//, 'https://');
}
export const useDownloadStore = defineStore(
'download',
() => {
// ── State ──────────────────────────────────────────────────────────────
const tasks = ref(new Map<string, DownloadTask>());
const completedList = ref<any[]>([]);
const settings = ref<DownloadSettings>(createDefaultDownloadSettings());
const isLoadingCompleted = ref(false);
// Track whether IPC listeners have been registered
let listenersInitialised = false;
// ── Computed ───────────────────────────────────────────────────────────
const downloadingList = computed(() => {
const active = [
DOWNLOAD_TASK_STATE.queued,
DOWNLOAD_TASK_STATE.downloading,
DOWNLOAD_TASK_STATE.paused
] as string[];
return [...tasks.value.values()]
.filter((t) => active.includes(t.state))
.sort((a, b) => a.createdAt - b.createdAt);
});
const downloadingCount = computed(() => downloadingList.value.length);
const totalProgress = computed(() => {
const list = downloadingList.value;
if (list.length === 0) return 0;
const sum = list.reduce((acc, t) => acc + t.progress, 0);
return sum / list.length;
});
// ── Actions ────────────────────────────────────────────────────────────
const addDownload = async (songInfo: DownloadTask['songInfo'], url: string, type: string) => {
if (!isElectron) return;
const validatedInfo = {
...songInfo,
picUrl: validatePicUrl(songInfo.picUrl)
};
const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? '';
const filename = `${validatedInfo.name} - ${artistNames}`;
await window.api.downloadAdd({ url, filename, songInfo: validatedInfo, type });
};
const batchDownload = async (
items: Array<{ songInfo: DownloadTask['songInfo']; url: string; type: string }>
) => {
if (!isElectron) return;
const validatedItems = items.map((item) => {
const validatedInfo = {
...item.songInfo,
picUrl: validatePicUrl(item.songInfo.picUrl)
};
const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? '';
const filename = `${validatedInfo.name} - ${artistNames}`;
return { url: item.url, filename, songInfo: validatedInfo, type: item.type };
});
await window.api.downloadAddBatch({ items: validatedItems });
};
const pauseTask = async (taskId: string) => {
if (!isElectron) return;
await window.api.downloadPause(taskId);
};
const resumeTask = async (taskId: string) => {
if (!isElectron) return;
await window.api.downloadResume(taskId);
};
const cancelTask = async (taskId: string) => {
if (!isElectron) return;
await window.api.downloadCancel(taskId);
tasks.value.delete(taskId);
};
const cancelAll = async () => {
if (!isElectron) return;
await window.api.downloadCancelAll();
tasks.value.clear();
};
const updateConcurrency = async (n: number) => {
if (!isElectron) return;
const clamped = Math.min(5, Math.max(1, n));
settings.value = { ...settings.value, maxConcurrent: clamped };
await window.api.downloadSetConcurrency(clamped);
};
const refreshCompleted = async () => {
if (!isElectron) return;
isLoadingCompleted.value = true;
try {
const list = await window.api.downloadGetCompleted();
completedList.value = list;
} finally {
isLoadingCompleted.value = false;
}
};
const deleteCompleted = async (filePath: string) => {
if (!isElectron) return;
await window.api.downloadDeleteCompleted(filePath);
completedList.value = completedList.value.filter((item) => item.filePath !== filePath);
};
const clearCompleted = async () => {
if (!isElectron) return;
await window.api.downloadClearCompleted();
completedList.value = [];
};
const loadPersistedQueue = async () => {
if (!isElectron) return;
const queue = await window.api.downloadGetQueue();
tasks.value.clear();
for (const task of queue) {
tasks.value.set(task.taskId, task);
}
};
const initListeners = () => {
if (!isElectron || listenersInitialised) return;
listenersInitialised = true;
window.api.onDownloadProgress((event) => {
const task = tasks.value.get(event.taskId);
if (task) {
tasks.value.set(event.taskId, {
...task,
progress: event.progress,
loaded: event.loaded,
total: event.total
});
}
});
window.api.onDownloadStateChange((event) => {
const { taskId, state, task } = event;
if (state === DOWNLOAD_TASK_STATE.completed || state === DOWNLOAD_TASK_STATE.cancelled) {
tasks.value.delete(taskId);
if (state === DOWNLOAD_TASK_STATE.completed) {
setTimeout(() => {
refreshCompleted();
}, 500);
}
} else {
tasks.value.set(taskId, task);
}
});
window.api.onDownloadBatchComplete((_event) => {
// no-op: main process handles the desktop notification
});
window.api.onDownloadRequestUrl(async (event) => {
try {
const { getSongUrl } = await import('@/store/modules/player');
const result = (await getSongUrl(event.songInfo.id, event.songInfo as any, true)) as any;
const url = typeof result === 'string' ? result : (result?.url ?? '');
await window.api.downloadProvideUrl(event.taskId, url);
} catch (err) {
console.error('[downloadStore] onDownloadRequestUrl failed:', err);
await window.api.downloadProvideUrl(event.taskId, '');
}
});
};
const cleanup = () => {
if (!isElectron) return;
window.api.removeDownloadListeners();
listenersInitialised = false;
};
return {
// state
tasks,
completedList,
settings,
isLoadingCompleted,
// computed
downloadingList,
downloadingCount,
totalProgress,
// actions
addDownload,
batchDownload,
pauseTask,
resumeTask,
cancelTask,
cancelAll,
updateConcurrency,
refreshCompleted,
deleteCompleted,
clearCompleted,
loadPersistedQueue,
initListeners,
cleanup
};
},
{
persist: {
key: 'download-settings',
// WARNING: Do NOT add 'tasks' — Map doesn't serialize with JSON.stringify
pick: ['settings']
}
}
);

View File

@@ -28,11 +28,9 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
*/ */
const playIntelligenceMode = async () => { const playIntelligenceMode = async () => {
const { useUserStore } = await import('./user'); const { useUserStore } = await import('./user');
const { usePlayerCoreStore } = await import('./playerCore');
const { usePlaylistStore } = await import('./playlist'); const { usePlaylistStore } = await import('./playlist');
const userStore = useUserStore(); const userStore = useUserStore();
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const { t } = i18n.global; const { t } = i18n.global;
@@ -101,7 +99,8 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
// 替换播放列表并开始播放 // 替换播放列表并开始播放
playlistStore.setPlayList(intelligenceSongs, false, true); playlistStore.setPlayList(intelligenceSongs, false, true);
await playerCore.handlePlayMusic(intelligenceSongs[0], true); const { playTrack } = await import('@/services/playbackController');
await playTrack(intelligenceSongs[0], true);
} else { } else {
message.error(t('player.playBar.intelligenceMode.failed')); message.error(t('player.playBar.intelligenceMode.failed'));
} }

View File

@@ -125,6 +125,9 @@ export const useLocalMusicStore = defineStore(
cachedMap.set(entry.filePath, entry); cachedMap.set(entry.filePath, entry);
} }
// 磁盘上实际存在的文件路径集合(扫描时收集)
const diskFilePaths = new Set<string>();
// 遍历每个文件夹进行扫描 // 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) { for (const folderPath of folderPaths.value) {
try { try {
@@ -141,6 +144,11 @@ export const useLocalMusicStore = defineStore(
const { files } = result; const { files } = result;
scanProgress.value += files.length; scanProgress.value += files.length;
// 记录磁盘上存在的文件
for (const file of files) {
diskFilePaths.add(file.path);
}
// 2. 增量扫描:基于修改时间筛选需重新解析的文件 // 2. 增量扫描:基于修改时间筛选需重新解析的文件
const parseTargets: string[] = []; const parseTargets: string[] = [];
for (const file of files) { for (const file of files) {
@@ -168,6 +176,13 @@ export const useLocalMusicStore = defineStore(
} }
} }
// 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目
for (const [filePath, entry] of cachedMap) {
if (!diskFilePaths.has(filePath)) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
}
// 5. 从 IndexedDB 重新加载完整列表 // 5. 从 IndexedDB 重新加载完整列表
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE); musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
} catch (error) { } catch (error) {

View File

@@ -75,7 +75,8 @@ export const usePlayerStore = defineStore('player', () => {
const playHistoryStore = usePlayHistoryStore(); const playHistoryStore = usePlayHistoryStore();
playHistoryStore.migrateFromLocalStorage(); playHistoryStore.migrateFromLocalStorage();
await playerCore.initializePlayState(); const { initializePlayState: initPlayState } = await import('@/services/playbackController');
await initPlayState();
await playlist.initializePlaylist(); await playlist.initializePlaylist();
}; };
@@ -112,11 +113,7 @@ export const usePlayerStore = defineStore('player', () => {
getVolume: playerCore.getVolume, getVolume: playerCore.getVolume,
increaseVolume: playerCore.increaseVolume, increaseVolume: playerCore.increaseVolume,
decreaseVolume: playerCore.decreaseVolume, decreaseVolume: playerCore.decreaseVolume,
handlePlayMusic: playerCore.handlePlayMusic,
playAudio: playerCore.playAudio,
handlePause: playerCore.handlePause, handlePause: playerCore.handlePause,
checkPlaybackState: playerCore.checkPlaybackState,
reparseCurrentSong: playerCore.reparseCurrentSong,
// ========== 播放列表管理 (Playlist) ========== // ========== 播放列表管理 (Playlist) ==========
playList, playList,

View File

@@ -1,23 +1,9 @@
import { cloneDeep } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import i18n from '@/../i18n/renderer';
import { getParsingMusicUrl } from '@/api/music';
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { playbackRequestManager } from '@/services/playbackRequestManager';
import { preloadService } from '@/services/preloadService';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import type { AudioOutputDevice } from '@/types/audio'; import type { AudioOutputDevice } from '@/types/audio';
import type { Platform, SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { usePlayHistoryStore } from './playHistory';
const { message } = createDiscreteApi(['message']);
/** /**
* 核心播放控制 Store * 核心播放控制 Store
@@ -43,10 +29,6 @@ export const usePlayerCoreStore = defineStore(
); );
const availableAudioDevices = ref<AudioOutputDevice[]>([]); const availableAudioDevices = ref<AudioOutputDevice[]>([]);
let checkPlayTime: NodeJS.Timeout | null = null;
let checkPlaybackRetryCount = 0;
const MAX_CHECKPLAYBACK_RETRIES = 3;
// ==================== Computed ==================== // ==================== Computed ====================
const currentSong = computed(() => playMusic.value); const currentSong = computed(() => playMusic.value);
const isPlaying = computed(() => isPlay.value); const isPlaying = computed(() => isPlay.value);
@@ -109,413 +91,6 @@ export const usePlayerCoreStore = defineStore(
return newVolume; return newVolume;
}; };
/**
* 播放状态检测
* 在播放开始后延迟检查音频是否真正在播放,防止无声播放
*/
const checkPlaybackState = (song: SongResult, requestId?: string, timeout: number = 6000) => {
if (checkPlayTime) {
clearTimeout(checkPlayTime);
}
const sound = audioService.getCurrentSound();
if (!sound) return;
// 如果没有提供 requestId创建一个临时标识
const actualRequestId = requestId || `check_${Date.now()}`;
const onPlayHandler = () => {
console.log(`[${actualRequestId}] 播放事件触发,歌曲成功开始播放`);
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
checkPlaybackRetryCount = 0; // 播放成功,重置重试计数
if (checkPlayTime) {
clearTimeout(checkPlayTime);
checkPlayTime = null;
}
};
const onPlayErrorHandler = async () => {
console.log('播放错误事件触发检查是否需要重新获取URL');
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
// 如果有 requestId验证其有效性
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log('请求已过期,跳过重试');
return;
}
// 检查重试次数限制
if (checkPlaybackRetryCount >= MAX_CHECKPLAYBACK_RETRIES) {
console.warn(`播放重试已达上限 (${MAX_CHECKPLAYBACK_RETRIES} 次),停止重试`);
checkPlaybackRetryCount = 0;
setPlayMusic(false);
return;
}
if (userPlayIntent.value && play.value) {
checkPlaybackRetryCount++;
console.log(
`播放失败尝试刷新URL并重新播放 (重试 ${checkPlaybackRetryCount}/${MAX_CHECKPLAYBACK_RETRIES})`
);
// 本地音乐不需要刷新 URL
if (!playMusic.value.playMusicUrl?.startsWith('local://')) {
playMusic.value.playMusicUrl = undefined;
}
const refreshedSong = { ...song, isFirstPlay: true };
await handlePlayMusic(refreshedSong, true);
}
};
audioService.on('play', onPlayHandler);
audioService.on('playerror', onPlayErrorHandler);
checkPlayTime = setTimeout(() => {
// 如果有 requestId验证其有效性
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log('请求已过期,跳过超时重试');
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
return;
}
// 双重确认Howler 报告未播放 + 用户仍想播放
// 额外检查底层 HTMLAudioElement 的状态,避免 EQ 重建期间的误判
const currentSound = audioService.getCurrentSound();
let htmlPlaying = false;
if (currentSound) {
try {
const sounds = (currentSound as any)._sounds as any[];
if (sounds?.[0]?._node instanceof HTMLMediaElement) {
const node = sounds[0]._node as HTMLMediaElement;
htmlPlaying = !node.paused && !node.ended && node.readyState > 2;
}
} catch {
// 静默忽略
}
}
if (htmlPlaying) {
// 底层 HTMLAudioElement 实际在播放,不需要重试
console.log('底层音频元素正在播放,跳过超时重试');
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
return;
}
if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) {
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
// 检查重试次数限制
if (checkPlaybackRetryCount >= MAX_CHECKPLAYBACK_RETRIES) {
console.warn(`超时重试已达上限 (${MAX_CHECKPLAYBACK_RETRIES} 次),停止重试`);
checkPlaybackRetryCount = 0;
setPlayMusic(false);
return;
}
checkPlaybackRetryCount++;
console.log(
`${timeout}ms后歌曲未真正播放尝试重新获取URL (重试 ${checkPlaybackRetryCount}/${MAX_CHECKPLAYBACK_RETRIES})`
);
// 本地音乐不需要刷新 URL
if (!playMusic.value.playMusicUrl?.startsWith('local://')) {
playMusic.value.playMusicUrl = undefined;
}
(async () => {
const refreshedSong = { ...song, isFirstPlay: true };
await handlePlayMusic(refreshedSong, true);
})();
} else {
audioService.off('play', onPlayHandler);
audioService.off('playerror', onPlayErrorHandler);
}
}, timeout);
};
/**
* 核心播放处理函数
*/
const handlePlayMusic = async (music: SongResult, shouldPlay: boolean = true) => {
// 如果是新歌曲,重置已尝试的音源和重试计数
if (music.id !== playMusic.value.id) {
SongSourceConfigManager.clearTriedSources(music.id);
checkPlaybackRetryCount = 0;
}
// 创建新的播放请求并取消之前的所有请求
const requestId = playbackRequestManager.createRequest(music);
console.log(`[handlePlayMusic] 开始处理歌曲: ${music.name}, 请求ID: ${requestId}`);
const currentSound = audioService.getCurrentSound();
if (currentSound) {
console.log('主动停止并卸载当前音频实例');
currentSound.stop();
currentSound.unload();
}
// 验证请求是否仍然有效
if (!playbackRequestManager.isRequestValid(requestId)) {
console.log(`[handlePlayMusic] 请求已失效: ${requestId}`);
return false;
}
// 激活请求
if (!playbackRequestManager.activateRequest(requestId)) {
console.log(`[handlePlayMusic] 无法激活请求: ${requestId}`);
return false;
}
const originalMusic = { ...music };
const { loadLrc } = useLyrics();
const { getSongDetail } = useSongDetail();
// 并行加载歌词和背景色
const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([
(async () => {
if (music.lyric && music.lyric.lrcTimeArray.length > 0) {
return music.lyric;
}
return await loadLrc(music.id);
})(),
(async () => {
if (music.backgroundColor && music.primaryColor) {
return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor };
}
return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30'));
})()
]);
// 在更新状态前再次验证请求
if (!playbackRequestManager.isRequestValid(requestId)) {
console.log(`[handlePlayMusic] 加载歌词/背景色后请求已失效: ${requestId}`);
return false;
}
// 设置歌词和背景色
music.lyric = lyrics;
music.backgroundColor = backgroundColor;
music.primaryColor = primaryColor;
music.playLoading = true;
// 更新 playMusic 和播放状态
playMusic.value = music;
play.value = shouldPlay;
isPlay.value = shouldPlay;
userPlayIntent.value = shouldPlay;
// 更新标题
let title = music.name;
if (music.source === 'netease' && music?.song?.artists) {
title += ` - ${music.song.artists.reduce(
(prev: string, curr: any) => `${prev}${curr.name}/`,
''
)}`;
}
document.title = 'AlgerMusic - ' + title;
try {
// 添加到历史记录
const playHistoryStore = usePlayHistoryStore();
if (music.isPodcast) {
if (music.program) {
playHistoryStore.addPodcast(music.program);
}
} else {
playHistoryStore.addMusic(music);
}
// 获取歌曲详情
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
// 在获取详情后再次验证请求
if (!playbackRequestManager.isRequestValid(requestId)) {
console.log(`[handlePlayMusic] 获取歌曲详情后请求已失效: ${requestId}`);
playbackRequestManager.failRequest(requestId);
return false;
}
updatedPlayMusic.lyric = lyrics;
playMusic.value = updatedPlayMusic;
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
// 在拆分后补充:触发预加载下一首/下下首(与 playlist store 保持一致)
try {
const { usePlaylistStore } = await import('./playlist');
const playlistStore = usePlaylistStore();
// 基于当前歌曲在播放列表中的位置来预加载
const list = playlistStore.playList;
if (Array.isArray(list) && list.length > 0) {
const idx = list.findIndex(
(item: SongResult) =>
item.id === updatedPlayMusic.id && item.source === updatedPlayMusic.source
);
if (idx !== -1) {
setTimeout(() => {
playlistStore.preloadNextSongs(idx);
}, 3000);
}
}
} catch (e) {
console.warn('预加载触发失败(可能是依赖未加载或循环依赖),已忽略:', e);
}
try {
const result = await playAudio(requestId);
if (result) {
// 播放成功,清除 isFirstPlay 标记,避免暂停时被误判为新歌
playMusic.value.isFirstPlay = false;
playbackRequestManager.completeRequest(requestId);
return true;
} else {
playbackRequestManager.failRequest(requestId);
return false;
}
} catch (error) {
console.error('自动播放音频失败:', error);
playbackRequestManager.failRequest(requestId);
return false;
}
} catch (error) {
console.error('处理播放音乐失败:', error);
message.error(i18n.global.t('player.playFailed'));
if (playMusic.value) {
playMusic.value.playLoading = false;
}
playbackRequestManager.failRequest(requestId);
return false;
}
};
/**
* 播放音频
*/
const playAudio = async (requestId?: string) => {
if (!playMusicUrl.value || !playMusic.value) return null;
// 如果提供了 requestId验证请求是否仍然有效
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log(`[playAudio] 请求已失效: ${requestId}`);
return null;
}
try {
const shouldPlay = play.value;
console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停');
// 检查保存的进度
let initialPosition = 0;
const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}');
console.log(
'[playAudio] 读取保存的进度:',
savedProgress,
'当前歌曲ID:',
playMusic.value.id
);
if (savedProgress.songId === playMusic.value.id) {
initialPosition = savedProgress.progress;
console.log('[playAudio] 恢复播放进度:', initialPosition);
}
// 使用 PreloadService 获取音频
// 优先使用已预加载的 sound通过 consume 获取并从缓存中移除)
// 如果没有预加载,则进行加载
let sound: Howl;
try {
// 先尝试消耗预加载的 sound
const preloadedSound = preloadService.consume(playMusic.value.id);
if (preloadedSound && preloadedSound.state() === 'loaded') {
console.log(`[playAudio] 使用预加载的音频: ${playMusic.value.name}`);
sound = preloadedSound;
} else {
// 没有预加载或预加载状态不正常,需要加载
console.log(`[playAudio] 没有预加载,开始加载: ${playMusic.value.name}`);
sound = await preloadService.load(playMusic.value);
}
} catch (error) {
console.error('PreloadService 加载失败:', error);
// 如果 PreloadService 失败,尝试直接播放作为回退
// 但通常 PreloadService 失败意味着 URL 问题
throw error;
}
// 播放新音频,传入已加载的 sound 实例
const newSound = await audioService.play(
playMusicUrl.value,
playMusic.value,
shouldPlay,
initialPosition || 0,
sound
);
// 播放后再次验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log(`[playAudio] 播放后请求已失效: ${requestId}`);
newSound.stop();
newSound.unload();
return null;
}
// 添加播放状态检测
if (shouldPlay && requestId) {
checkPlaybackState(playMusic.value, requestId);
}
// 发布音频就绪事件
window.dispatchEvent(
new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } })
);
// 时长检查已在 preloadService.ts 中完成
return newSound;
} catch (error) {
console.error('播放音频失败:', error);
const errorMsg = error instanceof Error ? error.message : String(error);
// 操作锁错误不应该停止播放状态,只需要重试
if (errorMsg.includes('操作锁激活')) {
console.log('由于操作锁正在使用将在1000ms后重试');
try {
audioService.forceResetOperationLock();
console.log('已强制重置操作锁');
} catch (e) {
console.error('重置操作锁失败:', e);
}
setTimeout(() => {
// 验证请求是否仍然有效再重试
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log('重试时请求已失效,跳过重试');
return;
}
if (userPlayIntent.value && play.value) {
playAudio(requestId).catch((e) => {
console.error('重试播放失败:', e);
setPlayMusic(false);
});
}
}, 1000);
} else {
// 非操作锁错误,停止播放并通知用户
setPlayMusic(false);
console.warn('播放音频失败(非操作锁错误),由调用方处理重试');
message.error(i18n.global.t('player.playFailed'));
}
return null;
}
};
/** /**
* 暂停播放 * 暂停播放
*/ */
@@ -540,109 +115,14 @@ export const usePlayerCoreStore = defineStore(
setIsPlay(value); setIsPlay(value);
userPlayIntent.value = value; userPlayIntent.value = value;
} else { } else {
await handlePlayMusic(value); const { playTrack } = await import('@/services/playbackController');
await playTrack(value);
play.value = true; play.value = true;
isPlay.value = true; isPlay.value = true;
userPlayIntent.value = true; userPlayIntent.value = true;
} }
}; };
/**
* 使用指定音源重新解析当前歌曲
*/
const reparseCurrentSong = async (sourcePlatform: Platform, isAuto: boolean = false) => {
try {
const currentSong = playMusic.value;
if (!currentSong || !currentSong.id) {
console.warn('没有有效的播放对象');
return false;
}
// 使用 SongSourceConfigManager 保存配置
SongSourceConfigManager.setConfig(
currentSong.id,
[sourcePlatform],
isAuto ? 'auto' : 'manual'
);
const currentSound = audioService.getCurrentSound();
if (currentSound) {
currentSound.pause();
}
const numericId =
typeof currentSong.id === 'string' ? parseInt(currentSong.id, 10) : currentSong.id;
console.log(`使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`);
const songData = cloneDeep(currentSong);
const res = await getParsingMusicUrl(numericId, songData);
if (res && res.data && res.data.data && res.data.data.url) {
const newUrl = res.data.data.url;
console.log(`解析成功获取新URL: ${newUrl.substring(0, 50)}...`);
const updatedMusic = {
...currentSong,
playMusicUrl: newUrl,
expiredAt: Date.now() + 1800000
};
await handlePlayMusic(updatedMusic, true);
// 更新播放列表中的歌曲信息
const { usePlaylistStore } = await import('./playlist');
const playlistStore = usePlaylistStore();
playlistStore.updateSong(updatedMusic);
return true;
} else {
console.warn(`使用音源 ${sourcePlatform} 解析失败`);
return false;
}
} catch (error) {
console.error('重新解析失败:', error);
return false;
}
};
/**
* 初始化播放状态
*/
const initializePlayState = async () => {
const { useSettingsStore } = await import('./settings');
const settingStore = useSettingsStore();
if (playMusic.value && Object.keys(playMusic.value).length > 0) {
try {
console.log('恢复上次播放的音乐:', playMusic.value.name);
const isPlaying = settingStore.setData.autoPlay;
// 本地音乐local:// 协议)不需要重新获取 URL保留原始路径
const isLocalMusic = playMusic.value.playMusicUrl?.startsWith('local://');
await handlePlayMusic(
{
...playMusic.value,
isFirstPlay: true,
playMusicUrl: isLocalMusic ? playMusic.value.playMusicUrl : undefined
},
isPlaying
);
} catch (error) {
console.error('重新获取音乐链接失败:', error);
play.value = false;
isPlay.value = false;
playMusic.value = {} as SongResult;
playMusicUrl.value = '';
}
}
setTimeout(() => {
audioService.setPlaybackRate(playbackRate.value);
}, 2000);
};
// ==================== 音频输出设备管理 ==================== // ==================== 音频输出设备管理 ====================
/** /**
@@ -707,12 +187,7 @@ export const usePlayerCoreStore = defineStore(
getVolume, getVolume,
increaseVolume, increaseVolume,
decreaseVolume, decreaseVolume,
handlePlayMusic,
playAudio,
handlePause, handlePause,
checkPlaybackState,
reparseCurrentSong,
initializePlayState,
refreshAudioDevices, refreshAudioDevices,
setAudioOutputDevice, setAudioOutputDevice,
initAudioDeviceListener initAudioDeviceListener

View File

@@ -87,7 +87,6 @@ export const usePlaylistStore = defineStore(
// 连续失败计数器(用于防止无限循环) // 连续失败计数器(用于防止无限循环)
const consecutiveFailCount = ref(0); const consecutiveFailCount = ref(0);
const MAX_CONSECUTIVE_FAILS = 5; // 最大连续失败次数 const MAX_CONSECUTIVE_FAILS = 5; // 最大连续失败次数
const SINGLE_TRACK_MAX_RETRIES = 3; // 单曲最大重试次数
// ==================== Computed ==================== // ==================== Computed ====================
const currentPlayList = computed(() => playList.value); const currentPlayList = computed(() => playList.value);
@@ -416,103 +415,104 @@ export const usePlaylistStore = defineStore(
} }
}; };
/** let nextPlayRetryTimer: ReturnType<typeof setTimeout> | null = null;
* 下一首
* @param singleTrackRetryCount 单曲重试次数(同一首歌的重试) const cancelRetryTimer = () => {
*/ if (nextPlayRetryTimer) {
const _nextPlay = async (singleTrackRetryCount: number = 0) => { clearTimeout(nextPlayRetryTimer);
nextPlayRetryTimer = null;
}
};
const _nextPlay = async (retryCount: number = 0, autoEnd: boolean = false) => {
try { try {
if (playList.value.length === 0) { if (playList.value.length === 0) return;
return;
// User-initiated (retryCount=0): reset state
if (retryCount === 0) {
cancelRetryTimer();
consecutiveFailCount.value = 0;
} }
const playerCore = usePlayerCoreStore(); const playerCore = usePlayerCoreStore();
const sleepTimerStore = useSleepTimerStore(); const sleepTimerStore = useSleepTimerStore();
// 检查是否超过最大连续失败次数
if (consecutiveFailCount.value >= MAX_CONSECUTIVE_FAILS) { if (consecutiveFailCount.value >= MAX_CONSECUTIVE_FAILS) {
console.error(`[nextPlay] 连续${MAX_CONSECUTIVE_FAILS}歌曲播放失败,停止播放`); console.error(`[nextPlay] 连续${MAX_CONSECUTIVE_FAILS}首播放失败,停止`);
getMessage().warning(i18n.global.t('player.consecutiveFailsError')); getMessage().warning(i18n.global.t('player.consecutiveFailsError'));
consecutiveFailCount.value = 0; // 重置计数器 consecutiveFailCount.value = 0;
playerCore.setIsPlay(false); playerCore.setIsPlay(false);
return; return;
} }
// 顺序播放模式:播放到最后一首后停止 // Sequential mode: at the last song
if (playMode.value === 0 && playListIndex.value >= playList.value.length - 1) { if (playMode.value === 0 && playListIndex.value >= playList.value.length - 1) {
if (sleepTimerStore.sleepTimer.type === 'end') { if (autoEnd) {
sleepTimerStore.stopPlayback(); // 歌曲自然播放结束:停止播放
console.log('[nextPlay] 顺序播放:最后一首播放完毕,停止');
if (sleepTimerStore.sleepTimer.type === 'end') {
sleepTimerStore.stopPlayback();
}
getMessage().info(i18n.global.t('player.playListEnded'));
playerCore.setIsPlay(false);
const { audioService } = await import('@/services/audioService');
audioService.pause();
} else {
// 用户手动点击下一首:保持当前播放,只提示
console.log('[nextPlay] 顺序播放:已是最后一首,保持当前播放');
getMessage().info(i18n.global.t('player.playListEnded'));
} }
console.log('[nextPlay] 顺序播放模式:已播放到最后一首,停止播放');
playerCore.setIsPlay(false);
const { audioService } = await import('@/services/audioService');
audioService.pause();
return; return;
} }
const currentIndex = playListIndex.value;
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length; const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
const nextSong = { ...playList.value[nowPlayListIndex] }; const nextSong = { ...playList.value[nowPlayListIndex] };
// 同一首歌重试时强制刷新在线 URL避免卡在失效链接上 // Force refresh URL on retry
if (singleTrackRetryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) { if (retryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) {
nextSong.playMusicUrl = undefined; nextSong.playMusicUrl = undefined;
nextSong.expiredAt = undefined; nextSong.expiredAt = undefined;
} }
console.log( console.log(
`[nextPlay] 尝试播放: ${nextSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}, 单曲重试: ${singleTrackRetryCount}/${SINGLE_TRACK_MAX_RETRIES}, 连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` `[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}, 重试: ${retryCount}/1`
);
console.log(
'[nextPlay] Current mode:',
playMode.value,
'Playlist length:',
playList.value.length
); );
// 先尝试播放歌曲 const { playTrack } = await import('@/services/playbackController');
const success = await playerCore.handlePlayMusic(nextSong, true); const success = await playTrack(nextSong, true);
// Check if we were superseded by a newer operation
if (playerCore.playMusic.id !== nextSong.id) {
console.log('[nextPlay] 被新操作取代,静默退出');
return;
}
if (success) { if (success) {
// 播放成功,重置所有计数器并更新索引
consecutiveFailCount.value = 0; consecutiveFailCount.value = 0;
playListIndex.value = nowPlayListIndex; playListIndex.value = nowPlayListIndex;
console.log(`[nextPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`);
console.log(
'[nextPlay] New current song in list:',
playList.value[playListIndex.value]?.name
);
sleepTimerStore.handleSongChange(); sleepTimerStore.handleSongChange();
} else { } else {
console.error(`[nextPlay] 播放失败: ${nextSong.name}`); // Retry once, then skip to next
if (retryCount < 1) {
// 单曲重试逻辑 console.log(`[nextPlay] 播放失败1秒后重试`);
if (singleTrackRetryCount < SINGLE_TRACK_MAX_RETRIES) { nextPlayRetryTimer = setTimeout(() => {
console.log( nextPlayRetryTimer = null;
`[nextPlay] 单曲重试 ${singleTrackRetryCount + 1}/${SINGLE_TRACK_MAX_RETRIES}` _nextPlay(retryCount + 1);
);
// 不更新索引,重试同一首歌
setTimeout(() => {
_nextPlay(singleTrackRetryCount + 1);
}, 1000); }, 1000);
} else { } else {
// 单曲重试次数用尽,递增连续失败计数,尝试下一首
consecutiveFailCount.value++; consecutiveFailCount.value++;
console.log( console.log(
`[nextPlay] 单曲重试用尽,连续失败计数: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` `[nextPlay] 重试用尽,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}`
); );
if (playList.value.length > 1) { if (playList.value.length > 1) {
// 更新索引到失败的歌曲位置,这样下次递归调用会继续往下
playListIndex.value = nowPlayListIndex; playListIndex.value = nowPlayListIndex;
getMessage().warning(i18n.global.t('player.parseFailedPlayNext')); getMessage().warning(i18n.global.t('player.parseFailedPlayNext'));
nextPlayRetryTimer = setTimeout(() => {
// 延迟后尝试下一首(重置单曲重试计数) nextPlayRetryTimer = null;
setTimeout(() => {
_nextPlay(0); _nextPlay(0);
}, 500); }, 500);
} else { } else {
// 只有一首歌且失败
getMessage().error(i18n.global.t('player.playFailed')); getMessage().error(i18n.global.t('player.playFailed'));
playerCore.setIsPlay(false); playerCore.setIsPlay(false);
} }
@@ -525,73 +525,33 @@ export const usePlaylistStore = defineStore(
const nextPlay = useThrottleFn(_nextPlay, 500); const nextPlay = useThrottleFn(_nextPlay, 500);
/** /** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */
* 上一首 const nextPlayOnEnd = () => {
*/ _nextPlay(0, true);
};
const _prevPlay = async () => { const _prevPlay = async () => {
try { try {
if (playList.value.length === 0) { if (playList.value.length === 0) return;
return;
}
cancelRetryTimer();
const playerCore = usePlayerCoreStore(); const playerCore = usePlayerCoreStore();
const currentIndex = playListIndex.value;
const nowPlayListIndex = const nowPlayListIndex =
(playListIndex.value - 1 + playList.value.length) % playList.value.length; (playListIndex.value - 1 + playList.value.length) % playList.value.length;
const prevSong = { ...playList.value[nowPlayListIndex] }; const prevSong = { ...playList.value[nowPlayListIndex] };
console.log( console.log(
`[prevPlay] 尝试播放上一首: ${prevSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}` `[prevPlay] ${prevSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}`
); );
let success = false; const { playTrack } = await import('@/services/playbackController');
let retryCount = 0; const success = await playTrack(prevSong);
const maxRetries = 2;
// 先尝试播放歌曲,成功后再更新索引
while (!success && retryCount < maxRetries) {
success = await playerCore.handlePlayMusic(prevSong);
if (!success) {
retryCount++;
console.error(`播放上一首失败,尝试 ${retryCount}/${maxRetries}`);
if (retryCount >= maxRetries) {
console.error('多次尝试播放失败,将从播放列表中移除此歌曲');
const newPlayList = [...playList.value];
newPlayList.splice(nowPlayListIndex, 1);
if (newPlayList.length > 0) {
const keepCurrentIndexPosition = true;
setPlayList(newPlayList, keepCurrentIndexPosition);
if (newPlayList.length === 1) {
playListIndex.value = 0;
} else {
const newPrevIndex =
(playListIndex.value - 1 + newPlayList.length) % newPlayList.length;
playListIndex.value = newPrevIndex;
}
setTimeout(() => {
prevPlay();
}, 300);
return;
} else {
console.error('播放列表为空,停止尝试');
break;
}
}
}
}
if (success) { if (success) {
// 播放成功,更新索引
playListIndex.value = nowPlayListIndex; playListIndex.value = nowPlayListIndex;
console.log(`[prevPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); console.log(`[prevPlay] 播放成功,索引: ${nowPlayListIndex}`);
} else { } else if (playerCore.playMusic.id === prevSong.id) {
console.error(`[prevPlay] 播放上一首失败,保持当前索引: ${currentIndex}`); // Only show error if not superseded
playerCore.setIsPlay(false); playerCore.setIsPlay(false);
getMessage().error(i18n.global.t('player.playFailed')); getMessage().error(i18n.global.t('player.playFailed'));
} }
@@ -609,16 +569,12 @@ export const usePlaylistStore = defineStore(
playListDrawerVisible.value = value; playListDrawerVisible.value = value;
}; };
/**
* 设置播放兼容旧API
*/
const setPlay = async (song: SongResult) => { const setPlay = async (song: SongResult) => {
try { try {
const playerCore = usePlayerCoreStore(); const playerCore = usePlayerCoreStore();
// 检查URL是否已过期 // Check URL expiration
if (song.expiredAt && song.expiredAt < Date.now()) { if (song.expiredAt && song.expiredAt < Date.now()) {
// 本地音乐local:// 协议)不会过期
if (!song.playMusicUrl?.startsWith('local://')) { if (!song.playMusicUrl?.startsWith('local://')) {
console.info(`歌曲URL已过期重新获取: ${song.name}`); console.info(`歌曲URL已过期重新获取: ${song.name}`);
song.playMusicUrl = undefined; song.playMusicUrl = undefined;
@@ -626,11 +582,10 @@ export const usePlaylistStore = defineStore(
} }
} }
// 如果是当前正在播放的音乐,则切换播放/暂停状态 // Toggle play/pause for current song
if ( if (
playerCore.playMusic.id === song.id && playerCore.playMusic.id === song.id &&
playerCore.playMusic.playMusicUrl === song.playMusicUrl && playerCore.playMusic.playMusicUrl === song.playMusicUrl
!song.isFirstPlay
) { ) {
if (playerCore.play) { if (playerCore.play) {
playerCore.setPlayMusic(false); playerCore.setPlayMusic(false);
@@ -644,10 +599,9 @@ export const usePlaylistStore = defineStore(
const sound = audioService.getCurrentSound(); const sound = audioService.getCurrentSound();
if (sound) { if (sound) {
sound.play(); sound.play();
// 在恢复播放时也进行状态检测防止URL已过期导致无声
playerCore.checkPlaybackState(playerCore.playMusic);
} else { } else {
console.warn('[PlaylistStore.setPlay] 无可用音频实例,尝试重建播放链路'); // No audio instance, rebuild via playTrack
const { playTrack } = await import('@/services/playbackController');
const recoverSong = { const recoverSong = {
...playerCore.playMusic, ...playerCore.playMusic,
isFirstPlay: true, isFirstPlay: true,
@@ -655,7 +609,7 @@ export const usePlaylistStore = defineStore(
? playerCore.playMusic.playMusicUrl ? playerCore.playMusic.playMusicUrl
: undefined : undefined
}; };
const recovered = await playerCore.handlePlayMusic(recoverSong, true); const recovered = await playTrack(recoverSong, true);
if (!recovered) { if (!recovered) {
playerCore.setIsPlay(false); playerCore.setIsPlay(false);
getMessage().error(i18n.global.t('player.playFailed')); getMessage().error(i18n.global.t('player.playFailed'));
@@ -665,33 +619,24 @@ export const usePlaylistStore = defineStore(
return; return;
} }
if (song.isFirstPlay) { if (song.isFirstPlay) song.isFirstPlay = false;
song.isFirstPlay = false;
}
// 查找歌曲在播放列表中的索引 // Update playlist index
const songIndex = playList.value.findIndex( const songIndex = playList.value.findIndex(
(item: SongResult) => item.id === song.id && item.source === song.source (item: SongResult) => item.id === song.id && item.source === song.source
); );
// 更新播放索引
if (songIndex !== -1 && songIndex !== playListIndex.value) { if (songIndex !== -1 && songIndex !== playListIndex.value) {
console.log('歌曲索引不匹配,更新为:', songIndex); console.log('歌曲索引不匹配,更新为:', songIndex);
playListIndex.value = songIndex; playListIndex.value = songIndex;
} }
const success = await playerCore.handlePlayMusic(song); const { playTrack } = await import('@/services/playbackController');
const success = await playTrack(song);
// playerCore 的状态由其自己的 store 管理
if (success) { if (success) {
playerCore.isPlay = true; playerCore.isPlay = true;
// 预加载下一首歌曲
if (songIndex !== -1) { if (songIndex !== -1) {
setTimeout(() => { setTimeout(() => preloadNextSongs(playListIndex.value), 3000);
preloadNextSongs(playListIndex.value);
}, 3000);
} }
} }
return success; return success;
@@ -740,6 +685,7 @@ export const usePlaylistStore = defineStore(
restoreOriginalOrder, restoreOriginalOrder,
preloadNextSongs, preloadNextSongs,
nextPlay: nextPlay as unknown as typeof _nextPlay, nextPlay: nextPlay as unknown as typeof _nextPlay,
nextPlayOnEnd,
prevPlay: prevPlay as unknown as typeof _prevPlay, prevPlay: prevPlay as unknown as typeof _prevPlay,
setPlayListDrawerVisible, setPlayListDrawerVisible,
setPlay, setPlay,

View File

@@ -28,6 +28,25 @@ export interface IElectronAPI {
) => Promise<{ files: { path: string; modifiedTime: number }[]; count: number }>; ) => Promise<{ files: { path: string; modifiedTime: number }[]; count: number }>;
/** 批量解析本地音乐文件元数据 */ /** 批量解析本地音乐文件元数据 */
parseLocalMusicMetadata: (_filePaths: string[]) => Promise<LocalMusicMeta[]>; parseLocalMusicMetadata: (_filePaths: string[]) => Promise<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;
} }
declare global { declare global {

View File

@@ -1,6 +1,7 @@
import { onMounted, onUnmounted } from 'vue'; import { onMounted, onUnmounted } from 'vue';
import i18n from '@/../i18n/renderer'; import i18n from '@/../i18n/renderer';
import { audioService } from '@/services/audioService';
import { usePlayerStore, useSettingsStore } from '@/store'; import { usePlayerStore, useSettingsStore } from '@/store';
import { import {
@@ -37,6 +38,26 @@ const onUpdateAppShortcuts = (_event: unknown, shortcuts: unknown) => {
updateAppShortcuts(shortcuts); updateAppShortcuts(shortcuts);
}; };
const onMprisSeekOrSetPosition = (_event: unknown, position: number) => {
if (audioService) {
audioService.seek(position);
}
};
const onMprisPlay = async () => {
const playerStore = usePlayerStore();
if (!playerStore.play && playerStore.playMusic?.id) {
await playerStore.setPlay({ ...playerStore.playMusic });
}
};
const onMprisPause = async () => {
const playerStore = usePlayerStore();
if (playerStore.play) {
await playerStore.handlePause();
}
};
function shouldSkipAction(action: ShortcutAction): boolean { function shouldSkipAction(action: ShortcutAction): boolean {
const now = Date.now(); const now = Date.now();
const lastTimestamp = actionTimestamps.get(action) ?? 0; const lastTimestamp = actionTimestamps.get(action) ?? 0;
@@ -192,6 +213,10 @@ export function initAppShortcuts() {
window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut); window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut);
window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts); window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts);
window.electron.ipcRenderer.on('mpris-seek', onMprisSeekOrSetPosition);
window.electron.ipcRenderer.on('mpris-set-position', onMprisSeekOrSetPosition);
window.electron.ipcRenderer.on('mpris-play', onMprisPlay);
window.electron.ipcRenderer.on('mpris-pause', onMprisPause);
const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts'); const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
updateAppShortcuts(storedShortcuts); updateAppShortcuts(storedShortcuts);
@@ -211,6 +236,10 @@ export function cleanupAppShortcuts() {
window.electron.ipcRenderer.removeListener('global-shortcut', onGlobalShortcut); window.electron.ipcRenderer.removeListener('global-shortcut', onGlobalShortcut);
window.electron.ipcRenderer.removeListener('update-app-shortcuts', onUpdateAppShortcuts); window.electron.ipcRenderer.removeListener('update-app-shortcuts', onUpdateAppShortcuts);
window.electron.ipcRenderer.removeListener('mpris-seek', onMprisSeekOrSetPosition);
window.electron.ipcRenderer.removeListener('mpris-set-position', onMprisSeekOrSetPosition);
window.electron.ipcRenderer.removeListener('mpris-play', onMprisPlay);
window.electron.ipcRenderer.removeListener('mpris-pause', onMprisPause);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
} }

View File

@@ -99,9 +99,9 @@ import { useRoute, useRouter } from 'vue-router';
import { getNewAlbums } from '@/api/album'; import { getNewAlbums } from '@/api/album';
import { getAlbum } from '@/api/list'; import { getAlbum } from '@/api/list';
import StickyTabPage from '@/components/common/StickyTabPage.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator'; import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerCoreStore } from '@/store/modules/playerCore'; import StickyTabPage from '@/components/common/StickyTabPage.vue';
import { playTrack } from '@/services/playbackController';
import { usePlaylistStore } from '@/store/modules/playlist'; import { usePlaylistStore } from '@/store/modules/playlist';
import { calculateAnimationDelay, getImgUrl } from '@/utils'; import { calculateAnimationDelay, getImgUrl } from '@/utils';
@@ -213,7 +213,6 @@ const playAlbum = async (album: any) => {
try { try {
const { data } = await getAlbum(album.id); const { data } = await getAlbum(album.id);
if (data.code === 200 && data.songs?.length > 0) { if (data.code === 200 && data.songs?.length > 0) {
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const albumCover = data.album?.picUrl || album.picUrl; const albumCover = data.album?.picUrl || album.picUrl;
@@ -228,7 +227,7 @@ const playAlbum = async (album: any) => {
})); }));
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
await playerCore.handlePlayMusic(playlist[0], true); await playTrack(playlist[0], true);
} }
} catch (error) { } catch (error) {
console.error('Failed to play album:', error); console.error('Failed to play album:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -94,23 +94,33 @@
<p>{{ t('favorite.emptyTip') }}</p> <p>{{ t('favorite.emptyTip') }}</p>
</div> </div>
<div v-else class="space-y-1 pb-24" :class="{ 'max-w-[400px]': isComponent }"> <div
<song-item v-else
v-for="(song, index) in favoriteSongs" class="space-y-1"
:key="song.id" :class="{ 'max-w-[400px]': isComponent }"
:item="song" :style="{ paddingBottom: contentPaddingBottom }"
:favorite="false" >
class="rounded-xl hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors" <div class="favorite-list-section">
:class="[ <song-item
setAnimationClass('animate__bounceInLeft'), v-for="(song, index) in renderedItems"
{ '!bg-primary/10': selectedSongs.includes(song.id as number) } :key="song.id"
]" :item="song"
:style="getItemAnimationDelay(index)" :favorite="false"
:selectable="isSelecting" class="rounded-xl hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
:selected="selectedSongs.includes(song.id as number)" :class="[
@play="handlePlay" index < 20 ? setAnimationClass('animate__bounceInLeft') : '',
@select="handleSelect" { '!bg-primary/10': selectedSongs.includes(song.id as number) }
/> ]"
:style="index < 20 ? getItemAnimationDelay(index) : undefined"
:selectable="isSelecting"
:selected="selectedSongs.includes(song.id as number)"
@play="handlePlay"
@select="handleSelect"
/>
</div>
<!-- 未渲染项占位 -->
<div v-if="placeholderHeight > 0" :style="{ height: placeholderHeight + 'px' }" />
<div v-if="isComponent" class="pt-4 text-center"> <div v-if="isComponent" class="pt-4 text-center">
<n-button text type="primary" @click="handleMore"> <n-button text type="primary" @click="handleMore">
@@ -138,7 +148,6 @@
</div> </div>
</div> </div>
</n-scrollbar> </n-scrollbar>
<play-bottom />
</div> </div>
</div> </div>
</template> </template>
@@ -149,9 +158,9 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getMusicDetail } from '@/api/music'; import { getMusicDetail } from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload'; import { useDownload } from '@/hooks/useDownload';
import { useProgressiveRender } from '@/hooks/useProgressiveRender';
import { usePlayerStore } from '@/store'; import { usePlayerStore } from '@/store';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils'; import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils';
@@ -162,6 +171,31 @@ const favoriteList = computed(() => playerStore.favoriteList);
const favoriteSongs = ref<SongResult[]>([]); const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false); const loading = ref(false);
const noMore = ref(false); const noMore = ref(false);
const scrollbarRef = ref();
// 手工虚拟化
const {
renderedItems,
placeholderHeight,
contentPaddingBottom,
handleScroll: progressiveScroll,
resetRenderLimit
} = useProgressiveRender({
items: favoriteSongs,
itemHeight: 64,
listSelector: '.favorite-list-section',
initialCount: 40,
onReachEnd: () => {
if (!loading.value && !noMore.value) {
currentPage.value++;
getFavoriteSongs();
}
}
});
const handleScroll = (e: Event) => {
progressiveScroll(e);
};
// 多选相关 // 多选相关
const isSelecting = ref(false); const isSelecting = ref(false);
@@ -191,28 +225,24 @@ const handleSelect = (songId: number, selected: boolean) => {
// 批量下载 // 批量下载
const handleBatchDownload = async () => { const handleBatchDownload = async () => {
// 获取选中歌曲的信息
const selectedSongsList = selectedSongs.value const selectedSongsList = selectedSongs.value
.map((songId) => favoriteSongs.value.find((s) => s.id === songId)) .map((songId) => favoriteSongs.value.find((s) => s.id === songId))
.filter((song) => song) as SongResult[]; .filter((song) => song) as SongResult[];
// 使用hook中的批量下载功能
await batchDownloadMusic(selectedSongsList); await batchDownloadMusic(selectedSongsList);
// 下载完成后取消选择
cancelSelect(); cancelSelect();
}; };
// 排序相关 // 排序相关
const isDescending = ref(true); // 默认倒序显示 const isDescending = ref(true);
// 切换排序方式
const toggleSort = (descending: boolean) => { const toggleSort = (descending: boolean) => {
if (isDescending.value === descending) return; if (isDescending.value === descending) return;
isDescending.value = descending; isDescending.value = descending;
currentPage.value = 1; currentPage.value = 1;
favoriteSongs.value = []; favoriteSongs.value = [];
noMore.value = false; noMore.value = false;
resetRenderLimit();
getFavoriteSongs(); getFavoriteSongs();
}; };
@@ -229,16 +259,14 @@ const props = defineProps({
// 获取当前页的收藏歌曲ID // 获取当前页的收藏歌曲ID
const getCurrentPageIds = () => { const getCurrentPageIds = () => {
let ids = [...favoriteList.value]; // 复制一份以免修改原数组 let ids = [...favoriteList.value];
// 根据排序方式调整顺序
if (isDescending.value) { if (isDescending.value) {
ids = ids.reverse(); // 倒序,最新收藏的在前面 ids = ids.reverse();
} }
const startIndex = (currentPage.value - 1) * pageSize; const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize; const endIndex = startIndex + pageSize;
// 返回原始ID不进行类型转换
return ids.slice(startIndex, endIndex); return ids.slice(startIndex, endIndex);
}; };
@@ -259,7 +287,6 @@ const getFavoriteSongs = async () => {
const musicIds = currentIds.filter((id) => typeof id === 'number') as number[]; const musicIds = currentIds.filter((id) => typeof id === 'number') as number[];
// 处理音乐数据
let neteaseSongs: SongResult[] = []; let neteaseSongs: SongResult[] = [];
if (musicIds.length > 0) { if (musicIds.length > 0) {
const res = await getMusicDetail(musicIds); const res = await getMusicDetail(musicIds);
@@ -272,31 +299,20 @@ const getFavoriteSongs = async () => {
} }
} }
console.log('获取数据统计:', {
neteaseSongs: neteaseSongs.length
});
// 合并数据,保持原有顺序
const newSongs = currentIds const newSongs = currentIds
.map((id) => { .map((id) => {
const strId = String(id); const strId = String(id);
// 查找音乐
const found = neteaseSongs.find((song) => String(song.id) === strId); const found = neteaseSongs.find((song) => String(song.id) === strId);
return found; return found;
}) })
.filter((song): song is SongResult => !!song); .filter((song): song is SongResult => !!song);
console.log(`最终歌曲列表: ${newSongs.length}`);
// 追加新数据而不是替换
if (currentPage.value === 1) { if (currentPage.value === 1) {
favoriteSongs.value = newSongs; favoriteSongs.value = newSongs;
} else { } else {
favoriteSongs.value = [...favoriteSongs.value, ...newSongs]; favoriteSongs.value = [...favoriteSongs.value, ...newSongs];
} }
// 判断是否还有更多数据
noMore.value = favoriteSongs.value.length >= favoriteList.value.length; noMore.value = favoriteSongs.value.length >= favoriteList.value.length;
} catch (error) { } catch (error) {
console.error('获取收藏歌曲失败:', error); console.error('获取收藏歌曲失败:', error);
@@ -305,17 +321,6 @@ const getFavoriteSongs = async () => {
} }
}; };
// 处理滚动事件
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, offsetHeight } = e.target;
const threshold = 100; // 距离底部多少像素时加载更多
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
currentPage.value++;
getFavoriteSongs();
}
};
const hasLoaded = ref(false); const hasLoaded = ref(false);
onMounted(async () => { onMounted(async () => {
@@ -326,13 +331,13 @@ onMounted(async () => {
} }
}); });
// 监听收藏列表变化,变化时重置并重新加载
watch( watch(
favoriteList, favoriteList,
async () => { async () => {
hasLoaded.value = false; hasLoaded.value = false;
currentPage.value = 1; currentPage.value = 1;
noMore.value = false; noMore.value = false;
resetRenderLimit();
await getFavoriteSongs(); await getFavoriteSongs();
hasLoaded.value = true; hasLoaded.value = true;
}, },
@@ -363,7 +368,6 @@ const isIndeterminate = computed(() => {
return selectedSongs.value.length > 0 && selectedSongs.value.length < favoriteSongs.value.length; return selectedSongs.value.length > 0 && selectedSongs.value.length < favoriteSongs.value.length;
}); });
// 处理全选/取消全选
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
selectedSongs.value = favoriteSongs.value.map((song) => song.id as number); selectedSongs.value = favoriteSongs.value.map((song) => song.id as number);

View File

@@ -61,7 +61,7 @@ import { useRouter } from 'vue-router';
import { getTopAlbum } from '@/api/home'; import { getTopAlbum } from '@/api/home';
import { getAlbum } from '@/api/list'; import { getAlbum } from '@/api/list';
import { navigateToMusicList } from '@/components/common/MusicListNavigator'; import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerCoreStore } from '@/store/modules/playerCore'; import { playTrack } from '@/services/playbackController';
import { usePlaylistStore } from '@/store/modules/playlist'; import { usePlaylistStore } from '@/store/modules/playlist';
import { calculateAnimationDelay, isElectron, isMobile } from '@/utils'; import { calculateAnimationDelay, isElectron, isMobile } from '@/utils';
@@ -178,7 +178,6 @@ const playAlbum = async (album: any) => {
try { try {
const { data } = await getAlbum(album.id); const { data } = await getAlbum(album.id);
if (data.code === 200 && data.songs?.length > 0) { if (data.code === 200 && data.songs?.length > 0) {
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const albumCover = data.album?.picUrl || album.picUrl; const albumCover = data.album?.picUrl || album.picUrl;
@@ -193,7 +192,7 @@ const playAlbum = async (album: any) => {
})); }));
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
await playerCore.handlePlayMusic(playlist[0], true); await playTrack(playlist[0], true);
} }
} catch (error) { } catch (error) {
console.error('Failed to play album:', error); console.error('Failed to play album:', error);

View File

@@ -146,10 +146,9 @@ const getArtistNames = (song: any) => {
}; };
const handleSongClick = async (_song: any, index: number) => { const handleSongClick = async (_song: any, index: number) => {
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
const { usePlaylistStore } = await import('@/store/modules/playlist'); const { usePlaylistStore } = await import('@/store/modules/playlist');
const { playTrack } = await import('@/services/playbackController');
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const playlist = songs.value.map((s: any) => ({ const playlist = songs.value.map((s: any) => ({
@@ -163,16 +162,15 @@ const handleSongClick = async (_song: any, index: number) => {
})); }));
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
await playerCore.handlePlayMusic(playlist[index], true); await playTrack(playlist[index], true);
}; };
const playAll = async () => { const playAll = async () => {
if (songs.value.length === 0) return; if (songs.value.length === 0) return;
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
const { usePlaylistStore } = await import('@/store/modules/playlist'); const { usePlaylistStore } = await import('@/store/modules/playlist');
const { playTrack } = await import('@/services/playbackController');
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const playlist = songs.value.map((s: any) => ({ const playlist = songs.value.map((s: any) => ({
@@ -186,7 +184,7 @@ const playAll = async () => {
})); }));
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
await playerCore.handlePlayMusic(playlist[0], true); await playTrack(playlist[0], true);
}; };
</script> </script>

View File

@@ -441,7 +441,8 @@ const handleFmPlay = async () => {
]; ];
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
playerCore.isFmPlaying = true; playerCore.isFmPlaying = true;
await playerCore.handlePlayMusic(playlist[0], true); const { playTrack } = await import('@/services/playbackController');
await playTrack(playlist[0], true);
} catch (error) { } catch (error) {
console.error('Failed to play Personal FM:', error); console.error('Failed to play Personal FM:', error);
} }
@@ -597,9 +598,7 @@ const showDayRecommend = () => {
const playDayRecommend = async () => { const playDayRecommend = async () => {
if (dayRecommendSongs.value.length === 0) return; if (dayRecommendSongs.value.length === 0) return;
try { try {
const { usePlayerCoreStore } = await import('@/store/modules/playerCore');
const { usePlaylistStore } = await import('@/store/modules/playlist'); const { usePlaylistStore } = await import('@/store/modules/playlist');
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const songs = dayRecommendSongs.value.map((s: any) => ({ const songs = dayRecommendSongs.value.map((s: any) => ({
id: s.id, id: s.id,
@@ -611,7 +610,8 @@ const playDayRecommend = async () => {
playLoading: false playLoading: false
})); }));
playlistStore.setPlayList(songs, false, false); playlistStore.setPlayList(songs, false, false);
await playerCore.handlePlayMusic(songs[0], true); const { playTrack } = await import('@/services/playbackController');
await playTrack(songs[0], true);
} catch (error) { } catch (error) {
console.error('Failed to play daily recommend:', error); console.error('Failed to play daily recommend:', error);
} }

View File

@@ -60,7 +60,7 @@ import { useRouter } from 'vue-router';
import { getPersonalizedPlaylist } from '@/api/home'; import { getPersonalizedPlaylist } from '@/api/home';
import { getListDetail } from '@/api/list'; import { getListDetail } from '@/api/list';
import { navigateToMusicList } from '@/components/common/MusicListNavigator'; import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerCoreStore } from '@/store/modules/playerCore'; import { playTrack } from '@/services/playbackController';
import { usePlaylistStore } from '@/store/modules/playlist'; import { usePlaylistStore } from '@/store/modules/playlist';
import { calculateAnimationDelay, isElectron, isMobile } from '@/utils'; import { calculateAnimationDelay, isElectron, isMobile } from '@/utils';
@@ -154,7 +154,6 @@ const playPlaylist = async (item: any) => {
try { try {
const { data } = await getListDetail(item.id); const { data } = await getListDetail(item.id);
if (data.playlist?.tracks?.length > 0) { if (data.playlist?.tracks?.length > 0) {
const playerCore = usePlayerCoreStore();
const playlistStore = usePlaylistStore(); const playlistStore = usePlaylistStore();
const playlist = data.playlist.tracks.map((s: any) => ({ const playlist = data.playlist.tracks.map((s: any) => ({
@@ -168,7 +167,7 @@ const playPlaylist = async (item: any) => {
})); }));
playlistStore.setPlayList(playlist, false, false); playlistStore.setPlayList(playlist, false, false);
await playerCore.handlePlayMusic(playlist[0], true); await playTrack(playlist[0], true);
} }
} catch (error) { } catch (error) {
console.error('Failed to play playlist:', error); console.error('Failed to play playlist:', error);

View File

@@ -15,30 +15,21 @@
</div> </div>
<!-- Hero 内容 --> <!-- Hero 内容 -->
<div class="hero-content relative z-10 page-padding-x pt-10 pb-8"> <div class="hero-content relative z-10 page-padding-x pt-6 pb-4">
<div class="flex flex-col md:flex-row gap-8 items-center md:items-end"> <div class="flex items-center gap-5">
<div class="cover-wrapper relative group"> <div
<div class="cover-container relative w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center shadow-lg ring-2 ring-white/50 dark:ring-neutral-800/50 shrink-0"
class="cover-container relative w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-primary/10 flex items-center justify-center shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50" >
> <i class="ri-folder-music-fill text-4xl text-primary opacity-80" />
<i class="ri-folder-music-fill text-6xl text-primary opacity-80" />
</div>
</div> </div>
<div class="info-content text-center md:text-left"> <div class="info-content min-w-0">
<div class="badge mb-3">
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
>
{{ t('localMusic.title') }}
</span>
</div>
<h1 <h1
class="text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight" class="text-2xl md:text-3xl font-bold text-neutral-900 dark:text-white tracking-tight"
> >
{{ t('localMusic.title') }} {{ t('localMusic.title') }}
</h1> </h1>
<p class="mt-4 text-sm md:text-base text-neutral-500 dark:text-neutral-400"> <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{{ t('localMusic.songCount', { count: localMusicStore.musicList.length }) }} {{ t('localMusic.songCount', { count: localMusicStore.musicList.length }) }}
</p> </p>
</div> </div>
@@ -46,7 +37,7 @@
</div> </div>
</section> </section>
<!-- Action Bar (Sticky) --> <!-- Action Bar (Sticky on scroll) -->
<section <section
class="action-bar sticky top-0 z-20 page-padding-x py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50" class="action-bar sticky top-0 z-20 page-padding-x py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
> >
@@ -145,24 +136,15 @@
</button> </button>
</div> </div>
<!-- 虚拟列表 --> <!-- 歌曲列表 -->
<div v-else-if="filteredList.length > 0" class="song-list-container"> <div v-else-if="filteredList.length > 0" class="song-list-container">
<n-virtual-list <song-item
class="song-virtual-list" v-for="(item, index) in filteredSongResults"
style="max-height: calc(100vh - 280px)" :key="item.id"
:items="filteredSongResults" :index="index"
:item-size="70" :item="item"
item-resizable @play="handlePlaySong"
key-field="id" />
>
<template #default="{ item, index }">
<div>
<song-item :index="index" :item="item" @play="handlePlaySong" />
<!-- 列表末尾留白 -->
<div v-if="index === filteredSongResults.length - 1" class="h-36"></div>
</div>
</template>
</n-virtual-list>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500"> <div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll"> <n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll">
<div class="music-list-content pb-32"> <div class="music-list-content" :style="{ paddingBottom: contentPaddingBottom }">
<!-- Hero Section Action Bar --> <!-- Hero Section Action Bar -->
<n-spin :show="loading"> <n-spin :show="loading">
<!-- Hero Section --> <!-- Hero Section -->
@@ -217,23 +217,50 @@
</div> </div>
<!-- Locate Current Song --> <!-- Locate Current Song -->
<button <n-tooltip v-if="currentPlayingIndex >= 0" trigger="hover">
v-if="currentPlayingIndex >= 0" <template #trigger>
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all" <button
:title="t('comp.musicList.locateCurrent', '定位当前播放')" class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="scrollToCurrentSong" @click="scrollToCurrentSong"
> >
<i class="ri-focus-3-line text-lg" /> <i class="ri-focus-3-line text-lg" />
</button> </button>
</template>
{{ t('comp.musicList.locateCurrent') }}
</n-tooltip>
<!-- Layout Toggle --> <!-- Layout Toggle -->
<button <n-tooltip v-if="!isMobile" trigger="hover">
v-if="!isMobile" <template #trigger>
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all" <button
@click="toggleLayout" class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
> @click="toggleLayout"
<i :class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'" class="text-lg" /> >
</button> <i
:class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'"
class="text-lg"
/>
</button>
</template>
{{
isCompactLayout
? t('comp.musicList.normalLayout')
: t('comp.musicList.compactLayout')
}}
</n-tooltip>
<!-- Scroll to Top -->
<n-tooltip trigger="hover">
<template #trigger>
<button
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="scrollToTop"
>
<i class="ri-arrow-up-line text-lg" />
</button>
</template>
{{ t('comp.musicList.scrollToTop') }}
</n-tooltip>
</div> </div>
</div> </div>
</section> </section>
@@ -296,7 +323,6 @@
</section> </section>
</div> </div>
</n-scrollbar> </n-scrollbar>
<play-bottom />
</div> </div>
</template> </template>
@@ -314,7 +340,6 @@ import {
subscribePlaylist, subscribePlaylist,
updatePlaylistTracks updatePlaylistTracks
} from '@/api/music'; } from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload'; import { useDownload } from '@/hooks/useDownload';
import { useScrollTitle } from '@/hooks/useScrollTitle'; import { useScrollTitle } from '@/hooks/useScrollTitle';
@@ -338,6 +363,10 @@ const message = useMessage();
const playHistoryStore = usePlayHistoryStore(); const playHistoryStore = usePlayHistoryStore();
const loading = ref(false); const loading = ref(false);
const isPlaying = computed(() => !!playerStore.playMusicUrl);
const contentPaddingBottom = computed(() =>
isPlaying.value && !isMobile.value ? '220px' : '80px'
);
const fetchData = async () => { const fetchData = async () => {
const id = route.params.id; const id = route.params.id;
@@ -428,7 +457,7 @@ const canRemove = computed(() => {
const canCollect = ref(false); const canCollect = ref(false);
const isCollected = ref(false); const isCollected = ref(false);
const pageSize = 40; const pageSize = 200;
const initialAnimateCount = 20; // 仅前 20 项有入场动画 const initialAnimateCount = 20; // 仅前 20 项有入场动画
const displayedSongs = ref<SongResult[]>([]); const displayedSongs = ref<SongResult[]>([]);
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存 const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
@@ -832,6 +861,10 @@ const toggleLayout = () => {
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal'); localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
}; };
const scrollToTop = () => {
scrollbarRef.value?.scrollTo({ top: 0, behavior: 'smooth' });
};
const checkCollectionStatus = () => { const checkCollectionStatus = () => {
const type = route.query.type as string; const type = route.query.type as string;
if (type === 'playlist' && listInfo.value?.id) { if (type === 'playlist' && listInfo.value?.id) {

81
src/shared/download.ts Normal file
View File

@@ -0,0 +1,81 @@
// Shared types for download system, importable by both main and renderer
// Follows precedent: src/shared/appUpdate.ts
export const DOWNLOAD_TASK_STATE = {
queued: 'queued',
downloading: 'downloading',
paused: 'paused',
completed: 'completed',
error: 'error',
cancelled: 'cancelled'
} as const;
export type DownloadTaskState = (typeof DOWNLOAD_TASK_STATE)[keyof typeof DOWNLOAD_TASK_STATE];
export type DownloadSongInfo = {
id: number;
name: string;
picUrl: string;
ar: { name: string }[];
al: { name: string; picUrl: string };
};
export type DownloadTask = {
taskId: string;
url: string;
filename: string;
songInfo: DownloadSongInfo;
type: string;
state: DownloadTaskState;
progress: number;
loaded: number;
total: number;
tempFilePath: string;
finalFilePath: string;
error?: string;
createdAt: number;
batchId?: string;
};
export type DownloadSettings = {
path: string;
nameFormat: string;
separator: string;
saveLyric: boolean;
maxConcurrent: number;
};
export type DownloadProgressEvent = {
taskId: string;
progress: number;
loaded: number;
total: number;
};
export type DownloadStateChangeEvent = {
taskId: string;
state: DownloadTaskState;
task: DownloadTask;
};
export type DownloadBatchCompleteEvent = {
batchId: string;
total: number;
success: number;
failed: number;
};
export type DownloadRequestUrlEvent = {
taskId: string;
songInfo: DownloadSongInfo;
};
export function createDefaultDownloadSettings(): DownloadSettings {
return {
path: '',
nameFormat: '{songName} - {artistName}',
separator: ' - ',
saveLyric: false,
maxConcurrent: 3
};
}