Compare commits

...

9 Commits

30 changed files with 1128 additions and 795 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

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

View File

@@ -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>
## 技术栈

View File

@@ -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",

View 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
View 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()
})
}

View 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)
}

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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 {

View File

@@ -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: {

View File

@@ -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'),

View File

@@ -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(() => {

View File

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

View File

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

View File

@@ -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子元素能够正确计算高度 */

View File

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

View File

@@ -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', {

View File

@@ -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) {

View File

@@ -23,10 +23,3 @@
<PlayMusic />
</div>
</template>
<style lang="scss" scoped>
.animate__animated {
position: absolute;
width: 100%;
}
</style>

View File

@@ -221,6 +221,8 @@ onUnmounted(() => {
.find-container {
padding: 2rem;
width: 100%;
height: 100%;
overflow-y: auto;
margin: 0 auto;
}

View File

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

View File

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

View File

@@ -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 {

View File

@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden auto;
}
.search-header {