Compare commits

...

19 Commits

Author SHA1 Message Date
sqj
a9ad32e8ea 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:26:39 +08:00
sqj
ca3213d0b3 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:16:02 +08:00
sqj
7c7455786e 🚀feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 21:07:05 +08:00
sqj
68fb9bcec5 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 20:53:45 +08:00
sqj
54e2842b1b �feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 20:15:43 +08:00
sqj
ce743e1b65 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-10-31 01:18:19 +08:00
sqj
32c9fdbfeb feat: 1.歌曲播放器喜欢和下载功能,列表快速加入喜欢。2. 歌曲播放时自动 点击播放其他歌曲自动添加到下一曲播放而不是提到最前,屎山逻辑(bushi。fix: 对于副屏用户优化 全屏宽高限制 导致无法全屏问题。(嘿嘿摸鱼这么多天就更新这些吧周末看看能不能把桌面歌词搞了,容我学习亿下下) 2025-10-31 01:17:36 +08:00
时迁酱
9df236b2e0 Update contact information and sponsorship section 2025-10-30 18:40:29 +08:00
时迁酱
0988c71282 Update README.md 2025-10-30 18:39:28 +08:00
sqj
60881f7f48 feat docs 2025-10-25 20:39:54 +08:00
sqj
775f87aa86 feat docs 2025-10-25 20:14:30 +08:00
sqj
b1c471f15c fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:39 +08:00
sqj
f7ecfa1fa9 fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:08 +08:00
sqj
d44be6022a fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败 2025-10-11 22:54:10 +08:00
sqj
0c512bccff fix: 修复歌曲无法下载,支持更多音质选择 2025-10-09 20:25:27 +08:00
sqj
b07cc2359a fix: 修复歌曲播放缓存内存泄露问题 feat:歌曲播放出错自动切歌不是暂停 2025-10-09 01:47:07 +08:00
时迁酱
46756a8b09 Merge pull request #12 from GladerJ/main
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 16:51:53 +08:00
Glader
deb73fa789 修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 15:15:30 +08:00
sqj
910ab1ff10 docs: add 赞助名单 2025-10-07 22:24:40 +08:00
67 changed files with 3857 additions and 17153 deletions

View File

@@ -30,7 +30,7 @@ jobs:
- name: Install Dependencies
run: |
npm i -g yarn
yarn install # 安装项目依赖
yarn # 安装项目依赖
- name: Build Electron App for windows
if: matrix.os == 'windows-latest' # 只在Windows上运行

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,12 +5,16 @@
| 昵称 | 赞助金额 |
| :-------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |
| **群友**:🍀 | 5 |
| **群友**:🍀 | 5 |
| **群友**:涟漪 | 50 |
| **作者朋友** | 188 |
| **群友**:我叫阿狸 | 3 |
| RiseSun | 9.9 |
| **b站小友**:光牙阿普斯木兰 | 5 |
| 青禾 | 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

@@ -6,8 +6,12 @@ asar: true
files:
- '!**/.vscode/*'
- '!src/*'
- '!website/*'
- '!scripts/*'
- '!assets/*'
- '!docs/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md,.idea,.kiro,.codebuddy}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
@@ -81,4 +85,4 @@ publish:
provider: generic
url: https://update.ceru.shiqianjiang.cn
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -8,7 +8,6 @@ import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
import wasm from 'vite-plugin-wasm'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
@@ -16,6 +15,14 @@ export default defineConfig({
alias: {
'@common': resolve('src/common')
}
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
lyric: resolve(__dirname, 'src/web/lyric.html')
}
}
}
},
preload: {

15117
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.4.0",
"version": "1.4.7",
"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,9 +68,9 @@
"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",
"pinia-plugin-persistedstate": "4.5.0",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",

View File

@@ -0,0 +1,10 @@
export interface lyricConfig {
fontSize: number
mainColor: string
shadowColor: string
// 窗口位置
x?: number
y?: number
width?: number
height?: number
}

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

105
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,105 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
import { app, powerSaveBlocker } from 'electron'
import { type BrowserWindow, 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: any = (global as any).__ceru_tray__ || null
let isQuitting = false
// 托盘菜单与图标由主进程统一创建,这里不再重复创建
// 播放/暂停由主进程托盘菜单触发 'music-control' 事件
// 应用退出前的清理
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()
// 显示托盘通知(可选)
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
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()
// 显示托盘通知
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
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()
})
}

154
src/main/events/lyric.ts Normal file
View File

@@ -0,0 +1,154 @@
import { BrowserWindow, ipcMain, screen } from 'electron'
import { isAbsolute, relative, resolve } from 'path'
import { lyricConfig } from '@common/types/config'
import { configManager } from '../services/ConfigManager'
import lyricWindow from '../windows/lyric-window'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
/**
* 歌词相关 IPC
*/
const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
// const mainWin = mainWindow.getWin()
const lyricWin = lyricWindow.getWin()
// 切换桌面歌词
ipcMain.on('change-desktop-lyric', (_event, val: boolean) => {
if (val) {
lyricWin?.show()
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
} else lyricWin?.hide()
})
ipcMain.on('win-show', () => {
mainWin?.show()
})
// 音乐名称更改
ipcMain.on('play-song-change', (_, title) => {
if (!title) return
lyricWin?.webContents.send('play-song-change', title)
})
// 音乐歌词更改
ipcMain.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
lyricWin?.webContents.send('play-lyric-change', lyricData)
})
// 播放状态更改(播放/暂停)
ipcMain.on('play-status-change', (_, status: boolean) => {
lyricWin?.webContents.send('play-status-change', status)
})
// 获取窗口位置
ipcMain.handle('get-window-bounds', () => {
return lyricWin?.getBounds()
})
// 同步获取窗口位置(回退)
ipcMain.on('get-window-bounds-sync', (event) => {
event.returnValue = lyricWin?.getBounds()
})
// 获取屏幕尺寸
ipcMain.handle('get-screen-size', () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
return { width, height }
})
// 同步获取屏幕尺寸(回退)
ipcMain.on('get-screen-size-sync', (event) => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
event.returnValue = { width, height }
})
// 移动窗口
ipcMain.on('move-window', (_, x, y, width, height) => {
lyricWin?.setBounds({ x, y, width, height })
// 保存配置
lyricStore.set({ ...lyricStore.get(), x, y, width, height })
// 保持置顶
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
})
// 更新高度
ipcMain.on('update-window-height', (_, height) => {
if (!lyricWin) return
const { width } = lyricWin.getBounds()
// 更新窗口高度
lyricWin.setBounds({ width, height })
})
// 获取配置
ipcMain.handle('get-desktop-lyric-option', () => {
return lyricStore.get()
})
// 同步获取配置(用于 invoke 不可用的回退)
ipcMain.on('get-desktop-lyric-option-sync', (event) => {
event.returnValue = lyricStore.get()
})
// 保存配置
ipcMain.on('set-desktop-lyric-option', (_, option, callback: boolean = false) => {
lyricStore.set(option)
// 触发窗口更新
if (callback && lyricWin) {
lyricWin.webContents.send('desktop-lyric-option-change', option)
}
mainWin?.webContents.send('desktop-lyric-option-change', option)
})
// 发送主程序事件
ipcMain.on('send-main-event', (_, name, val) => {
mainWin?.webContents.send(name, val)
})
// 关闭桌面歌词
ipcMain.on('closeDesktopLyric', () => {
lyricWin?.hide()
mainWin?.webContents.send('closeDesktopLyric')
})
// 锁定/解锁桌面歌词
let lyricLockState = false
ipcMain.on('toogleDesktopLyricLock', (_, isLock: boolean) => {
if (!lyricWin) return
lyricLockState = !!isLock
// 是否穿透
if (lyricLockState) {
lyricWin.setIgnoreMouseEvents(true, { forward: true })
} else {
lyricWin.setIgnoreMouseEvents(false)
}
// 广播到桌面歌词窗口与主窗口,保持两端状态一致
lyricWin.webContents.send('toogleDesktopLyricLock', lyricLockState)
mainWin?.webContents.send('toogleDesktopLyricLock', lyricLockState)
})
// 查询当前桌面歌词锁定状态
ipcMain.handle('get-lyric-lock-state', () => lyricLockState)
// 检查是否是子文件夹
ipcMain.handle('check-if-subfolder', (_, localFilesPath: string[], selectedDir: string) => {
const resolvedSelectedDir = resolve(selectedDir)
const allPaths = localFilesPath.map((p) => resolve(p))
return allPaths.some((existingPath) => {
const relativePath = relative(existingPath, resolvedSelectedDir)
return relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)
})
})
}
export default initLyricIpc

View File

@@ -97,7 +97,7 @@ function getDefaultMessage(type: string, data: any, pluginName: string): string
*/
export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: string): void {
try {
console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// 获取主窗口实例
const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed())

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,29 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import {
app,
shell,
BrowserWindow,
ipcMain,
screen,
Rectangle,
Display,
Tray,
Menu
} 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 lyricWindow from './windows/lyric-window'
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import initLyricIpc from './events/lyric'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -25,27 +42,34 @@ 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 mainWindow: BrowserWindow | null = null
let isQuitting = false
let tray: Tray | null = null
let trayLyricLocked = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
function updateTrayMenu() {
const lyricWin = lyricWindow.getWin()
const isVisible = !!lyricWin && lyricWin.isVisible()
const toggleLyricLabel = isVisible ? '隐藏桌面歌词' : '显示桌面歌词'
const toggleLockLabel = trayLyricLocked ? '解锁桌面歌词' : '锁定桌面歌词'
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
label: toggleLyricLabel,
click: () => {
const target = !isVisible
ipcMain.emit('change-desktop-lyric', null, target)
}
},
{
label: toggleLockLabel,
click: () => {
const next = !trayLyricLocked
ipcMain.emit('toogleDesktopLyricLock', null, next)
}
},
{ type: 'separator' },
{
label: '显示主窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
@@ -56,8 +80,6 @@ function createTray(): void {
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
@@ -65,46 +87,102 @@ function createTray(): void {
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray?.setContextMenu(contextMenu)
}
tray.setContextMenu(contextMenu)
function setupTray() {
// 全局单例防重复(热重载/多次执行保护)
const g: any = global as any
if (g.__ceru_tray__) {
try {
g.__ceru_tray__.destroy()
} catch {}
g.__ceru_tray__ = null
}
if (tray) {
try {
tray.destroy()
} catch {}
tray = null
}
const iconPath = path.join(__dirname, '../../resources/logo.ico')
tray = new Tray(iconPath)
tray.setToolTip('Ceru Music')
updateTrayMenu()
// 双击托盘图标显示窗口
// 左键单击切换主窗口显示
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
if (!mainWindow) return
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
})
// 防重复注册 IPC 监听(仅注册一次)
if (!g.__ceru_tray_ipc_bound__) {
ipcMain.on('toogleDesktopLyricLock', (_e, isLock: boolean) => {
trayLyricLocked = !!isLock
updateTrayMenu()
})
ipcMain.on('change-desktop-lyric', () => {
updateTrayMenu()
})
g.__ceru_tray_ipc_bound__ = true
}
// 记录全局托盘句柄
g.__ceru_tray__ = tray
app.once('before-quit', () => {
try {
tray?.destroy()
} catch {}
tray = null
g.__ceru_tray__ = null
})
}
/**
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
* @param {BrowserWindow} win - 要更新的窗口实例
*/
function updateWindowMaxLimits(win: BrowserWindow | null): void {
if (!win) return
// 1. 获取窗口的当前边界 (bounds)
const currentBounds: Rectangle = win.getBounds()
// 2. 查找包含该边界的显示器
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
// 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,
show: false,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
@@ -130,24 +208,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 }
@@ -173,28 +257,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.
@@ -205,80 +272,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
@@ -295,16 +288,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
@@ -312,63 +295,11 @@ 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)
}
})
createWindow()
createTray()
lyricWindow.create()
initLyricIpc(mainWindow)
// 仅在主进程初始化一次托盘
setupTray()
// 注册自动更新事件
registerAutoUpdateEvents()
@@ -394,13 +325,7 @@ app.whenReady().then(() => {
// 当所有窗口关闭时不退出应用,因为我们有系统托盘
app.on('window-all-closed', () => {
// 在 macOS 上,应用通常会保持活跃状态
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
// 保持应用常驻,通过系统托盘管理
})
// In this file you can include the rest of your app's specific main process
@@ -408,57 +333,14 @@ app.on('before-quit', () => {
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)
}

47
src/main/logger/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// 日志输出
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { app } from 'electron'
import log from 'electron-log'
// 日志文件路径
const logDir = join(app.getPath('logs'))
// 是否存在日志目录
if (!existsSync(logDir)) mkdirSync(logDir)
// 获取日期 - YYYY-MM-DD
const dateString = new Date().toISOString().slice(0, 10)
const logFilePath = join(logDir, `${dateString}.log`)
console.log(logFilePath, '546444444444444444444444444444444444')
// 配置日志系统
log.transports.console.useStyles = true // 颜色输出
log.transports.file.level = 'info' // 仅记录 info 及以上级别
log.transports.file.resolvePathFn = (): string => logFilePath // 日志文件路径
log.transports.file.maxSize = 2 * 1024 * 1024 // 文件最大 2MB
// 日志格式化
// log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}] [{level}] [{scope}] {text}";
// 绑定默认事件
const defaultLog = log.scope('default')
console.log = defaultLog.log
console.info = defaultLog.info
console.warn = defaultLog.warn
console.error = defaultLog.error
// 分作用域导出
export { defaultLog }
export const ipcLog = log.scope('ipc')
export const trayLog = log.scope('tray')
export const thumbarLog = log.scope('thumbar')
export const storeLog = log.scope('store')
export const updateLog = log.scope('update')
export const systemLog = log.scope('system')
export const configLog = log.scope('config')
export const windowsLog = log.scope('windows')
export const processLog = log.scope('process')
export const preloadLog = log.scope('preload')
export const rendererLog = log.scope('renderer')
export const shortcutLog = log.scope('shortcut')
export const serverLog = log.scope('server')

View File

@@ -60,6 +60,7 @@ export class ConfigManager {
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
this.saveConfig()
}
// 删除配置项

View File

@@ -493,7 +493,7 @@ class CeruMusicPluginHost {
}, timeout)
try {
console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
const fetchOptions = {
method: 'GET',
@@ -504,7 +504,7 @@ class CeruMusicPluginHost {
const response = await fetch(url, fetchOptions)
clearTimeout(timeoutId)
console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
const body = await this._parseResponseBody(response)
const headers = this._extractHeaders(response)
@@ -515,11 +515,11 @@ class CeruMusicPluginHost {
headers
}
console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
url,
status: response.status,
bodyType: typeof body
})
// console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
// url,
// status: response.status,
// bodyType: typeof body
// })
return result
} catch (error: any) {

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

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate } from '../index'
import { decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
limit: 30,
@@ -9,87 +10,72 @@ export default {
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = httpFetch(
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(
str
)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
)
return searchRequest.promise.then(({ body }) => body)
},
filterData(rawData) {
const types = []
const _types = {}
if (rawData.FileSize !== 0) {
const size = sizeFormate(rawData.FileSize)
types.push({ type: '128k', size, hash: rawData.FileHash })
_types['128k'] = {
size,
hash: rawData.FileHash
}
}
if (rawData.HQFileSize !== 0) {
const size = sizeFormate(rawData.HQFileSize)
types.push({ type: '320k', size, hash: rawData.HQFileHash })
_types['320k'] = {
size,
hash: rawData.HQFileHash
}
}
if (rawData.SQFileSize !== 0) {
const size = sizeFormate(rawData.SQFileSize)
types.push({ type: 'flac', size, hash: rawData.SQFileHash })
_types.flac = {
size,
hash: rawData.SQFileHash
}
}
if (rawData.ResFileSize !== 0) {
const size = sizeFormate(rawData.ResFileSize)
types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash })
_types.flac24bit = {
size,
hash: rawData.ResFileHash
}
}
return {
singer: decodeName(formatSingerName(rawData.Singers, 'name')),
name: decodeName(rawData.SongName),
albumName: decodeName(rawData.AlbumName),
albumId: rawData.AlbumID,
songmid: rawData.Audioid,
source: 'kg',
interval: formatPlayTime(rawData.Duration),
_interval: rawData.Duration,
img: null,
lrc: null,
otherSource: null,
hash: rawData.FileHash,
types,
_types,
typeUrl: {}
}
},
handleResult(rawData) {
const ids = new Set()
const list = []
async handleResult(rawData) {
let ids = new Set()
const items = []
rawData.forEach((item) => {
const key = item.Audioid + item.FileHash
if (ids.has(key)) return
ids.add(key)
list.push(this.filterData(item))
for (const childItem of item.Grp) {
const key = item.Audioid + item.FileHash
if (ids.has(key)) continue
if (!ids.has(key)) {
ids.add(key)
list.push(this.filterData(childItem))
items.push(item)
}
for (const childItem of item.Grp || []) {
const childKey = childItem.Audioid + childItem.FileHash
if (!ids.has(childKey)) {
ids.add(childKey)
items.push(childItem)
}
}
})
const hashList = items.map((item) => item.FileHash)
let qualityInfoMap = {}
try {
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return items.map((item) => {
const { types = [], _types = {} } = qualityInfoMap[item.FileHash] || {}
return {
singer: decodeName(formatSingerName(item.Singers, 'name')),
name: decodeName(item.SongName),
albumName: decodeName(item.AlbumName),
albumId: item.AlbumID,
songmid: item.Audioid,
source: 'kg',
interval: formatPlayTime(item.Duration),
_interval: item.Duration,
img: null,
lrc: null,
otherSource: null,
hash: item.FileHash,
types,
_types,
typeUrl: {},
}
})
return list
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then((result) => {
return this.musicSearch(str, page, limit).then(async (result) => {
if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.data.lists)
let list = await this.handleResult(result.data.lists)
if (list == null) return this.search(str, page, limit, retryNum)
@@ -102,8 +88,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'kg'
source: 'kg',
})
})
}
}
},
}

