mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-25 08:47:22 +08:00
Compare commits
18 Commits
7759d9b23a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97220761cf | |||
| 7282e876f4 | |||
| 6b22713854 | |||
| 0d960aa8d5 | |||
| e066efb373 | |||
| b0b3eb3326 | |||
| 4a50886a68 | |||
| f9222b699d | |||
| 030a1f1c85 | |||
| 3f31278131 | |||
| 33fc4f768c | |||
| 8e3e4e610c | |||
| 03b52cd6e2 | |||
| 8726af556a | |||
| 0ab784024c | |||
| ad2df12957 | |||
| a407045527 | |||
| 38723165a0 |
@@ -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
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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']
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -58,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}'
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ export default {
|
|||||||
addToPlaylistSuccess: 'プレイリストに追加しました',
|
addToPlaylistSuccess: 'プレイリストに追加しました',
|
||||||
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
|
||||||
locateCurrent: '再生中の曲を表示',
|
locateCurrent: '再生中の曲を表示',
|
||||||
|
scrollToTop: 'トップに戻る',
|
||||||
|
compactLayout: 'コンパクト表示',
|
||||||
|
normalLayout: '通常表示',
|
||||||
historyRecommend: '履歴の日次推薦',
|
historyRecommend: '履歴の日次推薦',
|
||||||
fetchDatesFailed: '日付リストの取得に失敗しました',
|
fetchDatesFailed: '日付リストの取得に失敗しました',
|
||||||
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
fetchSongsFailed: '楽曲リストの取得に失敗しました',
|
||||||
|
|||||||
@@ -58,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}'
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ export default {
|
|||||||
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
addToPlaylistSuccess: '재생 목록에 추가 성공',
|
||||||
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
|
||||||
locateCurrent: '현재 재생 곡 찾기',
|
locateCurrent: '현재 재생 곡 찾기',
|
||||||
|
scrollToTop: '맨 위로',
|
||||||
|
compactLayout: '간결한 레이아웃',
|
||||||
|
normalLayout: '일반 레이아웃',
|
||||||
historyRecommend: '일일 기록 권장',
|
historyRecommend: '일일 기록 권장',
|
||||||
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
fetchDatesFailed: '날짜를 가져오지 못했습니다',
|
||||||
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
fetchSongsFailed: '곡을 가져오지 못했습니다',
|
||||||
|
|||||||
@@ -58,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}'
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ export default {
|
|||||||
addToPlaylistSuccess: '添加到播放列表成功',
|
addToPlaylistSuccess: '添加到播放列表成功',
|
||||||
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
|
||||||
locateCurrent: '定位当前播放',
|
locateCurrent: '定位当前播放',
|
||||||
|
scrollToTop: '回到顶部',
|
||||||
|
compactLayout: '紧凑布局',
|
||||||
|
normalLayout: '常规布局',
|
||||||
historyRecommend: '历史日推',
|
historyRecommend: '历史日推',
|
||||||
fetchDatesFailed: '获取日期列表失败',
|
fetchDatesFailed: '获取日期列表失败',
|
||||||
fetchSongsFailed: '获取歌曲列表失败',
|
fetchSongsFailed: '获取歌曲列表失败',
|
||||||
|
|||||||
@@ -57,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}'
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ export default {
|
|||||||
addToPlaylistSuccess: '新增至播放清單成功',
|
addToPlaylistSuccess: '新增至播放清單成功',
|
||||||
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
|
||||||
locateCurrent: '定位當前播放',
|
locateCurrent: '定位當前播放',
|
||||||
|
scrollToTop: '回到頂部',
|
||||||
|
compactLayout: '緊湊佈局',
|
||||||
|
normalLayout: '常規佈局',
|
||||||
historyRecommend: '歷史日推',
|
historyRecommend: '歷史日推',
|
||||||
fetchDatesFailed: '獲取日期列表失敗',
|
fetchDatesFailed: '獲取日期列表失敗',
|
||||||
fetchSongsFailed: '獲取歌曲列表失敗',
|
fetchSongsFailed: '獲取歌曲列表失敗',
|
||||||
|
|||||||
@@ -57,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}'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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';
|
||||||
@@ -82,6 +83,9 @@ function initialize(configStore: any) {
|
|||||||
// 初始化远程控制服务
|
// 初始化远程控制服务
|
||||||
initializeRemoteControl(mainWindow);
|
initializeRemoteControl(mainWindow);
|
||||||
|
|
||||||
|
// 初始化 MPRIS 服务 (Linux)
|
||||||
|
initializeMpris(mainWindow);
|
||||||
|
|
||||||
// 初始化更新处理程序
|
// 初始化更新处理程序
|
||||||
setupUpdateHandlers(mainWindow);
|
setupUpdateHandlers(mainWindow);
|
||||||
}
|
}
|
||||||
@@ -92,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 {
|
||||||
@@ -171,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 所有窗口关闭时的处理
|
// 所有窗口关闭时的处理
|
||||||
|
|||||||
@@ -10,6 +10,65 @@ let isDragging = false;
|
|||||||
|
|
||||||
// 添加窗口大小变化防护
|
// 添加窗口大小变化防护
|
||||||
let originalSize = { width: 0, height: 0 };
|
let originalSize = { width: 0, height: 0 };
|
||||||
|
// 鼠标位置轮询仅在"锁定 + 可见"时启用,解锁态下 DOM 事件已足够
|
||||||
|
let mousePresenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastMouseInside: boolean | null = null;
|
||||||
|
let isLyricLocked = false;
|
||||||
|
let isLyricWindowVisible = false;
|
||||||
|
|
||||||
|
const isPointInsideWindow = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
bounds: { x: number; y: number; width: number; height: number }
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
point.x >= bounds.x &&
|
||||||
|
point.x < bounds.x + bounds.width &&
|
||||||
|
point.y >= bounds.y &&
|
||||||
|
point.y < bounds.y + bounds.height
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopMousePresenceTracking = () => {
|
||||||
|
if (mousePresenceTimer) {
|
||||||
|
clearInterval(mousePresenceTimer);
|
||||||
|
mousePresenceTimer = null;
|
||||||
|
}
|
||||||
|
lastMouseInside = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitMousePresence = () => {
|
||||||
|
if (!lyricWindow || lyricWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const mousePoint = screen.getCursorScreenPoint();
|
||||||
|
const bounds = lyricWindow.getBounds();
|
||||||
|
const isInside = isPointInsideWindow(mousePoint, bounds);
|
||||||
|
|
||||||
|
if (isInside === lastMouseInside) return;
|
||||||
|
|
||||||
|
lastMouseInside = isInside;
|
||||||
|
lyricWindow.webContents.send('lyric-mouse-presence', isInside);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMousePresenceTracking = () => {
|
||||||
|
if (mousePresenceTimer) return;
|
||||||
|
|
||||||
|
emitMousePresence();
|
||||||
|
mousePresenceTimer = setInterval(() => {
|
||||||
|
if (!lyricWindow || lyricWindow.isDestroyed()) {
|
||||||
|
stopMousePresenceTracking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitMousePresence();
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncMousePresenceTracking = () => {
|
||||||
|
if (isLyricLocked && isLyricWindowVisible && lyricWindow && !lyricWindow.isDestroyed()) {
|
||||||
|
startMousePresenceTracking();
|
||||||
|
} else {
|
||||||
|
stopMousePresenceTracking();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createWin = () => {
|
const createWin = () => {
|
||||||
console.log('Creating lyric window');
|
console.log('Creating lyric window');
|
||||||
@@ -102,12 +161,32 @@ const createWin = () => {
|
|||||||
|
|
||||||
// 监听窗口关闭事件
|
// 监听窗口关闭事件
|
||||||
lyricWindow.on('closed', () => {
|
lyricWindow.on('closed', () => {
|
||||||
|
stopMousePresenceTracking();
|
||||||
|
isLyricLocked = false;
|
||||||
|
isLyricWindowVisible = false;
|
||||||
if (lyricWindow) {
|
if (lyricWindow) {
|
||||||
lyricWindow.destroy();
|
lyricWindow.destroy();
|
||||||
lyricWindow = null;
|
lyricWindow = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lyricWindow.on('show', () => {
|
||||||
|
isLyricWindowVisible = true;
|
||||||
|
syncMousePresenceTracking();
|
||||||
|
});
|
||||||
|
lyricWindow.on('hide', () => {
|
||||||
|
isLyricWindowVisible = false;
|
||||||
|
stopMousePresenceTracking();
|
||||||
|
});
|
||||||
|
lyricWindow.on('minimize', () => {
|
||||||
|
isLyricWindowVisible = false;
|
||||||
|
stopMousePresenceTracking();
|
||||||
|
});
|
||||||
|
lyricWindow.on('restore', () => {
|
||||||
|
isLyricWindowVisible = true;
|
||||||
|
syncMousePresenceTracking();
|
||||||
|
});
|
||||||
|
|
||||||
// 监听窗口大小变化事件,保存新的尺寸
|
// 监听窗口大小变化事件,保存新的尺寸
|
||||||
lyricWindow.on('resize', () => {
|
lyricWindow.on('resize', () => {
|
||||||
// 如果正在拖动,忽略大小调整事件
|
// 如果正在拖动,忽略大小调整事件
|
||||||
@@ -205,6 +284,17 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('set-lyric-lock-state', (_, isLocked: boolean) => {
|
||||||
|
isLyricLocked = isLocked;
|
||||||
|
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||||
|
// 锁定时禁用 resize,避免鼠标移到边缘仍显示调整光标
|
||||||
|
lyricWindow.setResizable(!isLocked);
|
||||||
|
// 设置初始穿透状态,后续 polling 会按实际位置纠正
|
||||||
|
lyricWindow.setIgnoreMouseEvents(isLocked, { forward: true });
|
||||||
|
}
|
||||||
|
syncMousePresenceTracking();
|
||||||
|
});
|
||||||
|
|
||||||
// 处理鼠标事件
|
// 处理鼠标事件
|
||||||
ipcMain.on('mouseenter-lyric', () => {
|
ipcMain.on('mouseenter-lyric', () => {
|
||||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+23
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,14 +131,16 @@ 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+203
-153
@@ -52,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',
|
||||||
[
|
[
|
||||||
@@ -64,25 +69,27 @@ 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 setupKeyboardListeners = () => {
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
let audioListenersInitialized = false;
|
let audioListenersInitialized = false;
|
||||||
@@ -142,124 +149,125 @@ const parseLyricsString = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置音乐相关的监听器
|
// 解析当前 playMusic.lyric 写入 lrcArray, 供 watcher / openLyric / onLyricWindowReady 共用
|
||||||
|
const ensureLyricsLoaded = async (force = false) => {
|
||||||
|
const songId = playMusic.value?.id;
|
||||||
|
if (!songId) {
|
||||||
|
lrcArray.value = [];
|
||||||
|
lrcTimeArray.value = [];
|
||||||
|
nowIndex.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && lrcArray.value.length > 0) return;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const lyricData = playMusic.value.lyric;
|
||||||
|
if (lyricData && typeof lyricData === 'string') {
|
||||||
|
const {
|
||||||
|
lrcArray: parsedLrcArray,
|
||||||
|
lrcTimeArray: parsedTimeArray,
|
||||||
|
hasWordByWord
|
||||||
|
} = await parseLyricsString(lyricData);
|
||||||
|
lrcArray.value = parsedLrcArray;
|
||||||
|
lrcTimeArray.value = parsedTimeArray;
|
||||||
|
|
||||||
|
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||||
|
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||||
|
}
|
||||||
|
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
|
||||||
|
const rawLrc = lyricData.lrcArray || [];
|
||||||
|
lrcTimeArray.value = lyricData.lrcTimeArray || [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||||
|
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||||
|
lrcArray.value = rawLrc as any;
|
||||||
|
}
|
||||||
|
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
|
||||||
|
try {
|
||||||
|
let filePath = decodeURIComponent(playMusic.value.playMusicUrl.replace('local:///', ''));
|
||||||
|
// 处理 Windows 路径:/C:/... → C:/...
|
||||||
|
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||||
|
filePath = filePath.slice(1);
|
||||||
|
}
|
||||||
|
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
|
||||||
|
if (embeddedLyrics) {
|
||||||
|
const {
|
||||||
|
lrcArray: parsedLrcArray,
|
||||||
|
lrcTimeArray: parsedTimeArray,
|
||||||
|
hasWordByWord
|
||||||
|
} = await parseLyricsString(embeddedLyrics);
|
||||||
|
lrcArray.value = parsedLrcArray;
|
||||||
|
lrcTimeArray.value = parsedTimeArray;
|
||||||
|
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||||
|
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
|
||||||
|
}
|
||||||
|
} else if (typeof songId === 'number') {
|
||||||
|
try {
|
||||||
|
const { getMusicLrc } = await import('@/api/music');
|
||||||
|
const res = await getMusicLrc(songId);
|
||||||
|
if (res?.data?.lrc?.lyric) {
|
||||||
|
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||||
|
res.data.lrc.lyric
|
||||||
|
);
|
||||||
|
lrcArray.value = apiLrcArray;
|
||||||
|
lrcTimeArray.value = apiTimeArray;
|
||||||
|
}
|
||||||
|
} catch (apiErr) {
|
||||||
|
console.error('API lyrics fallback failed:', apiErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to extract embedded lyrics:', err);
|
||||||
|
}
|
||||||
|
} else if (typeof songId === 'number') {
|
||||||
|
// 在线歌曲但 lyric 字段尚未加载, 主动调 API 兜底
|
||||||
|
try {
|
||||||
|
const { getMusicLrc } = await import('@/api/music');
|
||||||
|
const res = await getMusicLrc(songId);
|
||||||
|
if (res?.data?.lrc?.lyric) {
|
||||||
|
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||||
|
res.data.lrc.lyric
|
||||||
|
);
|
||||||
|
lrcArray.value = apiLrcArray;
|
||||||
|
lrcTimeArray.value = apiTimeArray;
|
||||||
|
}
|
||||||
|
} catch (apiErr) {
|
||||||
|
console.error('API lyrics fallback failed:', apiErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElectron && isLyricWindowOpen.value) {
|
||||||
|
sendLyricToWin();
|
||||||
|
setTimeout(() => sendLyricToWin(), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setupMusicWatchers = () => {
|
const setupMusicWatchers = () => {
|
||||||
const store = getPlayerStore();
|
const store = getPlayerStore();
|
||||||
|
|
||||||
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
// 切歌时 id 变化, 强制重新解析
|
||||||
watch(
|
watch(
|
||||||
() => store.playMusic.id,
|
() => store.playMusic.id,
|
||||||
async (newId, oldId) => {
|
async (newId, oldId) => {
|
||||||
// 如果没有歌曲ID,清空歌词
|
if (newId !== oldId) nowIndex.value = 0;
|
||||||
if (!newId) {
|
await ensureLyricsLoaded(true);
|
||||||
lrcArray.value = [];
|
|
||||||
lrcTimeArray.value = [];
|
|
||||||
nowIndex.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 避免相同ID的重复执行(但允许初始化时执行)
|
|
||||||
if (newId === oldId && lrcArray.value.length > 0) return;
|
|
||||||
|
|
||||||
// 歌曲切换时重置歌词索引
|
|
||||||
if (newId !== oldId) {
|
|
||||||
nowIndex.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick(async () => {
|
|
||||||
console.log('歌曲切换,更新歌词数据');
|
|
||||||
|
|
||||||
// 检查是否有原始歌词字符串需要解析
|
|
||||||
const lyricData = playMusic.value.lyric;
|
|
||||||
if (lyricData && typeof lyricData === 'string') {
|
|
||||||
// 如果歌词是字符串格式,使用新的解析器
|
|
||||||
const {
|
|
||||||
lrcArray: parsedLrcArray,
|
|
||||||
lrcTimeArray: parsedTimeArray,
|
|
||||||
hasWordByWord
|
|
||||||
} = await parseLyricsString(lyricData);
|
|
||||||
lrcArray.value = parsedLrcArray;
|
|
||||||
lrcTimeArray.value = parsedTimeArray;
|
|
||||||
|
|
||||||
// 更新歌曲的歌词数据结构
|
|
||||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
|
||||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
|
||||||
}
|
|
||||||
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
|
|
||||||
// 使用现有的歌词数据结构
|
|
||||||
const rawLrc = lyricData.lrcArray || [];
|
|
||||||
lrcTimeArray.value = lyricData.lrcTimeArray || [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
|
||||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
|
||||||
lrcArray.value = rawLrc as any;
|
|
||||||
}
|
|
||||||
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
|
|
||||||
// 从下载/本地文件的 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) {
|
|
||||||
console.log('歌词窗口已打开,同步最新歌词数据');
|
|
||||||
// 不管歌词数组是否为空,都发送最新数据
|
|
||||||
sendLyricToWin();
|
|
||||||
|
|
||||||
// 再次延迟发送,确保歌词窗口已完全加载
|
|
||||||
setTimeout(() => {
|
|
||||||
sendLyricToWin();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 同一首歌但 lyric 字段后到 (重启 + autoPlay 关闭场景)
|
||||||
|
watch(
|
||||||
|
() => playMusic.value?.lyric,
|
||||||
|
() => {
|
||||||
|
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||||
|
ensureLyricsLoaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupAudioListeners = () => {
|
const setupAudioListeners = () => {
|
||||||
@@ -327,6 +335,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;
|
||||||
@@ -370,6 +384,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 继续尝试
|
||||||
@@ -418,6 +441,11 @@ const setupAudioListeners = () => {
|
|||||||
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) {
|
||||||
@@ -459,7 +487,10 @@ const setupAudioListeners = () => {
|
|||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
|
window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
|
||||||
}
|
}
|
||||||
// 启动进度更新
|
// 兜底: 重启后首次点播放时 lrcArray 仍为空则主动加载
|
||||||
|
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||||
|
ensureLyricsLoaded();
|
||||||
|
}
|
||||||
startProgressInterval();
|
startProgressInterval();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -805,6 +836,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;
|
||||||
|
|
||||||
@@ -842,28 +897,20 @@ const stopLyricSync = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改openLyric函数,添加定时同步
|
export const openLyric = async () => {
|
||||||
export const openLyric = () => {
|
|
||||||
if (!isElectron) return;
|
if (!isElectron) return;
|
||||||
|
|
||||||
// 检查是否有播放中的歌曲
|
|
||||||
if (!playMusic.value || !playMusic.value.id) {
|
if (!playMusic.value || !playMusic.value.id) {
|
||||||
console.log('没有正在播放的歌曲,无法打开歌词窗口');
|
console.log('没有正在播放的歌曲,无法打开歌词窗口');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Opening lyric window with current song:', playMusic.value?.name);
|
|
||||||
|
|
||||||
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
||||||
if (isLyricWindowOpen.value) {
|
if (isLyricWindowOpen.value) {
|
||||||
// 立即打开窗口
|
|
||||||
window.api.openLyric();
|
window.api.openLyric();
|
||||||
|
|
||||||
// 确保有歌词数据,如果没有,则使用默认的"无歌词"提示
|
// 先发"加载中"占位, 防止窗口启动期间显示"无歌词"
|
||||||
if (!lrcArray.value || lrcArray.value.length === 0) {
|
if (!lrcArray.value || lrcArray.value.length === 0) {
|
||||||
// 如果当前播放的歌曲有ID但没有歌词,则尝试加载歌词
|
|
||||||
console.log('尝试加载歌词数据...');
|
|
||||||
// 发送默认的"无歌词"数据
|
|
||||||
const emptyLyricData = {
|
const emptyLyricData = {
|
||||||
type: 'empty',
|
type: 'empty',
|
||||||
nowIndex: 0,
|
nowIndex: 0,
|
||||||
@@ -877,12 +924,15 @@ export const openLyric = () => {
|
|||||||
playMusic: playMusic.value
|
playMusic: playMusic.value
|
||||||
};
|
};
|
||||||
window.api.sendLyric(JSON.stringify(emptyLyricData));
|
window.api.sendLyric(JSON.stringify(emptyLyricData));
|
||||||
|
|
||||||
|
// 关键: 主动加载歌词, 不依赖 watcher
|
||||||
|
// (重启场景下 playerCore.playMusic 整体替换可能未触发 lyric watcher)
|
||||||
|
await ensureLyricsLoaded(true);
|
||||||
} else {
|
} else {
|
||||||
// 发送完整歌词数据
|
|
||||||
sendLyricToWin();
|
sendLyricToWin();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟重发一次,以防窗口加载略慢
|
// 延迟重发, 防窗口加载慢丢消息
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isLyricWindowOpen.value) {
|
if (isLyricWindowOpen.value) {
|
||||||
sendLyricToWin();
|
sendLyricToWin();
|
||||||
@@ -1004,11 +1054,13 @@ export const initAudioListeners = async () => {
|
|||||||
window.api.onLyricWindowClosed(() => {
|
window.api.onLyricWindowClosed(() => {
|
||||||
isLyricWindowOpen.value = false;
|
isLyricWindowOpen.value = false;
|
||||||
});
|
});
|
||||||
// 歌词窗口 Vue 加载完成后,发送完整歌词数据
|
window.api.onLyricWindowReady(async () => {
|
||||||
window.api.onLyricWindowReady(() => {
|
if (!isLyricWindowOpen.value) return;
|
||||||
if (isLyricWindowOpen.value) {
|
// 窗口加载完成时再兜底加载一次, 防止 openLyric 阶段 lyric 字段尚未到位
|
||||||
sendLyricToWin();
|
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||||
|
await ensureLyricsLoaded(true);
|
||||||
}
|
}
|
||||||
|
sendLyricToWin();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,18 +1077,14 @@ export const initAudioListeners = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加音频就绪事件监听器
|
// 音频就绪事件处理器(提取为命名函数,防止重复注册)
|
||||||
window.addEventListener('audio-ready', ((event: CustomEvent) => {
|
const handleAudioReady = ((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 = audioService.getCurrentSound();
|
||||||
|
|
||||||
// 设置音频监听器
|
|
||||||
setupAudioListeners();
|
setupAudioListeners();
|
||||||
|
|
||||||
// 获取当前播放位置并更新显示
|
|
||||||
const currentSound = audioService.getCurrentSound();
|
const currentSound = audioService.getCurrentSound();
|
||||||
if (currentSound) {
|
if (currentSound) {
|
||||||
const currentPosition = currentSound.currentTime;
|
const currentPosition = currentSound.currentTime;
|
||||||
@@ -1044,10 +1092,12 @@ window.addEventListener('audio-ready', ((event: CustomEvent) => {
|
|||||||
nowTime.value = 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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -605,13 +605,6 @@ class AudioService {
|
|||||||
|
|
||||||
public async getAudioOutputDevices(): Promise<AudioOutputDevice[]> {
|
public async getAudioOutputDevices(): Promise<AudioOutputDevice[]> {
|
||||||
try {
|
try {
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
|
||||||
} catch {
|
|
||||||
// Continue even if permission denied
|
|
||||||
}
|
|
||||||
|
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
const audioOutputs = devices.filter((d) => d.kind === 'audiooutput');
|
const audioOutputs = devices.filter((d) => d.kind === 'audiooutput');
|
||||||
|
|
||||||
|
|||||||
@@ -585,8 +585,7 @@ export const usePlaylistStore = defineStore(
|
|||||||
// Toggle play/pause for current song
|
// 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="download-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
<div class="download-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||||
<n-scrollbar class="h-full">
|
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleDownloadScroll">
|
||||||
<div class="download-content pb-32">
|
<div class="download-content" :style="{ paddingBottom: contentPaddingBottom }">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
||||||
<!-- Background with Blur -->
|
<!-- Background with Blur -->
|
||||||
@@ -210,86 +210,96 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<div
|
<div class="downloaded-list-section">
|
||||||
v-for="(item, index) in downloadStore.completedList"
|
|
||||||
:key="item.path || item.filePath"
|
|
||||||
class="downloaded-item group animate-item p-3 rounded-2xl flex items-center gap-4 hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-all"
|
|
||||||
:style="{ animationDelay: `${index * 0.03}s` }"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="relative w-12 h-12 rounded-xl overflow-hidden shadow-lg flex-shrink-0"
|
v-for="(item, index) in renderedDownloaded"
|
||||||
|
:key="item.path || item.filePath"
|
||||||
|
class="downloaded-item group p-3 rounded-2xl flex items-center gap-4 hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-all"
|
||||||
|
:class="{ 'animate-item': index < 20 }"
|
||||||
|
:style="index < 20 ? { animationDelay: `${index * 0.03}s` } : undefined"
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
:src="getImgUrl(item.picUrl, '100y100')"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
@error="handleCoverError"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
class="relative w-12 h-12 rounded-xl overflow-hidden shadow-lg flex-shrink-0"
|
||||||
@click="handlePlayMusic(item)"
|
|
||||||
>
|
>
|
||||||
<i class="ri-play-fill text-white text-xl" />
|
<img
|
||||||
</div>
|
:src="getImgUrl(item.picUrl, '100y100')"
|
||||||
</div>
|
class="w-full h-full object-cover"
|
||||||
|
@error="handleCoverError"
|
||||||
<div class="flex-1 min-w-0">
|
/>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-bold text-neutral-900 dark:text-white truncate">{{
|
|
||||||
item.displayName || item.filename
|
|
||||||
}}</span>
|
|
||||||
<span class="text-xs text-neutral-400 flex-shrink-0">{{
|
|
||||||
formatSize(item.size)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 mt-1">
|
|
||||||
<span class="text-xs text-neutral-500 truncate max-w-[150px]">{{
|
|
||||||
item.ar?.map((a) => a.name).join(', ')
|
|
||||||
}}</span>
|
|
||||||
<div
|
<div
|
||||||
class="hidden md:flex items-center gap-1 text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-full truncate"
|
class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||||
|
@click="handlePlayMusic(item)"
|
||||||
>
|
>
|
||||||
<i class="ri-folder-line" />
|
<i class="ri-play-fill text-white text-xl" />
|
||||||
<span class="truncate">{{ shortenPath(item.path || item.filePath) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex-1 min-w-0">
|
||||||
<n-tooltip trigger="hover">
|
<div class="flex items-center gap-2">
|
||||||
<template #trigger>
|
<span class="text-sm font-bold text-neutral-900 dark:text-white truncate">{{
|
||||||
<button
|
item.displayName || item.filename
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
|
}}</span>
|
||||||
@click="copyPath(item.path || item.filePath)"
|
<span class="text-xs text-neutral-400 flex-shrink-0">{{
|
||||||
|
formatSize(item.size)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mt-1">
|
||||||
|
<span class="text-xs text-neutral-500 truncate max-w-[150px]">{{
|
||||||
|
item.ar?.map((a) => a.name).join(', ')
|
||||||
|
}}</span>
|
||||||
|
<div
|
||||||
|
class="hidden md:flex items-center gap-1 text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-full truncate"
|
||||||
>
|
>
|
||||||
<i class="ri-file-copy-line" />
|
<i class="ri-folder-line" />
|
||||||
</button>
|
<span class="truncate">{{
|
||||||
</template>
|
shortenPath(item.path || item.filePath)
|
||||||
{{ t('download.path.copy') || '复制路径' }}
|
}}</span>
|
||||||
</n-tooltip>
|
</div>
|
||||||
<n-tooltip trigger="hover">
|
</div>
|
||||||
<template #trigger>
|
</div>
|
||||||
<button
|
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
|
<div class="flex items-center gap-1">
|
||||||
@click="openDirectory(item.path || item.filePath)"
|
<n-tooltip trigger="hover">
|
||||||
>
|
<template #trigger>
|
||||||
<i class="ri-folder-open-line" />
|
<button
|
||||||
</button>
|
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
|
||||||
</template>
|
@click="copyPath(item.path || item.filePath)"
|
||||||
{{ t('download.settingsPanel.open') }}
|
>
|
||||||
</n-tooltip>
|
<i class="ri-file-copy-line" />
|
||||||
<n-tooltip trigger="hover">
|
</button>
|
||||||
<template #trigger>
|
</template>
|
||||||
<button
|
{{ t('download.path.copy') || '复制路径' }}
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all"
|
</n-tooltip>
|
||||||
@click="handleDelete(item)"
|
<n-tooltip trigger="hover">
|
||||||
>
|
<template #trigger>
|
||||||
<i class="ri-delete-bin-line" />
|
<button
|
||||||
</button>
|
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
|
||||||
</template>
|
@click="openDirectory(item.path || item.filePath)"
|
||||||
{{ t('common.delete') }}
|
>
|
||||||
</n-tooltip>
|
<i class="ri-folder-open-line" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ t('download.settingsPanel.open') }}
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all"
|
||||||
|
@click="handleDelete(item)"
|
||||||
|
>
|
||||||
|
<i class="ri-delete-bin-line" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 未渲染项占位 -->
|
||||||
|
<div
|
||||||
|
v-if="downloadedPlaceholderHeight > 0"
|
||||||
|
:style="{ height: downloadedPlaceholderHeight + 'px' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,8 +335,38 @@
|
|||||||
@positive-click="clearDownloadRecords"
|
@positive-click="clearDownloadRecords"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 未保存下载设置确认对话框 -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showNotSaveConfirm"
|
||||||
|
preset="dialog"
|
||||||
|
type="warning"
|
||||||
|
:z-index="3200"
|
||||||
|
:title="t('download.save.title')"
|
||||||
|
:content="t('download.save.message')"
|
||||||
|
:positive-text="t('download.save.confirm')"
|
||||||
|
:negative-text="t('download.save.discard')"
|
||||||
|
@positive-click="saveDownloadSettings"
|
||||||
|
@negative-click="discardDownloadSettings"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<n-button @click="showNotSaveConfirm = false">{{ t('download.save.cancel') }}</n-button>
|
||||||
|
<n-button type="error" @click="discardDownloadSettings">{{
|
||||||
|
t('download.save.discard')
|
||||||
|
}}</n-button>
|
||||||
|
<n-button type="primary" @click="saveDownloadSettings">{{
|
||||||
|
t('download.save.confirm')
|
||||||
|
}}</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
<!-- 下载设置抽屉 -->
|
<!-- 下载设置抽屉 -->
|
||||||
<n-drawer v-model:show="showSettingsDrawer" :width="400" placement="right">
|
<n-drawer
|
||||||
|
:show="showSettingsDrawer"
|
||||||
|
:width="400"
|
||||||
|
placement="right"
|
||||||
|
:z-index="3100"
|
||||||
|
@update:show="handleDrawerUpdate"
|
||||||
|
>
|
||||||
<n-drawer-content :title="t('download.settingsPanel.title')" closable>
|
<n-drawer-content :title="t('download.settingsPanel.title')" closable>
|
||||||
<div class="download-settings-content space-y-8 py-4">
|
<div class="download-settings-content space-y-8 py-4">
|
||||||
<!-- Path Section -->
|
<!-- Path Section -->
|
||||||
@@ -510,6 +550,7 @@ import { useMessage } from 'naive-ui';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { useProgressiveRender } from '@/hooks/useProgressiveRender';
|
||||||
import { useDownloadStore } from '@/store/modules/download';
|
import { useDownloadStore } from '@/store/modules/download';
|
||||||
import { usePlayerStore } from '@/store/modules/player';
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
import type { SongResult } from '@/types/music';
|
import type { SongResult } from '@/types/music';
|
||||||
@@ -521,8 +562,23 @@ const { t } = useI18n();
|
|||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
const scrollbarRef = ref();
|
||||||
|
|
||||||
const tabName = ref('downloading');
|
const completedList = computed(() => downloadStore.completedList);
|
||||||
|
|
||||||
|
const {
|
||||||
|
renderedItems: renderedDownloaded,
|
||||||
|
placeholderHeight: downloadedPlaceholderHeight,
|
||||||
|
contentPaddingBottom,
|
||||||
|
handleScroll: handleDownloadScroll
|
||||||
|
} = useProgressiveRender({
|
||||||
|
items: completedList,
|
||||||
|
itemHeight: 72,
|
||||||
|
listSelector: '.downloaded-list-section',
|
||||||
|
initialCount: 40
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabName = ref(downloadStore.downloadingList.length > 0 ? 'downloading' : 'downloaded');
|
||||||
|
|
||||||
// ── Status helpers ──────────────────────────────────────────────────────────
|
// ── Status helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -705,12 +761,40 @@ const clearDownloadRecords = async () => {
|
|||||||
// ── Download settings ───────────────────────────────────────────────────────
|
// ── Download settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const showSettingsDrawer = ref(false);
|
const showSettingsDrawer = ref(false);
|
||||||
|
const showNotSaveConfirm = ref(false);
|
||||||
const downloadSettings = ref({
|
const downloadSettings = ref({
|
||||||
path: '',
|
path: '',
|
||||||
nameFormat: '{songName} - {artistName}',
|
nameFormat: '{songName} - {artistName}',
|
||||||
separator: ' - ',
|
separator: ' - ',
|
||||||
saveLyric: false
|
saveLyric: false
|
||||||
});
|
});
|
||||||
|
const originalDownloadSettings = ref({ ...downloadSettings.value });
|
||||||
|
|
||||||
|
watch(showSettingsDrawer, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
originalDownloadSettings.value = { ...downloadSettings.value };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDrawerUpdate = (show: boolean) => {
|
||||||
|
if (show) {
|
||||||
|
showSettingsDrawer.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isModified =
|
||||||
|
JSON.stringify(downloadSettings.value) !== JSON.stringify(originalDownloadSettings.value);
|
||||||
|
if (isModified) {
|
||||||
|
showNotSaveConfirm.value = true;
|
||||||
|
} else {
|
||||||
|
showSettingsDrawer.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardDownloadSettings = () => {
|
||||||
|
downloadSettings.value = { ...originalDownloadSettings.value };
|
||||||
|
showNotSaveConfirm.value = false;
|
||||||
|
showSettingsDrawer.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const formatComponents = ref([
|
const formatComponents = ref([
|
||||||
{ id: 1, type: 'songName' },
|
{ id: 1, type: 'songName' },
|
||||||
@@ -824,7 +908,9 @@ const saveDownloadSettings = () => {
|
|||||||
downloadStore.refreshCompleted();
|
downloadStore.refreshCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalDownloadSettings.value = { ...downloadSettings.value };
|
||||||
message.success(t('download.settingsPanel.saveSuccess'));
|
message.success(t('download.settingsPanel.saveSuccess'));
|
||||||
|
showNotSaveConfirm.value = false;
|
||||||
showSettingsDrawer.value = false;
|
showSettingsDrawer.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ const displayMode = computed(() => lyricSetting.value.displayMode);
|
|||||||
const showTranslation = computed(() => lyricSetting.value.showTranslation);
|
const showTranslation = computed(() => lyricSetting.value.showTranslation);
|
||||||
|
|
||||||
let hideControlsTimer: number | null = null;
|
let hideControlsTimer: number | null = null;
|
||||||
|
let removeMousePresenceListener: (() => void) | null = null;
|
||||||
|
|
||||||
const isHovering = ref(false);
|
const isHovering = ref(false);
|
||||||
|
|
||||||
@@ -400,6 +401,7 @@ watch(
|
|||||||
// 锁定时自动关闭主题色面板
|
// 锁定时自动关闭主题色面板
|
||||||
showThemeColorPanel.value = false;
|
showThemeColorPanel.value = false;
|
||||||
}
|
}
|
||||||
|
windowData.electron.ipcRenderer.send('set-lyric-lock-state', newLock);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -782,10 +784,27 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 通知主窗口歌词窗口已就绪,请求发送完整歌词数据
|
// 通知主窗口歌词窗口已就绪,请求发送完整歌词数据
|
||||||
windowData.electron.ipcRenderer.send('lyric-ready');
|
windowData.electron.ipcRenderer.send('lyric-ready');
|
||||||
|
|
||||||
|
removeMousePresenceListener = window.ipcRenderer.on(
|
||||||
|
'lyric-mouse-presence',
|
||||||
|
(isInside: boolean) => {
|
||||||
|
isHovering.value = isInside;
|
||||||
|
|
||||||
|
if (lyricSetting.value.isLock) {
|
||||||
|
windowData.electron.ipcRenderer.send('set-ignore-mouse', !isInside);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
windowData.electron.ipcRenderer.send('set-lyric-lock-state', lyricSetting.value.isLock);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', updateContainerHeight);
|
window.removeEventListener('resize', updateContainerHeight);
|
||||||
|
if (removeMousePresenceListener) {
|
||||||
|
removeMousePresenceListener();
|
||||||
|
removeMousePresenceListener = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkTheme = () => {
|
const checkTheme = () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user