mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 19:37:38 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce743e1b65 | ||
|
|
32c9fdbfeb | ||
|
|
9df236b2e0 | ||
|
|
0988c71282 | ||
|
|
60881f7f48 | ||
|
|
775f87aa86 | ||
|
|
b1c471f15c | ||
|
|
f7ecfa1fa9 | ||
|
|
d44be6022a |
11
README.md
11
README.md
@@ -295,7 +295,11 @@ CeruMuisc/
|
|||||||
|
|
||||||
## 联系方式
|
## 联系方式
|
||||||
|
|
||||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
如有技术问题或合作意向
|
||||||
|
可通过如下方式联系
|
||||||
|
- QQ: 2115295703
|
||||||
|
- 微信:13600973542
|
||||||
|
- 邮箱:sqj@shiqianjiang.cn
|
||||||
|
|
||||||
## 项目开发者
|
## 项目开发者
|
||||||
|
|
||||||
@@ -357,8 +361,3 @@ CeruMuisc/
|
|||||||
|
|
||||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||||
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||||
|
|
||||||
## 联系
|
|
||||||
|
|
||||||
关于项目问题也可联系
|
|
||||||
邮箱:sqj@shiqianjiang.cn
|
|
||||||
|
|||||||
BIN
docs/assets/image-20251003173109619.png
Normal file
BIN
docs/assets/image-20251003173109619.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
BIN
docs/assets/image-20251003173141699.png
Normal file
BIN
docs/assets/image-20251003173141699.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
BIN
docs/assets/image-20251003173654569.png
Normal file
BIN
docs/assets/image-20251003173654569.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 870 KiB |
@@ -5,7 +5,7 @@
|
|||||||
| 昵称 | 赞助金额 |
|
| 昵称 | 赞助金额 |
|
||||||
| :-------------------------: | :------: |
|
| :-------------------------: | :------: |
|
||||||
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
||||||
| **群友**:🍀 | 5 |
|
| **群友**:🍀 | 5 |
|
||||||
| **群友**:涟漪 | 50 |
|
| **群友**:涟漪 | 50 |
|
||||||
| **作者朋友** | 188 |
|
| **作者朋友** | 188 |
|
||||||
| **群友**:我叫阿狸 | 3 |
|
| **群友**:我叫阿狸 | 3 |
|
||||||
@@ -14,5 +14,7 @@
|
|||||||
| 青禾 | 8.8 |
|
| 青禾 | 8.8 |
|
||||||
| li peng | 200 |
|
| li peng | 200 |
|
||||||
| **群友**:XIZ | 3 |
|
| **群友**:XIZ | 3 |
|
||||||
|
| YL | 10 |
|
||||||
|
| **群友**:way1437 | 50 |
|
||||||
|
|
||||||
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ features:
|
|||||||
|
|
||||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||||
|
|
||||||
<img src="./assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="./assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem">
|
||||||
|
<img src="./assets/image-20251003173109619.png" alt="image-20251003173109619"/>
|
||||||
|
<img src= "./assets/image-20251003173654569.png">
|
||||||
|
</div>
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.4.2",
|
"version": "1.4.6",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/needle": "^3.3.0",
|
"@types/needle": "^3.3.0",
|
||||||
"NeteaseCloudMusicApi": "^4.27.0",
|
"NeteaseCloudMusicApi": "^4.27.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
@@ -57,8 +57,7 @@
|
|||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"howler": "^2.2.4",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
|
||||||
"hpagent": "^1.2.0",
|
"hpagent": "^1.2.0",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
@@ -69,7 +68,7 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"needle": "^3.3.1",
|
"needle": "^3.3.1",
|
||||||
"node-fetch": "2",
|
"node-fetch": "2",
|
||||||
"node-id3": "^0.2.9",
|
"node-taglib-sharp": "^6.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.5.0",
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"tdesign-icons-vue-next": "^0.4.1",
|
"tdesign-icons-vue-next": "^0.4.1",
|
||||||
|
|||||||
92
src/common/utils/quality.ts
Normal file
92
src/common/utils/quality.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export const QUALITY_ORDER = [
|
||||||
|
'master',
|
||||||
|
'atmos_plus',
|
||||||
|
'atmos',
|
||||||
|
'hires',
|
||||||
|
'flac24bit',
|
||||||
|
'flac',
|
||||||
|
'320k',
|
||||||
|
'192k',
|
||||||
|
'128k'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type KnownQuality = (typeof QUALITY_ORDER)[number]
|
||||||
|
export type QualityInput = KnownQuality | string | { type: string; size?: string }
|
||||||
|
|
||||||
|
const DISPLAY_NAME_MAP: Record<string, string> = {
|
||||||
|
'128k': '标准',
|
||||||
|
'192k': '高品',
|
||||||
|
'320k': '超高',
|
||||||
|
flac: '无损',
|
||||||
|
flac24bit: '超高解析',
|
||||||
|
hires: '高清臻音',
|
||||||
|
atmos: '全景环绕',
|
||||||
|
atmos_plus: '全景增强',
|
||||||
|
master: '超清母带'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取音质中文显示名称
|
||||||
|
*/
|
||||||
|
export function getQualityDisplayName(quality: QualityInput | null | undefined): string {
|
||||||
|
if (!quality) return ''
|
||||||
|
const type = typeof quality === 'object' ? (quality as any).type : quality
|
||||||
|
return DISPLAY_NAME_MAP[type] || String(type || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较两个音质优先级(返回负数表示 a 优于 b)
|
||||||
|
*/
|
||||||
|
export function compareQuality(aType: string, bType: string): number {
|
||||||
|
const ia = QUALITY_ORDER.indexOf(aType as KnownQuality)
|
||||||
|
const ib = QUALITY_ORDER.indexOf(bType as KnownQuality)
|
||||||
|
const va = ia === -1 ? QUALITY_ORDER.length : ia
|
||||||
|
const vb = ib === -1 ? QUALITY_ORDER.length : ib
|
||||||
|
return va - vb
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 types,兼容 string 与 {type,size}
|
||||||
|
*/
|
||||||
|
export function normalizeTypes(
|
||||||
|
types: Array<string | { type: string; size?: string }> | null | undefined
|
||||||
|
): string[] {
|
||||||
|
if (!types || !Array.isArray(types)) return []
|
||||||
|
return types
|
||||||
|
.map((t) => (typeof t === 'object' ? (t as any).type : t))
|
||||||
|
.filter((t): t is string => Boolean(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数组中最高音质类型
|
||||||
|
*/
|
||||||
|
export function getHighestQualityType(
|
||||||
|
types: Array<string | { type: string; size?: string }> | null | undefined
|
||||||
|
): string | null {
|
||||||
|
const arr = normalizeTypes(types)
|
||||||
|
if (!arr.length) return null
|
||||||
|
return arr.sort(compareQuality)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建并按优先级排序的 [{type, size}] 列表
|
||||||
|
* 支持传入:
|
||||||
|
* - 数组:[{type,size}]
|
||||||
|
* - _types 映射:{ [type]: { size } }
|
||||||
|
*/
|
||||||
|
export function buildQualityFormats(
|
||||||
|
input:
|
||||||
|
| Array<{ type: string; size?: string }>
|
||||||
|
| Record<string, { size?: string }>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
): Array<{ type: string; size?: string }> {
|
||||||
|
if (!input) return []
|
||||||
|
let list: Array<{ type: string; size?: string }>
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
list = input.map((i) => ({ type: i.type, size: i.size }))
|
||||||
|
} else {
|
||||||
|
list = Object.keys(input).map((k) => ({ type: k, size: input[k]?.size }))
|
||||||
|
}
|
||||||
|
return list.sort((a, b) => compareQuality(a.type, b.type))
|
||||||
|
}
|
||||||
150
src/main/events/index.ts
Normal file
150
src/main/events/index.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import InitPluginService from './plugins'
|
||||||
|
import '../services/musicSdk/index'
|
||||||
|
import aiEvents from '../events/ai'
|
||||||
|
import { app, powerSaveBlocker, Menu } from 'electron'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { type BrowserWindow, Tray, ipcMain } from 'electron'
|
||||||
|
export default function InitEventServices(mainWindow: BrowserWindow) {
|
||||||
|
InitPluginService()
|
||||||
|
aiEvents(mainWindow)
|
||||||
|
basisEvent(mainWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function basisEvent(mainWindow: BrowserWindow) {
|
||||||
|
let psbId: number | null = null
|
||||||
|
let tray: Tray | null = null
|
||||||
|
let isQuitting = false
|
||||||
|
function createTray(): void {
|
||||||
|
// 创建系统托盘
|
||||||
|
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
|
||||||
|
tray = new Tray(trayIconPath)
|
||||||
|
|
||||||
|
// 创建托盘菜单
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '显示窗口',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '播放/暂停',
|
||||||
|
click: () => {
|
||||||
|
// 这里可以添加播放控制逻辑
|
||||||
|
console.log('music-control')
|
||||||
|
mainWindow?.webContents.send('music-control')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
tray.setToolTip('Ceru Music')
|
||||||
|
|
||||||
|
// 单击托盘图标显示窗口
|
||||||
|
tray.on('click', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isVisible()) {
|
||||||
|
mainWindow.hide()
|
||||||
|
} else {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createTray()
|
||||||
|
// 应用退出前的清理
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 窗口控制 IPC 处理
|
||||||
|
ipcMain.on('window-minimize', () => {
|
||||||
|
mainWindow.minimize()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('window-maximize', () => {
|
||||||
|
if (mainWindow.isMaximized()) {
|
||||||
|
mainWindow.unmaximize()
|
||||||
|
} else {
|
||||||
|
mainWindow.maximize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('window-close', () => {
|
||||||
|
mainWindow.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mini 模式 IPC 处理 - 最小化到系统托盘
|
||||||
|
ipcMain.on('window-mini-mode', (_, isMini) => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (isMini) {
|
||||||
|
// 进入 Mini 模式:隐藏窗口到系统托盘
|
||||||
|
mainWindow.hide()
|
||||||
|
// 显示托盘通知(可选)
|
||||||
|
if (tray) {
|
||||||
|
tray.displayBalloon({
|
||||||
|
title: '澜音 Music',
|
||||||
|
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 退出 Mini 模式:显示窗口
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全屏模式 IPC 处理
|
||||||
|
ipcMain.on('window-toggle-fullscreen', () => {
|
||||||
|
const isFullScreen = mainWindow.isFullScreen()
|
||||||
|
mainWindow.setFullScreen(!isFullScreen)
|
||||||
|
})
|
||||||
|
// 阻止窗口关闭,改为隐藏到系统托盘
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
|
||||||
|
// 显示托盘通知
|
||||||
|
if (tray) {
|
||||||
|
tray.displayBalloon({
|
||||||
|
title: 'Ceru Music',
|
||||||
|
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 阻止系统息屏 IPC(开启/关闭)
|
||||||
|
ipcMain.handle('power-save-blocker:start', () => {
|
||||||
|
if (psbId == null) {
|
||||||
|
psbId = powerSaveBlocker.start('prevent-display-sleep')
|
||||||
|
}
|
||||||
|
return psbId
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('power-save-blocker:stop', () => {
|
||||||
|
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
|
||||||
|
powerSaveBlocker.stop(psbId)
|
||||||
|
}
|
||||||
|
psbId = null
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取应用版本号
|
||||||
|
ipcMain.handle('get-app-version', () => {
|
||||||
|
return app.getVersion()
|
||||||
|
})
|
||||||
|
}
|
||||||
80
src/main/events/plugins.ts
Normal file
80
src/main/events/plugins.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import pluginService from '../services/plugin'
|
||||||
|
function PluginEvent() {
|
||||||
|
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.selectAndAddPlugin(type)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error selecting and adding plugin:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.downloadAndAddPlugin(url, type)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error downloading and adding plugin:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.addPlugin(pluginCode, pluginName)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error adding plugin:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return pluginService.getPluginById(id)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting plugin by id:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
|
||||||
|
try {
|
||||||
|
// 使用新的 getPluginsList 方法,但保持 API 兼容性
|
||||||
|
return await pluginService.getPluginsList()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error loading all plugins:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.getPluginLog(pluginId)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting plugin log:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await pluginService.uninstallPlugin(pluginId)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error uninstalling plugin:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InitPluginService() {
|
||||||
|
setTimeout(async () => {
|
||||||
|
// 初始化插件系统
|
||||||
|
try {
|
||||||
|
await pluginService.initializePlugins()
|
||||||
|
PluginEvent()
|
||||||
|
console.log('插件系统初始化完成')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('插件系统初始化失败:', error)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
||||||
import type { SongList, Songs } from '@common/types/songList'
|
import type { SongList, Songs } from '@common/types/songList'
|
||||||
|
import { configManager } from '../services/ConfigManager'
|
||||||
|
|
||||||
// 创建新歌单
|
// 创建新歌单
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -21,6 +22,31 @@ ipcMain.handle(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 喜欢歌单ID持久化
|
||||||
|
ipcMain.handle('songlist:get-favorites-id', async () => {
|
||||||
|
try {
|
||||||
|
const id = configManager.get<string>('favoritesHashId', '')
|
||||||
|
return { success: true, data: id || null }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取喜欢歌单ID失败:', error)
|
||||||
|
return { success: false, error: '获取喜欢歌单ID失败' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('songlist:set-favorites-id', async (_, id: string) => {
|
||||||
|
try {
|
||||||
|
if (!id || typeof id !== 'string' || !id.trim()) {
|
||||||
|
return { success: false, error: '无效的歌单ID' }
|
||||||
|
}
|
||||||
|
configManager.set('favoritesHashId', id.trim())
|
||||||
|
const ok = configManager.saveConfig()
|
||||||
|
return { success: ok, message: ok ? '已保存喜欢歌单ID' : '保存失败' }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置喜欢歌单ID失败:', error)
|
||||||
|
return { success: false, error: '设置喜欢歌单ID失败' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取所有歌单
|
// 获取所有歌单
|
||||||
ipcMain.handle('songlist:get-all', async () => {
|
ipcMain.handle('songlist:get-all', async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen, powerSaveBlocker } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, screen, Rectangle, Display } from 'electron'
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/logo.png?asset'
|
import icon from '../../resources/logo.png?asset'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import pluginService from './services/plugin'
|
import InitEventServices from './events'
|
||||||
import aiEvents from './events/ai'
|
|
||||||
import './services/musicSdk/index'
|
import './events/musicCache'
|
||||||
|
import './events/songList'
|
||||||
|
import './events/directorySettings'
|
||||||
|
import './events/pluginNotice'
|
||||||
|
|
||||||
// 获取单实例锁
|
// 获取单实例锁
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
@@ -25,87 +29,44 @@ if (!gotTheLock) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// import wy from './utils/musicSdk/wy/index'
|
|
||||||
// import kg from './utils/musicSdk/kg/index'
|
|
||||||
// wy.hotSearch.getList().then((res) => {
|
|
||||||
// console.log(res)
|
|
||||||
// })
|
|
||||||
// kg.hotSearch.getList().then((res) => {
|
|
||||||
// console.log(res)
|
|
||||||
// })
|
|
||||||
let tray: Tray | null = null
|
|
||||||
let psbId: number | null = null
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let isQuitting = false
|
|
||||||
|
|
||||||
function createTray(): void {
|
/**
|
||||||
// 创建系统托盘
|
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
|
||||||
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
|
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
|
||||||
tray = new Tray(trayIconPath)
|
* @param {BrowserWindow} win - 要更新的窗口实例
|
||||||
|
*/
|
||||||
|
function updateWindowMaxLimits(win: BrowserWindow | null): void {
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
// 创建托盘菜单
|
// 1. 获取窗口的当前边界 (bounds)
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const currentBounds: Rectangle = win.getBounds()
|
||||||
{
|
|
||||||
label: '显示窗口',
|
|
||||||
click: () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.show()
|
|
||||||
mainWindow.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '播放/暂停',
|
|
||||||
click: () => {
|
|
||||||
// 这里可以添加播放控制逻辑
|
|
||||||
console.log('music-control')
|
|
||||||
mainWindow?.webContents.send('music-control')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: '退出',
|
|
||||||
click: () => {
|
|
||||||
isQuitting = true
|
|
||||||
app.quit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
tray.setContextMenu(contextMenu)
|
// 2. 查找包含该边界的显示器
|
||||||
tray.setToolTip('Ceru Music')
|
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
|
||||||
|
|
||||||
// 双击托盘图标显示窗口
|
// 3. 获取该显示器的完整尺寸 (full screen size)
|
||||||
tray.on('click', () => {
|
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
|
||||||
if (mainWindow) {
|
|
||||||
if (mainWindow.isVisible()) {
|
// 4. 应用新的最大尺寸限制
|
||||||
mainWindow.hide()
|
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
|
||||||
} else {
|
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
|
||||||
mainWindow.show()
|
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
|
||||||
mainWindow.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// return
|
|
||||||
// 获取保存的窗口位置和大小
|
// 获取保存的窗口位置和大小
|
||||||
const savedBounds = configManager.getWindowBounds()
|
const savedBounds = configManager.getWindowBounds()
|
||||||
|
|
||||||
// 获取屏幕尺寸
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay()
|
|
||||||
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
|
|
||||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
|
|
||||||
|
|
||||||
// 默认窗口配置
|
// 默认窗口配置
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 750,
|
height: 750,
|
||||||
minWidth: 1100,
|
minWidth: 1100,
|
||||||
minHeight: 670,
|
minHeight: 670,
|
||||||
maxWidth: screenWidth,
|
// ⚠️ 关键修改 1: 移除 maxWidth 和 maxHeight 的硬编码限制
|
||||||
maxHeight: screenHeight,
|
// maxWidth: screenWidth,
|
||||||
|
// maxHeight: screenHeight,
|
||||||
show: false,
|
show: false,
|
||||||
center: !savedBounds, // 如果有保存的位置,则不居中
|
center: !savedBounds, // 如果有保存的位置,则不居中
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
@@ -131,24 +92,30 @@ function createWindow(): void {
|
|||||||
mainWindow = new BrowserWindow(defaultOptions)
|
mainWindow = new BrowserWindow(defaultOptions)
|
||||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||||
|
|
||||||
// 监听窗口移动和调整大小事件,保存窗口位置和大小
|
// ⚠️ 关键修改 2: 监听 'moved' 事件,动态更新最大尺寸
|
||||||
mainWindow.on('moved', () => {
|
mainWindow.on('moved', () => {
|
||||||
|
// 当窗口移动时,确保最大尺寸限制随屏幕变化
|
||||||
|
updateWindowMaxLimits(mainWindow)
|
||||||
|
|
||||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||||
const bounds = mainWindow.getBounds()
|
const bounds = mainWindow.getBounds()
|
||||||
configManager.saveWindowBounds(bounds)
|
configManager.saveWindowBounds(bounds)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ⚠️ 关键修改 3: 窗口创建后立即应用一次最大尺寸限制
|
||||||
|
updateWindowMaxLimits(mainWindow)
|
||||||
|
|
||||||
mainWindow.on('resized', () => {
|
mainWindow.on('resized', () => {
|
||||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||||
const bounds = mainWindow.getBounds()
|
const bounds = mainWindow.getBounds()
|
||||||
|
|
||||||
// 获取当前屏幕尺寸
|
// 获取当前屏幕尺寸 (已在文件顶部导入 screen,无需 require)
|
||||||
const { screen } = require('electron')
|
|
||||||
const currentDisplay = screen.getDisplayMatching(bounds)
|
const currentDisplay = screen.getDisplayMatching(bounds)
|
||||||
|
// 使用 workAreaSize 避免窗口超出任务栏/Dock
|
||||||
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
|
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
|
||||||
|
|
||||||
// 确保窗口不超过屏幕尺寸
|
// 确保窗口不超过屏幕工作区域尺寸
|
||||||
let needResize = false
|
let needResize = false
|
||||||
const newBounds = { ...bounds }
|
const newBounds = { ...bounds }
|
||||||
|
|
||||||
@@ -174,29 +141,11 @@ function createWindow(): void {
|
|||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 阻止窗口关闭,改为隐藏到系统托盘
|
|
||||||
mainWindow.on('close', (event) => {
|
|
||||||
if (!isQuitting) {
|
|
||||||
event.preventDefault()
|
|
||||||
mainWindow?.hide()
|
|
||||||
|
|
||||||
// 显示托盘通知
|
|
||||||
if (tray) {
|
|
||||||
tray.displayBalloon({
|
|
||||||
iconType: 'info',
|
|
||||||
title: 'Ceru Music',
|
|
||||||
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
shell.openExternal(details.url).then()
|
shell.openExternal(details.url).then()
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
InitEventServices(mainWindow)
|
||||||
// HMR for renderer base on electron-vite cli.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
@@ -206,80 +155,6 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return await pluginService.selectAndAddPlugin(type)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error selecting and adding plugin:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return await pluginService.downloadAndAddPlugin(url, type)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error downloading and adding plugin:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return await pluginService.addPlugin(pluginCode, pluginName)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error adding plugin:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return pluginService.getPluginById(id)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error getting plugin by id:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
|
|
||||||
try {
|
|
||||||
// 使用新的 getPluginsList 方法,但保持 API 兼容性
|
|
||||||
return await pluginService.getPluginsList()
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading all plugins:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return await pluginService.getPluginLog(pluginId)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error getting plugin log:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
|
|
||||||
try {
|
|
||||||
return await pluginService.uninstallPlugin(pluginId)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error uninstalling plugin:', error)
|
|
||||||
return { error: error.message }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取应用版本号
|
|
||||||
ipcMain.handle('get-app-version', () => {
|
|
||||||
return app.getVersion()
|
|
||||||
})
|
|
||||||
|
|
||||||
aiEvents(mainWindow)
|
|
||||||
import './events/musicCache'
|
|
||||||
import './events/songList'
|
|
||||||
import './events/directorySettings'
|
|
||||||
import './events/pluginNotice'
|
|
||||||
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
|
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
@@ -296,16 +171,6 @@ app.whenReady().then(() => {
|
|||||||
app.setName('澜音')
|
app.setName('澜音')
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
// 初始化插件系统
|
|
||||||
try {
|
|
||||||
await pluginService.initializePlugins()
|
|
||||||
console.log('插件系统初始化完成')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('插件系统初始化失败:', error)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
// Default open or close DevTools by F12 in development
|
// Default open or close DevTools by F12 in development
|
||||||
// and ignore CommandOrControl + R in production.
|
// and ignore CommandOrControl + R in production.
|
||||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
@@ -313,79 +178,7 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 窗口控制 IPC 处理
|
|
||||||
ipcMain.on('window-minimize', () => {
|
|
||||||
const window = BrowserWindow.getFocusedWindow()
|
|
||||||
if (window) {
|
|
||||||
window.minimize()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('window-maximize', () => {
|
|
||||||
const window = BrowserWindow.getFocusedWindow()
|
|
||||||
if (window) {
|
|
||||||
if (window.isMaximized()) {
|
|
||||||
window.unmaximize()
|
|
||||||
} else {
|
|
||||||
window.maximize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('window-close', () => {
|
|
||||||
const window = BrowserWindow.getFocusedWindow()
|
|
||||||
if (window) {
|
|
||||||
window.close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mini 模式 IPC 处理 - 最小化到系统托盘
|
|
||||||
ipcMain.on('window-mini-mode', (_, isMini) => {
|
|
||||||
if (mainWindow) {
|
|
||||||
if (isMini) {
|
|
||||||
// 进入 Mini 模式:隐藏窗口到系统托盘
|
|
||||||
mainWindow.hide()
|
|
||||||
// 显示托盘通知(可选)
|
|
||||||
if (tray) {
|
|
||||||
tray.displayBalloon({
|
|
||||||
title: '澜音 Music',
|
|
||||||
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 退出 Mini 模式:显示窗口
|
|
||||||
mainWindow.show()
|
|
||||||
mainWindow.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 全屏模式 IPC 处理
|
|
||||||
ipcMain.on('window-toggle-fullscreen', () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
const isFullScreen = mainWindow.isFullScreen()
|
|
||||||
mainWindow.setFullScreen(!isFullScreen)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阻止系统息屏 IPC(开启/关闭)
|
|
||||||
ipcMain.handle('power-save-blocker:start', () => {
|
|
||||||
if (psbId == null) {
|
|
||||||
psbId = powerSaveBlocker.start('prevent-display-sleep')
|
|
||||||
}
|
|
||||||
return psbId
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('power-save-blocker:stop', () => {
|
|
||||||
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
|
|
||||||
powerSaveBlocker.stop(psbId)
|
|
||||||
}
|
|
||||||
psbId = null
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
createTray()
|
|
||||||
|
|
||||||
// 注册自动更新事件
|
// 注册自动更新事件
|
||||||
registerAutoUpdateEvents()
|
registerAutoUpdateEvents()
|
||||||
@@ -415,67 +208,19 @@ app.on('window-all-closed', () => {
|
|||||||
// 在其他平台上,我们也保持应用运行,因为有系统托盘
|
// 在其他平台上,我们也保持应用运行,因为有系统托盘
|
||||||
})
|
})
|
||||||
|
|
||||||
// 应用退出前的清理
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
isQuitting = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
// code. You can also put them in separate files and require them here.
|
// code. You can also put them in separate files and require them here.
|
||||||
|
|
||||||
let ping: NodeJS.Timeout
|
let ping: NodeJS.Timeout
|
||||||
function startPing() {
|
function startPing() {
|
||||||
let interval = 3000
|
// 已迁移到 Howler,不再使用 DOM <audio> 轮询。
|
||||||
|
// 如需主进程感知播放结束,请在渲染进程通过 IPC 主动通知。
|
||||||
|
if (ping) {
|
||||||
|
clearInterval(ping)
|
||||||
|
}
|
||||||
ping = setInterval(() => {
|
ping = setInterval(() => {
|
||||||
if (mainWindow) {
|
// 保留占位,避免调用方报错;不再做任何轮询。
|
||||||
mainWindow.webContents
|
// 可在此处监听自定义 IPC 事件以扩展行为。
|
||||||
.executeJavaScript(
|
clearInterval(ping)
|
||||||
`
|
}, 1000)
|
||||||
(function() {
|
|
||||||
const audio = document.getElementById("globaAudio");
|
|
||||||
if(!audio) return { playing:false, ended: false };
|
|
||||||
|
|
||||||
if(audio.ended) return { playing:false, ended: true };
|
|
||||||
|
|
||||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
console.log(res)
|
|
||||||
if (res.duration - res.currentTime <= 20) {
|
|
||||||
clearInterval(ping)
|
|
||||||
interval = 500
|
|
||||||
ping = setInterval(() => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.webContents
|
|
||||||
.executeJavaScript(
|
|
||||||
`
|
|
||||||
(function() {
|
|
||||||
const audio = document.getElementById("globaAudio");
|
|
||||||
if(!audio) return { playing:false, ended: false };
|
|
||||||
|
|
||||||
if(audio.ended) return { playing:false, ended: true };
|
|
||||||
|
|
||||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
console.log(res)
|
|
||||||
if (res && res.ended) {
|
|
||||||
mainWindow?.webContents.send('song-ended')
|
|
||||||
console.log('next song')
|
|
||||||
clearInterval(ping)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => console.warn(err))
|
|
||||||
}
|
|
||||||
}, interval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => console.warn(err))
|
|
||||||
}
|
|
||||||
}, interval)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import NodeID3 from 'node-id3'
|
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
|
||||||
import ffmpegStatic from 'ffmpeg-static'
|
|
||||||
import ffmpeg from 'fluent-ffmpeg'
|
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
@@ -75,6 +73,32 @@ function formatTimestamp(timeMs: number): string {
|
|||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据图片 URL 和 Content-Type 解析扩展名,默认返回 .jpg
|
||||||
|
function resolveCoverExt(imgUrl: string, contentType?: string): string {
|
||||||
|
const validExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp'])
|
||||||
|
let urlExt: string | undefined
|
||||||
|
try {
|
||||||
|
const pathname = new URL(imgUrl).pathname
|
||||||
|
const i = pathname.lastIndexOf('.')
|
||||||
|
if (i !== -1) {
|
||||||
|
urlExt = pathname.substring(i).toLowerCase()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (urlExt && validExts.has(urlExt)) {
|
||||||
|
return urlExt === '.jpeg' ? '.jpg' : urlExt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType) {
|
||||||
|
if (contentType.includes('image/png')) return '.png'
|
||||||
|
if (contentType.includes('image/webp')) return '.webp'
|
||||||
|
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg'
|
||||||
|
if (contentType.includes('image/bmp')) return '.bmp'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||||
*/
|
*/
|
||||||
@@ -156,265 +180,6 @@ function convertOldFormat(timestamp: string, content: string): string {
|
|||||||
return `[${timestamp}]${convertedContent}`
|
return `[${timestamp}]${convertedContent}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入音频标签的辅助函数
|
|
||||||
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
|
||||||
try {
|
|
||||||
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
|
|
||||||
|
|
||||||
// 获取文件扩展名来判断格式
|
|
||||||
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
|
|
||||||
console.log('文件格式:', fileExtension)
|
|
||||||
|
|
||||||
// 根据文件格式选择不同的标签写入方法
|
|
||||||
if (fileExtension === 'mp3') {
|
|
||||||
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
|
||||||
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
|
|
||||||
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
|
|
||||||
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
|
|
||||||
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
|
|
||||||
} else {
|
|
||||||
console.warn('不支持的音频格式:', fileExtension)
|
|
||||||
// 尝试使用 NodeID3 作为后备方案
|
|
||||||
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('写入音频标签时发生错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MP3 格式标签写入
|
|
||||||
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
|
||||||
const tags: any = {}
|
|
||||||
|
|
||||||
// 写入基础信息
|
|
||||||
if (tagWriteOptions.basicInfo) {
|
|
||||||
tags.title = songInfo.name || ''
|
|
||||||
tags.artist = songInfo.singer || ''
|
|
||||||
tags.album = songInfo.albumName || ''
|
|
||||||
tags.year = songInfo.year || ''
|
|
||||||
tags.genre = songInfo.genre || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入歌词
|
|
||||||
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
|
||||||
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
|
||||||
tags.unsynchronisedLyrics = {
|
|
||||||
language: 'chi',
|
|
||||||
shortText: 'Lyrics',
|
|
||||||
text: convertedLrc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入封面
|
|
||||||
if (tagWriteOptions.cover && songInfo.img) {
|
|
||||||
try {
|
|
||||||
const coverResponse = await axios({
|
|
||||||
method: 'GET',
|
|
||||||
url: songInfo.img,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 10000
|
|
||||||
})
|
|
||||||
|
|
||||||
if (coverResponse.data) {
|
|
||||||
tags.image = {
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
type: {
|
|
||||||
id: 3,
|
|
||||||
name: 'front cover'
|
|
||||||
},
|
|
||||||
description: 'Cover',
|
|
||||||
imageBuffer: Buffer.from(coverResponse.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (coverError) {
|
|
||||||
console.warn('获取封面失败:', coverError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入标签到文件
|
|
||||||
if (Object.keys(tags).length > 0) {
|
|
||||||
const success = NodeID3.write(tags, filePath)
|
|
||||||
if (success) {
|
|
||||||
console.log('MP3音频标签写入成功:', filePath)
|
|
||||||
} else {
|
|
||||||
console.warn('MP3音频标签写入失败:', filePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
|
|
||||||
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
|
||||||
try {
|
|
||||||
console.log('开始写入 FLAC 标签:', filePath)
|
|
||||||
|
|
||||||
// 准备新的标签数据
|
|
||||||
const newTags: any = {}
|
|
||||||
|
|
||||||
// 写入基础信息
|
|
||||||
if (tagWriteOptions.basicInfo) {
|
|
||||||
if (songInfo.name) newTags.TITLE = songInfo.name
|
|
||||||
if (songInfo.singer) newTags.ARTIST = songInfo.singer
|
|
||||||
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
|
|
||||||
if (songInfo.year) newTags.DATE = songInfo.year.toString()
|
|
||||||
if (songInfo.genre) newTags.GENRE = songInfo.genre
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入歌词
|
|
||||||
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
|
||||||
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
|
||||||
newTags.LYRICS = convertedLrc
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('准备写入的标签:', newTags)
|
|
||||||
|
|
||||||
// 使用 ffmpeg-static 写入 FLAC 标签
|
|
||||||
if (path.extname(filePath).toLowerCase() === '.flac') {
|
|
||||||
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
|
|
||||||
} else {
|
|
||||||
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('写入 Vorbis Comment 标签失败:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 fluent-ffmpeg 写入 FLAC 标签
|
|
||||||
async function writeFLACTagsWithFFmpeg(
|
|
||||||
filePath: string,
|
|
||||||
tags: any,
|
|
||||||
songInfo: any,
|
|
||||||
tagWriteOptions: any
|
|
||||||
) {
|
|
||||||
let tempOutputPath: string | null = null
|
|
||||||
let tempCoverPath: string | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!ffmpegStatic) {
|
|
||||||
throw new Error('ffmpeg-static 不可用')
|
|
||||||
}
|
|
||||||
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
|
|
||||||
|
|
||||||
// 创建临时输出文件
|
|
||||||
tempOutputPath = filePath + '.temp.flac'
|
|
||||||
|
|
||||||
// 创建 fluent-ffmpeg 实例
|
|
||||||
let command = ffmpeg(filePath)
|
|
||||||
.audioCodec('copy') // 复制音频编解码器,不重新编码
|
|
||||||
.output(tempOutputPath)
|
|
||||||
|
|
||||||
// 添加元数据标签
|
|
||||||
for (const [key, value] of Object.entries(tags)) {
|
|
||||||
if (value) {
|
|
||||||
// fluent-ffmpeg 会自动处理特殊字符转义
|
|
||||||
command = command.outputOptions(['-metadata', `${key}=${value}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理封面
|
|
||||||
if (tagWriteOptions.cover && songInfo.img) {
|
|
||||||
try {
|
|
||||||
console.log('开始下载封面:', songInfo.img)
|
|
||||||
const coverResponse = await axios({
|
|
||||||
method: 'GET',
|
|
||||||
url: songInfo.img,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 10000
|
|
||||||
})
|
|
||||||
|
|
||||||
if (coverResponse.data) {
|
|
||||||
// 保存临时封面文件
|
|
||||||
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
|
|
||||||
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
|
|
||||||
|
|
||||||
// 添加封面作为输入
|
|
||||||
command = command.input(tempCoverPath).outputOptions([
|
|
||||||
'-map',
|
|
||||||
'0:a', // 映射原始文件的音频流
|
|
||||||
'-map',
|
|
||||||
'1:v', // 映射封面的视频流
|
|
||||||
'-c:v',
|
|
||||||
'copy', // 复制视频编解码器
|
|
||||||
'-disposition:v:0',
|
|
||||||
'attached_pic' // 设置为附加图片
|
|
||||||
])
|
|
||||||
|
|
||||||
console.log('封面已添加到命令中')
|
|
||||||
}
|
|
||||||
} catch (coverError) {
|
|
||||||
console.warn(
|
|
||||||
'下载封面失败,跳过封面写入:',
|
|
||||||
coverError instanceof Error ? coverError.message : coverError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行 ffmpeg 命令
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
command
|
|
||||||
.on('start', () => {
|
|
||||||
console.log('执行 ffmpeg 命令')
|
|
||||||
})
|
|
||||||
.on('progress', (progress) => {
|
|
||||||
if (progress.percent) {
|
|
||||||
console.log('处理进度:', Math.round(progress.percent) + '%')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
console.log('ffmpeg 处理完成')
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
.on('error', (err, _, stderr) => {
|
|
||||||
console.error('ffmpeg 错误:', err.message)
|
|
||||||
if (stderr) {
|
|
||||||
console.error('ffmpeg stderr:', stderr)
|
|
||||||
}
|
|
||||||
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查临时文件是否创建成功
|
|
||||||
if (!fs.existsSync(tempOutputPath)) {
|
|
||||||
throw new Error('ffmpeg 未能创建输出文件')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 替换原文件
|
|
||||||
await fsPromise.rename(tempOutputPath, filePath)
|
|
||||||
tempOutputPath = null // 标记已处理,避免重复清理
|
|
||||||
|
|
||||||
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
// 清理所有临时文件
|
|
||||||
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
|
|
||||||
|
|
||||||
for (const tempFile of filesToClean) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempFile)) {
|
|
||||||
await fsPromise.unlink(tempFile)
|
|
||||||
console.log('已清理临时文件:', tempFile)
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn(
|
|
||||||
'清理临时文件失败:',
|
|
||||||
tempFile,
|
|
||||||
cleanupError instanceof Error ? cleanupError.message : cleanupError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MP4/M4A 格式标签写入
|
|
||||||
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
|
|
||||||
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
|
|
||||||
// 可以使用 ffmpeg 或其他工具实现
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取自定义下载目录
|
// 获取自定义下载目录
|
||||||
const getDownloadDirectory = (): string => {
|
const getDownloadDirectory = (): string => {
|
||||||
// 使用配置管理服务获取下载目录
|
// 使用配置管理服务获取下载目录
|
||||||
@@ -492,13 +257,71 @@ export default async function download(
|
|||||||
delete fileLock[songPath]
|
delete fileLock[songPath]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入标签信息
|
// 写入标签信息(使用 node-taglib-sharp)
|
||||||
if (tagWriteOptions && fs.existsSync(songPath)) {
|
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||||
try {
|
try {
|
||||||
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
const baseName = path.basename(songPath, path.extname(songPath))
|
||||||
|
const dirName = path.dirname(songPath)
|
||||||
|
let coverExt = '.jpg'
|
||||||
|
let coverPath = path.join(dirName, `${baseName}${coverExt}`)
|
||||||
|
let coverDownloaded = false
|
||||||
|
|
||||||
|
// 下载封面(仅当启用且有URL)
|
||||||
|
if (tagWriteOptions.cover && songInfo?.img) {
|
||||||
|
try {
|
||||||
|
const coverRes = await axios.get(songInfo.img, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
const ct =
|
||||||
|
(coverRes.headers && (coverRes.headers['content-type'] as string | undefined)) ||
|
||||||
|
undefined
|
||||||
|
coverExt = resolveCoverExt(songInfo.img, ct)
|
||||||
|
coverPath = path.join(dirName, `${baseName}${coverExt}`)
|
||||||
|
await fsPromise.writeFile(coverPath, Buffer.from(coverRes.data))
|
||||||
|
coverDownloaded = true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('下载封面失败:', e instanceof Error ? e.message : e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取歌曲文件并设置标签
|
||||||
|
const songFile = File.createFromPath(songPath)
|
||||||
|
|
||||||
|
// 使用默认 ID3v2.3
|
||||||
|
Id3v2Settings.forceDefaultVersion = true
|
||||||
|
Id3v2Settings.defaultVersion = 3
|
||||||
|
|
||||||
|
songFile.tag.title = songInfo?.name || '未知曲目'
|
||||||
|
songFile.tag.album = songInfo?.albumName || '未知专辑'
|
||||||
|
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
|
||||||
|
songFile.tag.performers = artists
|
||||||
|
songFile.tag.albumArtists = artists
|
||||||
|
// 写入歌词(转换为标准 LRC)
|
||||||
|
if (tagWriteOptions.lyrics && songInfo?.lrc) {
|
||||||
|
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||||
|
songFile.tag.lyrics = convertedLrc
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入封面
|
||||||
|
if (tagWriteOptions.cover && coverDownloaded) {
|
||||||
|
const songCover = Picture.fromPath(coverPath)
|
||||||
|
songFile.tag.pictures = [songCover]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存并释放
|
||||||
|
songFile.save()
|
||||||
|
songFile.dispose()
|
||||||
|
|
||||||
|
// 删除临时封面
|
||||||
|
if (coverDownloaded) {
|
||||||
|
try {
|
||||||
|
await fsPromise.unlink(coverPath)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('写入音频标签失败:', error)
|
console.warn('写入音乐元信息失败:', error)
|
||||||
throw ffmpegStatic
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export default {
|
|||||||
if (!result) throw new Error('failed')
|
if (!result) throw new Error('failed')
|
||||||
}
|
}
|
||||||
id = result[1]
|
id = result[1]
|
||||||
// console.log(id)
|
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
@@ -222,7 +221,8 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body } = await requestObj_listDetail.promise
|
const { body } = await requestObj_listDetail.promise
|
||||||
|
console.log(body);
|
||||||
|
|
||||||
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
||||||
const cdlist = body.cdlist[0]
|
const cdlist = body.cdlist[0]
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default {
|
|||||||
const { statusCode, body } = await requestObj_listDetail.promise
|
const { statusCode, body } = await requestObj_listDetail.promise
|
||||||
if (statusCode !== 200 || body.code !== this.successCode)
|
if (statusCode !== 200 || body.code !== this.successCode)
|
||||||
return this.getListDetail(id, page, ++tryNum)
|
return this.getListDetail(id, page, ++tryNum)
|
||||||
let limit = 1000
|
let limit = 50
|
||||||
let rangeStart = (page - 1) * limit
|
let rangeStart = (page - 1) * limit
|
||||||
let list
|
let list
|
||||||
try {
|
try {
|
||||||
|
|||||||
2
src/preload/index.d.ts
vendored
2
src/preload/index.d.ts
vendored
@@ -53,6 +53,8 @@ interface CustomAPI {
|
|||||||
validateIntegrity: (hashId: string) => Promise<any>
|
validateIntegrity: (hashId: string) => Promise<any>
|
||||||
repairData: (hashId: string) => Promise<any>
|
repairData: (hashId: string) => Promise<any>
|
||||||
forceSave: (hashId: string) => Promise<any>
|
forceSave: (hashId: string) => Promise<any>
|
||||||
|
getFavoritesId: () => Promise<any>
|
||||||
|
setFavoritesId: (favoritesId: string) => Promise<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
ai: {
|
ai: {
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ const api = {
|
|||||||
validateIntegrity: (hashId: string) =>
|
validateIntegrity: (hashId: string) =>
|
||||||
ipcRenderer.invoke('songlist:validate-integrity', hashId),
|
ipcRenderer.invoke('songlist:validate-integrity', hashId),
|
||||||
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
|
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
|
||||||
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId)
|
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId),
|
||||||
|
|
||||||
|
// 喜欢歌单ID持久化
|
||||||
|
getFavoritesId: () => ipcRenderer.invoke('songlist:get-favorites-id'),
|
||||||
|
setFavoritesId: (id: string) => ipcRenderer.invoke('songlist:set-favorites-id', id)
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
||||||
<div
|
<div
|
||||||
v-for="(song, index) in visibleItems"
|
v-for="(song, index) in visibleItems"
|
||||||
:key="song.id || song.songmid"
|
:key="`${song.source || ''}-${song.songmid}-${song.albumId || ''}-${index}`"
|
||||||
class="song-item"
|
class="song-item"
|
||||||
@mouseenter="hoveredSong = song.id || song.songmid"
|
@mouseenter="hoveredSong = song.id || song.songmid"
|
||||||
@mouseleave="hoveredSong = null"
|
@mouseleave="hoveredSong = null"
|
||||||
@@ -58,8 +58,17 @@
|
|||||||
|
|
||||||
<!-- 喜欢按钮 -->
|
<!-- 喜欢按钮 -->
|
||||||
<div class="col-like">
|
<div class="col-like">
|
||||||
<button class="action-btn like-btn" @click.stop>
|
<button
|
||||||
<i class="icon-heart"></i>
|
class="action-btn like-btn"
|
||||||
|
title="喜欢/取消喜欢"
|
||||||
|
@click.stop="onToggleLike(song)"
|
||||||
|
>
|
||||||
|
<HeartIcon
|
||||||
|
:fill-color="isLiked(song) ? ['#e5484d', '#e5484d'] : ''"
|
||||||
|
:stroke-color="isLiked(song) ? [] : [contrastTextColor, contrastTextColor]"
|
||||||
|
:stroke-width="isLiked(song) ? 0 : 2"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +119,8 @@ import {
|
|||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
AddIcon,
|
AddIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
DeleteIcon
|
DeleteIcon,
|
||||||
|
HeartIcon
|
||||||
} from 'tdesign-icons-vue-next'
|
} from 'tdesign-icons-vue-next'
|
||||||
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
||||||
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
||||||
@@ -290,8 +300,9 @@ const getQualityDisplayName = (quality: any) => {
|
|||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const onScroll = (event: Event) => {
|
const onScroll = (event: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement | null
|
||||||
scrollTop.value = target.scrollTop
|
// 兼容程序触发的假事件,target 可能为 null
|
||||||
|
scrollTop.value = target?.scrollTop ?? scrollContainer.value?.scrollTop ?? 0
|
||||||
emit('scroll', event)
|
emit('scroll', event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +416,89 @@ const loadPlaylists = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 喜欢功能(列表内心形) ===
|
||||||
|
const favoritesId = ref<string | null>(null)
|
||||||
|
const likedSet = ref<Set<string | number>>(new Set())
|
||||||
|
const contrastTextColor = 'var(--song-list-btn-color)'
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
try {
|
||||||
|
const favIdRes = await (window as any).api?.songList?.getFavoritesId?.()
|
||||||
|
const id: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
favoritesId.value = id
|
||||||
|
if (!id) {
|
||||||
|
likedSet.value = new Set()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existsRes = await songListAPI.exists(id)
|
||||||
|
if (!existsRes.success || !existsRes.data) {
|
||||||
|
favoritesId.value = null
|
||||||
|
likedSet.value = new Set()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const songsRes = await songListAPI.getSongs(id)
|
||||||
|
if (songsRes.success && Array.isArray(songsRes.data)) {
|
||||||
|
likedSet.value = new Set(songsRes.data.map((s: any) => s.songmid))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载“我的喜欢”失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLiked = (song: Song) => likedSet.value.has(song.songmid)
|
||||||
|
|
||||||
|
const ensureFavoritesId = async (): Promise<string | null> => {
|
||||||
|
if (favoritesId.value) {
|
||||||
|
const existsRes = await songListAPI.exists(favoritesId.value)
|
||||||
|
if (existsRes.success && existsRes.data) return favoritesId.value
|
||||||
|
favoritesId.value = null
|
||||||
|
}
|
||||||
|
const searchRes = await songListAPI.search('我的喜欢', 'local')
|
||||||
|
if (searchRes.success && Array.isArray(searchRes.data)) {
|
||||||
|
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
|
||||||
|
if (exact?.id) {
|
||||||
|
favoritesId.value = exact.id
|
||||||
|
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
|
||||||
|
return favoritesId.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const createRes = await songListAPI.create('我的喜欢', '', 'local')
|
||||||
|
if (!createRes.success || !createRes.data?.id) {
|
||||||
|
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
favoritesId.value = createRes.data.id
|
||||||
|
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
|
||||||
|
return favoritesId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleLike = async (song: Song) => {
|
||||||
|
try {
|
||||||
|
const id = await ensureFavoritesId()
|
||||||
|
if (!id) return
|
||||||
|
if (isLiked(song)) {
|
||||||
|
const removeRes = await songListAPI.removeSong(id, song.songmid)
|
||||||
|
if (removeRes.success && removeRes.data) {
|
||||||
|
likedSet.value.delete(song.songmid)
|
||||||
|
// MessagePlugin.success('已取消喜欢')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(removeRes.error || '取消喜欢失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const addRes = await songListAPI.addSongs(id, [toRaw(song) as any])
|
||||||
|
if (addRes.success) {
|
||||||
|
likedSet.value.add(song.songmid)
|
||||||
|
// MessagePlugin.success('已添加到“我的喜欢”')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('切换喜欢失败:', e)
|
||||||
|
MessagePlugin.error(e?.message || '操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加歌曲到歌单
|
// 添加歌曲到歌单
|
||||||
const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
||||||
try {
|
try {
|
||||||
@@ -420,7 +514,7 @@ const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 组件挂载后触发一次重新计算
|
// 组件挂载后触发一次重新计算
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (scrollContainer.value) {
|
if (scrollContainer.value) {
|
||||||
@@ -431,10 +525,15 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 加载歌单列表
|
// 加载歌单列表
|
||||||
loadPlaylists()
|
await loadPlaylists()
|
||||||
|
// 预加载“我的喜欢”集合(确保方法存在于当前文件作用域)
|
||||||
|
await loadFavorites()
|
||||||
|
|
||||||
// 监听歌单变化事件
|
// 监听歌单变化事件
|
||||||
window.addEventListener('playlist-updated', loadPlaylists)
|
window.addEventListener('playlist-updated', async () => {
|
||||||
|
await loadPlaylists()
|
||||||
|
await loadFavorites()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -92,94 +92,135 @@ const state = reactive({
|
|||||||
// 监听歌曲ID变化,获取歌词
|
// 监听歌曲ID变化,获取歌词
|
||||||
watch(
|
watch(
|
||||||
() => props.songId,
|
() => props.songId,
|
||||||
async (newId) => {
|
async (newId, _oldId, onCleanup) => {
|
||||||
if (!newId || !props.songInfo) return
|
if (!newId || !props.songInfo) return
|
||||||
let lyricText = ''
|
// 竞态与取消控制,防止内存泄漏与过期结果覆盖
|
||||||
let parsedLyrics: LyricLine[] = []
|
let active = true
|
||||||
// 创建一个符合 MusicItem 接口的对象,只包含必要的基本属性
|
const abort = new AbortController()
|
||||||
|
onCleanup(() => {
|
||||||
|
active = false
|
||||||
|
abort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具函数:清洗响应式对象,避免序列化问题
|
||||||
|
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
||||||
|
|
||||||
|
// 工具函数:按来源解析逐字歌词
|
||||||
|
const parseCrLyricBySource = (source: string, text: string): LyricLine[] => {
|
||||||
|
return source === 'tx' ? parseQrc(text) : parseYrc(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数:合并翻译到主歌词
|
||||||
|
const mergeTranslation = (base: LyricLine[], tlyric?: string): LyricLine[] => {
|
||||||
|
if (!tlyric || base.length === 0) return base
|
||||||
|
|
||||||
|
const translated = parseLrc(tlyric)
|
||||||
|
if (!translated || translated.length === 0) return base
|
||||||
|
|
||||||
|
// 将译文按 startTime-endTime 建立索引,便于精确匹配
|
||||||
|
const keyOf = (s: number, e: number) => `${s}-${e}`
|
||||||
|
const joinWords = (line: LyricLine) => (line.words || []).map((w) => w.word).join('')
|
||||||
|
|
||||||
|
const tMap = new Map<string, LyricLine>()
|
||||||
|
for (const tl of translated) {
|
||||||
|
tMap.set(keyOf(tl.startTime, tl.endTime), tl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态容差:与行时长相关,避免长/短行同一阈值导致误配
|
||||||
|
const baseTolerance = 300 // 上限
|
||||||
|
const ratioTolerance = 0.4 // 与行时长的比例
|
||||||
|
|
||||||
|
// 锚点对齐 + 顺序映射:以第一行为锚点,后续按索引顺序插入译文
|
||||||
|
const translatedSorted = translated.slice().sort((a, b) => a.startTime - b.startTime)
|
||||||
|
|
||||||
|
if (base.length > 0) {
|
||||||
|
const firstBase = base[0]
|
||||||
|
const firstDuration = Math.max(1, firstBase.endTime - firstBase.startTime)
|
||||||
|
const firstTol = Math.min(baseTolerance, firstDuration * ratioTolerance)
|
||||||
|
|
||||||
|
// 在容差内寻找与第一行起始时间最接近的译文行作为锚点
|
||||||
|
let anchorIndex: number | null = null
|
||||||
|
let bestDiff = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < translatedSorted.length; i++) {
|
||||||
|
const diff = Math.abs(translatedSorted[i].startTime - firstBase.startTime)
|
||||||
|
if (diff <= firstTol && diff < bestDiff) {
|
||||||
|
bestDiff = diff
|
||||||
|
anchorIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorIndex !== null) {
|
||||||
|
// 从锚点开始顺序映射
|
||||||
|
let j = anchorIndex
|
||||||
|
for (let i = 0; i < base.length && j < translatedSorted.length; i++, j++) {
|
||||||
|
const bl = base[i]
|
||||||
|
const tl = translatedSorted[j]
|
||||||
|
if (tl.words[0].word === '//' || !bl.words[0].word) continue
|
||||||
|
const text = joinWords(tl)
|
||||||
|
if (text) bl.translatedLyric = text
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未找到锚点:保持原样
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查是否为网易云音乐,只有网易云才使用ttml接口
|
const source =
|
||||||
const isNetease =
|
props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg'
|
||||||
props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy'
|
let parsedLyrics: LyricLine[] = []
|
||||||
const songinfo: any = _.cloneDeep(toRaw(props.songInfo))
|
|
||||||
|
|
||||||
if (isNetease) {
|
if (source === 'wy') {
|
||||||
// 网易云音乐优先尝试ttml接口
|
// 网易云:优先尝试 TTML
|
||||||
try {
|
try {
|
||||||
const res = (await (
|
const res = await (
|
||||||
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`)
|
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`, {
|
||||||
).text()) as any
|
signal: abort.signal
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
if (!active) return
|
||||||
if (!res || res.length < 100) throw new Error('ttml 无歌词')
|
if (!res || res.length < 100) throw new Error('ttml 无歌词')
|
||||||
parsedLyrics = parseTTML(res).lines
|
parsedLyrics = parseTTML(res).lines
|
||||||
} catch {
|
} catch {
|
||||||
// ttml失败后使用新的歌词API
|
// 回退到统一歌词 API
|
||||||
const lyricData = await window.api.music.requestSdk('getLyric', {
|
const lyricData = await window.api.music.requestSdk('getLyric', {
|
||||||
source: 'wy',
|
source: 'wy',
|
||||||
songInfo: songinfo
|
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
|
||||||
})
|
})
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
if (lyricData.crlyric) {
|
if (lyricData?.crlyric) {
|
||||||
// 使用逐字歌词
|
parsedLyrics = parseYrc(lyricData.crlyric)
|
||||||
lyricText = lyricData.crlyric
|
} else if (lyricData?.lyric) {
|
||||||
|
parsedLyrics = parseLrc(lyricData.lyric)
|
||||||
parsedLyrics = parseYrc(lyricText)
|
|
||||||
} else if (lyricData.lyric) {
|
|
||||||
lyricText = lyricData.lyric
|
|
||||||
parsedLyrics = parseLrc(lyricText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyricData.tlyric) {
|
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
||||||
const translatedline = parseLrc(lyricData.tlyric)
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedLyrics.length; i++) {
|
|
||||||
if (translatedline[i] && translatedline[i].words[0]) {
|
|
||||||
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 其他音乐平台直接使用新的歌词API
|
// 其他来源:直接统一歌词 API
|
||||||
const source = props.songInfo && 'source' in props.songInfo ? props.songInfo.source : 'kg'
|
|
||||||
// 创建一个纯净的对象,避免Vue响应式对象序列化问题
|
|
||||||
const cleanSongInfo = JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
|
||||||
const lyricData = await window.api.music.requestSdk('getLyric', {
|
const lyricData = await window.api.music.requestSdk('getLyric', {
|
||||||
source: source,
|
source,
|
||||||
songInfo: cleanSongInfo
|
songInfo: getCleanSongInfo()
|
||||||
})
|
})
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
if (lyricData.crlyric) {
|
if (lyricData?.crlyric) {
|
||||||
// 使用逐字歌词
|
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
|
||||||
lyricText = lyricData.crlyric
|
} else if (lyricData?.lyric) {
|
||||||
if (source === 'tx') {
|
parsedLyrics = parseLrc(lyricData.lyric)
|
||||||
parsedLyrics = parseQrc(lyricText)
|
|
||||||
} else {
|
|
||||||
parsedLyrics = parseYrc(lyricText)
|
|
||||||
}
|
|
||||||
} else if (lyricData.lyric) {
|
|
||||||
lyricText = lyricData.lyric
|
|
||||||
parsedLyrics = parseLrc(lyricText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyricData.tlyric) {
|
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
||||||
const translatedline = parseLrc(lyricData.tlyric)
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedLyrics.length; i++) {
|
|
||||||
if (translatedline[i] && translatedline[i].words[0]) {
|
|
||||||
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedLyrics.length > 0) {
|
|
||||||
state.lyricLines = parsedLyrics
|
|
||||||
} else {
|
|
||||||
state.lyricLines = []
|
|
||||||
}
|
}
|
||||||
|
if (!active) return
|
||||||
|
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取歌词失败:', error)
|
console.error('获取歌词失败:', error)
|
||||||
|
// 若已无效或已清理,避免写入与持有引用
|
||||||
|
if (!active) return
|
||||||
state.lyricLines = []
|
state.lyricLines = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,7 +252,9 @@ async function updateTextColor() {
|
|||||||
useBlackText.value = false // 默认使用白色文本
|
useBlackText.value = false // 默认使用白色文本
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const jumpTime = (e) => {
|
||||||
|
if (Audio.value.audio) Audio.value.audio.currentTime = e.line.getLine().startTime / 1000
|
||||||
|
}
|
||||||
// 监听封面图片变化
|
// 监听封面图片变化
|
||||||
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
|
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
|
||||||
|
|
||||||
@@ -256,6 +299,8 @@ onBeforeUnmount(async () => {
|
|||||||
if (unsubscribePlay.value) {
|
if (unsubscribePlay.value) {
|
||||||
unsubscribePlay.value()
|
unsubscribePlay.value()
|
||||||
}
|
}
|
||||||
|
bgRef.value?.bgRender?.dispose()
|
||||||
|
lyricPlayerRef.value?.lyricPlayer?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听音频URL变化
|
// 监听音频URL变化
|
||||||
@@ -316,7 +361,7 @@ const lyricTranslateY = computed(() => {
|
|||||||
:album-is-video="false"
|
:album-is-video="false"
|
||||||
:fps="30"
|
:fps="30"
|
||||||
:flow-speed="4"
|
:flow-speed="4"
|
||||||
:has-lyric="state.lyricLines.length > 10 && playSetting.getBgPlaying"
|
:has-lyric="state.lyricLines.length > 10"
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1"
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1"
|
||||||
/>
|
/>
|
||||||
<!-- 全屏按钮 -->
|
<!-- 全屏按钮 -->
|
||||||
@@ -382,11 +427,7 @@ const lyricTranslateY = computed(() => {
|
|||||||
class="lyric-player"
|
class="lyric-player"
|
||||||
:enable-spring="playSetting.getisJumpLyric"
|
:enable-spring="playSetting.getisJumpLyric"
|
||||||
:enable-scale="playSetting.getisJumpLyric"
|
:enable-scale="playSetting.getisJumpLyric"
|
||||||
@line-click="
|
@line-click="jumpTime"
|
||||||
(e) => {
|
|
||||||
if (Audio.audio) Audio.audio.currentTime = e.line.getLine().startTime / 1000
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
</LyricPlayer>
|
</LyricPlayer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,12 +29,45 @@ import {
|
|||||||
} from '@renderer/utils/playlist/playlistManager'
|
} from '@renderer/utils/playlist/playlistManager'
|
||||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||||
import defaultCoverImg from '/default-cover.png'
|
import defaultCoverImg from '/default-cover.png'
|
||||||
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
|
import { HeartIcon, DownloadIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { songListAPI } from '@renderer/api/songList'
|
||||||
|
|
||||||
const controlAudio = ControlAudioStore()
|
const controlAudio = ControlAudioStore()
|
||||||
const localUserStore = LocalUserDetailStore()
|
const localUserStore = LocalUserDetailStore()
|
||||||
const { Audio } = storeToRefs(controlAudio)
|
const { Audio } = storeToRefs(controlAudio)
|
||||||
const { list, userInfo } = storeToRefs(localUserStore)
|
const { list, userInfo } = storeToRefs(localUserStore)
|
||||||
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
||||||
|
|
||||||
|
// 当前歌曲是否已在“我的喜欢”
|
||||||
|
const likeState = ref(false)
|
||||||
|
const isLiked = computed(() => likeState.value)
|
||||||
|
|
||||||
|
const refreshLikeState = async () => {
|
||||||
|
try {
|
||||||
|
if (!userInfo.value.lastPlaySongId) {
|
||||||
|
likeState.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const favIdRes = await window.api.songList.getFavoritesId()
|
||||||
|
const favoritesId: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
if (!favoritesId) {
|
||||||
|
likeState.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasRes = await songListAPI.hasSong(favoritesId, userInfo.value.lastPlaySongId)
|
||||||
|
likeState.value = !!(hasRes.success && hasRes.data)
|
||||||
|
} catch {
|
||||||
|
likeState.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userInfo.value.lastPlaySongId,
|
||||||
|
() => refreshLikeState()
|
||||||
|
)
|
||||||
|
onMounted(() => refreshLikeState())
|
||||||
const showFullPlay = ref(false)
|
const showFullPlay = ref(false)
|
||||||
document.addEventListener('keydown', KeyEvent)
|
document.addEventListener('keydown', KeyEvent)
|
||||||
// 处理最小化右键的事件
|
// 处理最小化右键的事件
|
||||||
@@ -362,6 +395,19 @@ const handleVolumeDragEnd = () => {
|
|||||||
window.removeEventListener('mouseup', handleVolumeDragEnd)
|
window.removeEventListener('mouseup', handleVolumeDragEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleVolumeWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const volumeStep = event.deltaY > 0 ? -5 : 5
|
||||||
|
const updatedVolume = Math.max(0, Math.min(100, volumeValue.value + volumeStep))
|
||||||
|
|
||||||
|
if (updatedVolume === volumeValue.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeValue.value = updatedVolume
|
||||||
|
}
|
||||||
|
|
||||||
// 播放列表相关
|
// 播放列表相关
|
||||||
const showPlaylist = ref(false)
|
const showPlaylist = ref(false)
|
||||||
const playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
|
const playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
|
||||||
@@ -620,6 +666,86 @@ const toggleFullPlay = () => {
|
|||||||
showFullPlay.value = !showFullPlay.value
|
showFullPlay.value = !showFullPlay.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左侧操作:喜欢/取消喜欢(支持切换)
|
||||||
|
const onToggleLike = async () => {
|
||||||
|
try {
|
||||||
|
// 获取当前播放歌曲对象
|
||||||
|
const currentSong = list.value.find((s) => s.songmid === userInfo.value.lastPlaySongId)
|
||||||
|
if (!currentSong) {
|
||||||
|
MessagePlugin.warning('当前没有正在播放的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取持久化的“我的喜欢”歌单ID
|
||||||
|
const favIdRes = await window.api.songList.getFavoritesId()
|
||||||
|
let favoritesId: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
|
||||||
|
// 如果已有ID但歌单不存在,则置空
|
||||||
|
if (favoritesId) {
|
||||||
|
const existsRes = await songListAPI.exists(favoritesId)
|
||||||
|
if (!existsRes.success || !existsRes.data) {
|
||||||
|
favoritesId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有ID,尝试查找同名歌单;找不到则创建
|
||||||
|
if (!favoritesId) {
|
||||||
|
const searchRes = await songListAPI.search('我的喜欢', 'local')
|
||||||
|
if (searchRes.success && Array.isArray(searchRes.data)) {
|
||||||
|
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
|
||||||
|
favoritesId = exact?.id || null
|
||||||
|
}
|
||||||
|
if (!favoritesId) {
|
||||||
|
const createRes = await songListAPI.create('我的喜欢', '', 'local')
|
||||||
|
if (!createRes.success || !createRes.data?.id) {
|
||||||
|
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
favoritesId = createRes.data.id
|
||||||
|
}
|
||||||
|
// 持久化ID到主进程配置
|
||||||
|
await window.api.songList.setFavoritesId(favoritesId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前状态决定添加或移除
|
||||||
|
if (likeState.value) {
|
||||||
|
const removeRes = await songListAPI.removeSong(
|
||||||
|
favoritesId!,
|
||||||
|
userInfo.value.lastPlaySongId as any
|
||||||
|
)
|
||||||
|
if (removeRes.success && removeRes.data) {
|
||||||
|
likeState.value = false
|
||||||
|
// MessagePlugin.success('已取消喜欢')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(removeRes.error || '取消喜欢失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const addRes = await songListAPI.addSongs(favoritesId!, [
|
||||||
|
_.cloneDeep(toRaw(currentSong)) as any
|
||||||
|
])
|
||||||
|
if (addRes.success) {
|
||||||
|
likeState.value = true
|
||||||
|
// MessagePlugin.success('已添加到“我的喜欢”')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('切换喜欢状态失败:', error)
|
||||||
|
MessagePlugin.error('操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadSingleSong(_.cloneDeep(toRaw(songInfo.value)) as any)
|
||||||
|
MessagePlugin.success('开始下载当前歌曲')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('下载失败:', e)
|
||||||
|
MessagePlugin.error('下载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 进度条相关
|
// 进度条相关
|
||||||
const progressRef = ref<HTMLDivElement | null>(null)
|
const progressRef = ref<HTMLDivElement | null>(null)
|
||||||
const isDraggingProgress = ref(false)
|
const isDraggingProgress = ref(false)
|
||||||
@@ -864,6 +990,36 @@ watch(showFullPlay, (val) => {
|
|||||||
<div class="song-name">{{ songInfo.name }}</div>
|
<div class="song-name">{{ songInfo.name }}</div>
|
||||||
<div class="artist-name">{{ songInfo.singer }}</div>
|
<div class="artist-name">{{ songInfo.singer }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="left-actions">
|
||||||
|
<t-tooltip :content="isLiked ? '已喜欢' : '喜欢'">
|
||||||
|
<t-button
|
||||||
|
class="control-btn"
|
||||||
|
variant="text"
|
||||||
|
shape="circle"
|
||||||
|
:disabled="!songInfo.songmid"
|
||||||
|
@click.stop="onToggleLike"
|
||||||
|
>
|
||||||
|
<heart-icon
|
||||||
|
:fill-color="isLiked ? ['#FF7878', '#FF7878'] : ''"
|
||||||
|
:stroke-color="isLiked ? [] : [contrastTextColor, contrastTextColor]"
|
||||||
|
:stroke-width="isLiked ? 0 : 2"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</t-button>
|
||||||
|
</t-tooltip>
|
||||||
|
<t-tooltip content="下载">
|
||||||
|
<t-button
|
||||||
|
class="control-btn"
|
||||||
|
variant="text"
|
||||||
|
shape="circle"
|
||||||
|
:disabled="!songInfo.songmid"
|
||||||
|
@click.stop="onDownload"
|
||||||
|
>
|
||||||
|
<DownloadIcon size="18" />
|
||||||
|
</t-button>
|
||||||
|
</t-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中间:播放控制 -->
|
<!-- 中间:播放控制 -->
|
||||||
@@ -909,6 +1065,7 @@ watch(showFullPlay, (val) => {
|
|||||||
class="volume-control"
|
class="volume-control"
|
||||||
@mouseenter="showVolumeSlider = true"
|
@mouseenter="showVolumeSlider = true"
|
||||||
@mouseleave="showVolumeSlider = false"
|
@mouseleave="showVolumeSlider = false"
|
||||||
|
@wheel.prevent="handleVolumeWheel"
|
||||||
>
|
>
|
||||||
<button class="control-btn">
|
<button class="control-btn">
|
||||||
<shengyin style="width: 1.5em; height: 1.5em" />
|
<shengyin style="width: 1.5em; height: 1.5em" />
|
||||||
@@ -1176,6 +1333,38 @@ watch(showFullPlay, (val) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左侧操作按钮 */
|
||||||
|
.left-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 12px;
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: v-bind(contrastTextColor);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: v-bind(hoverColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 中间:播放控制 */
|
/* 中间:播放控制 */
|
||||||
.center-controls {
|
.center-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -606,8 +606,8 @@ const handleSuggestionSelect = (suggestion: any, _type: any) => {
|
|||||||
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
// overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 0;
|
height: 0;
|
||||||
/* 确保flex子元素能够正确计算高度 */
|
/* 确保flex子元素能够正确计算高度 */
|
||||||
|
|||||||
@@ -67,18 +67,11 @@ function setAnimate(routerObj: RouteRecordRaw[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAnimate(routes)
|
setAnimate(routes)
|
||||||
|
|
||||||
const option: RouterOptions = {
|
const option: RouterOptions = {
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes,
|
routes
|
||||||
scrollBehavior(_to_, _from_, savedPosition) {
|
|
||||||
if (savedPosition) {
|
|
||||||
return savedPosition
|
|
||||||
} else {
|
|
||||||
return { top: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = createRouter(option)
|
const router = createRouter(option)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
|||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { useSettingsStore } from '@renderer/store/Settings'
|
import { useSettingsStore } from '@renderer/store/Settings'
|
||||||
import { toRaw, h } from 'vue'
|
import { toRaw, h } from 'vue'
|
||||||
|
import {
|
||||||
|
QUALITY_ORDER,
|
||||||
|
getQualityDisplayName,
|
||||||
|
buildQualityFormats,
|
||||||
|
getHighestQualityType,
|
||||||
|
compareQuality,
|
||||||
|
type KnownQuality
|
||||||
|
} from '@common/utils/quality'
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
singer: string
|
singer: string
|
||||||
@@ -18,33 +26,17 @@ interface MusicItem {
|
|||||||
typeUrl: Record<string, any>
|
typeUrl: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityMap: Record<string, string> = {
|
|
||||||
'128k': '标准音质',
|
|
||||||
'192k': '高品音质',
|
|
||||||
'320k': '超高品质',
|
|
||||||
flac: '无损音质',
|
|
||||||
flac24bit: '超高解析',
|
|
||||||
hires: '高清臻音',
|
|
||||||
atmos: '全景环绕',
|
|
||||||
master: '超清母带'
|
|
||||||
}
|
|
||||||
const qualityKey = Object.keys(qualityMap)
|
|
||||||
|
|
||||||
// 创建音质选择弹窗
|
// 创建音质选择弹窗
|
||||||
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
|
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// 获取歌曲支持的音质列表
|
// 获取歌曲支持的音质列表
|
||||||
const availableQualities = songInfo.types || []
|
const availableQualities = buildQualityFormats(songInfo.types || [])
|
||||||
// 展示全部音质,但对超出用户最高音质的项做禁用呈现
|
// 展示全部音质,但对超出用户最高音质的项做禁用呈现
|
||||||
const userMaxIndex = qualityKey.indexOf(userQuality)
|
const userMaxIndex = QUALITY_ORDER.indexOf(userQuality as KnownQuality)
|
||||||
const qualityOptions = [...availableQualities]
|
const qualityOptions = [...availableQualities]
|
||||||
|
|
||||||
// 按音质优先级排序
|
// 按音质优先级排序(高→低)
|
||||||
qualityOptions.sort((a, b) => {
|
qualityOptions.sort((a, b) => compareQuality(a.type, b.type))
|
||||||
const aIndex = qualityKey.indexOf(a.type)
|
|
||||||
const bIndex = qualityKey.indexOf(b.type)
|
|
||||||
return bIndex - aIndex // 降序排列,高音质在前
|
|
||||||
})
|
|
||||||
|
|
||||||
const dialog = DialogPlugin.confirm({
|
const dialog = DialogPlugin.confirm({
|
||||||
header: '选择下载音质(可滚动)',
|
header: '选择下载音质(可滚动)',
|
||||||
@@ -70,8 +62,8 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
qualityOptions.map((quality) => {
|
qualityOptions.map((quality) => {
|
||||||
const idx = qualityKey.indexOf(quality.type)
|
const idx = QUALITY_ORDER.indexOf(quality.type as KnownQuality)
|
||||||
const disabled = idx !== -1 && idx > userMaxIndex
|
const disabled = userMaxIndex !== -1 && idx !== -1 && idx < userMaxIndex
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
@@ -132,7 +124,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
|||||||
: '#333'
|
: '#333'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
qualityMap[quality.type] || quality.type
|
getQualityDisplayName(quality.type)
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
@@ -173,6 +165,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
|||||||
|
|
||||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
console.log('开始下载', toRaw(songInfo))
|
||||||
const LocalUserDetail = LocalUserDetailStore()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
const userQuality = LocalUserDetail.userSource.quality as string
|
const userQuality = LocalUserDetail.userSource.quality as string
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
@@ -193,16 +186,20 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let quality = selectedQuality
|
let quality = selectedQuality as string
|
||||||
|
|
||||||
// 检查选择的音质是否超出歌曲支持的最高音质
|
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||||
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
|
const songMaxQuality = getHighestQualityType(songInfo.types)
|
||||||
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
|
if (
|
||||||
|
songMaxQuality &&
|
||||||
|
QUALITY_ORDER.indexOf(quality as KnownQuality) <
|
||||||
|
QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)
|
||||||
|
) {
|
||||||
quality = songMaxQuality
|
quality = songMaxQuality
|
||||||
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
|
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${getQualityDisplayName(quality)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
console.log(`使用音质下载: ${quality} - ${getQualityDisplayName(quality)}`)
|
||||||
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
||||||
|
|
||||||
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
||||||
|
|||||||
@@ -83,16 +83,34 @@ export async function addToPlaylistAndPlay(
|
|||||||
playSongCallback: (song: SongList) => Promise<void>
|
playSongCallback: (song: SongList) => Promise<void>
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 使用store的方法添加歌曲到第一位
|
// 获取当前正在播放的歌曲索引
|
||||||
localUserStore.addSongToFirst(song)
|
const currentId = localUserStore.userInfo?.lastPlaySongId
|
||||||
|
const currentIndex =
|
||||||
|
currentId !== undefined && currentId !== null
|
||||||
|
? localUserStore.list.findIndex((item: SongList) => item.songmid === currentId)
|
||||||
|
: -1
|
||||||
|
|
||||||
// 播放歌曲 - 确保正确处理Promise
|
// 如果目标歌曲已在列表中,先移除以避免重复
|
||||||
|
const existingIndex = localUserStore.list.findIndex(
|
||||||
|
(item: SongList) => item.songmid === song.songmid
|
||||||
|
)
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
localUserStore.list.splice(existingIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
// 正在播放:插入到当前歌曲的下一首
|
||||||
|
localUserStore.list.splice(currentIndex + 1, 0, song)
|
||||||
|
} else {
|
||||||
|
// 未在播放:添加到第一位
|
||||||
|
localUserStore.addSongToFirst(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放插入的歌曲
|
||||||
const playResult = playSongCallback(song)
|
const playResult = playSongCallback(song)
|
||||||
if (playResult && typeof playResult.then === 'function') {
|
if (playResult && typeof playResult.then === 'function') {
|
||||||
await playResult
|
await playResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// await MessagePlugin.success('已添加到播放列表并开始播放')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('播放失败:', error)
|
console.error('播放失败:', error)
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
|
|||||||
@@ -23,10 +23,3 @@
|
|||||||
<PlayMusic />
|
<PlayMusic />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.animate__animated {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ onUnmounted(() => {
|
|||||||
.find-container {
|
.find-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
|
import { ref, onMounted, toRaw, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
@@ -27,6 +27,10 @@ const LocalUserDetail = LocalUserDetailStore()
|
|||||||
// 响应式状态
|
// 响应式状态
|
||||||
const songs = ref<MusicItem[]>([])
|
const songs = ref<MusicItem[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 50
|
||||||
const currentSong = ref<MusicItem | null>(null)
|
const currentSong = ref<MusicItem | null>(null)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const playlistInfo = ref({
|
const playlistInfo = ref({
|
||||||
@@ -60,8 +64,8 @@ const fetchPlaylistSongs = async () => {
|
|||||||
// 处理本地歌单
|
// 处理本地歌单
|
||||||
await fetchLocalPlaylistSongs()
|
await fetchLocalPlaylistSongs()
|
||||||
} else {
|
} else {
|
||||||
// 处理网络歌单
|
// 处理网络歌单(重置并加载第一页)
|
||||||
await fetchNetworkPlaylistSongs()
|
await fetchNetworkPlaylistSongs(true)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取歌单歌曲失败:', error)
|
console.error('获取歌单歌曲失败:', error)
|
||||||
@@ -116,22 +120,43 @@ const fetchLocalPlaylistSongs = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取网络歌单歌曲
|
/**
|
||||||
const fetchNetworkPlaylistSongs = async () => {
|
* 获取网络歌单歌曲,支持重置与分页追加
|
||||||
|
* @param reset 是否重置为第一页
|
||||||
|
*/
|
||||||
|
const fetchNetworkPlaylistSongs = async (reset = false) => {
|
||||||
try {
|
try {
|
||||||
// 调用API获取歌单详情和歌曲列表
|
// 并发保护:首次加载使用 loading,分页加载使用 loadingMore
|
||||||
|
if ((reset && !loading.value) || (!reset && loadingMore.value)) return
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
currentPage.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
songs.value = []
|
||||||
|
loading.value = true
|
||||||
|
} else {
|
||||||
|
if (!hasMore.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const result = (await window.api.music.requestSdk('getPlaylistDetail', {
|
const result = (await window.api.music.requestSdk('getPlaylistDetail', {
|
||||||
source: playlistInfo.value.source,
|
source: playlistInfo.value.source,
|
||||||
id: playlistInfo.value.id,
|
id: playlistInfo.value.id,
|
||||||
page: 1
|
page: currentPage.value
|
||||||
})) as any
|
})) as any
|
||||||
|
const limit = Number(result?.limit ?? pageSize)
|
||||||
|
|
||||||
console.log(result)
|
if (result && Array.isArray(result.list)) {
|
||||||
if (result && result.list) {
|
const newList = result.list
|
||||||
songs.value = result.list
|
|
||||||
|
|
||||||
// 获取歌曲封面
|
if (reset) {
|
||||||
setPic(0, playlistInfo.value.source)
|
songs.value = newList
|
||||||
|
} else {
|
||||||
|
songs.value = [...songs.value, ...newList]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取新增歌曲封面
|
||||||
|
setPic((currentPage.value - 1) * limit, playlistInfo.value.source)
|
||||||
|
|
||||||
// 如果API返回了歌单详细信息,更新歌单信息
|
// 如果API返回了歌单详细信息,更新歌单信息
|
||||||
if (result.info) {
|
if (result.info) {
|
||||||
@@ -143,10 +168,28 @@ const fetchNetworkPlaylistSongs = async () => {
|
|||||||
total: result.info.total || playlistInfo.value.total
|
total: result.info.total || playlistInfo.value.total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新分页状态
|
||||||
|
currentPage.value += 1
|
||||||
|
const total = result.info?.total ?? playlistInfo.value.total ?? 0
|
||||||
|
if (total) {
|
||||||
|
hasMore.value = songs.value.length < total
|
||||||
|
} else {
|
||||||
|
hasMore.value = newList.length >= limit
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasMore.value = false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取网络歌单失败:', error)
|
console.error('获取网络歌单失败:', error)
|
||||||
songs.value = []
|
if (reset) songs.value = []
|
||||||
|
hasMore.value = false
|
||||||
|
} finally {
|
||||||
|
if (reset) {
|
||||||
|
loading.value = false
|
||||||
|
} else {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,45 +432,44 @@ const handleShufflePlaylist = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 滚动事件处理
|
/**
|
||||||
|
* 滚动事件处理:更新头部紧凑状态,并在接近底部时触发分页加载
|
||||||
|
*/
|
||||||
const handleScroll = (event?: Event) => {
|
const handleScroll = (event?: Event) => {
|
||||||
let scrollTop = 0
|
let scrollTop = 0
|
||||||
|
let scrollHeight = 0
|
||||||
|
let clientHeight = 0
|
||||||
|
|
||||||
if (event && event.target) {
|
if (event && event.target) {
|
||||||
scrollTop = (event.target as HTMLElement).scrollTop
|
const target = event.target as HTMLElement
|
||||||
|
scrollTop = target.scrollTop
|
||||||
|
scrollHeight = target.scrollHeight
|
||||||
|
clientHeight = target.clientHeight
|
||||||
} else if (scrollContainer.value) {
|
} else if (scrollContainer.value) {
|
||||||
scrollTop = scrollContainer.value.scrollTop
|
scrollTop = scrollContainer.value.scrollTop
|
||||||
|
scrollHeight = scrollContainer.value.scrollHeight
|
||||||
|
clientHeight = scrollContainer.value.clientHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollY.value = scrollTop
|
scrollY.value = scrollTop
|
||||||
// 当滚动超过100px时,启用紧凑模式
|
// 当滚动超过100px时,启用紧凑模式
|
||||||
isHeaderCompact.value = scrollY.value > 100
|
isHeaderCompact.value = scrollY.value > 100
|
||||||
|
|
||||||
|
// 触底加载(参考 search.vue)
|
||||||
|
if (
|
||||||
|
scrollHeight > 0 &&
|
||||||
|
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||||
|
!loadingMore.value &&
|
||||||
|
hasMore.value &&
|
||||||
|
!isLocalPlaylist.value
|
||||||
|
) {
|
||||||
|
fetchNetworkPlaylistSongs(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时获取数据
|
// 组件挂载时获取数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPlaylistSongs()
|
fetchPlaylistSongs()
|
||||||
|
|
||||||
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
|
|
||||||
setTimeout(() => {
|
|
||||||
// 查找 SongVirtualList 内部的虚拟滚动容器
|
|
||||||
const virtualListContainer = document.querySelector('.virtual-scroll-container')
|
|
||||||
|
|
||||||
if (virtualListContainer) {
|
|
||||||
scrollContainer.value = virtualListContainer as HTMLElement
|
|
||||||
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
console.log('滚动监听器已添加到:', virtualListContainer)
|
|
||||||
} else {
|
|
||||||
console.warn('未找到虚拟滚动容器')
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 组件卸载时清理事件监听
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (scrollContainer.value) {
|
|
||||||
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const localSongs = ref<LocalSong[]>([
|
|||||||
// 歌单列表
|
// 歌单列表
|
||||||
const playlists = ref<SongList[]>([])
|
const playlists = ref<SongList[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
// 喜欢歌单ID(用于排序与标记)
|
||||||
|
const favoritesId = ref<string | null>(null)
|
||||||
|
|
||||||
// 对话框状态
|
// 对话框状态
|
||||||
const showCreatePlaylistDialog = ref(false)
|
const showCreatePlaylistDialog = ref(false)
|
||||||
@@ -192,6 +194,18 @@ const loadPlaylists = async () => {
|
|||||||
const result = await songListAPI.getAll()
|
const result = await songListAPI.getAll()
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
playlists.value = result.data || []
|
playlists.value = result.data || []
|
||||||
|
// 读取“我的喜欢”ID并置顶与标记
|
||||||
|
try {
|
||||||
|
const favRes = await (window as any).api?.songList?.getFavoritesId?.()
|
||||||
|
favoritesId.value = (favRes && favRes.data) || null
|
||||||
|
if (favoritesId.value) {
|
||||||
|
const idx = playlists.value.findIndex((p) => p.id === favoritesId.value)
|
||||||
|
if (idx > 0) {
|
||||||
|
const fav = playlists.value.splice(idx, 1)[0]
|
||||||
|
playlists.value.unshift(fav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
MessagePlugin.error(result.error || '加载歌单失败')
|
MessagePlugin.error(result.error || '加载歌单失败')
|
||||||
}
|
}
|
||||||
@@ -517,7 +531,7 @@ const setPicForPlaylist = async (songs: any[], source: string) => {
|
|||||||
// 处理网络歌单导入
|
// 处理网络歌单导入
|
||||||
const handleNetworkPlaylistImport = async (input: string) => {
|
const handleNetworkPlaylistImport = async (input: string) => {
|
||||||
try {
|
try {
|
||||||
const load1 = MessagePlugin.loading('正在解析歌单链接...')
|
const load1 = MessagePlugin.loading('正在解析歌单链接...', 0)
|
||||||
|
|
||||||
let playlistId: string = ''
|
let playlistId: string = ''
|
||||||
let platformName: string = ''
|
let platformName: string = ''
|
||||||
@@ -541,38 +555,51 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
}
|
}
|
||||||
platformName = '网易云音乐'
|
platformName = '网易云音乐'
|
||||||
} else if (importPlatformType.value === 'tx') {
|
} else if (importPlatformType.value === 'tx') {
|
||||||
// QQ音乐歌单ID解析 - 支持多种链接格式
|
// QQ音乐歌单ID解析:优先通过 SDK 解析,失败再回退到正则
|
||||||
const qqPlaylistRegexes = [
|
let parsedId = ''
|
||||||
// 标准歌单链接
|
try {
|
||||||
/(?:y\.qq\.com\/n\/ryqq\/playlist\/|music\.qq\.com\/.*[?&]id=|playlist[?&]id=)(\d+)/i,
|
const parsed: any = await window.api.music.requestSdk('parsePlaylistId', {
|
||||||
// 分享链接格式
|
source: 'tx',
|
||||||
/(?:i\.y\.qq\.com\/n2\/m\/share\/details\/taoge\.html.*[?&]id=)(\d+)/i,
|
url: input
|
||||||
// 其他可能的分享格式
|
})
|
||||||
/(?:c\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=)(\d+)/i,
|
console.log('QQ音乐歌单解析结果', parsed)
|
||||||
// 手机版链接
|
if (parsed) parsedId = parsed
|
||||||
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
} catch (e) {}
|
||||||
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
|
||||||
/[?&]id=(\d+)/i
|
|
||||||
]
|
|
||||||
|
|
||||||
let match: RegExpMatchArray | null = null
|
if (parsedId) {
|
||||||
for (const regex of qqPlaylistRegexes) {
|
playlistId = parsedId
|
||||||
match = input.match(regex)
|
} else {
|
||||||
if (match && match[1]) {
|
const qqPlaylistRegexes = [
|
||||||
playlistId = match[1]
|
// 标准歌单链接(强烈推荐)
|
||||||
break
|
/(?:y\.qq\.com\/n\/ryqq\/playlist\/|music\.qq\.com\/.*[?&]id=|playlist[?&]id=)(\d+)/i,
|
||||||
|
// 分享链接格式
|
||||||
|
/(?:i\.y\.qq\.com\/n2\/m\/share\/details\/taoge\.html.*[?&]id=)(\d+)/i,
|
||||||
|
// 其他可能的分享格式 https:\/\/c\d+\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=([A-Za-z0-9]+)/i,
|
||||||
|
// 手机版链接
|
||||||
|
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
||||||
|
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
||||||
|
/[?&]id=(\d+)/i
|
||||||
|
]
|
||||||
|
|
||||||
|
let match: RegExpMatchArray | null = null
|
||||||
|
for (const regex of qqPlaylistRegexes) {
|
||||||
|
match = input.match(regex)
|
||||||
|
if (match && match[1]) {
|
||||||
|
playlistId = match[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!match || !match[1]) {
|
if (!match || !match[1]) {
|
||||||
// 检查是否直接输入的是纯数字ID
|
// 检查是否直接输入的是纯数字ID
|
||||||
const numericMatch = input.match(/^\d+$/)
|
const numericMatch = input.match(/^\d+$/)
|
||||||
if (numericMatch) {
|
if (numericMatch) {
|
||||||
playlistId = input
|
playlistId = input
|
||||||
} else {
|
} else {
|
||||||
MessagePlugin.error('无法识别的QQ音乐歌单链接或ID格式,请检查链接是否正确')
|
MessagePlugin.error('无法识别的QQ音乐歌单链接或ID格式,请检查链接是否正确')
|
||||||
load1.then((res) => res.close())
|
load1.then((res) => res.close())
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
platformName = 'QQ音乐'
|
platformName = 'QQ音乐'
|
||||||
@@ -680,18 +707,11 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证歌单ID是否有效
|
|
||||||
if (!playlistId || playlistId.length < 6) {
|
|
||||||
MessagePlugin.error('歌单ID格式不正确')
|
|
||||||
load1.then((res) => res.close())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭加载提示
|
// 关闭加载提示
|
||||||
load1.then((res) => res.close())
|
load1.then((res) => res.close())
|
||||||
|
|
||||||
// 获取歌单详情
|
// 获取歌单详情
|
||||||
const load2 = MessagePlugin.loading('正在获取歌单信息...')
|
const load2 = MessagePlugin.loading('正在获取歌单信息,请不要离开页面...', 0)
|
||||||
|
|
||||||
const getListDetail = async (page: number) => {
|
const getListDetail = async (page: number) => {
|
||||||
let detailResult: any
|
let detailResult: any
|
||||||
@@ -701,6 +721,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
id: playlistId,
|
id: playlistId,
|
||||||
page: page
|
page: page
|
||||||
})) as any
|
})) as any
|
||||||
|
console.log('list', detailResult)
|
||||||
} catch {
|
} catch {
|
||||||
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
||||||
load2.then((res) => res.close())
|
load2.then((res) => res.close())
|
||||||
@@ -728,6 +749,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (detailResult.total < songs.length) break
|
||||||
page++
|
page++
|
||||||
const { list: songsList } = await getListDetail(page)
|
const { list: songsList } = await getListDetail(page)
|
||||||
if (!(songsList && songsList.length)) {
|
if (!(songsList && songsList.length)) {
|
||||||
@@ -1022,6 +1044,14 @@ onMounted(() => {
|
|||||||
<div class="playlist-info">
|
<div class="playlist-info">
|
||||||
<div class="playlist-name" :title="playlist.name" @click="viewPlaylist(playlist)">
|
<div class="playlist-name" :title="playlist.name" @click="viewPlaylist(playlist)">
|
||||||
{{ playlist.name }}
|
{{ playlist.name }}
|
||||||
|
<t-tag
|
||||||
|
v-if="playlist.id === favoritesId"
|
||||||
|
theme="danger"
|
||||||
|
variant="light-outline"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
>我的喜欢</t-tag
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="playlist.description"
|
v-if="playlist.description"
|
||||||
@@ -1441,7 +1471,8 @@ onMounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page {
|
.page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
// height: 100%;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.local-container {
|
.local-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -1774,7 +1805,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.playlist-cover {
|
.playlist-cover {
|
||||||
height: 180px;
|
height: 180px;
|
||||||
background: linear-gradient(135deg, var(--td-brand-color-4) 0%, var(--td-brand-color-6) 100%);
|
background: #e4e4e4;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ const formatPlayTime = (timeStr: string): string => {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
// min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-header {
|
.search-header {
|
||||||
|
|||||||
Reference in New Issue
Block a user