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%;" />
|
||||
|
||||
## 联系
|
||||
|
||||
关于项目问题也可联系
|
||||
邮箱: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 |
|
||||
| **群友**:🍀 | 5 |
|
||||
| **群友**:🍀 | 5 |
|
||||
| **群友**:涟漪 | 50 |
|
||||
| **作者朋友** | 188 |
|
||||
| **群友**:我叫阿狸 | 3 |
|
||||
@@ -14,5 +14,7 @@
|
||||
| 青禾 | 8.8 |
|
||||
| li peng | 200 |
|
||||
| **群友**:XIZ | 3 |
|
||||
| YL | 10 |
|
||||
| **群友**:way1437 | 50 |
|
||||
|
||||
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
||||
|
||||
@@ -46,7 +46,10 @@ features:
|
||||
|
||||
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",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.6",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||
"@pixi/filter-color-matrix": "^7.4.3",
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/needle": "^3.3.0",
|
||||
"NeteaseCloudMusicApi": "^4.27.0",
|
||||
"animate.css": "^4.1.1",
|
||||
@@ -57,8 +57,7 @@
|
||||
"dompurify": "^3.2.6",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.3.9",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"howler": "^2.2.4",
|
||||
"hpagent": "^1.2.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"jss": "^10.10.0",
|
||||
@@ -69,7 +68,7 @@
|
||||
"mitt": "^3.0.1",
|
||||
"needle": "^3.3.1",
|
||||
"node-fetch": "2",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-taglib-sharp": "^6.0.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"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 ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
||||
import type { SongList, Songs } from '@common/types/songList'
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
// 创建新歌单
|
||||
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 () => {
|
||||
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 { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/logo.png?asset'
|
||||
import path from 'node:path'
|
||||
import pluginService from './services/plugin'
|
||||
import aiEvents from './events/ai'
|
||||
import './services/musicSdk/index'
|
||||
import InitEventServices from './events'
|
||||
|
||||
import './events/musicCache'
|
||||
import './events/songList'
|
||||
import './events/directorySettings'
|
||||
import './events/pluginNotice'
|
||||
|
||||
// 获取单实例锁
|
||||
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 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
|
||||
|
||||
// 创建托盘菜单
|
||||
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()
|
||||
}
|
||||
}
|
||||
])
|
||||
// 1. 获取窗口的当前边界 (bounds)
|
||||
const currentBounds: Rectangle = win.getBounds()
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.setToolTip('Ceru Music')
|
||||
// 2. 查找包含该边界的显示器
|
||||
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
|
||||
|
||||
// 双击托盘图标显示窗口
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
// 3. 获取该显示器的完整尺寸 (full screen size)
|
||||
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
|
||||
|
||||
// 4. 应用新的最大尺寸限制
|
||||
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
|
||||
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
|
||||
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// return
|
||||
// 获取保存的窗口位置和大小
|
||||
const savedBounds = configManager.getWindowBounds()
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
|
||||
|
||||
// 默认窗口配置
|
||||
const defaultOptions = {
|
||||
width: 1100,
|
||||
height: 750,
|
||||
minWidth: 1100,
|
||||
minHeight: 670,
|
||||
maxWidth: screenWidth,
|
||||
maxHeight: screenHeight,
|
||||
// ⚠️ 关键修改 1: 移除 maxWidth 和 maxHeight 的硬编码限制
|
||||
// maxWidth: screenWidth,
|
||||
// maxHeight: screenHeight,
|
||||
show: false,
|
||||
center: !savedBounds, // 如果有保存的位置,则不居中
|
||||
autoHideMenuBar: true,
|
||||
@@ -131,24 +92,30 @@ function createWindow(): void {
|
||||
mainWindow = new BrowserWindow(defaultOptions)
|
||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||
|
||||
// 监听窗口移动和调整大小事件,保存窗口位置和大小
|
||||
// ⚠️ 关键修改 2: 监听 'moved' 事件,动态更新最大尺寸
|
||||
mainWindow.on('moved', () => {
|
||||
// 当窗口移动时,确保最大尺寸限制随屏幕变化
|
||||
updateWindowMaxLimits(mainWindow)
|
||||
|
||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
configManager.saveWindowBounds(bounds)
|
||||
}
|
||||
})
|
||||
|
||||
// ⚠️ 关键修改 3: 窗口创建后立即应用一次最大尺寸限制
|
||||
updateWindowMaxLimits(mainWindow)
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
|
||||
// 获取当前屏幕尺寸
|
||||
const { screen } = require('electron')
|
||||
// 获取当前屏幕尺寸 (已在文件顶部导入 screen,无需 require)
|
||||
const currentDisplay = screen.getDisplayMatching(bounds)
|
||||
// 使用 workAreaSize 避免窗口超出任务栏/Dock
|
||||
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
|
||||
|
||||
// 确保窗口不超过屏幕尺寸
|
||||
// 确保窗口不超过屏幕工作区域尺寸
|
||||
let needResize = false
|
||||
const newBounds = { ...bounds }
|
||||
|
||||
@@ -174,29 +141,11 @@ function createWindow(): void {
|
||||
mainWindow.on('ready-to-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) => {
|
||||
shell.openExternal(details.url).then()
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
InitEventServices(mainWindow)
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
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'
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
@@ -296,16 +171,6 @@ app.whenReady().then(() => {
|
||||
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
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
@@ -313,79 +178,7 @@ app.whenReady().then(() => {
|
||||
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()
|
||||
createTray()
|
||||
|
||||
// 注册自动更新事件
|
||||
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
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
let ping: NodeJS.Timeout
|
||||
function startPing() {
|
||||
let interval = 3000
|
||||
|
||||
// 已迁移到 Howler,不再使用 DOM <audio> 轮询。
|
||||
// 如需主进程感知播放结束,请在渲染进程通过 IPC 主动通知。
|
||||
if (ping) {
|
||||
clearInterval(ping)
|
||||
}
|
||||
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.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)
|
||||
// 保留占位,避免调用方报错;不再做任何轮询。
|
||||
// 可在此处监听自定义 IPC 事件以扩展行为。
|
||||
clearInterval(ping)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import NodeID3 from 'node-id3'
|
||||
import ffmpegStatic from 'ffmpeg-static'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
|
||||
import path from 'node:path'
|
||||
import axios from 'axios'
|
||||
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')}`
|
||||
}
|
||||
|
||||
// 根据图片 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)字符
|
||||
*/
|
||||
@@ -156,265 +180,6 @@ function convertOldFormat(timestamp: string, content: string): string {
|
||||
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 => {
|
||||
// 使用配置管理服务获取下载目录
|
||||
@@ -492,13 +257,71 @@ export default async function download(
|
||||
delete fileLock[songPath]
|
||||
}
|
||||
|
||||
// 写入标签信息
|
||||
// 写入标签信息(使用 node-taglib-sharp)
|
||||
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||
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) {
|
||||
console.warn('写入音频标签失败:', error)
|
||||
throw ffmpegStatic
|
||||
console.warn('写入音乐元信息失败:', error)
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -205,7 +205,6 @@ export default {
|
||||
if (!result) throw new Error('failed')
|
||||
}
|
||||
id = result[1]
|
||||
// console.log(id)
|
||||
}
|
||||
return id
|
||||
},
|
||||
@@ -222,6 +221,7 @@ export default {
|
||||
},
|
||||
})
|
||||
const { body } = await requestObj_listDetail.promise
|
||||
console.log(body);
|
||||
|
||||
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
||||
const cdlist = body.cdlist[0]
|
||||
|
||||
@@ -83,7 +83,7 @@ export default {
|
||||
const { statusCode, body } = await requestObj_listDetail.promise
|
||||
if (statusCode !== 200 || body.code !== this.successCode)
|
||||
return this.getListDetail(id, page, ++tryNum)
|
||||
let limit = 1000
|
||||
let limit = 50
|
||||
let rangeStart = (page - 1) * limit
|
||||
let list
|
||||
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>
|
||||
repairData: (hashId: string) => Promise<any>
|
||||
forceSave: (hashId: string) => Promise<any>
|
||||
getFavoritesId: () => Promise<any>
|
||||
setFavoritesId: (favoritesId: string) => Promise<any>
|
||||
}
|
||||
|
||||
ai: {
|
||||
|
||||
@@ -116,7 +116,11 @@ const api = {
|
||||
validateIntegrity: (hashId: string) =>
|
||||
ipcRenderer.invoke('songlist:validate-integrity', 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'),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
||||
<div
|
||||
v-for="(song, index) in visibleItems"
|
||||
:key="song.id || song.songmid"
|
||||
:key="`${song.source || ''}-${song.songmid}-${song.albumId || ''}-${index}`"
|
||||
class="song-item"
|
||||
@mouseenter="hoveredSong = song.id || song.songmid"
|
||||
@mouseleave="hoveredSong = null"
|
||||
@@ -58,8 +58,17 @@
|
||||
|
||||
<!-- 喜欢按钮 -->
|
||||
<div class="col-like">
|
||||
<button class="action-btn like-btn" @click.stop>
|
||||
<i class="icon-heart"></i>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +119,8 @@ import {
|
||||
PlayCircleIcon,
|
||||
AddIcon,
|
||||
FolderIcon,
|
||||
DeleteIcon
|
||||
DeleteIcon,
|
||||
HeartIcon
|
||||
} from 'tdesign-icons-vue-next'
|
||||
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
||||
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
||||
@@ -290,8 +300,9 @@ const getQualityDisplayName = (quality: any) => {
|
||||
|
||||
// 处理滚动事件
|
||||
const onScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
const target = event.target as HTMLElement | null
|
||||
// 兼容程序触发的假事件,target 可能为 null
|
||||
scrollTop.value = target?.scrollTop ?? scrollContainer.value?.scrollTop ?? 0
|
||||
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) => {
|
||||
try {
|
||||
@@ -420,7 +514,7 @@ const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 组件挂载后触发一次重新计算
|
||||
nextTick(() => {
|
||||
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(() => {
|
||||
|
||||
@@ -92,94 +92,135 @@ const state = reactive({
|
||||
// 监听歌曲ID变化,获取歌词
|
||||
watch(
|
||||
() => props.songId,
|
||||
async (newId) => {
|
||||
async (newId, _oldId, onCleanup) => {
|
||||
if (!newId || !props.songInfo) return
|
||||
let lyricText = ''
|
||||
let parsedLyrics: LyricLine[] = []
|
||||
// 创建一个符合 MusicItem 接口的对象,只包含必要的基本属性
|
||||
// 竞态与取消控制,防止内存泄漏与过期结果覆盖
|
||||
let active = true
|
||||
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 {
|
||||
// 检查是否为网易云音乐,只有网易云才使用ttml接口
|
||||
const isNetease =
|
||||
props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy'
|
||||
const songinfo: any = _.cloneDeep(toRaw(props.songInfo))
|
||||
const source =
|
||||
props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg'
|
||||
let parsedLyrics: LyricLine[] = []
|
||||
|
||||
if (isNetease) {
|
||||
// 网易云音乐优先尝试ttml接口
|
||||
if (source === 'wy') {
|
||||
// 网易云:优先尝试 TTML
|
||||
try {
|
||||
const res = (await (
|
||||
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`)
|
||||
).text()) as any
|
||||
const res = await (
|
||||
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`, {
|
||||
signal: abort.signal
|
||||
})
|
||||
).text()
|
||||
if (!active) return
|
||||
if (!res || res.length < 100) throw new Error('ttml 无歌词')
|
||||
parsedLyrics = parseTTML(res).lines
|
||||
} catch {
|
||||
// ttml失败后使用新的歌词API
|
||||
// 回退到统一歌词 API
|
||||
const lyricData = await window.api.music.requestSdk('getLyric', {
|
||||
source: 'wy',
|
||||
songInfo: songinfo
|
||||
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
|
||||
})
|
||||
if (!active) return
|
||||
|
||||
if (lyricData.crlyric) {
|
||||
// 使用逐字歌词
|
||||
lyricText = lyricData.crlyric
|
||||
|
||||
parsedLyrics = parseYrc(lyricText)
|
||||
} else if (lyricData.lyric) {
|
||||
lyricText = lyricData.lyric
|
||||
parsedLyrics = parseLrc(lyricText)
|
||||
if (lyricData?.crlyric) {
|
||||
parsedLyrics = parseYrc(lyricData.crlyric)
|
||||
} else if (lyricData?.lyric) {
|
||||
parsedLyrics = parseLrc(lyricData.lyric)
|
||||
}
|
||||
|
||||
if (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
|
||||
}
|
||||
}
|
||||
}
|
||||
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
||||
}
|
||||
} else {
|
||||
// 其他音乐平台直接使用新的歌词API
|
||||
const source = props.songInfo && 'source' in props.songInfo ? props.songInfo.source : 'kg'
|
||||
// 创建一个纯净的对象,避免Vue响应式对象序列化问题
|
||||
const cleanSongInfo = JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
||||
// 其他来源:直接统一歌词 API
|
||||
const lyricData = await window.api.music.requestSdk('getLyric', {
|
||||
source: source,
|
||||
songInfo: cleanSongInfo
|
||||
source,
|
||||
songInfo: getCleanSongInfo()
|
||||
})
|
||||
if (!active) return
|
||||
|
||||
if (lyricData.crlyric) {
|
||||
// 使用逐字歌词
|
||||
lyricText = lyricData.crlyric
|
||||
if (source === 'tx') {
|
||||
parsedLyrics = parseQrc(lyricText)
|
||||
} else {
|
||||
parsedLyrics = parseYrc(lyricText)
|
||||
}
|
||||
} else if (lyricData.lyric) {
|
||||
lyricText = lyricData.lyric
|
||||
parsedLyrics = parseLrc(lyricText)
|
||||
if (lyricData?.crlyric) {
|
||||
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
|
||||
} else if (lyricData?.lyric) {
|
||||
parsedLyrics = parseLrc(lyricData.lyric)
|
||||
}
|
||||
|
||||
if (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 = []
|
||||
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
||||
}
|
||||
if (!active) return
|
||||
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
||||
} catch (error) {
|
||||
console.error('获取歌词失败:', error)
|
||||
// 若已无效或已清理,避免写入与持有引用
|
||||
if (!active) return
|
||||
state.lyricLines = []
|
||||
}
|
||||
},
|
||||
@@ -211,7 +252,9 @@ async function updateTextColor() {
|
||||
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 })
|
||||
|
||||
@@ -256,6 +299,8 @@ onBeforeUnmount(async () => {
|
||||
if (unsubscribePlay.value) {
|
||||
unsubscribePlay.value()
|
||||
}
|
||||
bgRef.value?.bgRender?.dispose()
|
||||
lyricPlayerRef.value?.lyricPlayer?.dispose()
|
||||
})
|
||||
|
||||
// 监听音频URL变化
|
||||
@@ -316,7 +361,7 @@ const lyricTranslateY = computed(() => {
|
||||
:album-is-video="false"
|
||||
:fps="30"
|
||||
: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"
|
||||
/>
|
||||
<!-- 全屏按钮 -->
|
||||
@@ -382,11 +427,7 @@ const lyricTranslateY = computed(() => {
|
||||
class="lyric-player"
|
||||
:enable-spring="playSetting.getisJumpLyric"
|
||||
:enable-scale="playSetting.getisJumpLyric"
|
||||
@line-click="
|
||||
(e) => {
|
||||
if (Audio.audio) Audio.audio.currentTime = e.line.getLine().startTime / 1000
|
||||
}
|
||||
"
|
||||
@line-click="jumpTime"
|
||||
>
|
||||
</LyricPlayer>
|
||||
</div>
|
||||
|
||||
@@ -29,12 +29,45 @@ import {
|
||||
} from '@renderer/utils/playlist/playlistManager'
|
||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||
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 localUserStore = LocalUserDetailStore()
|
||||
const { Audio } = storeToRefs(controlAudio)
|
||||
const { list, userInfo } = storeToRefs(localUserStore)
|
||||
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)
|
||||
document.addEventListener('keydown', KeyEvent)
|
||||
// 处理最小化右键的事件
|
||||
@@ -362,6 +395,19 @@ const 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 playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
|
||||
@@ -620,6 +666,86 @@ const toggleFullPlay = () => {
|
||||
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 isDraggingProgress = ref(false)
|
||||
@@ -864,6 +990,36 @@ watch(showFullPlay, (val) => {
|
||||
<div class="song-name">{{ songInfo.name }}</div>
|
||||
<div class="artist-name">{{ songInfo.singer }}</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>
|
||||
|
||||
<!-- 中间:播放控制 -->
|
||||
@@ -909,6 +1065,7 @@ watch(showFullPlay, (val) => {
|
||||
class="volume-control"
|
||||
@mouseenter="showVolumeSlider = true"
|
||||
@mouseleave="showVolumeSlider = false"
|
||||
@wheel.prevent="handleVolumeWheel"
|
||||
>
|
||||
<button class="control-btn">
|
||||
<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 {
|
||||
display: flex;
|
||||
|
||||
@@ -606,8 +606,8 @@ const handleSuggestionSelect = (suggestion: any, _type: any) => {
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
// overflow-y: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 0;
|
||||
/* 确保flex子元素能够正确计算高度 */
|
||||
|
||||
@@ -67,18 +67,11 @@ function setAnimate(routerObj: RouteRecordRaw[]) {
|
||||
}
|
||||
}
|
||||
setAnimate(routes)
|
||||
|
||||
const option: RouterOptions = {
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior(_to_, _from_, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
routes
|
||||
}
|
||||
|
||||
const router = createRouter(option)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -2,6 +2,14 @@ import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { useSettingsStore } from '@renderer/store/Settings'
|
||||
import { toRaw, h } from 'vue'
|
||||
import {
|
||||
QUALITY_ORDER,
|
||||
getQualityDisplayName,
|
||||
buildQualityFormats,
|
||||
getHighestQualityType,
|
||||
compareQuality,
|
||||
type KnownQuality
|
||||
} from '@common/utils/quality'
|
||||
|
||||
interface MusicItem {
|
||||
singer: string
|
||||
@@ -18,33 +26,17 @@ interface MusicItem {
|
||||
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> {
|
||||
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]
|
||||
|
||||
// 按音质优先级排序
|
||||
qualityOptions.sort((a, b) => {
|
||||
const aIndex = qualityKey.indexOf(a.type)
|
||||
const bIndex = qualityKey.indexOf(b.type)
|
||||
return bIndex - aIndex // 降序排列,高音质在前
|
||||
})
|
||||
// 按音质优先级排序(高→低)
|
||||
qualityOptions.sort((a, b) => compareQuality(a.type, b.type))
|
||||
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: '选择下载音质(可滚动)',
|
||||
@@ -70,8 +62,8 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
||||
}
|
||||
},
|
||||
qualityOptions.map((quality) => {
|
||||
const idx = qualityKey.indexOf(quality.type)
|
||||
const disabled = idx !== -1 && idx > userMaxIndex
|
||||
const idx = QUALITY_ORDER.indexOf(quality.type as KnownQuality)
|
||||
const disabled = userMaxIndex !== -1 && idx !== -1 && idx < userMaxIndex
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
@@ -132,7 +124,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
||||
: '#333'
|
||||
}
|
||||
},
|
||||
qualityMap[quality.type] || quality.type
|
||||
getQualityDisplayName(quality.type)
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
@@ -173,6 +165,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
||||
|
||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||
try {
|
||||
console.log('开始下载', toRaw(songInfo))
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
const userQuality = LocalUserDetail.userSource.quality as string
|
||||
const settingsStore = useSettingsStore()
|
||||
@@ -193,16 +186,20 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
let quality = selectedQuality
|
||||
let quality = selectedQuality as string
|
||||
|
||||
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
|
||||
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
|
||||
const songMaxQuality = getHighestQualityType(songInfo.types)
|
||||
if (
|
||||
songMaxQuality &&
|
||||
QUALITY_ORDER.indexOf(quality as KnownQuality) <
|
||||
QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)
|
||||
) {
|
||||
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 result = await window.api.music.requestSdk('downloadSingleSong', {
|
||||
|
||||
@@ -83,16 +83,34 @@ export async function addToPlaylistAndPlay(
|
||||
playSongCallback: (song: SongList) => Promise<void>
|
||||
) {
|
||||
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)
|
||||
if (playResult && typeof playResult.then === 'function') {
|
||||
await playResult
|
||||
}
|
||||
|
||||
// await MessagePlugin.success('已添加到播放列表并开始播放')
|
||||
} catch (error: any) {
|
||||
console.error('播放失败:', error)
|
||||
if (error.message) {
|
||||
|
||||
@@ -23,10 +23,3 @@
|
||||
<PlayMusic />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animate__animated {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -221,6 +221,8 @@ onUnmounted(() => {
|
||||
.find-container {
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
@@ -27,6 +27,10 @@ const LocalUserDetail = LocalUserDetailStore()
|
||||
// 响应式状态
|
||||
const songs = ref<MusicItem[]>([])
|
||||
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 isPlaying = ref(false)
|
||||
const playlistInfo = ref({
|
||||
@@ -60,8 +64,8 @@ const fetchPlaylistSongs = async () => {
|
||||
// 处理本地歌单
|
||||
await fetchLocalPlaylistSongs()
|
||||
} else {
|
||||
// 处理网络歌单
|
||||
await fetchNetworkPlaylistSongs()
|
||||
// 处理网络歌单(重置并加载第一页)
|
||||
await fetchNetworkPlaylistSongs(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌单歌曲失败:', error)
|
||||
@@ -116,22 +120,43 @@ const fetchLocalPlaylistSongs = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网络歌单歌曲
|
||||
const fetchNetworkPlaylistSongs = async () => {
|
||||
/**
|
||||
* 获取网络歌单歌曲,支持重置与分页追加
|
||||
* @param reset 是否重置为第一页
|
||||
*/
|
||||
const fetchNetworkPlaylistSongs = async (reset = false) => {
|
||||
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', {
|
||||
source: playlistInfo.value.source,
|
||||
id: playlistInfo.value.id,
|
||||
page: 1
|
||||
page: currentPage.value
|
||||
})) as any
|
||||
const limit = Number(result?.limit ?? pageSize)
|
||||
|
||||
console.log(result)
|
||||
if (result && result.list) {
|
||||
songs.value = result.list
|
||||
if (result && Array.isArray(result.list)) {
|
||||
const newList = result.list
|
||||
|
||||
// 获取歌曲封面
|
||||
setPic(0, playlistInfo.value.source)
|
||||
if (reset) {
|
||||
songs.value = newList
|
||||
} else {
|
||||
songs.value = [...songs.value, ...newList]
|
||||
}
|
||||
|
||||
// 获取新增歌曲封面
|
||||
setPic((currentPage.value - 1) * limit, playlistInfo.value.source)
|
||||
|
||||
// 如果API返回了歌单详细信息,更新歌单信息
|
||||
if (result.info) {
|
||||
@@ -143,10 +168,28 @@ const fetchNetworkPlaylistSongs = async () => {
|
||||
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) {
|
||||
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) => {
|
||||
let scrollTop = 0
|
||||
let scrollHeight = 0
|
||||
let clientHeight = 0
|
||||
|
||||
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) {
|
||||
scrollTop = scrollContainer.value.scrollTop
|
||||
scrollHeight = scrollContainer.value.scrollHeight
|
||||
clientHeight = scrollContainer.value.clientHeight
|
||||
}
|
||||
|
||||
scrollY.value = scrollTop
|
||||
// 当滚动超过100px时,启用紧凑模式
|
||||
isHeaderCompact.value = scrollY.value > 100
|
||||
|
||||
// 触底加载(参考 search.vue)
|
||||
if (
|
||||
scrollHeight > 0 &&
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!loadingMore.value &&
|
||||
hasMore.value &&
|
||||
!isLocalPlaylist.value
|
||||
) {
|
||||
fetchNetworkPlaylistSongs(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ const localSongs = ref<LocalSong[]>([
|
||||
// 歌单列表
|
||||
const playlists = ref<SongList[]>([])
|
||||
const loading = ref(false)
|
||||
// 喜欢歌单ID(用于排序与标记)
|
||||
const favoritesId = ref<string | null>(null)
|
||||
|
||||
// 对话框状态
|
||||
const showCreatePlaylistDialog = ref(false)
|
||||
@@ -192,6 +194,18 @@ const loadPlaylists = async () => {
|
||||
const result = await songListAPI.getAll()
|
||||
if (result.success) {
|
||||
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 {
|
||||
MessagePlugin.error(result.error || '加载歌单失败')
|
||||
}
|
||||
@@ -517,7 +531,7 @@ const setPicForPlaylist = async (songs: any[], source: string) => {
|
||||
// 处理网络歌单导入
|
||||
const handleNetworkPlaylistImport = async (input: string) => {
|
||||
try {
|
||||
const load1 = MessagePlugin.loading('正在解析歌单链接...')
|
||||
const load1 = MessagePlugin.loading('正在解析歌单链接...', 0)
|
||||
|
||||
let playlistId: string = ''
|
||||
let platformName: string = ''
|
||||
@@ -541,38 +555,51 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
}
|
||||
platformName = '网易云音乐'
|
||||
} else if (importPlatformType.value === 'tx') {
|
||||
// QQ音乐歌单ID解析 - 支持多种链接格式
|
||||
const qqPlaylistRegexes = [
|
||||
// 标准歌单链接
|
||||
/(?: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,
|
||||
// 其他可能的分享格式
|
||||
/(?:c\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=)(\d+)/i,
|
||||
// 手机版链接
|
||||
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
||||
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
||||
/[?&]id=(\d+)/i
|
||||
]
|
||||
// QQ音乐歌单ID解析:优先通过 SDK 解析,失败再回退到正则
|
||||
let parsedId = ''
|
||||
try {
|
||||
const parsed: any = await window.api.music.requestSdk('parsePlaylistId', {
|
||||
source: 'tx',
|
||||
url: input
|
||||
})
|
||||
console.log('QQ音乐歌单解析结果', parsed)
|
||||
if (parsed) parsedId = parsed
|
||||
} catch (e) {}
|
||||
|
||||
let match: RegExpMatchArray | null = null
|
||||
for (const regex of qqPlaylistRegexes) {
|
||||
match = input.match(regex)
|
||||
if (match && match[1]) {
|
||||
playlistId = match[1]
|
||||
break
|
||||
if (parsedId) {
|
||||
playlistId = parsedId
|
||||
} else {
|
||||
const qqPlaylistRegexes = [
|
||||
// 标准歌单链接(强烈推荐)
|
||||
/(?: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]) {
|
||||
// 检查是否直接输入的是纯数字ID
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的QQ音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
if (!match || !match[1]) {
|
||||
// 检查是否直接输入的是纯数字ID
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的QQ音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
platformName = 'QQ音乐'
|
||||
@@ -680,18 +707,11 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证歌单ID是否有效
|
||||
if (!playlistId || playlistId.length < 6) {
|
||||
MessagePlugin.error('歌单ID格式不正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
|
||||
// 关闭加载提示
|
||||
load1.then((res) => res.close())
|
||||
|
||||
// 获取歌单详情
|
||||
const load2 = MessagePlugin.loading('正在获取歌单信息...')
|
||||
const load2 = MessagePlugin.loading('正在获取歌单信息,请不要离开页面...', 0)
|
||||
|
||||
const getListDetail = async (page: number) => {
|
||||
let detailResult: any
|
||||
@@ -701,6 +721,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
id: playlistId,
|
||||
page: page
|
||||
})) as any
|
||||
console.log('list', detailResult)
|
||||
} catch {
|
||||
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
||||
load2.then((res) => res.close())
|
||||
@@ -728,6 +749,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (detailResult.total < songs.length) break
|
||||
page++
|
||||
const { list: songsList } = await getListDetail(page)
|
||||
if (!(songsList && songsList.length)) {
|
||||
@@ -1022,6 +1044,14 @@ onMounted(() => {
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name" :title="playlist.name" @click="viewPlaylist(playlist)">
|
||||
{{ playlist.name }}
|
||||
<t-tag
|
||||
v-if="playlist.id === favoritesId"
|
||||
theme="danger"
|
||||
variant="light-outline"
|
||||
size="small"
|
||||
style="margin-left: 6px"
|
||||
>我的喜欢</t-tag
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="playlist.description"
|
||||
@@ -1441,7 +1471,8 @@ onMounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
width: 100%;
|
||||
// height: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.local-container {
|
||||
padding: 2rem;
|
||||
@@ -1774,7 +1805,7 @@ onMounted(() => {
|
||||
|
||||
.playlist-cover {
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, var(--td-brand-color-4) 0%, var(--td-brand-color-6) 100%);
|
||||
background: #e4e4e4;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -210,7 +210,9 @@ const formatPlayTime = (timeStr: string): string => {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
// min-height: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
|
||||
Reference in New Issue
Block a user