View File

@@ -0,0 +1,190 @@
import { httpFetch } from '../../request'
import { dnsLookup } from '../utils'
import { headers, timeout } from '../options'
import { sizeFormate, decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
console.log(headers);
export const getBatchMusicQualityInfo = (hashList) => {
const resources = hashList.map((hash) => ({
id: 0,
type: 'audio',
hash,
}))
const requestObj = httpFetch(
`https://gateway.kugou.com/goodsmstore/v1/get_res_privilege?appid=1005&clientver=20049&clienttime=${Date.now()}&mid=NeZha`,
{
method: 'post',
timeout,
headers,
body: {
behavior: 'play',
clientver: '20049',
resource: resources,
area_code: '1',
quality: '128',
qualities: [
'128',
'320',
'flac',
'high',
'dolby',
'viper_atmos',
'viper_tape',
'viper_clear',
],
},
lookup: dnsLookup,
family: 4,
}
)
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.error_code != 0)
return Promise.reject(new Error('获取音质信息失败'))
body.data.forEach((songData, index) => {
const hash = hashList[index]
const types = []
const _types = {}
if (!songData || !songData.relate_goods) return
for (const quality_data of songData.relate_goods) {
if (quality_data.quality === '128') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '128k', size, hash: quality_data.hash })
_types['128k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === '320') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '320k', size, hash: quality_data.hash })
_types['320k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'flac') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'flac', size, hash: quality_data.hash })
_types.flac = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'high') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'hires', size, hash: quality_data.hash })
_types.hires = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_clear') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'master', size, hash: quality_data.hash })
_types.master = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_atmos') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'atmos', size, hash: quality_data.hash })
_types.atmos = {
size,
hash: quality_data.hash,
}
}
}
qualityInfoMap[hash] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}
export const getHashFromItem = (item) => {
if (item.hash) return item.hash
if (item.FileHash) return item.FileHash
if (item.audio_info && item.audio_info.hash) return item.audio_info.hash
return null
}
export const filterData = async (rawList, options = {}) => {
let processedList = rawList
if (options.removeDuplicates) {
let ids = new Set()
processedList = rawList.filter((item) => {
if (!item) return false
const audioId = item.audio_info?.audio_id || item.audio_id
if (ids.has(audioId)) return false
ids.add(audioId)
return true
})
}
const hashList = processedList.map((item) => getHashFromItem(item)).filter((hash) => hash)
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return processedList.map((item) => {
const hash = getHashFromItem(item)
const { types = [], _types = {} } = qualityInfoMap[hash] || {}
if (item.audio_info) {
return {
name: decodeName(item.songname),
singer: decodeName(item.author_name),
albumName: decodeName(item.album_info?.album_name || item.remark),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: options.fix
? formatPlayTime(parseInt(item.audio_info.timelength) / 1000)
: formatPlayTime(parseInt(item.audio_info.timelength)),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {},
}
}
return {
name: decodeName(item.songname),
singer: decodeName(item.singername) || formatSingerName(item.authors, 'author_name'),
albumName: decodeName(item.album_name || item.remark),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: options.fix ? formatPlayTime(item.duration / 1000) : formatPlayTime(item.duration),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {},
}
})
}

View File

