mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 03:15:07 +08:00
fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"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()
|
||||||
|
})
|
||||||
|
}
|
||||||
82
src/main/events/plugins.ts
Normal file
82
src/main/events/plugins.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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,12 +1,16 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen, powerSaveBlocker } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, screen } 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()
|
||||||
|
|
||||||
@@ -24,72 +28,9 @@ 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)
|
|
||||||
|
|
||||||
// 创建托盘菜单
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// return
|
|
||||||
// 获取保存的窗口位置和大小
|
// 获取保存的窗口位置和大小
|
||||||
const savedBounds = configManager.getWindowBounds()
|
const savedBounds = configManager.getWindowBounds()
|
||||||
|
|
||||||
@@ -174,29 +115,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 +129,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 +145,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 +152,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 +182,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
1
src/renderer/components.d.ts
vendored
1
src/renderer/components.d.ts
vendored
@@ -18,6 +18,7 @@ declare module 'vue' {
|
|||||||
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
||||||
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
||||||
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
||||||
|
NBackTop: typeof import('naive-ui')['NBackTop']
|
||||||
NBadge: typeof import('naive-ui')['NBadge']
|
NBadge: typeof import('naive-ui')['NBadge']
|
||||||
NCard: typeof import('naive-ui')['NCard']
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
|||||||
@@ -94,90 +94,106 @@ watch(
|
|||||||
() => props.songId,
|
() => props.songId,
|
||||||
async (newId) => {
|
async (newId) => {
|
||||||
if (!newId || !props.songInfo) return
|
if (!newId || !props.songInfo) return
|
||||||
let lyricText = ''
|
|
||||||
let parsedLyrics: LyricLine[] = []
|
// 工具函数:清洗响应式对象,避免序列化问题
|
||||||
// 创建一个符合 MusicItem 接口的对象,只包含必要的基本属性
|
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 TOLERANCE_MS = 1000
|
||||||
|
|
||||||
|
for (const bl of base) {
|
||||||
|
// 1) 先尝试精确匹配
|
||||||
|
let tLine = tMap.get(keyOf(bl.startTime, bl.endTime))
|
||||||
|
|
||||||
|
// 2) 若无精确匹配,按 startTime 进行容差范围内最近匹配
|
||||||
|
if (!tLine) {
|
||||||
|
let best: { line: LyricLine; diff: number } | null = null
|
||||||
|
for (const tl of translated) {
|
||||||
|
const diff = Math.abs(tl.startTime - bl.startTime)
|
||||||
|
// 要求结束时间也尽量接近,避免跨行误配
|
||||||
|
const endDiff = Math.abs(tl.endTime - bl.endTime)
|
||||||
|
const score = diff + endDiff
|
||||||
|
if (diff <= TOLERANCE_MS && endDiff <= TOLERANCE_MS) {
|
||||||
|
if (!best || score < best.diff) best = { line: tl, diff: score }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tLine = best?.line
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tLine) {
|
||||||
|
const text = joinWords(tLine)
|
||||||
|
if (text) bl.translatedLyric = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
).text()
|
||||||
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 (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 (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 = []
|
|
||||||
}
|
}
|
||||||
|
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取歌词失败:', error)
|
console.error('获取歌词失败:', error)
|
||||||
state.lyricLines = []
|
state.lyricLines = []
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -193,16 +185,16 @@ 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', {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -517,7 +517,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 = ''
|
||||||
@@ -691,7 +691,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
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
|
||||||
@@ -1441,7 +1441,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;
|
||||||
|
|||||||
@@ -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