mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 03:15:07 +08:00
feat: 搜索联想 fix: 网易云歌单导入数量限制1000的问题
This commit is contained in:
@@ -1,197 +0,0 @@
|
||||
# 音乐API接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
这是一个基于 Meting 库的音乐API接口,支持多个音乐平台的数据获取,包括歌曲信息、专辑、歌词、播放链接等。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **请求方式**: GET
|
||||
- **返回格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
- **跨域支持**: 是
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ------ | ---- | ------- | -------------- |
|
||||
| server | string | 否 | netease | 音乐平台 |
|
||||
| type | string | 否 | search | 请求类型 |
|
||||
| id | string | 否 | hello | 查询ID或关键词 |
|
||||
|
||||
### 支持的音乐平台 (server)
|
||||
|
||||
| 平台代码 | 平台名称 |
|
||||
| -------- | ---------- |
|
||||
| netease | 网易云音乐 |
|
||||
| tencent | QQ音乐 |
|
||||
| baidu | 百度音乐 |
|
||||
| xiami | 虾米音乐 |
|
||||
| kugou | 酷狗音乐 |
|
||||
| kuwo | 酷我音乐 |
|
||||
|
||||
### 支持的请求类型 (type)
|
||||
|
||||
| 类型 | 说明 | id参数说明 |
|
||||
| -------- | ------------ | ---------------- |
|
||||
| search | 搜索歌曲 | 搜索关键词 |
|
||||
| song | 获取歌曲详情 | 歌曲ID |
|
||||
| album | 获取专辑信息 | 专辑ID |
|
||||
| artist | 获取歌手信息 | 歌手ID |
|
||||
| playlist | 获取歌单信息 | 歌单ID |
|
||||
| lrc | 获取歌词 | 歌曲ID |
|
||||
| url | 获取播放链接 | 歌曲ID |
|
||||
| pic | 获取封面图片 | 歌曲/专辑/歌手ID |
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": {
|
||||
// 具体数据内容,根据请求类型不同而不同
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
## 请求示例
|
||||
|
||||
### 1. 搜索歌曲
|
||||
|
||||
```
|
||||
GET /?server=netease&type=search&id=周杰伦
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": [
|
||||
{
|
||||
"id": "186016",
|
||||
"name": "青花瓷",
|
||||
"artist": ["周杰伦"],
|
||||
"album": "我很忙",
|
||||
"pic_id": "109951163240682406",
|
||||
"url_id": "186016",
|
||||
"lyric_id": "186016"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取歌曲详情
|
||||
|
||||
```
|
||||
GET /?server=netease&type=song&id=186016
|
||||
```
|
||||
|
||||
### 3. 获取歌词
|
||||
|
||||
```
|
||||
GET /?server=netease&type=lrc&id=186016
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": {
|
||||
"lyric": "[00:00.00] 作词 : 方文山\n[00:01.00] 作曲 : 周杰伦\n[00:22.78]素胚勾勒出青花笔锋浓转淡\n..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 获取播放链接
|
||||
|
||||
```
|
||||
GET /?server=netease&type=url&id=186016
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": [
|
||||
{
|
||||
"id": "186016",
|
||||
"url": "http://music.163.com/song/media/outer/url?id=186016.mp3",
|
||||
"size": 4729252,
|
||||
"br": 128
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取专辑信息
|
||||
|
||||
```
|
||||
GET /?server=netease&type=album&id=18905
|
||||
```
|
||||
|
||||
### 6. 获取歌手信息
|
||||
|
||||
```
|
||||
GET /?server=netease&type=artist&id=6452
|
||||
```
|
||||
|
||||
### 7. 获取歌单信息
|
||||
|
||||
```
|
||||
GET /?server=netease&type=playlist&id=19723756
|
||||
```
|
||||
|
||||
### 8. 获取封面图片
|
||||
|
||||
```
|
||||
GET /?server=netease&type=pic&id=186016
|
||||
```
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误信息 | 说明 |
|
||||
| ------------------- | ---------------- |
|
||||
| require id. | 缺少必需的id参数 |
|
||||
| unsupported server. | 不支持的音乐平台 |
|
||||
| unsupported type. | 不支持的请求类型 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **代理支持**: 如果设置了环境变量 `METING_PROXY`,API会使用代理访问音乐平台
|
||||
2. **Cookie支持**: API会自动传递请求中的Cookie到音乐平台
|
||||
3. **跨域访问**: API已配置CORS,支持跨域请求
|
||||
4. **请求频率**: 建议控制请求频率,避免被音乐平台限制
|
||||
5. **数据时效性**: 音乐平台的数据可能会发生变化,建议适当缓存但不要过度依赖
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. **错误处理**: 请务必检查响应中的 `success` 字段
|
||||
2. **数据验证**: 返回的数据结构可能因平台而异,请做好数据验证
|
||||
3. **备用方案**: 建议支持多个音乐平台作为备用数据源
|
||||
4. **缓存策略**: 对于不经常变化的数据(如歌词、专辑信息)建议进行缓存
|
||||
|
||||
## 技术实现
|
||||
|
||||
本API基于以下技术栈:
|
||||
|
||||
- **PHP**: 后端语言
|
||||
- **Meting**: 音乐数据获取库
|
||||
- **Composer**: 依赖管理
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **v1.0.0**: 初始版本,支持基础的音乐数据获取功能
|
||||
@@ -3,7 +3,7 @@
|
||||
## 基础使用
|
||||
|
||||
1. 默认情况下,播放 **「搜索」「歌单」** 双击中的歌曲时,会自动将该歌曲添加到 **「列表」** 中的末尾后并不会播放,这与手动将歌曲添加到 **「列表」** 等价,亦或是通过点击歌曲前面小三角播放<img src="./assets/image-20250916132248046.png" alt="image-20250916132248046" style="float:right" />可以添加到歌曲开头也可进行该歌曲添加到 **「列表」** 中的开头并播放。歌曲会按照你选择的播放顺序进行播放。
|
||||
2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**<img src="./assets/image-20250916133531421.png" alt="image-20250916133531421" style="zoom: 20%;" />
|
||||
2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**<img src="./assets/image-20250916133531421.png" alt="image-20250916133531421" style="zoom: 50%;" />
|
||||
|
||||
## 歌曲列表的导出和分享
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
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({
|
||||
@@ -37,13 +38,20 @@ export default defineConfig({
|
||||
library: 'vue-next'
|
||||
})
|
||||
],
|
||||
imports: [
|
||||
'vue',
|
||||
{
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
|
||||
}
|
||||
],
|
||||
dts: true
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
TDesignResolver({
|
||||
library: 'vue-next'
|
||||
})
|
||||
}),
|
||||
NaiveUiResolver()
|
||||
],
|
||||
dts: true
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.3.12",
|
||||
"version": "1.3.13",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
@@ -71,6 +71,7 @@
|
||||
"node-fetch": "2",
|
||||
"node-id3": "^0.2.9",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"tdesign-icons-vue-next": "^0.4.1",
|
||||
"tdesign-vue-next": "^1.15.2",
|
||||
"vue-router": "^4.5.1",
|
||||
@@ -88,12 +89,14 @@
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"electron": "^38.1.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^4.0.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-vue": "^10.3.0",
|
||||
"naive-ui": "^2.43.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass-embedded": "^1.90.0",
|
||||
"scss": "^0.2.4",
|
||||
|
||||
@@ -24,3 +24,15 @@ export function request<T extends keyof MainApi>(
|
||||
}
|
||||
}
|
||||
ipcMain.handle('service-music-sdk-request', request)
|
||||
|
||||
// 处理搜索联想请求
|
||||
ipcMain.handle('service-music-tip-search', async (_, source, keyword) => {
|
||||
try {
|
||||
if (!source) throw new Error('请配置音源')
|
||||
const Api = main(source)
|
||||
return await Api.tipSearch({ keyword })
|
||||
} catch (error: any) {
|
||||
console.error('搜索联想错误:', error)
|
||||
return { result: { songs: [], order: ['songs'] } }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,427 +7,13 @@ import {
|
||||
PlaylistResult,
|
||||
GetSongListDetailsArg,
|
||||
PlaylistDetailResult,
|
||||
DownloadSingleSongArgs
|
||||
DownloadSingleSongArgs,
|
||||
TipSearchResult
|
||||
} from './type'
|
||||
import pluginService from '../plugin/index'
|
||||
import musicSdk from '../../utils/musicSdk/index'
|
||||
import { musicCacheService } from '../musicCache'
|
||||
import path from 'node:path'
|
||||
import fs from 'fs'
|
||||
import fsPromise from 'fs/promises'
|
||||
import axios from 'axios'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { configManager } from '../ConfigManager'
|
||||
import NodeID3 from 'node-id3'
|
||||
import ffmpegStatic from 'ffmpeg-static'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
|
||||
const fileLock: Record<string, boolean> = {}
|
||||
|
||||
/**
|
||||
* 转换LRC格式
|
||||
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
|
||||
* @param lrcContent 原始LRC内容
|
||||
* @returns 转换后的LRC内容
|
||||
*/
|
||||
function convertLrcFormat(lrcContent: string): string {
|
||||
if (!lrcContent) return ''
|
||||
|
||||
const lines = lrcContent.split('\n')
|
||||
const convertedLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过空行
|
||||
if (!line.trim()) {
|
||||
convertedLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
|
||||
if (newFormatMatch) {
|
||||
const [, startTimeMs, , content] = newFormatMatch
|
||||
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
|
||||
convertedLines.push(convertedLine)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
|
||||
if (oldFormatMatch) {
|
||||
const [, timestamp, content] = oldFormatMatch
|
||||
|
||||
// 如果内容中没有位置信息,直接返回原行
|
||||
if (!content.includes('(') || !content.includes(')')) {
|
||||
convertedLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const convertedLine = convertOldFormat(timestamp, content)
|
||||
convertedLines.push(convertedLine)
|
||||
continue
|
||||
}
|
||||
|
||||
// 其他行直接保留
|
||||
convertedLines.push(line)
|
||||
}
|
||||
|
||||
return convertedLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
|
||||
* @param timeMs 毫秒时间戳
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
function formatTimestamp(timeMs: number): string {
|
||||
const minutes = Math.floor(timeMs / 60000)
|
||||
const seconds = Math.floor((timeMs % 60000) / 1000)
|
||||
const milliseconds = timeMs % 1000
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||
*/
|
||||
function convertNewFormat(baseTimeMs: number, content: string): string {
|
||||
const baseTimestamp = formatTimestamp(baseTimeMs)
|
||||
let convertedContent = `<${baseTimestamp}>`
|
||||
|
||||
// 匹配模式:(开始时间,字符持续时间,0)字符
|
||||
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
|
||||
let match
|
||||
let isFirstChar = true
|
||||
|
||||
while ((match = charPattern.exec(content)) !== null) {
|
||||
const [, charStartMs, , , char] = match
|
||||
const charTimeMs = parseInt(charStartMs)
|
||||
const charTimestamp = formatTimestamp(charTimeMs)
|
||||
|
||||
if (isFirstChar) {
|
||||
// 第一个字符直接添加
|
||||
convertedContent += char.trim()
|
||||
isFirstChar = false
|
||||
} else {
|
||||
convertedContent += `<${charTimestamp}>${char.trim()}`
|
||||
}
|
||||
}
|
||||
|
||||
return `[${baseTimestamp}]${convertedContent}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||
*/
|
||||
function convertOldFormat(timestamp: string, content: string): string {
|
||||
// 解析基础时间戳(毫秒)
|
||||
const [minutes, seconds] = timestamp.split(':')
|
||||
const [sec, ms] = seconds.split('.')
|
||||
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
|
||||
|
||||
let convertedContent = `<${timestamp}>`
|
||||
|
||||
// 匹配所有字符(偏移,持续时间)的模式
|
||||
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
let isFirstChar = true
|
||||
|
||||
while ((match = charPattern.exec(content)) !== null) {
|
||||
const [fullMatch, char, offsetMs, _durationMs] = match
|
||||
const charTimeMs = baseTimeMs + parseInt(offsetMs)
|
||||
const charTimestamp = formatTimestamp(charTimeMs)
|
||||
|
||||
// 添加匹配前的普通文本
|
||||
if (match.index > lastIndex) {
|
||||
const beforeText = content.substring(lastIndex, match.index)
|
||||
if (beforeText.trim()) {
|
||||
convertedContent += beforeText
|
||||
}
|
||||
}
|
||||
|
||||
// 添加带时间戳的字符
|
||||
if (isFirstChar) {
|
||||
// 第一个字符直接添加,不需要额外的时间戳
|
||||
convertedContent += char
|
||||
isFirstChar = false
|
||||
} else {
|
||||
convertedContent += `<${charTimestamp}>${char}`
|
||||
}
|
||||
lastIndex = match.index + fullMatch.length
|
||||
}
|
||||
|
||||
// 添加剩余的普通文本
|
||||
if (lastIndex < content.length) {
|
||||
const remainingText = content.substring(lastIndex)
|
||||
if (remainingText.trim()) {
|
||||
convertedContent += remainingText
|
||||
}
|
||||
}
|
||||
|
||||
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 或其他工具实现
|
||||
}
|
||||
import download from '../../utils/downloadSongs'
|
||||
|
||||
function main(source: string) {
|
||||
const Api = musicSdk[source]
|
||||
@@ -436,6 +22,14 @@ function main(source: string) {
|
||||
return (await Api.musicSearch.search(keyword, page, limit)) as Promise<SearchResult>
|
||||
},
|
||||
|
||||
async tipSearch({ keyword }: { keyword: string }) {
|
||||
if (!Api.tipSearch?.tipSearchBySong) {
|
||||
// 如果音乐源没有实现tipSearch方法,返回空结果
|
||||
return [] as TipSearchResult
|
||||
}
|
||||
return (await Api.tipSearch.search(keyword)) as Promise<TipSearchResult>
|
||||
},
|
||||
|
||||
async getMusicUrl({ pluginId, songInfo, quality }: GetMusicUrlArg) {
|
||||
try {
|
||||
const usePlugin = pluginService.getPluginById(pluginId)
|
||||
@@ -506,93 +100,7 @@ function main(source: string) {
|
||||
}: DownloadSingleSongArgs) {
|
||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||
|
||||
// 获取自定义下载目录
|
||||
const getDownloadDirectory = (): string => {
|
||||
// 使用配置管理服务获取下载目录
|
||||
const directories = configManager.getDirectories()
|
||||
return directories.downloadDir
|
||||
}
|
||||
|
||||
// 从URL中提取文件扩展名,如果没有则默认为mp3
|
||||
const getFileExtension = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const lastDotIndex = pathname.lastIndexOf('.')
|
||||
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
|
||||
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
|
||||
// 验证是否为常见的音频格式
|
||||
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
|
||||
if (validExtensions.includes(extension)) {
|
||||
return extension
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('解析URL失败,使用默认扩展名:', error)
|
||||
}
|
||||
return 'mp3' // 默认扩展名
|
||||
}
|
||||
|
||||
const fileExtension = getFileExtension(url)
|
||||
const downloadDir = getDownloadDirectory()
|
||||
const songPath = path.join(
|
||||
downloadDir,
|
||||
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
|
||||
.replace(/[/\\:*?"<>|]/g, '')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
)
|
||||
|
||||
if (fileLock[songPath]) {
|
||||
throw new Error('歌曲正在下载中')
|
||||
} else {
|
||||
fileLock[songPath] = true
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(songPath)) {
|
||||
return {
|
||||
message: '歌曲已存在'
|
||||
}
|
||||
}
|
||||
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
|
||||
|
||||
if (url.startsWith('file://')) {
|
||||
const filePath = fileURLToPath(url)
|
||||
|
||||
const readStream = fs.createReadStream(filePath)
|
||||
const writeStream = fs.createWriteStream(songPath)
|
||||
|
||||
await pipeline(readStream, writeStream)
|
||||
} else {
|
||||
const songDataRes = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream'
|
||||
})
|
||||
|
||||
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
|
||||
}
|
||||
} finally {
|
||||
delete fileLock[songPath]
|
||||
}
|
||||
|
||||
// 写入标签信息
|
||||
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||
try {
|
||||
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
||||
} catch (error) {
|
||||
console.warn('写入音频标签失败:', error)
|
||||
throw ffmpegStatic
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: '下载成功',
|
||||
path: songPath
|
||||
}
|
||||
return await download(url, songInfo, tagWriteOptions)
|
||||
},
|
||||
|
||||
async parsePlaylistId({ url }: { url: string }) {
|
||||
|
||||
@@ -100,3 +100,6 @@ export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
||||
path?: string
|
||||
tagWriteOptions?: TagWriteOptions
|
||||
}
|
||||
|
||||
// 搜索联想结果的类型定义
|
||||
export type TipSearchResult = string[]
|
||||
|
||||
508
src/main/utils/downloadSongs.ts
Normal file
508
src/main/utils/downloadSongs.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import NodeID3 from 'node-id3'
|
||||
import ffmpegStatic from 'ffmpeg-static'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
import path from 'node:path'
|
||||
import axios from 'axios'
|
||||
import fs from 'fs'
|
||||
import fsPromise from 'fs/promises'
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const fileLock: Record<string, boolean> = {}
|
||||
|
||||
/**
|
||||
* 转换LRC格式
|
||||
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
|
||||
* @param lrcContent 原始LRC内容
|
||||
* @returns 转换后的LRC内容
|
||||
*/
|
||||
function convertLrcFormat(lrcContent: string): string {
|
||||
if (!lrcContent) return ''
|
||||
|
||||
const lines = lrcContent.split('\n')
|
||||
const convertedLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过空行
|
||||
if (!line.trim()) {
|
||||
convertedLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
|
||||
if (newFormatMatch) {
|
||||
const [, startTimeMs, , content] = newFormatMatch
|
||||
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
|
||||
convertedLines.push(convertedLine)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
|
||||
if (oldFormatMatch) {
|
||||
const [, timestamp, content] = oldFormatMatch
|
||||
|
||||
// 如果内容中没有位置信息,直接返回原行
|
||||
if (!content.includes('(') || !content.includes(')')) {
|
||||
convertedLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const convertedLine = convertOldFormat(timestamp, content)
|
||||
convertedLines.push(convertedLine)
|
||||
continue
|
||||
}
|
||||
|
||||
// 其他行直接保留
|
||||
convertedLines.push(line)
|
||||
}
|
||||
|
||||
return convertedLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
|
||||
* @param timeMs 毫秒时间戳
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
function formatTimestamp(timeMs: number): string {
|
||||
const minutes = Math.floor(timeMs / 60000)
|
||||
const seconds = Math.floor((timeMs % 60000) / 1000)
|
||||
const milliseconds = timeMs % 1000
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
|
||||
*/
|
||||
function convertNewFormat(baseTimeMs: number, content: string): string {
|
||||
const baseTimestamp = formatTimestamp(baseTimeMs)
|
||||
let convertedContent = `<${baseTimestamp}>`
|
||||
|
||||
// 匹配模式:(开始时间,字符持续时间,0)字符
|
||||
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
|
||||
let match
|
||||
let isFirstChar = true
|
||||
|
||||
while ((match = charPattern.exec(content)) !== null) {
|
||||
const [, charStartMs, , , char] = match
|
||||
const charTimeMs = parseInt(charStartMs)
|
||||
const charTimestamp = formatTimestamp(charTimeMs)
|
||||
|
||||
if (isFirstChar) {
|
||||
// 第一个字符直接添加
|
||||
convertedContent += char.trim()
|
||||
isFirstChar = false
|
||||
} else {
|
||||
convertedContent += `<${charTimestamp}>${char.trim()}`
|
||||
}
|
||||
}
|
||||
|
||||
return `[${baseTimestamp}]${convertedContent}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
|
||||
*/
|
||||
function convertOldFormat(timestamp: string, content: string): string {
|
||||
// 解析基础时间戳(毫秒)
|
||||
const [minutes, seconds] = timestamp.split(':')
|
||||
const [sec, ms] = seconds.split('.')
|
||||
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
|
||||
|
||||
let convertedContent = `<${timestamp}>`
|
||||
|
||||
// 匹配所有字符(偏移,持续时间)的模式
|
||||
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
let isFirstChar = true
|
||||
|
||||
while ((match = charPattern.exec(content)) !== null) {
|
||||
const [fullMatch, char, offsetMs, _durationMs] = match
|
||||
const charTimeMs = baseTimeMs + parseInt(offsetMs)
|
||||
const charTimestamp = formatTimestamp(charTimeMs)
|
||||
|
||||
// 添加匹配前的普通文本
|
||||
if (match.index > lastIndex) {
|
||||
const beforeText = content.substring(lastIndex, match.index)
|
||||
if (beforeText.trim()) {
|
||||
convertedContent += beforeText
|
||||
}
|
||||
}
|
||||
|
||||
// 添加带时间戳的字符
|
||||
if (isFirstChar) {
|
||||
// 第一个字符直接添加,不需要额外的时间戳
|
||||
convertedContent += char
|
||||
isFirstChar = false
|
||||
} else {
|
||||
convertedContent += `<${charTimestamp}>${char}`
|
||||
}
|
||||
lastIndex = match.index + fullMatch.length
|
||||
}
|
||||
|
||||
// 添加剩余的普通文本
|
||||
if (lastIndex < content.length) {
|
||||
const remainingText = content.substring(lastIndex)
|
||||
if (remainingText.trim()) {
|
||||
convertedContent += remainingText
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timestamp}]${convertedContent}`
|
||||
}
|
||||
|
||||
// 写入音频标签的辅助函数
|
||||
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||
try {
|
||||
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
|
||||
|
||||
// 获取文件扩展名来判断格式
|
||||
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
|
||||
console.log('文件格式:', fileExtension)
|
||||
|
||||
// 根据文件格式选择不同的标签写入方法
|
||||
if (fileExtension === 'mp3') {
|
||||
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
|
||||
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
|
||||
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
|
||||
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
|
||||
} else {
|
||||
console.warn('不支持的音频格式:', fileExtension)
|
||||
// 尝试使用 NodeID3 作为后备方案
|
||||
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('写入音频标签时发生错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MP3 格式标签写入
|
||||
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||
const tags: any = {}
|
||||
|
||||
// 写入基础信息
|
||||
if (tagWriteOptions.basicInfo) {
|
||||
tags.title = songInfo.name || ''
|
||||
tags.artist = songInfo.singer || ''
|
||||
tags.album = songInfo.albumName || ''
|
||||
tags.year = songInfo.year || ''
|
||||
tags.genre = songInfo.genre || ''
|
||||
}
|
||||
|
||||
// 写入歌词
|
||||
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||
tags.unsynchronisedLyrics = {
|
||||
language: 'chi',
|
||||
shortText: 'Lyrics',
|
||||
text: convertedLrc
|
||||
}
|
||||
}
|
||||
|
||||
// 写入封面
|
||||
if (tagWriteOptions.cover && songInfo.img) {
|
||||
try {
|
||||
const coverResponse = await axios({
|
||||
method: 'GET',
|
||||
url: songInfo.img,
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
if (coverResponse.data) {
|
||||
tags.image = {
|
||||
mime: 'image/jpeg',
|
||||
type: {
|
||||
id: 3,
|
||||
name: 'front cover'
|
||||
},
|
||||
description: 'Cover',
|
||||
imageBuffer: Buffer.from(coverResponse.data)
|
||||
}
|
||||
}
|
||||
} catch (coverError) {
|
||||
console.warn('获取封面失败:', coverError)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入标签到文件
|
||||
if (Object.keys(tags).length > 0) {
|
||||
const success = NodeID3.write(tags, filePath)
|
||||
if (success) {
|
||||
console.log('MP3音频标签写入成功:', filePath)
|
||||
} else {
|
||||
console.warn('MP3音频标签写入失败:', filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
|
||||
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
|
||||
try {
|
||||
console.log('开始写入 FLAC 标签:', filePath)
|
||||
|
||||
// 准备新的标签数据
|
||||
const newTags: any = {}
|
||||
|
||||
// 写入基础信息
|
||||
if (tagWriteOptions.basicInfo) {
|
||||
if (songInfo.name) newTags.TITLE = songInfo.name
|
||||
if (songInfo.singer) newTags.ARTIST = songInfo.singer
|
||||
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
|
||||
if (songInfo.year) newTags.DATE = songInfo.year.toString()
|
||||
if (songInfo.genre) newTags.GENRE = songInfo.genre
|
||||
}
|
||||
|
||||
// 写入歌词
|
||||
if (tagWriteOptions.lyrics && songInfo.lrc) {
|
||||
const convertedLrc = convertLrcFormat(songInfo.lrc)
|
||||
newTags.LYRICS = convertedLrc
|
||||
}
|
||||
|
||||
console.log('准备写入的标签:', newTags)
|
||||
|
||||
// 使用 ffmpeg-static 写入 FLAC 标签
|
||||
if (path.extname(filePath).toLowerCase() === '.flac') {
|
||||
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
|
||||
} else {
|
||||
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('写入 Vorbis Comment 标签失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 fluent-ffmpeg 写入 FLAC 标签
|
||||
async function writeFLACTagsWithFFmpeg(
|
||||
filePath: string,
|
||||
tags: any,
|
||||
songInfo: any,
|
||||
tagWriteOptions: any
|
||||
) {
|
||||
let tempOutputPath: string | null = null
|
||||
let tempCoverPath: string | null = null
|
||||
|
||||
try {
|
||||
if (!ffmpegStatic) {
|
||||
throw new Error('ffmpeg-static 不可用')
|
||||
}
|
||||
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
|
||||
|
||||
// 创建临时输出文件
|
||||
tempOutputPath = filePath + '.temp.flac'
|
||||
|
||||
// 创建 fluent-ffmpeg 实例
|
||||
let command = ffmpeg(filePath)
|
||||
.audioCodec('copy') // 复制音频编解码器,不重新编码
|
||||
.output(tempOutputPath)
|
||||
|
||||
// 添加元数据标签
|
||||
for (const [key, value] of Object.entries(tags)) {
|
||||
if (value) {
|
||||
// fluent-ffmpeg 会自动处理特殊字符转义
|
||||
command = command.outputOptions(['-metadata', `${key}=${value}`])
|
||||
}
|
||||
}
|
||||
|
||||
// 处理封面
|
||||
if (tagWriteOptions.cover && songInfo.img) {
|
||||
try {
|
||||
console.log('开始下载封面:', songInfo.img)
|
||||
const coverResponse = await axios({
|
||||
method: 'GET',
|
||||
url: songInfo.img,
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
if (coverResponse.data) {
|
||||
// 保存临时封面文件
|
||||
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
|
||||
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
|
||||
|
||||
// 添加封面作为输入
|
||||
command = command.input(tempCoverPath).outputOptions([
|
||||
'-map',
|
||||
'0:a', // 映射原始文件的音频流
|
||||
'-map',
|
||||
'1:v', // 映射封面的视频流
|
||||
'-c:v',
|
||||
'copy', // 复制视频编解码器
|
||||
'-disposition:v:0',
|
||||
'attached_pic' // 设置为附加图片
|
||||
])
|
||||
|
||||
console.log('封面已添加到命令中')
|
||||
}
|
||||
} catch (coverError) {
|
||||
console.warn(
|
||||
'下载封面失败,跳过封面写入:',
|
||||
coverError instanceof Error ? coverError.message : coverError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行 ffmpeg 命令
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
command
|
||||
.on('start', () => {
|
||||
console.log('执行 ffmpeg 命令')
|
||||
})
|
||||
.on('progress', (progress) => {
|
||||
if (progress.percent) {
|
||||
console.log('处理进度:', Math.round(progress.percent) + '%')
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('ffmpeg 处理完成')
|
||||
resolve()
|
||||
})
|
||||
.on('error', (err, _, stderr) => {
|
||||
console.error('ffmpeg 错误:', err.message)
|
||||
if (stderr) {
|
||||
console.error('ffmpeg stderr:', stderr)
|
||||
}
|
||||
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
|
||||
})
|
||||
.run()
|
||||
})
|
||||
|
||||
// 检查临时文件是否创建成功
|
||||
if (!fs.existsSync(tempOutputPath)) {
|
||||
throw new Error('ffmpeg 未能创建输出文件')
|
||||
}
|
||||
|
||||
// 替换原文件
|
||||
await fsPromise.rename(tempOutputPath, filePath)
|
||||
tempOutputPath = null // 标记已处理,避免重复清理
|
||||
|
||||
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
|
||||
} catch (error) {
|
||||
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
// 清理所有临时文件
|
||||
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
|
||||
|
||||
for (const tempFile of filesToClean) {
|
||||
try {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
await fsPromise.unlink(tempFile)
|
||||
console.log('已清理临时文件:', tempFile)
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
'清理临时文件失败:',
|
||||
tempFile,
|
||||
cleanupError instanceof Error ? cleanupError.message : cleanupError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MP4/M4A 格式标签写入
|
||||
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
|
||||
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
|
||||
// 可以使用 ffmpeg 或其他工具实现
|
||||
}
|
||||
|
||||
// 获取自定义下载目录
|
||||
const getDownloadDirectory = (): string => {
|
||||
// 使用配置管理服务获取下载目录
|
||||
const directories = configManager.getDirectories()
|
||||
return directories.downloadDir
|
||||
}
|
||||
|
||||
// 从URL中提取文件扩展名,如果没有则默认为mp3
|
||||
const getFileExtension = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const lastDotIndex = pathname.lastIndexOf('.')
|
||||
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
|
||||
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
|
||||
// 验证是否为常见的音频格式
|
||||
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
|
||||
if (validExtensions.includes(extension)) {
|
||||
return extension
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('解析URL失败,使用默认扩展名:', error)
|
||||
}
|
||||
return 'mp3' // 默认扩展名
|
||||
}
|
||||
|
||||
export default async function download(
|
||||
url: string,
|
||||
songInfo: any,
|
||||
tagWriteOptions: any
|
||||
): Promise<any> {
|
||||
const fileExtension = getFileExtension(url)
|
||||
const downloadDir = getDownloadDirectory()
|
||||
const songPath = path.join(
|
||||
downloadDir,
|
||||
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
|
||||
.replace(/[/\\:*?"<>|]/g, '')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
)
|
||||
|
||||
if (fileLock[songPath]) {
|
||||
throw new Error('歌曲正在下载中')
|
||||
} else {
|
||||
fileLock[songPath] = true
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(songPath)) {
|
||||
return {
|
||||
message: '歌曲已存在'
|
||||
}
|
||||
}
|
||||
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
|
||||
|
||||
if (url.startsWith('file://')) {
|
||||
const filePath = fileURLToPath(url)
|
||||
|
||||
const readStream = fs.createReadStream(filePath)
|
||||
const writeStream = fs.createWriteStream(songPath)
|
||||
|
||||
await pipeline(readStream, writeStream)
|
||||
} else {
|
||||
const songDataRes = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream'
|
||||
})
|
||||
|
||||
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
|
||||
}
|
||||
} finally {
|
||||
delete fileLock[songPath]
|
||||
}
|
||||
|
||||
// 写入标签信息
|
||||
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||
try {
|
||||
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
||||
} catch (error) {
|
||||
console.warn('写入音频标签失败:', error)
|
||||
throw ffmpegStatic
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: '下载成功',
|
||||
path: songPath
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import pic from './pic'
|
||||
import lyric from './lyric'
|
||||
import hotSearch from './hotSearch'
|
||||
import comment from './comment'
|
||||
// import tipSearch from './tipSearch'
|
||||
import tipSearch from './tipSearch'
|
||||
|
||||
const kg = {
|
||||
// tipSearch,
|
||||
tipSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
musicSearch,
|
||||
|
||||
@@ -5,10 +5,10 @@ import pic from './pic'
|
||||
import lyric from './lyric'
|
||||
import hotSearch from './hotSearch'
|
||||
import comment from './comment'
|
||||
// import tipSearch from './tipSearch'
|
||||
import tipSearch from './tipSearch'
|
||||
|
||||
const mg = {
|
||||
// tipSearch,
|
||||
tipSearch,
|
||||
songList,
|
||||
musicSearch,
|
||||
leaderboard,
|
||||
|
||||
@@ -4,10 +4,10 @@ import songList from './songList'
|
||||
import musicSearch from './musicSearch'
|
||||
import hotSearch from './hotSearch'
|
||||
import comment from './comment'
|
||||
// import tipSearch from './tipSearch'
|
||||
import tipSearch from './tipSearch'
|
||||
|
||||
const tx = {
|
||||
// tipSearch,
|
||||
tipSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
musicSearch,
|
||||
|
||||
@@ -5,9 +5,10 @@ import musicSearch from './musicSearch'
|
||||
import songList from './songList'
|
||||
import hotSearch from './hotSearch'
|
||||
import comment from './comment'
|
||||
import tipSearch from './tipSearch'
|
||||
|
||||
const wy = {
|
||||
// tipSearch,
|
||||
tipSearch,
|
||||
leaderboard,
|
||||
musicSearch,
|
||||
songList,
|
||||
|
||||
@@ -30,7 +30,7 @@ const api = {
|
||||
// 音乐相关方法
|
||||
music: {
|
||||
requestSdk: (api: string, args: any) =>
|
||||
ipcRenderer.invoke('service-music-sdk-request', api, args)
|
||||
ipcRenderer.invoke('service-music-sdk-request', api, args),
|
||||
},
|
||||
//音源插件
|
||||
plugins: {
|
||||
|
||||
68
src/renderer/auto-imports.d.ts
vendored
68
src/renderer/auto-imports.d.ts
vendored
@@ -7,4 +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']
|
||||
}
|
||||
// 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'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
4
src/renderer/components.d.ts
vendored
4
src/renderer/components.d.ts
vendored
@@ -18,6 +18,9 @@ declare module 'vue' {
|
||||
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
||||
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
||||
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
|
||||
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
|
||||
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
|
||||
@@ -26,6 +29,7 @@ declare module 'vue' {
|
||||
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchSuggest: typeof import('./src/components/search/searchSuggest.vue')['default']
|
||||
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
||||
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
||||
TAlert: typeof import('tdesign-vue-next')['Alert']
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { useAutoUpdate } from './composables/useAutoUpdate'
|
||||
import { NConfigProvider, darkTheme, NGlobalStyle } from 'naive-ui'
|
||||
|
||||
const userInfo = LocalUserDetailStore()
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
@@ -27,6 +28,8 @@ onMounted(() => {
|
||||
userInfo.init()
|
||||
setupSystemThemeListener()
|
||||
loadSavedTheme()
|
||||
syncNaiveTheme()
|
||||
window.addEventListener('theme-changed', () => syncNaiveTheme())
|
||||
|
||||
// 应用启动后延迟3秒检查更新,避免影响启动速度
|
||||
setTimeout(() => {
|
||||
@@ -43,6 +46,31 @@ const themes = [
|
||||
{ name: 'orange', label: '橙色', color: '#fb9458' }
|
||||
]
|
||||
|
||||
const naiveTheme = ref<any>(null)
|
||||
const themeOverrides = ref<any>({})
|
||||
|
||||
function syncNaiveTheme() {
|
||||
const docEl = document.documentElement
|
||||
const savedDarkMode = localStorage.getItem('dark-mode')
|
||||
const isDark = savedDarkMode === 'true'
|
||||
naiveTheme.value = isDark ? darkTheme : null
|
||||
|
||||
const computed = getComputedStyle(docEl)
|
||||
const primary = (computed.getPropertyValue('--td-brand-color') || '').trim()
|
||||
|
||||
const savedThemeName = localStorage.getItem('selected-theme') || 'default'
|
||||
const fallback = themes.find((t) => t.name === savedThemeName)?.color || '#2ba55b'
|
||||
const mainColor = primary || fallback
|
||||
|
||||
themeOverrides.value = {
|
||||
common: {
|
||||
primaryColor: mainColor,
|
||||
primaryColorHover: mainColor,
|
||||
primaryColorPressed: mainColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadSavedTheme = () => {
|
||||
const savedTheme = localStorage.getItem('selected-theme')
|
||||
const savedDarkMode = localStorage.getItem('dark-mode')
|
||||
@@ -86,6 +114,9 @@ const applyTheme = (themeName, darkMode = false) => {
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('selected-theme', themeName)
|
||||
localStorage.setItem('dark-mode', darkMode.toString())
|
||||
|
||||
// 同步 Naive UI 主题
|
||||
syncNaiveTheme()
|
||||
}
|
||||
|
||||
// 检测系统主题偏好
|
||||
@@ -113,6 +144,8 @@ const setupSystemThemeListener = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
|
||||
<NGlobalStyle />
|
||||
<div class="page">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition
|
||||
@@ -127,6 +160,7 @@ const setupSystemThemeListener = () => {
|
||||
<PluginNoticeDialog />
|
||||
<UpdateProgress />
|
||||
</div>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
<style>
|
||||
.pagesApp {
|
||||
|
||||
@@ -64,6 +64,9 @@ const applyTheme = (themeName: string, darkMode: boolean = false) => {
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('selected-theme', themeName)
|
||||
localStorage.setItem('dark-mode', darkMode.toString())
|
||||
|
||||
// 通知全局(App.vue)同步 Naive UI 主题
|
||||
window.dispatchEvent(new CustomEvent('theme-changed'))
|
||||
}
|
||||
|
||||
// 选择主题
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||
import SearchSuggest from '@renderer/components/search/searchSuggest.vue'
|
||||
import { SearchIcon } from 'tdesign-icons-vue-next'
|
||||
import { onMounted, ref, watchEffect, computed } from 'vue'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { searchValue } from '@renderer/store/search'
|
||||
import { useSearchStore } from '@renderer/store'
|
||||
|
||||
onMounted(() => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
@@ -128,18 +129,17 @@ const goForward = (): void => {
|
||||
|
||||
// 搜索相关
|
||||
const keyword = ref('')
|
||||
const SearchStore = useSearchStore()
|
||||
const inputRef = ref<any>(null)
|
||||
|
||||
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||
// const searchType = ref(1)
|
||||
|
||||
// 处理搜索事件
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.value.trim()) return
|
||||
const useSearch = searchValue()
|
||||
if (!SearchStore.getValue.trim()) return
|
||||
// 重新设置搜索关键字
|
||||
try {
|
||||
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
||||
useSearch.setValue(keyword.value.trim()) // 设置搜索关键字
|
||||
router.push({
|
||||
path: '/home/search'
|
||||
})
|
||||
@@ -151,6 +151,21 @@ const handleSearch = async () => {
|
||||
// 处理按键事件,按下回车键时触发搜索
|
||||
const handleKeyDown = () => {
|
||||
handleSearch()
|
||||
// 回车后取消输入框焦点
|
||||
inputRef.value?.blur?.()
|
||||
}
|
||||
|
||||
// 监听输入变化,更新SearchStore
|
||||
watchEffect(() => {
|
||||
SearchStore.setValue(keyword.value)
|
||||
})
|
||||
|
||||
// 处理搜索建议选择
|
||||
const handleSuggestionSelect = (suggestion: any, _type: any) => {
|
||||
console.log(111)
|
||||
|
||||
keyword.value = suggestion
|
||||
handleSearch()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -227,10 +242,13 @@ const handleKeyDown = () => {
|
||||
</div>
|
||||
</transition>
|
||||
<t-input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
placeholder="搜索音乐、歌手"
|
||||
style="width: 100%"
|
||||
@enter="handleKeyDown"
|
||||
@focus="SearchStore.setFocus(true)"
|
||||
@blur="SearchStore.setFocus(false)"
|
||||
>
|
||||
<template #suffix>
|
||||
<t-button
|
||||
@@ -244,6 +262,7 @@ const handleKeyDown = () => {
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
<SearchSuggest @to-search="handleSuggestionSelect" />
|
||||
</div>
|
||||
|
||||
<TitleBarControls></TitleBarControls>
|
||||
|
||||
272
src/renderer/src/components/search/searchSuggest.vue
Normal file
272
src/renderer/src/components/search/searchSuggest.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<Transition
|
||||
name="fadeDown"
|
||||
mode="out-in"
|
||||
@after-enter="calcSearchSuggestHeights"
|
||||
@after-leave="calcSearchSuggestHeights"
|
||||
>
|
||||
<n-card
|
||||
v-if="SearchStore.focus && SearchStore.value"
|
||||
class="search-suggest"
|
||||
content-style="padding: 0"
|
||||
:style="{
|
||||
height: `${searchSuggestHeights}px`,
|
||||
border: searchSuggestHeights === 0 ? 'none' : null
|
||||
}"
|
||||
>
|
||||
<n-scrollbar class="scrollbar">
|
||||
<!-- 直接搜索 -->
|
||||
<div
|
||||
ref="directSearchRef"
|
||||
class="direct"
|
||||
@click="emit('toSearch', SearchStore.value, 'keyword')"
|
||||
>
|
||||
<SvgIcon name="Search" :depth="3" />
|
||||
<n-text class="text text-hidden">直接搜索:{{ SearchStore.value }}</n-text>
|
||||
</div>
|
||||
<!-- 搜索建议 -->
|
||||
<Transition name="fade" mode="out-in" @after-leave="calcSearchSuggestHeights">
|
||||
<div
|
||||
v-if="Object.keys(searchSuggestData)?.length && searchSuggestData?.order"
|
||||
ref="searchSuggestRef"
|
||||
class="all-suggest"
|
||||
>
|
||||
<div v-for="(item, index) in searchSuggestData.order" :key="index" class="suggest">
|
||||
<div class="suggest-type">
|
||||
<SvgIcon :name="searchSuggestionsType[item].icon" />
|
||||
<n-text>{{ searchSuggestionsType[item].name }}</n-text>
|
||||
</div>
|
||||
<div
|
||||
v-for="(suggestItem, suggestIndex) in searchSuggestData[item]"
|
||||
:key="suggestIndex"
|
||||
class="suggest-item"
|
||||
@click="emit('toSearch', suggestItem, item)"
|
||||
>
|
||||
<n-text class="name">{{ suggestItem }}</n-text>
|
||||
<n-text v-if="suggestItem?.artist" class="artist" depth="3">
|
||||
{{ suggestItem.artist.name }}
|
||||
</n-text>
|
||||
<n-text v-else-if="suggestItem?.artists" class="artist" depth="3">
|
||||
{{ suggestItem.artists[0].name }}
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</n-scrollbar>
|
||||
</n-card>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSearchStore } from '@renderer/store'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
|
||||
const emit = defineEmits<{
|
||||
toSearch: [key: number | string, type: string]
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => LocalUserDetailStore().userSource.source,
|
||||
() => {
|
||||
getSearchSuggest(SearchStore.value)
|
||||
}
|
||||
)
|
||||
|
||||
const SearchStore = useSearchStore()
|
||||
|
||||
// 搜索建议数据
|
||||
const searchSuggestData = ref<any>({})
|
||||
const searchSuggestHeights = ref<number>(0)
|
||||
|
||||
// 搜索建议元素
|
||||
const directSearchRef = ref<HTMLElement | null>(null)
|
||||
const searchSuggestRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 搜索建议分类
|
||||
const searchSuggestionsType = {
|
||||
songs: {
|
||||
name: '单曲',
|
||||
icon: 'Music'
|
||||
},
|
||||
artists: {
|
||||
name: '歌手',
|
||||
icon: 'Artist'
|
||||
},
|
||||
albums: {
|
||||
name: '专辑',
|
||||
icon: 'Album'
|
||||
},
|
||||
playlists: {
|
||||
name: '歌单',
|
||||
icon: 'MusicList'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取搜索建议
|
||||
const getSearchSuggest = async (keywords: string) => {
|
||||
searchSuggestData.value = {}
|
||||
try {
|
||||
console.log('获取搜索建议', keywords)
|
||||
// 使用网易云音乐的搜索建议API
|
||||
const result = await window.api.music.requestSdk('tipSearch', {
|
||||
source: toRaw(LocalUserDetailStore().userSource.source || 'wy'),
|
||||
keyword: keywords
|
||||
})
|
||||
console.log('result', result)
|
||||
|
||||
if (result) {
|
||||
const res = result
|
||||
// 若为纯数组则包装为分组结构,保证模板可渲染
|
||||
searchSuggestData.value = Array.isArray(res) ? { order: ['songs'], songs: res } : res
|
||||
} else {
|
||||
searchSuggestData.value = {}
|
||||
}
|
||||
|
||||
// 计算高度
|
||||
nextTick(calcSearchSuggestHeights)
|
||||
} catch (error) {
|
||||
console.error('获取搜索建议失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算高度
|
||||
const calcSearchSuggestHeights = () => {
|
||||
const directSearchHeight = directSearchRef.value?.offsetHeight
|
||||
const searchSuggestionsHeight = searchSuggestRef.value?.offsetHeight
|
||||
if (directSearchHeight || searchSuggestionsHeight) {
|
||||
const totalHeight =
|
||||
(directSearchHeight || 0) +
|
||||
(searchSuggestionsHeight || 0) +
|
||||
(searchSuggestionsHeight ? 8 : 0) +
|
||||
20
|
||||
searchSuggestHeights.value = totalHeight
|
||||
} else {
|
||||
searchSuggestHeights.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框改变
|
||||
watchDebounced(
|
||||
() => SearchStore.value,
|
||||
(val) => {
|
||||
if (!val || val === '') return
|
||||
getSearchSuggest(val)
|
||||
},
|
||||
{ debounce: 300 }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-suggest {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-height: calc(100vh - 160px);
|
||||
z-index: 101;
|
||||
transition:
|
||||
height 0.3s ease,
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
:deep(.scrollbar) {
|
||||
max-height: calc(100vh - 160px);
|
||||
.n-scrollbar-content {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
.direct {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s;
|
||||
cursor: pointer;
|
||||
.n-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--n-border-color);
|
||||
}
|
||||
}
|
||||
.all-suggest {
|
||||
margin-top: 8px;
|
||||
.suggest {
|
||||
margin-bottom: 8px;
|
||||
.suggest-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--td-brand-color);
|
||||
.n-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.n-text {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
.suggest-item {
|
||||
padding: 10px 14px 10px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s;
|
||||
cursor: pointer;
|
||||
.name {
|
||||
white-space: normal;
|
||||
}
|
||||
.artist {
|
||||
&::before {
|
||||
content: ' - ';
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--n-border-color);
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 外层卡片下滑淡入/淡出 */
|
||||
.fadeDown-enter-active,
|
||||
.fadeDown-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
.fadeDown-enter-from,
|
||||
.fadeDown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.fadeDown-enter-to,
|
||||
.fadeDown-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 内层内容淡入/淡出(name="fade") */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,25 @@
|
||||
// 基础样式
|
||||
import './assets/base.css'
|
||||
import 'animate.css'
|
||||
|
||||
// 引入组件库的少量全局样式变量
|
||||
// import 'tdesign-vue-next/es/style/index.css' //tdesign 组件样式
|
||||
|
||||
// 引入iconfont图标样式
|
||||
import './assets/icon_font/iconfont.css'
|
||||
import './assets/icon_font/iconfont.js'
|
||||
|
||||
// vue
|
||||
import App from './App.vue'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
const app = createApp(App)
|
||||
import { createPinia } from 'pinia'
|
||||
// router
|
||||
import router from './router'
|
||||
// pinia
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
// pinia
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
//router
|
||||
app.use(router)
|
||||
app.use(createPinia())
|
||||
|
||||
//app
|
||||
app.mount('#app')
|
||||
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
* @property {string} url - 音频URL。
|
||||
*/
|
||||
let userInfo: any
|
||||
export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||
export const ControlAudioStore = defineStore(
|
||||
'controlAudio',
|
||||
() => {
|
||||
const Audio = reactive<ControlAudioState>({
|
||||
audio: null,
|
||||
isPlay: false,
|
||||
@@ -219,4 +221,8 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||
clearEventSubscribers,
|
||||
setDuration
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
persist: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import type { SongList } from '@renderer/types/audio'
|
||||
import type { UserInfo } from '@renderer/types/userInfo'
|
||||
|
||||
export const LocalUserDetailStore = defineStore('Local', () => {
|
||||
export const LocalUserDetailStore = defineStore(
|
||||
'Local',
|
||||
() => {
|
||||
const list = ref<SongList[]>([])
|
||||
const userInfo = ref<UserInfo>({})
|
||||
const initialization = ref(false)
|
||||
@@ -107,4 +109,8 @@ export const LocalUserDetailStore = defineStore('Local', () => {
|
||||
clearList,
|
||||
userSource
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
persist: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface SettingsState {
|
||||
tagWriteOptions?: TagWriteOptions
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
export const useSettingsStore = defineStore(
|
||||
'settings',
|
||||
() => {
|
||||
// 从本地存储加载设置,如果没有则使用默认值
|
||||
const loadSettings = (): SettingsState => {
|
||||
try {
|
||||
@@ -63,4 +65,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
updateSettings,
|
||||
toggleFloatBall
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
persist: false
|
||||
}
|
||||
)
|
||||
|
||||
1
src/renderer/src/store/index.ts
Normal file
1
src/renderer/src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { searchValue as useSearchStore } from './search'
|
||||
@@ -2,14 +2,20 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const searchValue = defineStore('search', {
|
||||
state: () => ({
|
||||
value: ''
|
||||
value: '',
|
||||
focus: false
|
||||
}),
|
||||
getters: {
|
||||
getValue: (state) => state.value
|
||||
getValue: (state) => state.value,
|
||||
getFocus: (state) => state.focus
|
||||
},
|
||||
actions: {
|
||||
setValue(value: string) {
|
||||
this.value = value
|
||||
},
|
||||
setFocus(focus: boolean) {
|
||||
this.focus = focus
|
||||
}
|
||||
}
|
||||
},
|
||||
persist: false
|
||||
})
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
<!--
|
||||
主题演示页面 - 展示暗色模式适配效果
|
||||
-->
|
||||
<template>
|
||||
<div class="theme-demo">
|
||||
<div class="demo-header">
|
||||
<h1 class="demo-title">主题演示</h1>
|
||||
<p class="demo-description">测试不同主题和暗色模式的效果</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 主题选择器 -->
|
||||
<div class="demo-section">
|
||||
<h2 class="section-title">主题选择</h2>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
|
||||
<!-- 组件演示 -->
|
||||
<div class="demo-section">
|
||||
<h2 class="section-title">组件演示</h2>
|
||||
|
||||
<div class="component-grid">
|
||||
<!-- 按钮演示 -->
|
||||
<div class="component-item">
|
||||
<h3 class="component-title">按钮</h3>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary">主要按钮</button>
|
||||
<button class="btn btn-secondary">次要按钮</button>
|
||||
<button class="btn btn-outline">轮廓按钮</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框演示 -->
|
||||
<div class="component-item">
|
||||
<h3 class="component-title">输入框</h3>
|
||||
<div class="input-group">
|
||||
<input type="text" class="input" placeholder="请输入内容" />
|
||||
<textarea class="textarea" placeholder="多行文本输入"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片演示 -->
|
||||
<div class="component-item">
|
||||
<h3 class="component-title">卡片</h3>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">卡片标题</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">这是卡片内容,用于展示暗色模式下的效果。</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-small btn-primary">操作</button>
|
||||
<button class="btn btn-small btn-outline">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表演示 -->
|
||||
<div class="component-item">
|
||||
<h3 class="component-title">列表</h3>
|
||||
<div class="list">
|
||||
<div class="list-item">
|
||||
<div class="list-content">
|
||||
<div class="list-title">列表项 1</div>
|
||||
<div class="list-description">这是列表项的描述文本</div>
|
||||
</div>
|
||||
<div class="list-action">
|
||||
<button class="btn btn-small btn-outline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="list-content">
|
||||
<div class="list-title">列表项 2</div>
|
||||
<div class="list-description">这是另一个列表项的描述</div>
|
||||
</div>
|
||||
<div class="list-action">
|
||||
<button class="btn btn-small btn-outline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 颜色演示 -->
|
||||
<div class="demo-section">
|
||||
<h2 class="section-title">颜色系统</h2>
|
||||
<div class="color-grid">
|
||||
<div class="color-item">
|
||||
<div class="color-swatch color-primary"></div>
|
||||
<span class="color-label">主色</span>
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<div class="color-swatch color-success"></div>
|
||||
<span class="color-label">成功</span>
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<div class="color-swatch color-warning"></div>
|
||||
<span class="color-label">警告</span>
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<div class="color-swatch color-error"></div>
|
||||
<span class="color-label">错误</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ThemeSelector from '../components/ThemeSelector.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-demo {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: var(--td-font-size-headline-large);
|
||||
color: var(--td-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.demo-description {
|
||||
font-size: var(--td-font-size-body-large);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
margin-bottom: 32px;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: var(--td-radius-large);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--td-font-size-title-large);
|
||||
color: var(--td-text-color-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.component-item {
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--td-radius-medium);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.component-title {
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--td-radius-medium);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 8px;
|
||||
font-size: var(--td-font-size-body-small);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--td-brand-color);
|
||||
color: var(--td-text-color-anti);
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--td-brand-color-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--td-bg-color-component);
|
||||
color: var(--td-text-color-primary);
|
||||
border-color: var(--td-component-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--td-bg-color-component-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: var(--td-radius-medium);
|
||||
background: var(--td-bg-color-container);
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 0 0 2px var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: var(--td-radius-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.list {
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: var(--td-radius-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.list-description {
|
||||
font-size: var(--td-font-size-body-small);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 颜色演示 */
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: var(--td-radius-medium);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.color-primary {
|
||||
background: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.color-success {
|
||||
background: var(--td-success-color);
|
||||
}
|
||||
|
||||
.color-warning {
|
||||
background: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.color-error {
|
||||
background: var(--td-error-color);
|
||||
}
|
||||
|
||||
.color-label {
|
||||
font-size: var(--td-font-size-body-small);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1320,7 +1320,7 @@ onMounted(() => {
|
||||
: importPlatformType === 'kw'
|
||||
? '支持链接或ID:http://www.kuwo.cn/playlist_detail/123456789 或 123456789'
|
||||
: importPlatformType === 'kg'
|
||||
? '支持链接或ID:https://www.kugou.com/yy/special/single/123456789 或 123456789'
|
||||
? '手机链接或酷狗码:https://www.kugou.com/yy/special/single/123456789 或 123456789'
|
||||
: importPlatformType === 'mg'
|
||||
? '支持链接或ID:https://music.migu.cn/v3/music/playlist/123456789 或 123456789'
|
||||
: '请输入歌单链接或ID'
|
||||
@@ -1369,11 +1369,11 @@ onMounted(() => {
|
||||
<li>其他包含ID的酷我音乐链接格式</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'kg'" class="tip-list">
|
||||
<li>酷狗码(推荐):123456789</li>
|
||||
<li>完整链接:https://www.kugou.com/yy/special/single/123456789</li>
|
||||
<li>手机版链接:https://m.kugou.com/songlist/gcid_3z9vj0yqz4bz00b</li>
|
||||
<li>旧版手机链接:https://m.kugou.com/playlist?id=123456789</li>
|
||||
<li>参数链接:https://www.kugou.com/playlist?specialid=123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'mg'" class="tip-list">
|
||||
<li>完整链接:https://music.migu.cn/v3/music/playlist/123456789</li>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch, toRaw } from 'vue'
|
||||
import { ref, computed, watch, toRaw } from 'vue'
|
||||
import { searchValue } from '@renderer/store/search'
|
||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface MusicItem {
|
||||
id: number
|
||||
@@ -32,14 +33,22 @@ const totalItems = ref(0)
|
||||
const currentSong = ref<MusicItem | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const search = searchValue()
|
||||
|
||||
onMounted(async () => {
|
||||
const router = useRouter()
|
||||
onActivated(async () => {
|
||||
const localUserStore = LocalUserDetailStore()
|
||||
console.log('sqjsqj', search.getValue)
|
||||
|
||||
if (search.getValue.trim() === '') {
|
||||
console.log('跳转')
|
||||
router.push({ name: 'find' })
|
||||
}
|
||||
watch(
|
||||
search,
|
||||
async () => {
|
||||
if (search.getFocus == true || search.getValue.trim() == keyword.value.trim()) return
|
||||
keyword.value = search.getValue
|
||||
console.log('search', search)
|
||||
|
||||
await performSearch(true)
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
Reference in New Issue
Block a user