@@ -1,8 +1,8 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, dateFormat, formatPlayCount } from '../../index'
import './vendors/infSign.min'
import { signatureParams } from './util'
import { filterData } from './quality_detail'
const handleSignature = (id, page, limit) =>
new Promise((resolve, reject) => {
@@ -14,7 +14,7 @@ const handleSignature = (id, page, limit) =>
isCDN: !0,
callback(i) {
resolve(i.signature)
}
},
}
)
})
@@ -27,36 +27,36 @@ export default {
listDetailLimit: 10000,
currentTagInfo: {
id: undefined,
info: undefined
info: undefined,
},
sortList: [
{
name: '推荐',
id: '5'
id: '5',
},
{
name: '最热',
id: '6'
id: '6',
},
{
name: '最新',
id: '7'
id: '7',
},
{
name: '热藏',
id: '3'
id: '3',
},
{
name: '飙升',
id: '8'
}
id: '8',
},
],
cache: new Map(),
regExps: {
listData: /global\.data = (\[.+\]);/,
listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/,
// https://www.kugou.com/yy/special/single/1067062.html
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
},
parseHtmlDesc(html) {
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
@@ -71,18 +71,17 @@ export default {
if (tryNum > 2) throw new Error('try max num')
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
const listData = body.match(this.regExps.listData)
const listInfo = body.match(this.regExps.listInfo)
let listData = body.match(this.regExps.listData)
let listInfo = body.match(this.regExps.listInfo)
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
const list = await this.getMusicInfos(JSON.parse(listData[1]))
// listData = this.filterData(JSON.parse(listData[1]))
let list = await this.getMusicInfos(JSON.parse(listData[1]))
let name
let pic
if (listInfo) {
name = listInfo[1]
pic = listInfo[2]
}
const desc = this.parseHtmlDesc(body)
let desc = this.parseHtmlDesc(body)
return {
list,
@@ -93,10 +92,10 @@ export default {
info: {
name,
img: pic,
desc
desc,
// author: body.result.info.userinfo.username,
// play_count: formatPlayCount(body.result.listen_num),
}
},
}
},
getInfoUrl(tagId) {
@@ -116,11 +115,11 @@ export default {
const result = []
if (rawData.status !== 1) return result
for (const key of Object.keys(rawData.data)) {
const tag = rawData.data[key]
let tag = rawData.data[key]
result.push({
id: tag.special_id,
name: tag.special_name,
source: 'kg'
source: 'kg',
})
}
return result
@@ -135,8 +134,8 @@ export default {
parent_name: tag.pname,
id: tag.id,
name: tag.name,
source: 'kg'
}))
source: 'kg',
})),
})
}
return result
@@ -159,7 +158,7 @@ export default {
{
method: 'post',
headers: {
'User-Agent': 'KuGou2012-8275-web_browser_event_handler'
'User-Agent': 'KuGou2012-8275-web_browser_event_handler',
},
body: {
appid: 1001,
@@ -170,8 +169,8 @@ export default {
platform: 'pc',
userid: '262643156',
return_min: 6,
return_max: 15
}
return_max: 15,
},
}
)
return this._requestObj_listRecommend.promise.then(({ body }) => {
@@ -190,7 +189,7 @@ export default {
total: item.songcount,
grade: item.grade,
desc: item.intro,
source: 'kg'
source: 'kg',
}))
},
@@ -219,7 +218,7 @@ export default {
},
createTask(hashs) {
const data = {
let data = {
area_code: '1',
show_privilege: 1,
show_album_info: '1',
@@ -230,16 +229,16 @@ export default {
dfid: '-',
clienttime: Date.now(),
key: 'OIlwieks28dk2k092lksi2UIkp',
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname'
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname',
}
let list = hashs
const tasks = []
let tasks = []
while (list.length) {
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
if (list.length < 100) break
list = list.slice(100)
}
const url = 'http://gateway.kugou.com/v2/album_audio/audio'
let url = 'http://gateway.kugou.com/v2/album_audio/audio'
return tasks.map((task) =>
this.createHttp(url, {
method: 'POST',
@@ -250,13 +249,13 @@ export default {
'KG-Fake': '0',
'KG-RF': '00869891',
'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi',
'x-router': 'kmr.service.kugou.com'
}
'x-router': 'kmr.service.kugou.com',
},
}).then((data) => data.map((s) => s[0]))
)
},
async getMusicInfos(list) {
return this.filterData2(
return await this.filterData(
await Promise.all(
this.createTask(this.deDuplication(list).map((item) => ({ hash: item.hash })))
).then(([...datas]) => datas.flat())
@@ -269,7 +268,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -277,13 +276,13 @@ export default {
mid: '21511157a05844bd085308bc76ef3343',
clienttime: 640612895,
key: '36164c4015e704673c588ee202b9ecb8',
data: id
}
data: id,
},
})
// console.log(songInfo)
// type 1单曲2歌单3电台4酷狗码5别人的播放队列
let songList
const info = songInfo.info
let info = songInfo.info
switch (info.type) {
case 2:
if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id)
@@ -299,7 +298,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -313,13 +312,13 @@ export default {
userid: info.userid,
collect_type: 0,
page: 1,
pagesize: info.count
}
}
pagesize: info.count,
},
},
})
// console.log(songList)
}
const list = await this.getMusicInfos(songList || songInfo.list)
let list = await this.getMusicInfos(songList || songInfo.list)
return {
list,
page: 1,
@@ -330,9 +329,9 @@ export default {
name: info.name,
img: (info.img_size && info.img_size.replace('{size}', 240)) || info.img,
// desc: body.result.info.list_desc,
author: info.username
author: info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
@@ -342,8 +341,8 @@ export default {
{
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
},
}
)
if (!songInfo.list) {
@@ -354,7 +353,7 @@ export default {
this.getUserListDetail5(chain)
)
}
const list = await this.getMusicInfos(songInfo.list)
let list = await this.getMusicInfos(songInfo.list)
// console.log(info, songInfo)
return {
list,
@@ -366,14 +365,14 @@ export default {
name: songInfo.info.name,
img: songInfo.info.img,
// desc: body.result.info.list_desc,
author: songInfo.info.username
author: songInfo.info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
deDuplication(datas) {
const ids = new Set()
let ids = new Set()
return datas.filter(({ hash }) => {
if (ids.has(hash)) return false
ids.add(hash)
@@ -388,29 +387,25 @@ export default {
data: [
{
id: gcid,
id_type: 2
}
]
}
const result = await this.createHttp(
`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`,
{
method: 'POST',
headers: {
'User-Agent':
'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/'
id_type: 2,
},
body
}
)
],
}
const result = await this.createHttp(`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/',
},
body,
})
return result.list[0].global_collection_id
},
async getUserListDetailByLink({ info }, link) {
const listInfo = info['0']
let listInfo = info['0']
let total = listInfo.count
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 90 ? 90 : total
@@ -423,8 +418,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
}
).then((data) => data.list.info)
)
@@ -442,13 +437,13 @@ export default {
name: listInfo.name,
img: listInfo.pic && listInfo.pic.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.list_create_username
author: listInfo.list_create_username,
// play_count: formatPlayCount(listInfo.count),
}
},
}
},
createGetListDetail2Task(id, total) {
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 300 ? 300 : total
@@ -464,7 +459,10 @@ export default {
'&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-'
tasks.push(
this.createHttp(
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`,
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163263991',
@@ -472,8 +470,8 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163263991'
}
clienttime: '1586163263991',
},
}
).then((data) => data.info)
)
@@ -481,14 +479,17 @@ export default {
return Promise.all(tasks).then(([...datas]) => datas.flat())
},
async getUserListDetail2(global_collection_id) {
const id = global_collection_id
let id = global_collection_id
if (id.length > 1000) throw new Error('get list error')
const params =
'appid=1058&specialid=0&global_specialid=' +
id +
'&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
const info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
let info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163242519',
@@ -496,12 +497,12 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163242519'
}
clienttime: '1586163242519',
},
}
)
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
const list = await this.getMusicInfos(songInfo)
let list = await this.getMusicInfos(songInfo)
// console.log(info, songInfo, list)
return {
list,
@@ -514,8 +515,8 @@ export default {
img: info.imgurl && info.imgurl.replace('{size}', 240),
desc: info.intro,
author: info.nickname,
play_count: formatPlayCount(info.playcount)
}
play_count: formatPlayCount(info.playcount),
},
}
},
@@ -524,8 +525,8 @@ export default {
const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
},
}).promise
let result = body.match(/var\sphpParam\s=\s({.+?});/)
if (result) result = JSON.parse(result[1])
@@ -534,13 +535,13 @@ export default {
},
async getUserListDetailByPcChain(chain) {
const key = `${chain}_pc_list`
let key = `${chain}_pc_list`
if (this.cache.has(key)) return this.cache.get(key)
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'
}
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
},
}).promise
let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/)
if (result) result = JSON.parse(result[1])
@@ -554,7 +555,7 @@ export default {
const limit = 100
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailById(songInfo.id, page, limit)
this.getUserListDetailById(songInfo.id, page, limit),
])
return {
list: list || [],
@@ -566,16 +567,16 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetail5(chain) {
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailByPcChain(chain)
this.getUserListDetailByPcChain(chain),
])
return {
list: list || [],
@@ -587,28 +588,28 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetailById(id, page, limit) {
const signature = await handleSignature(id, page, limit)
const info = await this.createHttp(
let info = await this.createHttp(
`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`,
{
headers: {
Referer: 'https://m3ws.kugou.com/share/index.php',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-'
}
dfid: '-',
},
}
)
// console.log(info)
const result = await this.getMusicInfos(info.info)
let result = await this.getMusicInfos(info.info)
// console.log(info, songInfo)
return result
},
@@ -616,19 +617,15 @@ export default {
async getUserListDetail(link, page, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
if (link.includes('#')) link = link.replace(/#.*$/, '')
if (link.includes('global_collection_id'))
return this.getUserListDetail2(
link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (link.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
}
}
if (link.includes('chain='))
return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('.html')) {
if (link.includes('zlist.html')) {
link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -650,34 +647,27 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode,
body
body,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
if (location) {
// console.log(location)
if (location.includes('global_collection_id'))
return this.getUserListDetail2(
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (location.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
}
}
if (location.includes('chain='))
return this.getUserListDetail3(
location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'),
page
)
if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (location.includes('.html')) {
if (location.includes('zlist.html')) {
let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -698,7 +688,7 @@ export default {
// console.log('location', location)
return this.getUserListDetail(location, page, ++retryNum)
}
if (typeof body === 'string') {
if (typeof body == 'string') {
let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1]
if (!global_collection_id) {
let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1]
@@ -729,184 +719,9 @@ export default {
return this.getListDetailBySpecialId(id, page)
},
filterData(rawList) {
// console.log(rawList)
return rawList.map((item) => {
const types = []
const _types = {}
if (item.filesize !== 0) {
const size = sizeFormate(item.filesize)
types.push({ type: '128k', size, hash: item.hash })
_types['128k'] = {
size,
hash: item.hash
}
}
if (item.filesize_320 !== 0) {
const size = sizeFormate(item.filesize_320)
types.push({ type: '320k', size, hash: item.hash_320 })
_types['320k'] = {
size,
hash: item.hash_320
}
}
if (item.filesize_ape !== 0) {
const size = sizeFormate(item.filesize_ape)
types.push({ type: 'ape', size, hash: item.hash_ape })
_types.ape = {
size,
hash: item.hash_ape
}
}
if (item.filesize_flac !== 0) {
const size = sizeFormate(item.filesize_flac)
types.push({ type: 'flac', size, hash: item.hash_flac })
_types.flac = {
size,
hash: item.hash_flac
}
}
return {
singer: decodeName(item.singername),
name: decodeName(item.songname),
albumName: decodeName(item.album_name),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: formatPlayTime(item.duration / 1000),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {}
}
})
},
// getSinger(singers) {
// let arr = []
// singers?.forEach(singer => {
// arr.push(singer.name)
// })
// return arr.join('、')
// },
// v9 API
// filterDatav9(rawList) {
// console.log(rawList)
// return rawList.map(item => {
// const types = []
// const _types = {}
// item.relate_goods.forEach(qualityObj => {
// if (qualityObj.level === 2) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '128k', size, hash: qualityObj.hash })
// _types['128k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 4) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '320k', size, hash: qualityObj.hash })
// _types['320k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 5) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac', size, hash: qualityObj.hash })
// _types.flac = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 6) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
// _types.flac24bit = {
// size,
// hash: qualityObj.hash,
// }
// }
// })
// const nameInfo = item.name.split(' - ')
// return {
// singer: this.getSinger(item.singerinfo),
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
// albumName: decodeName(item.albuminfo.name),
// albumId: item.albuminfo.id,
// songmid: item.audio_id,
// source: 'kg',
// interval: formatPlayTime(item.timelen / 1000),
// img: null,
// lrc: null,
// hash: item.hash,
// types,
// _types,
// typeUrl: {},
// }
// })
// },
// hash list filter
filterData2(rawList) {
// console.log(rawList)
const ids = new Set()
const list = []
rawList.forEach((item) => {
if (!item) return
if (ids.has(item.audio_info.audio_id)) return
ids.add(item.audio_info.audio_id)
const types = []
const _types = {}
if (item.audio_info.filesize !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize))
types.push({ type: '128k', size, hash: item.audio_info.hash })
_types['128k'] = {
size,
hash: item.audio_info.hash
}
}
if (item.audio_info.filesize_320 !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
_types['320k'] = {
size,
hash: item.audio_info.hash_320
}
}
if (item.audio_info.filesize_flac !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
_types.flac = {
size,
hash: item.audio_info.hash_flac
}
}
if (item.audio_info.filesize_high !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
_types.flac24bit = {
size,
hash: item.audio_info.hash_high
}
}
list.push({
singer: decodeName(item.author_name),
name: decodeName(item.songname),
albumName: decodeName(item.album_info.album_name),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {}
})
})
return list
async filterData(rawList) {
return await filterData(rawList, { removeDuplicates: true, fix: true })
},
// 获取列表信息
@@ -920,14 +735,14 @@ export default {
limit: body.data.params.pagesize,
page: body.data.params.p,
total: body.data.params.total,
source: 'kg'
source: 'kg',
}
})
},
// 获取列表数据
getList(sortId, tagId, page) {
const tasks = [this.getSongList(sortId, tagId, page)]
let tasks = [this.getSongList(sortId, tagId, page)]
tasks.push(
this.currentTagInfo.id === tagId
? Promise.resolve(this.currentTagInfo.info)
@@ -943,7 +758,7 @@ export default {
if (recommendList) list.unshift(...recommendList)
return {
list,
...info
...info,
}
})
},
@@ -958,13 +773,13 @@ export default {
return {
hotTag: this.filterInfoHotTag(body.data.hotTag),
tags: this.filterTagInfo(body.data.tagids),
source: 'kg'
source: 'kg',
}
})
},
getDetailPageUrl(id) {
if (typeof id === 'string') {
if (typeof id == 'string') {
if (/^https?:\/\//.test(id)) return id
id = id.replace('id_', '')
}
@@ -975,7 +790,9 @@ export default {
// http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0
// return httpFetch(`http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5`)
return httpFetch(
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(
text
)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
).promise.then(({ body }) => {
if (body.errcode != 0) throw new Error('filed')
// console.log(body.data.info)
@@ -991,17 +808,17 @@ export default {
grade: item.grade,
desc: item.intro,
total: item.songcount,
source: 'kg'
source: 'kg',
}
}),
limit,
total: body.data.total,
source: 'kg'
source: 'kg',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -1,13 +1,13 @@
// import '../../polyfill/array.find'
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
// import { debug } from '../../utils/env'
import { formatSinger } from './util'
export default {
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
},
limit: 30,
total: 0,
@@ -32,7 +32,7 @@ export default {
// console.log(rawData)
for (let i = 0; i < rawData.length; i++) {
const info = rawData[i]
const songId = info.MUSICRID.replace('MUSIC_', '')
let songId = info.MUSICRID.replace('MUSIC_', '')
// const format = (info.FORMATS || info.formats).split('|')
if (!info.N_MINFO) {
@@ -43,33 +43,39 @@ export default {
const types = []
const _types = {}
const infoArr = info.N_MINFO.split(';')
let infoArr = info.N_MINFO.split(';')
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
types.push({ type: 'hires', size: info[4] })
_types.hires = {
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -77,7 +83,7 @@ export default {
}
types.reverse()
const interval = parseInt(info.DURATION)
let interval = parseInt(info.DURATION)
result.push({
name: decodeName(info.SONGNAME),
@@ -95,7 +101,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
// console.log(result)
@@ -109,7 +115,7 @@ export default {
// console.log(result)
if (!result || (result.TOTAL !== '0' && result.SHOW === '0'))
return this.search(str, page, limit, ++retryNum)
const list = this.handleResult(result.abslist)
let list = this.handleResult(result.abslist)
if (list == null) return this.search(str, page, limit, ++retryNum)
@@ -122,8 +128,8 @@ export default {
allPage: this.allPage,
total: this.total,
limit,
source: 'kw'
source: 'kw',
})
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
import { formatSinger, objStr2JSON } from './util'
import album from './album'
@@ -13,18 +13,18 @@ export default {
sortList: [
{
name: '最新',
id: 'new'
id: 'new',
},
{
name: '最热',
id: 'hot'
}
id: 'hot',
},
],
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
// http://www.kuwo.cn/playlist_detail/2886046289
// https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl:
'http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0&loginUid=0&loginSid=0&appUid=76039576',
@@ -43,7 +43,9 @@ export default {
},
getListDetailUrl(id, page) {
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2858093057&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${
this.limit_song
}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
// http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=140&prod=pc
},
@@ -72,7 +74,7 @@ export default {
return rawList.map((item) => ({
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
source: 'kw',
}))
},
filterTagInfo(rawList) {
@@ -83,8 +85,8 @@ export default {
parent_name: type.name,
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
}))
source: 'kw',
})),
}))
},
@@ -95,7 +97,7 @@ export default {
let id
let type
if (tagId) {
const arr = tagId.split('-')
let arr = tagId.split('-')
id = arr[0]
type = arr[1]
} else {
@@ -110,7 +112,7 @@ export default {
total: body.data.total,
page: body.data.pn,
limit: body.data.rn,
source: 'kw'
source: 'kw',
}
} else if (!body.length) {
return this.getList(sortId, tagId, page, ++tryNum)
@@ -120,7 +122,7 @@ export default {
total: 1000,
page,
limit: 1000,
source: 'kw'
source: 'kw',
}
})
},
@@ -145,7 +147,7 @@ export default {
img: item.img,
grade: item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
},
filterList2(rawData) {
@@ -164,7 +166,7 @@ export default {
img: item.img,
grade: item.favorcnt && item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
)
})
@@ -188,8 +190,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -207,7 +209,9 @@ export default {
getListDetailDigest5Music(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj = httpFetch(
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${
this.limit_song
}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
)
return requestObj.promise.then(({ body }) => {
// console.log(body)
@@ -223,8 +227,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -235,33 +239,33 @@ export default {
filterBDListDetail(rawList) {
return rawList.map((item) => {
const types = []
const _types = {}
for (const info of item.audios) {
let types = []
let _types = {}
for (let info of item.audios) {
info.size = info.size?.toLocaleUpperCase()
switch (info.bitrate) {
case '4000':
types.push({ type: 'flac24bit', size: info.size })
_types.flac24bit = {
size: info.size
size: info.size,
}
break
case '2000':
types.push({ type: 'flac', size: info.size })
_types.flac = {
size: info.size
size: info.size,
}
break
case '320':
types.push({ type: '320k', size: info.size })
_types['320k'] = {
size: info.size
size: info.size,
}
break
case '128':
types.push({ type: '128k', size: info.size })
_types['128k'] = {
size: info.size
size: info.size,
}
break
}
@@ -282,7 +286,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -299,8 +303,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -311,7 +315,7 @@ export default {
img: infoData.data.pic,
desc: infoData.data.description,
author: infoData.data.creatorName,
play_count: infoData.data.playNum
play_count: infoData.data.playNum,
}
},
async getListDetailMusicListByBDUserPub(id) {
@@ -321,8 +325,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -334,18 +338,20 @@ export default {
img: infoData.data.userInfo.headImg,
desc: '',
author: infoData.data.userInfo.nickname,
play_count: ''
play_count: '',
}
},
async getListDetailMusicListByBDList(id, source, page, tryNum = 0) {
const { body: listData } = await httpFetch(
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${this.limit_song}`,
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${
this.limit_song
}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
@@ -359,7 +365,7 @@ export default {
page,
limit: listData.data.pageSize,
total: listData.data.total,
source: 'kw'
source: 'kw',
}
},
async getListDetailMusicListByBD(id, page) {
@@ -383,7 +389,7 @@ export default {
img: '',
desc: '',
author: '',
play_count: ''
play_count: '',
}
// console.log(listData)
return listData
@@ -415,35 +421,53 @@ export default {
filterListDetail(rawData) {
// console.log(rawData)
return rawData.map((item) => {
const infoArr = item.N_MINFO.split(';')
const types = []
const _types = {}
let infoArr = item.N_MINFO.split(';')
let types = []
let _types = {}
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '20501':
types.push({ type: 'atmos_plus', size: info[4] })
_types.atmos_plus = {
size: info[4].toLocaleUpperCase(),
}
break
case '20201':
types.push({ type: 'atmos', size: info[4] })
_types.atmos = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
types.push({ type: 'hires', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -464,7 +488,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -472,13 +496,13 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'kw'
source: 'kw',
}))
},
getDetailPageUrl(id) {
if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
else if (/^digest-/.test(id)) {
const result = id.split('__')
let result = id.split('__')
id = result[1]
}
return `http://www.kuwo.cn/playlist_detail/${id}`
@@ -486,7 +510,9 @@ export default {
search(text, page, limit = 20) {
return httpFetch(
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${
page - 1
}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
).promise.then(({ body }) => {
body = objStr2JSON(body)
// console.log(body)
@@ -501,17 +527,17 @@ export default {
// time: item.publish_time,
img: item.pic,
desc: decodeName(item.intro),
source: 'kw'
source: 'kw',
}
}),
limit,
total: parseInt(body.TOTAL),
source: 'kw'
source: 'kw',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../index'
import { sizeFormate, formatPlayTime } from '../../index'
import { toMD5, formatSingerName } from '../utils'
export const createSignature = (time, str) => {
@@ -17,100 +17,6 @@ export default {
page: 0,
allPage: 1,
// 旧版API
// musicSearch(str, page, limit) {
// const searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// headers: {
// // sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// // timestamp: 1578225871982,
// // appId: 'yyapp2',
// // mode: 'android',
// // ua: 'Android_migu',
// // version: '6.9.4',
// osVersion: 'android 7.0',
// 'User-Agent': 'okhttp/3.9.1',
// },
// })
// // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// return searchRequest.promise.then(({ body }) => body)
// },
// handleResult(rawData) {
// // console.log(rawData)
// let ids = new Set()
// const list = []
// rawData.forEach(item => {
// if (ids.has(item.id)) return
// ids.add(item.id)
// const types = []
// const _types = {}
// item.newRateFormats && item.newRateFormats.forEach(type => {
// let size
// switch (type.formatType) {
// case 'PQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '128k', size })
// _types['128k'] = {
// size,
// }
// break
// case 'HQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '320k', size })
// _types['320k'] = {
// size,
// }
// break
// case 'SQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac', size })
// _types.flac = {
// size,
// }
// break
// case 'ZQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac24bit', size })
// _types.flac24bit = {
// size,
// }
// break
// }
// })
// const albumNInfo = item.albums && item.albums.length
// ? {
// id: item.albums[0].id,
// name: item.albums[0].name,
// }
// : {}
// list.push({
// singer: this.getSinger(item.singers),
// name: item.name,
// albumName: albumNInfo.name,
// albumId: albumNInfo.id,
// songmid: item.songId,
// copyrightId: item.copyrightId,
// source: 'mg',
// interval: null,
// img: item.imgItems && item.imgItems.length ? item.imgItems[0].img : null,
// lrc: null,
// lrcUrl: item.lyricUrl,
// mrcUrl: item.mrcurl,
// trcUrl: item.trcUrl,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// })
// return list
// },
musicSearch(str, page, limit) {
const time = Date.now().toString()
const signData = createSignature(time, str)
@@ -124,8 +30,8 @@ export default {
sign: signData.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
)
return searchRequest.promise.then(({ body }) => body)
@@ -150,28 +56,28 @@ export default {
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
break
case 'HQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
break
case 'SQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
break
case 'ZQ24':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
types.push({ type: 'hires', size })
_types.hires = {
size,
}
break
}
@@ -196,7 +102,7 @@ export default {
trcUrl: data.trcUrl,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
})
@@ -212,7 +118,7 @@ export default {
return Promise.reject(new Error(result ? result.info : '搜索失败'))
const songResultData = result.songResultData || { resultList: [], totalCount: 0 }
const list = this.filterData(songResultData.resultList)
let list = this.filterData(songResultData.resultList)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = parseInt(songResultData.totalCount)
@@ -224,8 +130,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'mg'
source: 'mg',
}
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { dateFormat, formatPlayCount } from '../index'
import { dateFormat, formatPlayCount } from '../../index'
import { filterMusicInfoList } from './musicInfo'
import { createSignature } from './musicSearch'
import { createHttpFetch } from './utils/index'
@@ -17,14 +17,14 @@ export default {
sortList: [
{
name: '推荐',
id: '15127315'
id: '15127315',
// id: '1',
},
{
name: '最新',
id: '15127272'
id: '15127272',
// id: '2',
}
},
],
regExps: {
list: /<li><div class="thumb">.+?<\/li>/g,
@@ -32,7 +32,7 @@ export default {
/.+data-original="(.+?)".*data-id="(\d+)".*<div class="song-list-name"><a\s.*?>(.+?)<\/a>.+<i class="iconfont cf-bofangliang"><\/i>(.+?)<\/div>/,
// https://music.migu.cn/v3/music/playlist/161044573?page=1
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl: 'https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/musiclistplaza-taglist/release',
// tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0',
@@ -58,7 +58,7 @@ export default {
defaultHeaders: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'https://m.music.migu.cn/'
Referer: 'https://m.music.migu.cn/',
// language: 'Chinese',
// ua: 'Android_migu',
// mode: 'android',
@@ -74,7 +74,7 @@ export default {
} else if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
const requestObj_listDetail = httpFetch(this.getSongListDetailUrl(id, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
})
return requestObj_listDetail.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
@@ -85,7 +85,7 @@ export default {
page,
limit: this.limit_song,
total: body.totalCount,
source: 'mg'
source: 'mg',
}
})
},
@@ -97,7 +97,7 @@ export default {
const requestObj_listDetailInfo = httpFetch(
`https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=${id}`,
{
headers: this.defaultHeaders
headers: this.defaultHeaders,
}
)
return requestObj_listDetailInfo.promise.then(({ body }) => {
@@ -109,7 +109,7 @@ export default {
img: body.data.imgItem.img,
desc: body.data.summary,
author: body.data.ownerName,
play_count: formatPlayCount(body.data.opNumItem.playNum)
play_count: formatPlayCount(body.data.opNumItem.playNum),
})
return cachedDetailInfo
})
@@ -122,12 +122,12 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getDetailUrl(link, page, ++retryNum)
@@ -153,7 +153,7 @@ export default {
return Promise.all([
this.getListDetailList(id, page, retryNum),
this.getListDetailInfo(id, retryNum)
this.getListDetailInfo(id, retryNum),
]).then(([listData, info]) => {
listData.info = info
return listData
@@ -165,7 +165,7 @@ export default {
if (this._requestObj_list) this._requestObj_list.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
// headers: {
// sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// timestamp: 1578225871982,
@@ -205,7 +205,7 @@ export default {
total: parseInt(body.retMsg.countSize),
page,
limit: this.limit_list,
source: 'mg'
source: 'mg',
}
})
// return this._requestObj_list.promise.then(({ body }) => {
@@ -233,7 +233,7 @@ export default {
grade: item.grade,
total: item.contentCount,
desc: item.summary,
source: 'mg'
source: 'mg',
}))
},
@@ -254,7 +254,7 @@ export default {
hotTag: rawList[0].content.map(({ texts: [name, id] }) => ({
id,
name,
source: 'mg'
source: 'mg',
})),
tags: rawList.slice(1).map(({ header, content }) => ({
name: header.title,
@@ -263,10 +263,10 @@ export default {
// parent_name: objectInfo.columnTitle,
id,
name,
source: 'mg'
}))
source: 'mg',
})),
})),
source: 'mg'
source: 'mg',
}
// return {
// hotTag: rawList[0].objectInfo.contents.map(item => ({
@@ -313,7 +313,7 @@ export default {
name: item.name,
img: item.musicListPicUrl,
total: item.musicNum,
source: 'mg'
source: 'mg',
})
})
return list
@@ -331,8 +331,8 @@ export default {
sign: signResult.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
).then((body) => {
if (!body.songListResultData) throw new Error('get song list faild.')
@@ -342,12 +342,12 @@ export default {
list,
limit,
total: parseInt(body.songListResultData.totalCount),
source: 'mg'
source: 'mg',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -1,8 +1,7 @@
export const bHh = '624868746c'
export const headers = {
'User-Agent': 'lx-music request',
[bHh]: [bHh]
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
}
export const timeout = 15000

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate } from '../index'
import { formatPlayTime, sizeFormate } from '../../index'
import { formatSingerName } from '../utils'
export default {
@@ -15,7 +15,7 @@ export default {
const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)'
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
@@ -26,7 +26,7 @@ export default {
phonetype: '0',
devicelevel: '31',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI'
nettype: 'NETWORK_WIFI',
},
req: {
module: 'music.search.SearchCgiService',
@@ -37,10 +37,10 @@ export default {
num_per_page: limit,
page_num: page,
nqc_flag: 0,
grp: 1
}
}
}
grp: 1,
},
},
},
})
// searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`)
return searchRequest.promise.then(({ body }) => {
@@ -56,35 +56,56 @@ export default {
rawList.forEach((item) => {
if (!item.file?.media_mid) return
const types = []
const _types = {}
let types = []
let _types = {}
const file = item.file
if (file.size_128mp3 != 0) {
const size = sizeFormate(file.size_128mp3)
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
}
if (file.size_320mp3 !== 0) {
const size = sizeFormate(file.size_320mp3)
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
}
if (file.size_flac !== 0) {
const size = sizeFormate(file.size_flac)
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
}
if (file.size_hires !== 0) {
const size = sizeFormate(file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = {
size,
}
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = {
size,
}
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = {
size,
}
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = {
size,
}
}
// types.reverse()
@@ -113,7 +134,7 @@ export default {
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
// console.log(list)
@@ -123,7 +144,7 @@ export default {
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(({ body, meta }) => {
const list = this.handleResult(body.item_song)
let list = this.handleResult(body.item_song)
this.total = meta.estimate_sum
this.page = page
@@ -134,8 +155,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'tx'
source: 'tx',
})
})
}
}
},
}

View File

@@ -0,0 +1,86 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getBatchMusicQualityInfo = (songList) => {
const songIds = songList.map((item) => item.id)
const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
ct: '19',
cv: '1859',
uin: '0',
},
req: {
module: 'music.trackInfo.UniformRuleCtrl',
method: 'CgiGetTrackInfo',
param: {
types: Array(songIds.length).fill(1),
ids: songIds,
ctx: 0,
},
},
},
})
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('获取音质信息失败'))
// Process each track from the response
body.req.data.tracks.forEach((track) => {
const file = track.file
const songId = track.id
const types = []
const _types = {}
if (file.size_128mp3 != 0) {
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (file.size_320mp3 !== 0) {
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (file.size_flac !== 0) {
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (file.size_hires !== 0) {
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = { size }
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = { size }
}
qualityInfoMap[songId] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, formatPlayTime, dateFormat, formatPlayCount } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
_requestObj_tags: null,
@@ -12,12 +13,12 @@ export default {
sortList: [
{
name: '最热',
id: 5
id: 5,
},
{
name: '最新',
id: 2
}
id: 2,
},
],
regExps: {
hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g,
@@ -26,7 +27,7 @@ export default {
// https://y.qq.com/n/yqq/playlist/7217720898.html
// https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare
listDetailLink: /\/playlist\/(\d+)/,
listDetailLink2: /id=(\d+)/
listDetailLink2: /id=(\d+)/,
},
tagsUrl:
'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D',
@@ -45,10 +46,10 @@ export default {
category_id: id,
size: this.limit_list,
page: page - 1,
use_page: 1
use_page: 1,
},
module: 'playlist.PlayListCategoryServer'
}
module: 'playlist.PlayListCategoryServer',
},
})
)}`
}
@@ -62,10 +63,10 @@ export default {
sin: this.limit_list * (page - 1),
size: this.limit_list,
order: sortId,
cur_page: page
cur_page: page,
},
module: 'playlist.PlayListPlazaServer'
}
module: 'playlist.PlayListPlazaServer',
},
})
)}`
},
@@ -95,17 +96,17 @@ export default {
})
},
filterInfoHotTag(html) {
const hotTag = html.match(this.regExps.hotTagHtml)
let hotTag = html.match(this.regExps.hotTagHtml)
const hotTags = []
if (!hotTag) return hotTags
hotTag.forEach((tagHtml) => {
const result = tagHtml.match(this.regExps.hotTag)
let result = tagHtml.match(this.regExps.hotTag)
if (!result) return
hotTags.push({
id: parseInt(result[1]),
name: result[2],
source: 'tx'
source: 'tx',
})
})
return hotTags
@@ -118,8 +119,8 @@ export default {
parent_name: type.group_name,
id: item.id,
name: item.name,
source: 'tx'
}))
source: 'tx',
})),
}))
},
@@ -130,7 +131,9 @@ export default {
this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page))
// console.log(this.getListUrl(sortId, tagId, page))
return this._requestObj_list.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
if (body.code !== this.successCode) {
return this.getList(sortId, tagId, page, ++tryNum)
}
return tagId
? this.filterList2(body.playlist.data, page)
: this.filterList(body.playlist.data, page)
@@ -149,12 +152,12 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_ids?.length,
desc: decodeName(item.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: data.total,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
filterList2({ content }, page) {
@@ -169,12 +172,12 @@ export default {
img: basic.cover.medium_url || basic.cover.default_url,
// grade: basic.favorcnt / 10,
desc: decodeName(basic.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: content.total_cnt,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
@@ -184,7 +187,7 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(headers)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
@@ -202,7 +205,6 @@ export default {
if (!result) throw new Error('failed')
}
id = result[1]
// console.log(id)
}
return id
},
@@ -215,15 +217,16 @@ export default {
const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), {
headers: {
Origin: 'https://y.qq.com',
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`
}
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`,
},
})
const { body } = await requestObj_listDetail.promise
console.log(body);
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
const cdlist = body.cdlist[0]
return {
list: this.filterListDetail(cdlist.songlist),
list: await this.filterListDetail(cdlist.songlist),
page: 1,
limit: cdlist.songlist.length + 1,
total: cdlist.songlist.length,
@@ -233,44 +236,23 @@ export default {
img: cdlist.logo,
desc: decodeName(cdlist.desc).replace(/<br>/g, '\n'),
author: cdlist.nickname,
play_count: formatPlayCount(cdlist.visitnum)
}
play_count: formatPlayCount(cdlist.visitnum),
},
}
},
filterListDetail(rawList) {
// console.log(rawList)
async filterListDetail(rawList) {
const qualityInfoRequest = getBatchMusicQualityInfo(rawList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return rawList.map((item) => {
const types = []
const _types = {}
if (item.file.size_128mp3 !== 0) {
const size = sizeFormate(item.file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
if (item.file.size_320mp3 !== 0) {
const size = sizeFormate(item.file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
}
}
if (item.file.size_flac !== 0) {
const size = sizeFormate(item.file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
}
}
if (item.file.size_hires !== 0) {
const size = sizeFormate(item.file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
// types.reverse()
const { types = [], _types = {} } = qualityInfoMap[item.id] || {}
return {
singer: formatSingerName(item.singer, 'name'),
name: item.title,
@@ -292,7 +274,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -300,7 +282,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'tx'
source: 'tx',
}))
},
@@ -313,12 +295,16 @@ export default {
search(text, page, limit = 20, retryNum = 0) {
if (retryNum > 5) throw new Error('max retry')
return httpFetch(
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${page - 1}&num_per_page=${limit}&format=json&query=${encodeURIComponent(text)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${
page - 1
}&num_per_page=${limit}&format=json&query=${encodeURIComponent(
text
)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'http://y.qq.com/portal/search.html'
}
Referer: 'http://y.qq.com/portal/search.html',
},
}
).promise.then(({ body }) => {
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
@@ -335,17 +321,17 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_count,
desc: decodeName(decodeName(item.introduction)).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
}
}),
limit,
total: body.data.sum,
source: 'tx'
source: 'tx',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -10,16 +10,13 @@ export const getHostIp = (hostname) => {
if (typeof result === 'object') return result
if (result === true) return
ipMap.set(hostname, true)
// console.log(hostname)
dns.lookup(
hostname,
{
// family: 4,
all: false
all: false,
},
(err, address, family) => {
if (err) return console.log(err)
// console.log(address, family)
ipMap.set(hostname, { address, family })
}
)
@@ -42,11 +39,11 @@ export const formatSingerName = (singers, nameKey = 'name', join = '、') => {
if (Array.isArray(singers)) {
const singer = []
singers.forEach((item) => {
const name = item[nameKey]
let name = item[nameKey]
if (!name) return
singer.push(name)
})
return decodeName(singer.join(join))
}
return decodeName(String(singers ?? ''))
}
}

View File

@@ -1,57 +1,23 @@
import { httpFetch } from '../../request'
import { weapi } from './utils/crypto'
import { formatPlayTime, sizeFormate } from '../index'
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
import { formatPlayTime } from '../../index'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
getSinger(singers) {
const arr = []
let arr = []
singers?.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
},
filterList({ songs, privileges }) {
// console.log(songs, privileges)
async filterList({ songs, privileges }) {
const list = []
const idList = songs.map((item) => item.id)
const qualityInfoMap = await getBatchMusicQualityInfo(idList)
songs.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
const { types, _types } = qualityInfoMap[item.id] || { types: [], _types: {} }
if (item.pc) {
list.push({
@@ -67,7 +33,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
} else {
list.push({
@@ -83,11 +49,10 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
})
// console.log(list)
return list
},
async getList(ids = [], retryNum = 0) {
@@ -98,16 +63,15 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com'
origin: 'https://music.163.com',
},
form: weapi({
c: '[' + ids.map((id) => '{"id":' + id + '}').join(',') + ']',
ids: '[' + ids.join(',') + ']'
})
ids: '[' + ids.join(',') + ']',
}),
})
const { body, statusCode } = await requestObj.promise
if (statusCode != 200 || body.code !== 200) throw new Error('获取歌曲详情失败')
// console.log(body)
return { source: 'wy', list: this.filterList(body) }
}
}
return { source: 'wy', list: await this.filterList(body) }
},
}

View File

@@ -1,7 +1,5 @@
// import { httpFetch } from '../../request'
// import { weapi } from './utils/crypto'
import { sizeFormate, formatPlayTime } from '../index'
// import musicDetailApi from './musicDetail'
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../../index'
import { eapiRequest } from './utils/index'
export default {
@@ -9,101 +7,129 @@ export default {
total: 0,
page: 0,
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = eapiRequest('/api/cloudsearch/pc', {
s: str,
type: 1, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
})
return searchRequest.promise.then(({ body }) => body)
},
getSinger(singers) {
const arr = []
singers.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
return singers.map((singer) => singer.name).join('、')
},
handleResult(rawList) {
// console.log(rawList)
if (!rawList) return []
return rawList.map((item) => {
const types = []
const _types = {}
let size
if (item.privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
return Promise.all(
rawList.map(async (item) => {
const types = []
const _types = {}
let size
try {
const requestObj = httpFetch(
`https://music.163.com/api/song/music/detail/get?songId=${item.id}`,
{
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
}
)
const { body, statusCode } = await requestObj.promise
if (statusCode !== 200 || !body || body.code !== 200) {
throw new Error('Failed to get song quality information')
}
if (body.data.jm && body.data.jm.size) {
size = sizeFormate(body.data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (body.data.db && body.data.db.size) {
size = sizeFormate(body.data.db.size)
types.push({ type: 'dolby', size })
_types.dolby = { size }
}
if (body.data.hr && body.data.hr.size) {
size = sizeFormate(body.data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (body.data.sq && body.data.sq.size) {
size = sizeFormate(body.data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (body.data.h && body.data.h.size) {
size = sizeFormate(body.data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (body.data.m && body.data.m.size) {
size = sizeFormate(body.data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (body.data.l && body.data.l.size) {
size = sizeFormate(body.data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {},
}
} catch (error) {
console.error(error.message)
return null
}
}
switch (item.privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {}
}
})
})
)
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
return this.musicSearch(str, page, limit).then((result) => {
// console.log(result)
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.result.songs || [])
// console.log(list)
return this.handleResult(result.result.songs || []).then((list) => {
if (!list || list.length === 0) return this.search(str, page, limit, retryNum)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy'
}
// return result.data
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy',
}
})
})
}
}
},
}

View File

@@ -0,0 +1,91 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getMusicQualityInfo = (id) => {
const requestObj = httpFetch(`https://music.163.com/api/song/music/detail/get?songId=${id}`, {
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
})
const types = []
const _types = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 && body.code != 200) return Promise.reject(new Error('获取音质信息失败' + id))
const data = body.data
types.length = 0
Object.keys(_types).forEach((key) => delete _types[key])
if (data.l != null && data.l.size != null) {
let size = sizeFormate(data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (data.m != null && data.m.size != null) {
let size = sizeFormate(data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (data.h != null && data.h.size != null) {
let size = sizeFormate(data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (data.sq != null && data.sq.size != null) {
let size = sizeFormate(data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (data.hr != null && data.hr.size != null) {
let size = sizeFormate(data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (data.jm != null && data.jm.size != null) {
let size = sizeFormate(data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (data.je != null && data.je.size != null) {
let size = sizeFormate(data.je.size)
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
return { types: [...types], _types: { ..._types } }
})
return { requestObj, types, _types }
}
export const getBatchMusicQualityInfo = async (idList) => {
const ids = idList.filter((id) => id)
const qualityPromises = ids.map((id) => {
const result = getMusicQualityInfo(id)
return result.requestObj.promise.catch((err) => {
console.error(`获取歌曲 ${id} 音质信息失败:`, err)
return { types: [], _types: {} }
})
})
const qualityResults = await Promise.all(qualityPromises)
const qualityInfoMap = {}
ids.forEach((id, index) => {
qualityInfoMap[id] = qualityResults[index] || { types: [], _types: {} }
})
return qualityInfoMap
}

View File

@@ -1,9 +1,9 @@
import { weapi, linuxapi } from './utils/crypto'
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { /* formatPlayTime, */ dateFormat, formatPlayCount } from '../../index'
import musicDetailApi from './musicDetail'
import { eapiRequest } from './utils/index'
import { formatSingerName } from '../utils'
// import { formatSingerName } from '../utils'
export default {
_requestObj_tags: null,
@@ -16,16 +16,12 @@ export default {
sortList: [
{
name: '最热',
id: 'hot'
}
// {
// name: '最新',
// id: 'new',
// },
id: 'hot',
},
],
regExps: {
listDetailLink: /^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)/,
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/,
},
async handleParseId(link, retryNum = 0) {
@@ -34,9 +30,8 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(statusCode)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
const url = location == null ? link : location
return this.regExps.listDetailLink.test(url)
@@ -59,13 +54,11 @@ export default {
} else {
id = await this.handleParseId(id)
}
// console.log(id)
}
return { id, cookie }
},
async getListDetail(rawId, page, tryNum = 0) {
// 获取歌曲列表内的音乐
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (tryNum > 1000) return Promise.reject(new Error('try max num'))
const { id, cookie } = await this.getListId(rawId)
if (cookie) this.cookie = cookie
@@ -75,7 +68,7 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
Cookie: this.cookie
Cookie: this.cookie,
},
form: linuxapi({
method: 'POST',
@@ -83,36 +76,30 @@ export default {
params: {
id,
n: this.limit_song,
s: 8
}
})
s: 8,
},
}),
})
const { statusCode, body } = await requestObj_listDetail.promise
if (statusCode !== 200 || body.code !== this.successCode)
return this.getListDetail(id, page, ++tryNum)
const limit = 1000
const rangeStart = (page - 1) * limit
// console.log(body)
let limit = 50
let rangeStart = (page - 1) * limit
let list
if (body.playlist.trackIds.length == body.privileges.length) {
list = this.filterListDetail(body)
} else {
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
}
// console.log(list)
return {
list,
page,
@@ -124,119 +111,79 @@ export default {
name: body.playlist.name,
img: body.playlist.coverImgUrl,
desc: body.playlist.description,
author: body.playlist.creator.nickname
}
author: body.playlist.creator.nickname,
},
}
},
filterListDetail({ playlist: { tracks }, privileges }) {
// console.log(tracks, privileges)
const list = []
tracks.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = null
types.push({ type: 'flac', size })
_types.flac = {
size
}
// filterListDetail({ playlist: { tracks } }) {
// const list = []
// tracks.forEach((item) => {
// const types = []
// const _types = {}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
// if (item.pc) {
// list.push({
// singer: item.pc.ar ?? '',
// name: item.pc.sn ?? '',
// albumName: item.pc.alb ?? '',
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl ?? '',
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// } else {
// list.push({
// singer: formatSingerName(item.ar, 'name'),
// name: item.name ?? '',
// albumName: item.al?.name,
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl,
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// }
// })
// return list
// },
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
if (item.pc) {
list.push({
singer: item.pc.ar ?? '',
name: item.pc.sn ?? '',
albumName: item.pc.alb ?? '',
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl ?? '',
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
} else {
list.push({
singer: formatSingerName(item.ar, 'name'),
name: item.name ?? '',
albumName: item.al?.name,
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl,
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
}
})
return list
},
// 获取列表数据
getList(sortId, tagId, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (this._requestObj_list) this._requestObj_list.cancelHttp()
this._requestObj_list = httpFetch('https://music.163.com/weapi/playlist/list', {
method: 'post',
form: weapi({
cat: tagId || '全部', // 全部,华语,欧美,日语,韩语,粤语,小语种,流行,摇滚,民谣,电子,舞曲,说唱,轻音乐,爵士,乡村,R&B/Soul,古典,民族,英伦,金属,朋克,蓝调,雷鬼,世界音乐,拉丁,另类/独立,New Age,古风,后摇,Bossa Nova,清晨,夜晚,学习,工作,午休,下午茶,地铁,驾车,运动,旅行,散步,酒吧,怀旧,清新,浪漫,性感,伤感,治愈,放松,孤独,感动,兴奋,快乐,安静,思念,影视原声,ACG,儿童,校园,游戏,70后,80后,90后,网络歌曲,KTV,经典,翻唱,吉他,钢琴,器乐,榜单,00后
order: sortId, // hot,new
cat: tagId || '全部',
order: sortId,
limit: this.limit_list,
offset: this.limit_list * (page - 1),
total: true
})
total: true,
}),
})
return this._requestObj_list.promise.then(({ body }) => {
// console.log(body)
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
return {
list: this.filterList(body.playlists),
total: parseInt(body.total),
page,
limit: this.limit_list,
source: 'wy'
source: 'wy',
}
})
},
filterList(rawData) {
// console.log(rawData)
return rawData.map((item) => ({
play_count: formatPlayCount(item.playCount),
id: String(item.id),
@@ -247,20 +194,18 @@ export default {
grade: item.grade,
total: item.trackCount,
desc: item.description,
source: 'wy'
source: 'wy',
}))
},
// 获取标签
getTag(tryNum = 0) {
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_tags = httpFetch('https://music.163.com/weapi/playlist/catalogue', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_tags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterTagInfo(body)
})
@@ -274,7 +219,7 @@ export default {
parent_name: categories[item.category],
id: item.name,
name: item.name,
source: 'wy'
source: 'wy',
})
}
@@ -283,22 +228,20 @@ export default {
list.push({
name: categories[key],
list: subList[key],
source: 'wy'
source: 'wy',
})
}
return list
},
// 获取热门标签
getHotTag(tryNum = 0) {
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_hotTags = httpFetch('https://music.163.com/weapi/playlist/hottags', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_hotTags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterHotTagInfo(body.tags)
})
@@ -307,7 +250,7 @@ export default {
return rawList.map((item) => ({
id: item.playlistTag.name,
name: item.playlistTag.name,
source: 'wy'
source: 'wy',
}))
},
@@ -315,7 +258,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'wy'
source: 'wy',
}))
},
@@ -327,23 +270,18 @@ export default {
search(text, page, limit = 20) {
return eapiRequest('/api/cloudsearch/pc', {
s: text,
type: 1000, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
type: 1000,
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
}).promise.then(({ body }) => {
if (body.code != this.successCode) throw new Error('filed')
// console.log(body)
return {
list: this.filterList(body.result.playlists),
limit,
total: body.result.playlistCount,
source: 'wy'
source: 'wy',
}
})
}
}
// getList
// getTags
// getListDetail
},
}

45
src/main/windows/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { BrowserWindow, BrowserWindowConstructorOptions, app } from 'electron'
import { windowsLog } from '../logger'
import { join } from 'path'
import icon from '../../../resources/logo.png?asset'
export const createWindow = (
options: BrowserWindowConstructorOptions = {}
): BrowserWindow | null => {
try {
const defaultOptions: BrowserWindowConstructorOptions = {
title: app.getName(),
width: 1280,
height: 720,
frame: false, // 创建后是否显示窗口
center: true, // 窗口居中
icon, // 窗口图标
autoHideMenuBar: true, // 隐藏菜单栏
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// 禁用渲染器沙盒
sandbox: false,
// 禁用同源策略
webSecurity: false,
// 允许 HTTP
allowRunningInsecureContent: true,
// 禁用拼写检查
spellcheck: false,
// 启用 Node.js
nodeIntegration: true,
nodeIntegrationInWorker: true,
// 关闭上下文隔离,确保在窗口中注入 window.electron
contextIsolation: false,
backgroundThrottling: false
}
}
// 合并参数
options = Object.assign(defaultOptions, options)
// 创建窗口
const win = new BrowserWindow(options)
return win
} catch (error) {
windowsLog.error(error)
return null
}
}

View File

@@ -0,0 +1,92 @@
import { BrowserWindow, screen } from 'electron'
import { createWindow } from './index'
import { configManager } from '../services/ConfigManager'
import { join } from 'path'
import { lyricConfig } from '@common/types/config'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
class LyricWindow {
private win: BrowserWindow | null = null
constructor() {}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return
// 歌词窗口缩放
this.win?.on('resized', () => {
const bounds = this.win?.getBounds()
if (bounds) {
const { width, height } = bounds
console.log('歌词窗口缩放:', width, height);
lyricStore.set({
...lyricStore.get(),
width,
height
})
}
})
}
/**
* 创建主窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
const { width, height, x, y } = lyricStore.get()
this.win = createWindow({
width: width || 800,
height: height || 180,
minWidth: 440,
minHeight: 120,
maxWidth: 1600,
maxHeight: 300,
show: false,
// 窗口位置
x,
y,
transparent: true,
backgroundColor: 'rgba(0, 0, 0, 0)',
alwaysOnTop: true,
resizable: true,
movable: true,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false
})
if (!this.win) return null
// 加载地址(开发环境用项目根目录,生产用打包后的相对路径)
this.win.loadFile(join(__dirname, '../main/src/web/lyric.html'))
// 窗口事件
this.event()
return this.win
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
return this.win
}
}
export default new LyricWindow()

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

@@ -8,6 +8,11 @@ const api = {
console.log('preload: 发送 window-minimize 事件')
ipcRenderer.send('window-minimize')
},
// 阻止系统息屏
powerSaveBlocker: {
start: () => ipcRenderer.invoke('power-save-blocker:start'),
stop: () => ipcRenderer.invoke('power-save-blocker:stop')
},
maximize: () => {
console.log('preload: 发送 window-maximize 事件')
ipcRenderer.send('window-maximize')
@@ -111,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'),
@@ -198,14 +207,14 @@ const api = {
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ipcRenderer })
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
window.electron = { ...electronAPI, ipcRenderer }
// @ts-ignore (define in dts)
window.api = api
}

View File

@@ -7,90 +7,72 @@
export {}
declare global {
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
const EffectScope: (typeof import('vue'))['EffectScope']
const computed: (typeof import('vue'))['computed']
const createApp: (typeof import('vue'))['createApp']
const customRef: (typeof import('vue'))['customRef']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const effectScope: (typeof import('vue'))['effectScope']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const getCurrentWatcher: (typeof import('vue'))['getCurrentWatcher']
const h: (typeof import('vue'))['h']
const inject: (typeof import('vue'))['inject']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const isShallow: (typeof import('vue'))['isShallow']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
const provide: (typeof import('vue'))['provide']
const reactive: (typeof import('vue'))['reactive']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const toRaw: (typeof import('vue'))['toRaw']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const unref: (typeof import('vue'))['unref']
const useAttrs: (typeof import('vue'))['useAttrs']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVars: (typeof import('vue'))['useCssVars']
const useDialog: (typeof import('naive-ui'))['useDialog']
const useId: (typeof import('vue'))['useId']
const useLoadingBar: (typeof import('naive-ui'))['useLoadingBar']
const useMessage: (typeof import('naive-ui'))['useMessage']
const useModel: (typeof import('vue'))['useModel']
const useNotification: (typeof import('naive-ui'))['useNotification']
const useSlots: (typeof import('vue'))['useSlots']
const useTemplateRef: (typeof import('vue'))['useTemplateRef']
const watch: (typeof import('vue'))['watch']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type {
Component,
Slot,
Slots,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
ShallowRef,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -12,6 +12,7 @@ declare module 'vue' {
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
DesktopLyricStyle: typeof import('./src/components/Settings/DesktopLyricStyle.vue')['default']
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
@@ -40,6 +41,7 @@ declare module 'vue' {
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TColorPicker: typeof import('tdesign-vue-next')['ColorPicker']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
@@ -50,6 +52,7 @@ declare module 'vue' {
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TInputNumber: typeof import('tdesign-vue-next')['InputNumber']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']

View File

@@ -31,6 +31,15 @@ onMounted(() => {
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 全局监听来自主进程的播放控制事件,确保路由切换也可响应
const forward = (name: string, val?: any) => {
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
}
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev'))
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
checkForUpdates()

View File

@@ -0,0 +1,3 @@
<svg class="lyrics-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M192 240a112 112 0 0 1 111.616 102.784l0.384 9.216V832a16 16 0 0 0 12.352 15.552L320 848h66.816a48 48 0 0 1 6.528 95.552l-6.528 0.448H320a112 112 0 0 1-111.616-102.784L208 832V352a16 16 0 0 0-12.352-15.552L192 336H128a48 48 0 0 1-6.528-95.552L128 240h64z m640-157.568a112 112 0 0 1 111.616 102.848l0.384 9.152V832a112 112 0 0 1-102.784 111.616L832 944h-67.84a48 48 0 0 1-6.464-95.552l6.464-0.448H832a16 16 0 0 0 15.552-12.352L848 832V194.432a16 16 0 0 0-12.352-15.552L832 178.432H480a48 48 0 0 1-6.528-95.552l6.528-0.448H832z m-160 315.136c61.824 0 112 50.112 112 112v147.648a112 112 0 0 1-112 112h-128a112 112 0 0 1-112-112V509.568c0-61.888 50.176-112 112-112z m0 96h-128a16 16 0 0 0-16 16v147.648c0 8.832 7.168 16 16 16h128a16 16 0 0 0 16-16V509.568a16 16 0 0 0-16-16z m64-253.568a48 48 0 0 1 6.528 95.552l-6.528 0.448h-256a48 48 0 0 1-6.528-95.552L480 240h256zM256 82.432a48 48 0 0 1 6.528 95.616L256 178.432H128a48 48 0 0 1-6.528-95.552L128 82.432h128z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -234,6 +234,8 @@
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #fff7e6;
--song-list-quality-color: #fa8c16;
--song-list-source-bg: #f3feff;
--song-list-source-color: #00d4e3;
/* Search 页面专用变量 - 亮色主题 */
--search-bg: var(--theme-bg-tertiary);
@@ -597,6 +599,8 @@
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #3a2a1a;
--song-list-quality-color: #fa8c16;
--song-list-source-bg: #343939;
--song-list-source-color: #00eeff;
/* Search 页面专用变量 - 暗色主题 */
--search-bg: var(--theme-bg-tertiary);

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"
@@ -44,6 +44,9 @@
<span v-if="song.types && song.types.length > 0" class="quality-tag">
{{ getQualityDisplayName(song.types[song.types.length - 1]) }}
</span>
<span v-if="song.source" class="source-tag">
{{ song.source }}
</span>
{{ song.singer }}
</div>
</div>
@@ -58,8 +61,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 +122,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 +303,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 +419,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 +517,7 @@ const handleAddToSongList = async (song: Song, playlist: SongList) => {
}
}
onMounted(() => {
onMounted(async () => {
// 组件挂载后触发一次重新计算
nextTick(() => {
if (scrollContainer.value) {
@@ -431,10 +528,15 @@ onMounted(() => {
})
// 加载歌单列表
loadPlaylists()
await loadPlaylists()
// 预加载“我的喜欢”集合(确保方法存在于当前文件作用域)
await loadFavorites()
// 监听歌单变化事件
window.addEventListener('playlist-updated', loadPlaylists)
window.addEventListener('playlist-updated', async () => {
await loadPlaylists()
await loadFavorites()
})
})
onUnmounted(() => {
@@ -660,6 +762,14 @@ onUnmounted(() => {
font-size: 10px;
line-height: 1;
}
.source-tag {
background: var(--song-list-source-bg);
color: var(--song-list-source-color);
padding: 1px 4px;
border-radius: 2px;
font-size: 10px;
line-height: 1;
}
}
}
}

View File

@@ -28,6 +28,8 @@ const emit = defineEmits<{
const canvasRef = ref<HTMLCanvasElement>()
const animationId = ref<number>()
const analyser = ref<AnalyserNode>()
// 节流渲染,目标 ~30fps
const lastFrameTime = ref(0)
const dataArray = ref<Uint8Array>()
const resizeObserver = ref<ResizeObserver>()
const componentId = ref<string>(`visualizer-${Date.now()}-${Math.random()}`)
@@ -75,93 +77,87 @@ const initAudioAnalyser = () => {
}
// 绘制可视化
const draw = () => {
const draw = (ts?: number) => {
if (!canvasRef.value || !analyser.value || !dataArray.value) return
// 帧率节流 ~30fps
const now = ts ?? performance.now()
if (now - lastFrameTime.value < 33) {
animationId.value = requestAnimationFrame(draw)
return
}
lastFrameTime.value = now
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
if (!ctx) {
animationId.value = requestAnimationFrame(draw)
return
}
// 获取频域数据或生成模拟数据
if (analyser.value && dataArray.value) {
// 有真实音频分析器,获取真实数据
analyser.value.getByteFrequencyData(dataArray.value as Uint8Array<ArrayBuffer>)
} else {
// 没有音频分析器,生成模拟数据
const time = Date.now() * 0.001
const time = now * 0.001
for (let i = 0; i < dataArray.value.length; i++) {
// 生成基于时间的模拟频谱数据
const frequency = i / dataArray.value.length
const amplitude = Math.sin(time * 2 + frequency * 10) * 0.5 + 0.5
const bass = Math.sin(time * 4) * 0.3 + 0.7 // 低频变化
const bass = Math.sin(time * 4) * 0.3 + 0.7
dataArray.value[i] = Math.floor(amplitude * bass * 255 * (1 - frequency * 0.7))
}
}
// 计算低频音量 (80hz-120hz 范围)
// 假设采样率为 44100HzfftSize 为 256则每个频率 bin 约为 172Hz
// 80-120Hz 大约对应前 1-2 个 bin
const lowFreqStart = 0
const lowFreqEnd = Math.min(3, dataArray.value.length) // 取前几个低频 bin
// 计算低频音量(前 3 个 bin
let lowFreqSum = 0
for (let i = lowFreqStart; i < lowFreqEnd; i++) {
lowFreqSum += dataArray.value[i]
}
const lowFreqVolume = lowFreqSum / (lowFreqEnd - lowFreqStart) / 255
const lowBins = Math.min(3, dataArray.value.length)
for (let i = 0; i < lowBins; i++) lowFreqSum += dataArray.value[i]
emit('lowFreqUpdate', lowFreqSum / lowBins / 255)
// 发送低频音量给父组件
emit('lowFreqUpdate', lowFreqVolume)
// 完全清空画布
// 清屏
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 如果有背景色,再填充背景
// 背景
if (props.backgroundColor !== 'transparent') {
ctx.fillStyle = props.backgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
// 使用容器的实际尺寸进行计算,因为 ctx 已经缩放过了
// 计算尺寸
const container = canvas.parentElement
if (!container) return
if (!container) {
animationId.value = requestAnimationFrame(draw)
return
}
const containerRect = container.getBoundingClientRect()
const canvasWidth = containerRect.width
const canvasHeight = props.height
// 计算对称柱状参数
// 柱状参数
const halfBarCount = Math.floor(props.barCount / 2)
const barWidth = canvasWidth / 2 / halfBarCount
const maxBarHeight = canvasHeight * 0.9
const centerX = canvasWidth / 2
// 绘制左右对称的频谱柱状图
// 每帧仅创建一次渐变(自底向上),减少对象分配
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制对称频谱
for (let i = 0; i < halfBarCount; i++) {
// 增强低频响应,让可视化更敏感
let barHeight = (dataArray.value[i] / 255) * maxBarHeight
// 对数据进行增强处理,让变化更明显
barHeight = Math.pow(barHeight / maxBarHeight, 0.6) * maxBarHeight
const y = canvasHeight - barHeight
// 创建渐变色
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, y)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制左侧柱状图(从中心向左)
const leftX = centerX - (i + 1) * barWidth
ctx.fillRect(leftX, y, barWidth, barHeight)
// 绘制右侧柱状图(从中心向右)
const rightX = centerX + i * barWidth
ctx.fillRect(rightX, y, barWidth, barHeight)
}
// 继续动画
if (props.show && Audio.value.isPlay) {
animationId.value = requestAnimationFrame(draw)
}
@@ -286,6 +282,10 @@ onBeforeUnmount(() => {
analyser.value.disconnect()
analyser.value = undefined
}
// 通知管理器移除对该分析器的引用,防止 Map 持有导致 GC 不回收
try {
audioManager.removeAnalyser(componentId.value)
} catch {}
} catch (error) {
console.warn('清理音频资源时出错:', error)
}

View File

@@ -92,111 +92,211 @@ 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))
console.log(songinfo)
if (isNetease) {
// 网易云音乐优先尝试ttml接口
const source =
props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg'
let parsedLyrics: LyricLine[] = []
if (source === 'wy' || source === 'tx') {
// 网易云 / QQ 音乐:优先尝试 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-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`,
{
signal: abort.signal
})
).text()
if (!active) return
if (!res || res.length < 100) throw new Error('ttml 无歌词')
parsedLyrics = parseTTML(res).lines
console.log('搜索到ttml歌词', parsedLyrics)
} catch {
// ttml失败后使用新的歌词API
// 回退到统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: 'wy',
songInfo: songinfo
source,
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
})
console.log('网易云歌词数据:', lyricData)
if (!active) return
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
console.log('网易云逐字歌词', lyricText)
parsedLyrics = parseYrc(lyricText)
console.log('使用网易云逐字歌词', parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log('使用网易云普通歌词', parsedLyrics)
if (lyricData?.crlyric) {
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log('网易云翻译歌词:', translatedline)
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()
})
console.log(`${source}歌词数据:`, lyricData)
if (!active) return
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
if (source === 'tx') {
parsedLyrics = parseQrc(lyricText)
} else {
parsedLyrics = parseYrc(lyricText)
}
console.log(`使用${source}逐字歌词`, parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log(`使用${source}普通歌词`, parsedLyrics)
if (lyricData?.crlyric) {
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log(`${source}翻译歌词:`, translatedline)
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
console.log('歌词加载成功', parsedLyrics.length)
} else {
state.lyricLines = []
console.log('未找到歌词或解析失败')
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
}
if (!active) return
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
} catch (error) {
console.error('获取歌词失败:', error)
// 若已无效或已清理,避免写入与持有引用
if (!active) return
state.lyricLines = []
}
},
{ immediate: true }
)
// 桌面歌词联动:构建歌词负载、计算当前行并通过 IPC 推送
const buildLyricPayload = (lines: LyricLine[]) =>
(lines || []).map((l) => ({
content: (l.words || []).map((w) => w.word).join(''),
tran: l.translatedLyric || ''
}))
const lastLyricIndex = ref(-1)
const computeLyricIndex = (timeMs: number, lines: LyricLine[]) => {
if (!lines || lines.length === 0) return -1
const t = timeMs
const i = lines.findIndex((l) => t >= l.startTime && t < l.endTime)
if (i !== -1) return i
for (let j = lines.length - 1; j >= 0; j--) {
if (t >= lines[j].startTime) return j
}
return -1
}
// 歌词集合变化时先推一次集合index 为 -1由窗口自行处理占位
watch(
() => state.lyricLines,
(lines) => {
const payload = { index: -1, lyric: buildLyricPayload(lines) }
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload)
},
{ deep: true, immediate: true }
)
// 当前时间变化时,计算当前行并推送
watch(
() => state.currentTime,
(ms) => {
const idx = computeLyricIndex(ms, state.lyricLines)
if (idx !== lastLyricIndex.value) {
lastLyricIndex.value = idx
const payload = { index: idx, lyric: buildLyricPayload(state.lyricLines) }
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload)
}
}
)
// 播放状态推送(用于窗口播放/暂停按钮联动)
watch(
() => Audio.value.isPlay,
(playing) => {
;(window as any)?.electron?.ipcRenderer?.send?.('play-status-change', playing)
},
{ immediate: true }
)
// 歌曲标题推送
watch(
() => props.songInfo,
(info) => {
try {
const name = (info as any)?.name || ''
const artist = (info as any)?.singer || ''
const title = [name, artist].filter(Boolean).join(' - ')
if (title) (window as any)?.electron?.ipcRenderer?.send?.('play-song-change', title)
} catch {}
},
{ immediate: true, deep: true }
)
const bgRef = ref<BackgroundRenderRef | undefined>(undefined)
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
// 订阅音频事件,保持数据同步
const unsubscribeTimeUpdate = ref<(() => void) | undefined>(undefined)
const unsubscribePlay = ref<(() => void) | undefined>(undefined)
@@ -214,24 +314,51 @@ const useBlackText = ref(false)
async function updateTextColor() {
try {
useBlackText.value = await shouldUseBlackText(actualCoverImage.value)
console.log('使用黑色文本:', useBlackText.value)
} catch (error) {
console.error('获取对比色失败:', error)
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 })
// 在全屏播放显示时阻止系统息屏
const blockerActive = ref(false)
watch(
() => props.show,
async (visible) => {
try {
if (visible && !blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.start?.()
blockerActive.value = true
} else if (!visible && blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
blockerActive.value = false
}
} catch (e) {
console.error('powerSaveBlocker 切换失败:', e)
}
},
{ immediate: true }
)
// 组件挂载时初始化
onMounted(() => {
updateTextColor()
console.log('组件挂载完成', bgRef.value, lyricPlayerRef.value)
})
// 组件卸载前清理订阅
onBeforeUnmount(() => {
onBeforeUnmount(async () => {
// 组件卸载时确保恢复系统息屏
if (blockerActive.value) {
try {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
} catch {}
blockerActive.value = false
}
// 取消订阅以防止内存泄漏
if (unsubscribeTimeUpdate.value) {
unsubscribeTimeUpdate.value()
@@ -239,6 +366,8 @@ onBeforeUnmount(() => {
if (unsubscribePlay.value) {
unsubscribePlay.value()
}
bgRef.value?.bgRender?.dispose()
lyricPlayerRef.value?.lyricPlayer?.dispose()
})
// 监听音频URL变化
@@ -299,7 +428,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"
/>
<!-- 全屏按钮 -->
@@ -365,11 +494,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

@@ -1,5 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref, onActivated, onDeactivated } from 'vue'
import {
onMounted,
onUnmounted,
provide,
ref,
onActivated,
onDeactivated,
watch,
nextTick
} from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
const audioStore = ControlAudioStore()
@@ -17,6 +26,26 @@ onMounted(() => {
// window.api.ping(handleEnded)
})
/**
* 监听 URL 变化,先重置旧音频再加载新音频,避免旧解码/缓冲滞留
*/
watch(
() => audioStore.Audio.url,
async (newUrl) => {
const a = audioMeta.value
if (!a) return
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
await nextTick()
// 模板绑定会把 src 更新为 newUrl这里再触发一次 load
if (newUrl) {
a.load()
}
}
)
// 组件被激活时(从缓存中恢复)
onActivated(() => {
console.log('音频组件被激活')
@@ -71,22 +100,29 @@ const handlePlay = (): void => {
audioStore.publish('play')
}
let rafId: number | null = null
const startSetupInterval = (): void => {
if (rafId !== null) return
const onFrame = () => {
if (audioMeta.value && !audioMeta.value.paused) {
audioStore.publish('timeupdate')
audioStore.setCurrentTime((audioMeta.value && audioMeta.value.currentTime) || 0)
requestAnimationFrame(onFrame)
}
rafId = requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)
rafId = requestAnimationFrame(onFrame)
}
const handlePause = (): void => {
audioStore.Audio.isPlay = false
audioStore.publish('pause')
// 停止单实例 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
}
const handleError = (event: Event): void => {
@@ -112,8 +148,23 @@ const handleCanPlay = (): void => {
onUnmounted(() => {
// 组件卸载时清空所有订阅者
window.api.pingService.stop()
try {
window.api.pingService.stop()
} catch {}
// 停止 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
if (audioMeta.value) {
try {
audioMeta.value.pause()
} catch {}
audioMeta.value.removeAttribute('src')
audioMeta.value.load()
}
audioStore.clearAllSubscribers()
})
</script>

View File

@@ -29,13 +29,110 @@ 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, CheckIcon, LockOnIcon } 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)
// 桌面歌词开关与锁定状态
const desktopLyricOpen = ref(false)
const desktopLyricLocked = ref(false)
// 桌面歌词按钮逻辑:
// - 若未打开:打开桌面歌词
// - 若已打开且锁定:先解锁,不关闭
// - 若已打开且未锁定:关闭桌面歌词
const toggleDesktopLyric = async () => {
try {
if (!desktopLyricOpen.value) {
window.electron?.ipcRenderer?.send?.('change-desktop-lyric', true)
desktopLyricOpen.value = true
// 恢复最新锁定状态
const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state')
desktopLyricLocked.value = !!lock
return
}
// 已打开
const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state')
desktopLyricLocked.value = !!lock
if (desktopLyricLocked.value) {
// 先解锁,本次不关闭
window.electron?.ipcRenderer?.send?.('toogleDesktopLyricLock', false)
desktopLyricLocked.value = false
return
}
// 未锁定则关闭
window.electron?.ipcRenderer?.send?.('change-desktop-lyric', false)
desktopLyricOpen.value = false
} catch (e) {
console.error('切换桌面歌词失败:', e)
}
}
// 监听来自主进程的锁定状态广播
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
desktopLyricLocked.value = !!lock
})
// 监听主进程通知关闭桌面歌词
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
desktopLyricOpen.value = false
desktopLyricLocked.value = false
})
window.addEventListener('global-music-control', (e: any) => {
const name = e?.detail?.name
console.log(name);
switch (name) {
case 'play':
handlePlay()
break
case 'pause':
handlePause()
break
case 'playPrev':
playPrevious()
break
case 'playNext':
playNext()
break
}
})
document.addEventListener('keydown', KeyEvent)
// 处理最小化右键的事件
const removeMusicCtrlListener = window.api.onMusicCtrl(() => {
@@ -136,38 +233,45 @@ const playSong = async (song: SongList) => {
// 设置加载状态
isLoadingSong.value = true
// 检查是否需要恢复播放位置历史播放
// 检查是否需要恢复播放位置(历史播放)
const isHistoryPlay =
song.songmid === userInfo.value.lastPlaySongId &&
userInfo.value.currentTime !== undefined &&
userInfo.value.currentTime > 0
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = song.songmid
console.log(`准备恢复播放位置: ${pendingRestorePosition}`)
// 清除历史位置避免重复恢复
// 清除历史位置,避免重复恢复
userInfo.value.currentTime = 0
} else {
pendingRestorePosition = 0
pendingRestoreSongId = null
}
// 更新当前播放歌曲ID
// 立刻暂停当前播放 - 不等待渐变
if (Audio.value.isPlay && Audio.value.audio) {
Audio.value.isPlay = false
Audio.value.audio.pause()
// 恢复音量避免下次播放音量为0
Audio.value.audio.volume = Audio.value.volume / 100
}
// 立刻更新 UI 到新歌曲
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
userInfo.value.lastPlaySongId = song.songmid
// 如果播放列表是打开的滚动到当前播放歌曲
// 如果播放列表是打开的,滚动到当前播放歌曲
if (showPlaylist.value) {
nextTick(() => {
playlistDrawerRef.value?.scrollToCurrentSong()
})
}
// 更新歌曲信息并触发主题色更新
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
// 更新媒体会话元数据
mediaSessionController.updateMetadata({
title: song.name,
@@ -176,68 +280,85 @@ const playSong = async (song: SongList) => {
artworkUrl: song.img || defaultCoverImg
})
// 确保主题色更新
// 尝试获取 URL
let urlToPlay = ''
// 获取URL
// eslint-disable-next-line no-useless-catch
try {
urlToPlay = await getSongRealUrl(toRaw(song))
} catch (error) {
throw error
} catch (error: any) {
console.error('获取歌曲 URL 失败,播放下一首原歌曲:', error)
isLoadingSong.value = false
tryAutoNext('获取歌曲 URL 失败')
return
}
// 先停止当前播放
if (Audio.value.isPlay) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
// 在切换前彻底重置旧音频,释放缓冲与解码器
if (Audio.value.audio) {
const a = Audio.value.audio
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
}
// 设置URL这会触发音频重新加载
// 设置 URL(这会触发音频重新加载)
setUrl(urlToPlay)
// 等待音频准备就绪
await waitForAudioReady()
await setColor()
songInfo.value = {
...song
}
// // 短暂延迟确保音频状态稳定
// await new Promise((resolve) => setTimeout(resolve, 100))
// 开始播放
try {
start()
} catch (error) {
console.error('启动播放失败:', error)
// 如果是 AbortError尝试重新播放
if ((error as { name: string }).name === 'AbortError') {
console.log('检测到 AbortError尝试重新播放...')
await new Promise((resolve) => setTimeout(resolve, 200))
try {
const retryResult = start()
if (retryResult && typeof retryResult.then === 'function') {
await retryResult
}
} catch (retryError) {
console.error('重试播放失败:', retryError)
throw retryError
}
} else {
throw error
}
// 更新完整歌曲信息
songInfo.value = { ...song }
/**
* 提前关闭加载状态
* 这样UI不会卡在“加载中”用户能立刻看到播放键切换
*/
isLoadingSong.value = false
/**
* 异步开始播放不await以免阻塞UI
*/
start()
.catch(async (error: any) => {
console.error('启动播放失败:', error)
tryAutoNext('启动播放失败')
})
.then(() => {
autoNextCount.value = 0
})
/**
* 注册事件监听确保浏览器播放事件触发时同步关闭loading
* (多一道保险)
*/
if (Audio.value.audio) {
Audio.value.audio.addEventListener(
'playing',
() => {
isLoadingSong.value = false
},
{ once: true }
)
Audio.value.audio.addEventListener(
'error',
() => {
isLoadingSong.value = false
},
{ once: true }
)
}
} catch (error: any) {
console.error('播放歌曲失败:', error)
MessagePlugin.error('播放失败,原因:' + error.message)
console.error('播放歌曲失败(外层捕获):', error)
tryAutoNext('播放歌曲失败')
// MessagePlugin.error('播放失败,原因:' + error.message)
isLoadingSong.value = false
} finally {
// 无论成功还是失败,都清除加载状态
// 最后的保险,确保加载状态一定会被关闭
isLoadingSong.value = false
}
}
provide('PlaySong', playSong)
// 歌曲信息
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
@@ -246,6 +367,23 @@ const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
// 歌曲加载状态
const isLoadingSong = ref(false)
// 自动下一首次数限制不超过当前列表的30%
const autoNextCount = ref(0)
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
const tryAutoNext = (reason: string) => {
const limit = getAutoNextLimit()
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
MessagePlugin.error(
`自动下一首失败超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
)
return
}
autoNextCount.value++
playNext()
}
// 更新播放模式
const updatePlayMode = () => {
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
@@ -321,6 +459,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)
@@ -579,6 +730,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)
@@ -823,6 +1054,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>
<!-- 中间播放控制 -->
@@ -868,6 +1129,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" />
@@ -893,6 +1155,29 @@ watch(showFullPlay, (val) => {
</transition>
</div>
<!-- 桌面歌词开关按钮 -->
<t-tooltip
:content="
desktopLyricOpen ? (desktopLyricLocked ? '解锁歌词' : '关闭桌面歌词') : '打开桌面歌词'
"
>
<t-button
class="control-btn lyric-btn"
shape="circle"
variant="text"
:disabled="!songInfo.songmid"
@click.stop="toggleDesktopLyric"
>
<SvgIcon name="lyricOpen" size="18"></SvgIcon>
<transition name="fade" mode="out-in">
<template v-if="desktopLyricOpen">
<LockOnIcon v-if="desktopLyricLocked" key="lock" class="lyric-lock" size="8" />
<CheckIcon v-else key="check" class="lyric-check" size="8" />
</template>
</transition>
</t-button>
</t-tooltip>
<!-- 播放列表按钮 -->
<t-tooltip content="播放列表">
<n-badge :value="list.length" :max="99" color="#bbb">
@@ -1135,6 +1420,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;
@@ -1214,6 +1531,7 @@ watch(showFullPlay, (val) => {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.iconfont {
font-size: 18px;
@@ -1222,6 +1540,17 @@ watch(showFullPlay, (val) => {
&:hover {
color: v-bind(hoverColor);
}
&.lyric-btn .lyric-check,
&.lyric-btn .lyric-lock {
position: absolute;
right: -1px;
bottom: -1px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 2px #fff;
color: v-bind(maincolor);
}
}
}
}

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface LyricOption {
fontSize: number
mainColor: string
shadowColor: string
x: number
y: number
width: number
height: number
}
const loading = ref(false)
const saving = ref(false)
const option = ref<LyricOption>({
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: 0,
y: 0,
width: 800,
height: 180
})
const shadowRgb = ref<{ r: number; g: number; b: number }>({ r: 255, g: 255, b: 255 })
const mainHex = ref<string>('#73BCFC')
const shadowColorStr = ref<string>('rgba(255, 255, 255, 0.5')
const parseColorToRgb = (input: string) => {
const rgbaMatch = input?.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i)
if (rgbaMatch) {
return { r: Number(rgbaMatch[1]), g: Number(rgbaMatch[2]), b: Number(rgbaMatch[3]) }
}
const hexMatch = input?.match(/^#([0-9a-f]{6})$/i)
if (hexMatch) {
const hex = hexMatch[1]
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
}
}
return { r: 255, g: 255, b: 255 }
}
const onMainColorChange = (val: string) => {
mainHex.value = val
option.value.mainColor = val
}
const onShadowColorChange = (val: string) => {
shadowColorStr.value = val
option.value.shadowColor = val
}
const original = ref<LyricOption | null>(null)
const loadOption = async () => {
loading.value = true
try {
const res = await window.electron.ipcRenderer.invoke('get-desktop-lyric-option')
if (res) {
option.value = { ...option.value, ...res }
original.value = { ...option.value }
mainHex.value = option.value.mainColor || '#73BCFC'
shadowColorStr.value = option.value.shadowColor || 'rgba(255,255,255,0.5)'
shadowRgb.value = parseColorToRgb(shadowColorStr.value)
}
} catch (e) {
console.warn('加载桌面歌词配置失败:', e)
} finally {
loading.value = false
}
}
const applyOption = () => {
saving.value = true
try {
// 传入 callback=true 让桌面歌词窗口即时更新
const payload = { ...option.value, shadowColor: shadowColorStr.value }
window.electron.ipcRenderer.send('set-desktop-lyric-option', payload, true)
} finally {
setTimeout(() => (saving.value = false), 200)
}
}
const resetOption = () => {
if (!original.value) return
option.value = { ...original.value }
applyOption()
}
const toggleDesktopLyric = (enabled: boolean) => {
window.electron.ipcRenderer.send('change-desktop-lyric', enabled)
}
onMounted(() => {
loadOption()
})
</script>
<template>
<div class="lyric-style">
<div class="header">
<h3>桌面歌词样式</h3>
<p>自定义桌面歌词的字体大小颜色与阴影效果并可预览与即时应用</p>
</div>
<div class="controls">
<div class="row">
<div class="field">
<label>字体大小(px)</label>
<t-input-number v-model="option.fontSize" :min="12" :max="96" :step="1" />
</div>
<div class="field">
<label>主颜色</label>
<t-color-picker
v-model="mainHex"
:color-modes="['monochrome']"
format="HEX"
@change="onMainColorChange"
/>
</div>
<div class="field">
<label>阴影颜色</label>
<t-color-picker
v-model="shadowColorStr"
:color-modes="['monochrome']"
format="RGBA"
:enable-alpha="true"
@change="onShadowColorChange"
/>
</div>
</div>
<div class="row">
<div class="field">
<label>宽度</label>
<t-input-number v-model="option.width" :min="300" :max="1600" :step="10" />
</div>
<div class="field">
<label>高度</label>
<t-input-number v-model="option.height" :min="100" :max="600" :step="10" />
</div>
</div>
<div class="actions">
<t-button :loading="loading" theme="default" variant="outline" @click="loadOption"
>刷新</t-button
>
<t-button :loading="saving" theme="primary" @click="applyOption">应用到桌面歌词</t-button>
<t-button theme="default" @click="resetOption">还原</t-button>
<t-switch @change="toggleDesktopLyric($event as boolean)">显示桌面歌词</t-switch>
</div>
</div>
<div class="preview">
<div
class="preview-lyric"
:style="{
fontSize: option.fontSize + 'px',
color: mainHex,
textShadow: `0 0 6px ${shadowColorStr}`
}"
>
这是桌面歌词预览
</div>
</div>
</div>
</template>
<style scoped>
.lyric-style {
display: flex;
flex-direction: column;
gap: 16px;
}
.header h3 {
margin: 0 0 6px 0;
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.color-input {
width: 48px;
height: 32px;
padding: 0;
border: 1px solid var(--td-border-level-1-color);
border-radius: var(--td-radius-small);
background: var(--td-bg-color-container);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview {
padding: 16px;
border: 1px dashed var(--td-border-level-1-color);
border-radius: var(--td-radius-medium);
background: var(--settings-preview-bg);
}
.preview-lyric {
text-align: center;
font-weight: 700;
}
</style>

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

@@ -4,6 +4,8 @@ class AudioManager {
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
private audioContexts = new WeakMap<HTMLAudioElement, AudioContext>()
private analysers = new Map<string, AnalyserNode>()
// 为每个 audioElement 复用一个分流器,避免重复断开重连主链路
private splitters = new WeakMap<HTMLAudioElement, GainNode>()
static getInstance(): AudioManager {
if (!AudioManager.instance) {
@@ -60,16 +62,19 @@ class AudioManager {
analyser.fftSize = fftSize
analyser.smoothingTimeConstant = 0.6
// 创建增益节点作为中介,避免直接断开主音频链
const gainNode = context.createGain()
gainNode.gain.value = 1.0
// 复用每个 audioElement 的分流器source -> splitter -> destination
let splitter = this.splitters.get(audioElement)
if (!splitter) {
splitter = context.createGain()
splitter.gain.value = 1.0
// 仅第一次建立主链路,不要断开已有连接,避免累积
source.connect(splitter)
splitter.connect(context.destination)
this.splitters.set(audioElement, splitter)
}
// 连接source -> gainNode -> analyser
// -> destination (保持音频播放)
source.disconnect() // 先断开所有连接
source.connect(gainNode)
gainNode.connect(context.destination) // 确保音频继续播放
gainNode.connect(analyser) // 连接到分析器
// 将分析器挂到分流器上,不影响主链路
splitter.connect(analyser)
// 存储分析器引用
this.analysers.set(id, analyser)
@@ -104,6 +109,15 @@ class AudioManager {
context.close()
}
// 断开并移除分流器
const splitter = this.splitters.get(audioElement)
if (splitter) {
try {
splitter.disconnect()
} catch {}
this.splitters.delete(audioElement)
}
this.audioSources.delete(audioElement)
this.audioContexts.delete(audioElement)

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,44 +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 LocalUserDetail = LocalUserDetailStore()
// 获取歌曲支持的音质列表
const availableQualities = songInfo.types || []
// 检查用户设置的音质是否为特殊音质
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
const availableQualities = buildQualityFormats(songInfo.types || [])
// 展示全部音质,但对超出用户最高音质的项做禁用呈现
const userMaxIndex = QUALITY_ORDER.indexOf(userQuality as KnownQuality)
const qualityOptions = [...availableQualities]
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
if (!hasSpecialQuality) {
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
}
}
// 按音质优先级排序
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: '选择下载音质(可滚动)',
@@ -80,35 +61,48 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
msOverflowStyle: 'none'
}
},
qualityOptions.map((quality) =>
h(
qualityOptions.map((quality) => {
const idx = QUALITY_ORDER.indexOf(quality.type as KnownQuality)
const disabled = userMaxIndex !== -1 && idx !== -1 && idx < userMaxIndex
return h(
'div',
{
key: quality.type,
class: 'quality-item',
title: disabled ? '超出你的最高音质设置,已禁用' : undefined,
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
margin: '8px 0',
border: '1px solid #e7e7e7',
border: '1px solid ' + (disabled ? '#f0f0f0' : '#e7e7e7'),
borderRadius: '6px',
cursor: 'pointer',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
backgroundColor:
quality.type === userQuality ? (disabled ? '#f5faff' : '#e6f7ff') : '#fff',
opacity: disabled ? 0.55 : 1
},
onClick: () => {
if (disabled) return
dialog.destroy()
resolve(quality.type)
},
onMouseenter: (e: MouseEvent) => {
if (disabled) return
const target = e.target as HTMLElement
target.style.backgroundColor = '#f0f9ff'
target.style.borderColor = '#1890ff'
},
onMouseleave: (e: MouseEvent) => {
const target = e.target as HTMLElement
if (disabled) {
target.style.backgroundColor =
quality.type === userQuality ? '#f5faff' : '#fff'
target.style.borderColor = '#f0f0f0'
return
}
target.style.backgroundColor =
quality.type === userQuality ? '#e6f7ff' : '#fff'
target.style.borderColor = '#e7e7e7'
@@ -122,17 +116,22 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
style: {
fontWeight: '500',
fontSize: '14px',
color: quality.type === userQuality ? '#1890ff' : '#333'
color:
quality.type === userQuality
? disabled
? '#8fbfff'
: '#1890ff'
: '#333'
}
},
qualityMap[quality.type] || quality.type
getQualityDisplayName(quality.type)
),
h(
'div',
{
style: {
fontSize: '12px',
color: '#999',
color: disabled ? '#bbb' : '#999',
marginTop: '2px'
}
},
@@ -145,7 +144,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
class: 'quality-size',
style: {
fontSize: '12px',
color: '#666',
color: disabled ? '#999' : '#666',
fontWeight: '500'
}
},
@@ -153,7 +152,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
)
]
)
)
})
)
]
),
@@ -166,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()
@@ -186,68 +186,20 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
return
}
let quality = selectedQuality
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果选择的是特殊音质,先尝试下载
if (isSpecialQuality) {
try {
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const specialResult = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
})
;(await tip).close()
// 如果成功获取特殊音质链接,处理结果并返回
if (specialResult) {
if (!Object.hasOwn(specialResult, 'path')) {
MessagePlugin.info(specialResult.message)
} else {
await NotifyPlugin.success({
title: '下载成功',
content: `${specialResult.message} 保存位置: ${specialResult.path}`
})
}
return
}
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载失败,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
} catch (specialError) {
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载出错,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
}
}
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', {
@@ -255,7 +207,8 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions),
isCache: true
})
;(await tip).close()

View File

@@ -42,38 +42,6 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
const settingsStore = useSettingsStore()
const isCache = settingsStore.settings.autoCacheMusic ?? true
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果是特殊音质,先尝试获取对应链接
if (isSpecialQuality) {
try {
console.log(`尝试获取特殊音质: ${quality} - ${qualityMap[quality]}`)
const specialUrlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality,
isCache
})
// 如果成功获取特殊音质链接,直接返回
if (
typeof specialUrlData === 'string' ||
(typeof specialUrlData === 'object' && !specialUrlData.error)
) {
console.log(`成功获取${qualityMap[quality]}链接`)
return specialUrlData as string
}
console.log(`获取${qualityMap[quality]}链接失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
} catch (specialError) {
console.log(`获取${qualityMap[quality]}链接出错,回退到标准逻辑:`, specialError)
// 特殊音质获取失败,继续执行原有逻辑
}
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
@@ -115,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 {

View File

@@ -18,6 +18,7 @@ import DirectorySettings from '@renderer/components/Settings/DirectorySettings.v
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
import DesktopLyricStyle from '@renderer/components/Settings/DesktopLyricStyle.vue'
import Versions from '@renderer/components/Versions.vue'
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
import { playSetting as usePlaySetting } from '@renderer/store/playSetting'
@@ -419,6 +420,10 @@ const getTagOptionsStatus = () => {
<h3>应用主题色</h3>
<ThemeSelector />
</div>
<div class="setting-group">
<DesktopLyricStyle />
</div>
</div>
<!-- AI 功能设置 -->

541
src/web/lyric.html Normal file
View File

@@ -0,0 +1,541 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>澜音 - 桌面歌词</title>
<style>
* {
margin: 0;
padding: 0;
user-select: none;
box-sizing: border-box;
-webkit-user-drag: none;
}
:root {
--font-size: 30;
--main-color: #73BCFC;
--shadow-color: rgba(255, 255, 255, 0.5);
}
body {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 12px;
cursor: pointer;
color: var(--main-color);
overflow: hidden;
transition: opacity 0.3s;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
z-index: 0;
opacity: 0;
cursor: move;
transition: opacity 0.3s;
}
&:hover {
&::after {
opacity: 1;
}
header {
.meta {
opacity: 0;
}
.tools {
opacity: 1;
}
}
}
&.lock-lyric {
cursor: none;
/* 鼠标穿透 */
pointer-events: none;
* {
pointer-events: none;
}
&::after {
opacity: 0;
}
&:hover {
opacity: 0;
}
header {
.meta {
opacity: 1;
}
.tools {
opacity: 0;
}
}
}
}
header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
.meta {
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
opacity: 0.9;
transition: opacity 0.3s;
}
.tools {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
opacity: 0;
transition: opacity 0.3s;
gap: 8px;
.item {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
cursor: pointer;
transition:
transform 0.3s,
background-color 0.3s;
&.hidden {
display: none;
}
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.95);
}
svg {
width: 24px;
height: 24px;
}
}
}
#song-artist {
margin-top: 4px;
font-size: 12px;
opacity: 0.8;
}
}
main {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12px;
margin: 12px;
z-index: 1;
max-width: 100%;
pointer-events: auto;
#lyric-text {
font-size: calc(var(--font-size) * 1px);
font-weight: bold;
}
#lyric-tran {
font-size: calc(var(--font-size) * 1px - 5px);
margin-top: 8px;
opacity: 0.6;
}
}
span {
padding: 0 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 4px var(--shadow-color);
transition: opacity 0.3s;
/* animation: 15s wordsLoop linear infinite normal; */
}
@keyframes wordsLoop {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-100%);
}
}
</style>
</head>
<body>
<header>
<div class="meta">
<span id="song-name">CeruMusic澜音</span>
<span id="song-artist">未知艺术家</span>
</div>
<div class="tools" id="tools">
<div id="show-app" class="item" title="打开应用">
<svg
width="1200"
height="1200"
viewBox="0 0 1200 1200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="1200" height="1200" rx="379" fill="white" />
<path
d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z"
fill="url(#paint0_linear_4_16)"
stroke="#29293A"
stroke-opacity="0.23"
/>
<defs>
<linearGradient
id="paint0_linear_4_16"
x1="678.412"
y1="-1151.29"
x2="796.511"
y2="832.071"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.572115" stop-color="#B8F1ED" />
<stop offset="0.9999" stop-color="#B8F1CC" />
</linearGradient>
</defs>
</svg>
</div>
<div id="font-size-reduce" class="item" title="缩小字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.5 7h-2L3 21h2.2l1.1-3h6.2l1.1 3H16zm-3.4 9l2.4-6.3l2.4 6.3zM22 7h-8V5h8z"
/>
</svg>
</div>
<div id="font-size-add" class="item" title="放大字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8.5 7h2L16 21h-2.4l-1.1-3H6.3l-1.1 3H3zm-1.4 9h4.8L9.5 9.7zM22 5v2h-3v3h-2V7h-3V5h3V2h2v3z"
/>
</svg>
</div>
<div id="play-prev" class="item" title="上一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M7 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1m3.66 6.82l5.77 4.07c.66.47 1.58-.01 1.58-.82V7.93c0-.81-.91-1.28-1.58-.82l-5.77 4.07a1 1 0 0 0 0 1.64"
/>
</svg>
</div>
<!-- 播放暂停 -->
<div id="pause" class="item hidden" title="暂停">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2m6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2"
/>
</svg>
</div>
<div id="play" class="item" title="播放">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18a1 1 0 0 0 0-1.69L9.54 5.98A.998.998 0 0 0 8 6.82"
/>
</svg>
</div>
<div id="play-next" class="item" title="下一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m7.58 16.89l5.77-4.07c.56-.4.56-1.24 0-1.63L7.58 7.11C6.91 6.65 6 7.12 6 7.93v8.14c0 .81.91 1.28 1.58.82M16 7v10c0 .55.45 1 1 1s1-.45 1-1V7c0-.55-.45-1-1-1s-1 .45-1 1"
/>
</svg>
</div>
<!-- 锁定 -->
<div id="lock-lyric" class="item" title="锁定/解锁">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2M9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9zm9 14H6V10h12zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2"
/>
</svg>
</div>
<!-- 关闭 -->
<div id="close-lyric" class="item" title="关闭">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z"
/>
</svg>
</div>
</div>
</header>
<main id="lyric-content">
<span id="lyric-text">该歌曲暂无歌词</span>
<span id="lyric-tran"></span>
</main>
<script>
class LyricsWindow {
constructor() {
// 获取元素
this.songNameDom = document.getElementById('song-name')
this.songArtistDom = document.getElementById('song-artist')
this.lyricContentDom = document.getElementById('lyric-content')
this.lyricTextDom = document.getElementById('lyric-text')
this.lyricTranDom = document.getElementById('lyric-tran')
this.pauseDom = document.getElementById('pause')
this.playDom = document.getElementById('play')
// 窗口位置
this.isDragging = false
this.startX = 0
this.startY = 0
this.startWinX = 0
this.startWinY = 0
this.winWidth = 0
this.winHeight = 0
// 临时变量
// this.lyricIndex = -1;
// 初始化
this.restoreOptions()
this.menuClick()
this.setupIPCListeners()
this.setupWindowDragListeners()
this.setupMutationObserver()
}
// 歌词切换动画
updateLyrics(content = '纯音乐,请欣赏', translation = '') {
// document.startViewTransition(() => {
// this.lyricTextDom.innerHTML = content;
// this.lyricTranDom.innerHTML = translation;
// });
this.lyricTextDom.innerHTML = content
this.lyricTranDom.innerHTML = translation
}
// 获取配置
async restoreOptions() {
try {
const defaultOptions = await window.electron.ipcRenderer.invoke(
'get-desktop-lyric-option'
)
if (defaultOptions) this.changeOptions(defaultOptions)
return defaultOptions
} catch (error) {
console.error('Failed to restore options:', error)
}
}
// 修改配置
changeOptions(options, callback = true) {
if (!options) return
const { fontSize, mainColor, shadowColor } = options
document.documentElement.style.setProperty('--font-size', fontSize)
document.documentElement.style.setProperty('--main-color', mainColor)
document.documentElement.style.setProperty('--shadow-color', shadowColor)
if (callback) window.electron.ipcRenderer.send('set-desktop-lyric-option', options)
}
// 菜单点击事件
menuClick() {
const toolsDom = document.getElementById('tools')
if (!toolsDom) return
// 菜单项点击
toolsDom.addEventListener('click', async (event) => {
const target = event.target.closest('div')
if (!target) return
console.log(target)
const id = target.id
if (!id) return
// 获取配置
const options = await this.restoreOptions()
switch (id) {
case 'show-app': {
window.electron.ipcRenderer.send('win-show')
break
}
case 'font-size-add': {
let fontSize = options.fontSize
if (fontSize < 60) {
fontSize++
this.changeOptions({ ...options, fontSize })
}
break
}
case 'font-size-reduce': {
let fontSize = options.fontSize
if (fontSize > 10) {
fontSize--
this.changeOptions({ ...options, fontSize })
}
break
}
case 'play': {
window.electron.ipcRenderer.send('send-main-event', 'play')
break
}
case 'pause': {
window.electron.ipcRenderer.send('send-main-event', 'pause')
break
}
case 'play-prev': {
window.electron.ipcRenderer.send('send-main-event', 'playPrev')
break
}
case 'play-next': {
window.electron.ipcRenderer.send('send-main-event', 'playNext')
break
}
case 'close-lyric': {
window.electron.ipcRenderer.send('closeDesktopLyric')
break
}
case 'lock-lyric': {
const locked = !document.body.classList.contains('lock-lyric')
document.body.classList.toggle('lock-lyric', locked)
window.electron.ipcRenderer.send('toogleDesktopLyricLock', locked)
break
}
default:
break
}
})
}
// 监听 IPC 事件
setupIPCListeners() {
window.electron.ipcRenderer.on('play-song-change', (_, title) => {
if (!title) return
const [songName, songArtist] = title.split(' - ')
this.songNameDom.innerHTML = songName
this.songArtistDom.innerHTML = songArtist
this.updateLyrics(title)
})
window.electron.ipcRenderer.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
this.parsedLyricsData(lyricData)
})
window.electron.ipcRenderer.on('play-status-change', (_, status) => {
this.playDom.classList.toggle('hidden', status)
this.pauseDom.classList.toggle('hidden', !status)
})
// 配置变化
window.electron.ipcRenderer.on('desktop-lyric-option-change', (_, options) => {
this.changeOptions(options, false)
})
// 歌词锁定(仅更新样式,不再回传,避免事件循环)
window.electron.ipcRenderer.on('toogleDesktopLyricLock', (_, lock) => {
document.body.classList.toggle('lock-lyric', lock)
})
}
// 解析歌词
parsedLyricsData(lyricData) {
if (!this.lyricContentDom || !this.lyricTextDom) return
const { index, lyric } = lyricData
// 更换文字
if (!lyric || index < 0) {
if (lyric.length === 0) this.updateLyrics()
} else {
const { content, tran } = lyric[index]
this.updateLyrics(content, tran || '')
}
}
// 拖拽窗口
setupWindowDragListeners() {
document.addEventListener('mousedown', this.startDrag.bind(this))
document.addEventListener('mousemove', this.dragWindow.bind(this))
document.addEventListener('mouseup', this.endDrag.bind(this))
}
// 开始拖拽
async startDrag(event) {
this.isDragging = true
const { screenX, screenY } = event
const {
x: winX,
y: winY,
width,
height
} = await window.electron.ipcRenderer.invoke('get-window-bounds')
this.startX = screenX
this.startY = screenY
this.startWinX = winX
this.startWinY = winY
this.winWidth = width
this.winHeight = height
}
// 拖拽
async dragWindow(event) {
if (!this.isDragging) return
const { screenX, screenY } = event
let newWinX = this.startWinX + (screenX - this.startX)
let newWinY = this.startWinY + (screenY - this.startY)
const { width: screenWidth, height: screenHeight } =
await window.electron.ipcRenderer.invoke('get-screen-size')
newWinX = Math.max(0, Math.min(screenWidth - this.winWidth, newWinX))
newWinY = Math.max(0, Math.min(screenHeight - this.winHeight, newWinY))
window.electron.ipcRenderer.send(
'move-window',
newWinX,
newWinY,
this.winWidth,
this.winHeight
)
}
// 结束拖拽
endDrag() {
this.isDragging = false
}
// 更新高度
updateWindowHeight() {
const bodyHeight = document.body.scrollHeight
window.electron.ipcRenderer.send('update-window-height', bodyHeight)
}
// 动态监听高度
setupMutationObserver() {
const observer = new MutationObserver(this.updateWindowHeight.bind(this))
observer.observe(document.body, { childList: true, subtree: true, attributes: true })
this.updateWindowHeight()
}
}
new LyricsWindow()
</script>
</body>
</html>