mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +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" />可以添加到歌曲开头也可进行该歌曲添加到 **「列表」** 中的开头并播放。歌曲会按照你选择的播放顺序进行播放。
|
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 Components from 'unplugin-vue-components/vite'
|
||||||
import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
|
import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
|
||||||
import wasm from 'vite-plugin-wasm'
|
import wasm from 'vite-plugin-wasm'
|
||||||
|
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import topLevelAwait from 'vite-plugin-top-level-await'
|
import topLevelAwait from 'vite-plugin-top-level-await'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -37,13 +38,20 @@ export default defineConfig({
|
|||||||
library: 'vue-next'
|
library: 'vue-next'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
{
|
||||||
|
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
|
||||||
|
}
|
||||||
|
],
|
||||||
dts: true
|
dts: true
|
||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
resolvers: [
|
resolvers: [
|
||||||
TDesignResolver({
|
TDesignResolver({
|
||||||
library: 'vue-next'
|
library: 'vue-next'
|
||||||
})
|
}),
|
||||||
|
NaiveUiResolver()
|
||||||
],
|
],
|
||||||
dts: true
|
dts: true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.3.12",
|
"version": "1.3.13",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"node-fetch": "2",
|
"node-fetch": "2",
|
||||||
"node-id3": "^0.2.9",
|
"node-id3": "^0.2.9",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"tdesign-icons-vue-next": "^0.4.1",
|
"tdesign-icons-vue-next": "^0.4.1",
|
||||||
"tdesign-vue-next": "^1.15.2",
|
"tdesign-vue-next": "^1.15.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -88,12 +89,14 @@
|
|||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
"electron": "^38.1.0",
|
"electron": "^38.1.0",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
"electron-icon-builder": "^2.0.1",
|
"electron-icon-builder": "^2.0.1",
|
||||||
"electron-vite": "^4.0.0",
|
"electron-vite": "^4.0.0",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"eslint-plugin-vue": "^10.3.0",
|
"eslint-plugin-vue": "^10.3.0",
|
||||||
|
"naive-ui": "^2.43.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"sass-embedded": "^1.90.0",
|
"sass-embedded": "^1.90.0",
|
||||||
"scss": "^0.2.4",
|
"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-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,
|
PlaylistResult,
|
||||||
GetSongListDetailsArg,
|
GetSongListDetailsArg,
|
||||||
PlaylistDetailResult,
|
PlaylistDetailResult,
|
||||||
DownloadSingleSongArgs
|
DownloadSingleSongArgs,
|
||||||
|
TipSearchResult
|
||||||
} from './type'
|
} from './type'
|
||||||
import pluginService from '../plugin/index'
|
import pluginService from '../plugin/index'
|
||||||
import musicSdk from '../../utils/musicSdk/index'
|
import musicSdk from '../../utils/musicSdk/index'
|
||||||
import { musicCacheService } from '../musicCache'
|
import { musicCacheService } from '../musicCache'
|
||||||
import path from 'node:path'
|
import download from '../../utils/downloadSongs'
|
||||||
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 或其他工具实现
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(source: string) {
|
function main(source: string) {
|
||||||
const Api = musicSdk[source]
|
const Api = musicSdk[source]
|
||||||
@@ -436,6 +22,14 @@ function main(source: string) {
|
|||||||
return (await Api.musicSearch.search(keyword, page, limit)) as Promise<SearchResult>
|
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) {
|
async getMusicUrl({ pluginId, songInfo, quality }: GetMusicUrlArg) {
|
||||||
try {
|
try {
|
||||||
const usePlugin = pluginService.getPluginById(pluginId)
|
const usePlugin = pluginService.getPluginById(pluginId)
|
||||||
@@ -506,93 +100,7 @@ function main(source: string) {
|
|||||||
}: DownloadSingleSongArgs) {
|
}: DownloadSingleSongArgs) {
|
||||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||||
|
return await download(url, songInfo, tagWriteOptions)
|
||||||
// 获取自定义下载目录
|
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async parsePlaylistId({ url }: { url: string }) {
|
async parsePlaylistId({ url }: { url: string }) {
|
||||||
|
|||||||
@@ -100,3 +100,6 @@ export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
|||||||
path?: string
|
path?: string
|
||||||
tagWriteOptions?: TagWriteOptions
|
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 lyric from './lyric'
|
||||||
import hotSearch from './hotSearch'
|
import hotSearch from './hotSearch'
|
||||||
import comment from './comment'
|
import comment from './comment'
|
||||||
// import tipSearch from './tipSearch'
|
import tipSearch from './tipSearch'
|
||||||
|
|
||||||
const kg = {
|
const kg = {
|
||||||
// tipSearch,
|
tipSearch,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
songList,
|
songList,
|
||||||
musicSearch,
|
musicSearch,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import pic from './pic'
|
|||||||
import lyric from './lyric'
|
import lyric from './lyric'
|
||||||
import hotSearch from './hotSearch'
|
import hotSearch from './hotSearch'
|
||||||
import comment from './comment'
|
import comment from './comment'
|
||||||
// import tipSearch from './tipSearch'
|
import tipSearch from './tipSearch'
|
||||||
|
|
||||||
const mg = {
|
const mg = {
|
||||||
// tipSearch,
|
tipSearch,
|
||||||
songList,
|
songList,
|
||||||
musicSearch,
|
musicSearch,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import songList from './songList'
|
|||||||
import musicSearch from './musicSearch'
|
import musicSearch from './musicSearch'
|
||||||
import hotSearch from './hotSearch'
|
import hotSearch from './hotSearch'
|
||||||
import comment from './comment'
|
import comment from './comment'
|
||||||
// import tipSearch from './tipSearch'
|
import tipSearch from './tipSearch'
|
||||||
|
|
||||||
const tx = {
|
const tx = {
|
||||||
// tipSearch,
|
tipSearch,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
songList,
|
songList,
|
||||||
musicSearch,
|
musicSearch,
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import musicSearch from './musicSearch'
|
|||||||
import songList from './songList'
|
import songList from './songList'
|
||||||
import hotSearch from './hotSearch'
|
import hotSearch from './hotSearch'
|
||||||
import comment from './comment'
|
import comment from './comment'
|
||||||
|
import tipSearch from './tipSearch'
|
||||||
|
|
||||||
const wy = {
|
const wy = {
|
||||||
// tipSearch,
|
tipSearch,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
musicSearch,
|
musicSearch,
|
||||||
songList,
|
songList,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const api = {
|
|||||||
// 音乐相关方法
|
// 音乐相关方法
|
||||||
music: {
|
music: {
|
||||||
requestSdk: (api: string, args: any) =>
|
requestSdk: (api: string, args: any) =>
|
||||||
ipcRenderer.invoke('service-music-sdk-request', api, args)
|
ipcRenderer.invoke('service-music-sdk-request', api, args),
|
||||||
},
|
},
|
||||||
//音源插件
|
//音源插件
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
68
src/renderer/auto-imports.d.ts
vendored
68
src/renderer/auto-imports.d.ts
vendored
@@ -7,4 +7,72 @@
|
|||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
|
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']
|
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
||||||
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
||||||
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
||||||
|
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']
|
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
|
||||||
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
|
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
|
||||||
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.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']
|
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SearchSuggest: typeof import('./src/components/search/searchSuggest.vue')['default']
|
||||||
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
||||||
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
||||||
TAlert: typeof import('tdesign-vue-next')['Alert']
|
TAlert: typeof import('tdesign-vue-next')['Alert']
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { useAutoUpdate } from './composables/useAutoUpdate'
|
import { useAutoUpdate } from './composables/useAutoUpdate'
|
||||||
|
import { NConfigProvider, darkTheme, NGlobalStyle } from 'naive-ui'
|
||||||
|
|
||||||
const userInfo = LocalUserDetailStore()
|
const userInfo = LocalUserDetailStore()
|
||||||
const { checkForUpdates } = useAutoUpdate()
|
const { checkForUpdates } = useAutoUpdate()
|
||||||
@@ -27,6 +28,8 @@ onMounted(() => {
|
|||||||
userInfo.init()
|
userInfo.init()
|
||||||
setupSystemThemeListener()
|
setupSystemThemeListener()
|
||||||
loadSavedTheme()
|
loadSavedTheme()
|
||||||
|
syncNaiveTheme()
|
||||||
|
window.addEventListener('theme-changed', () => syncNaiveTheme())
|
||||||
|
|
||||||
// 应用启动后延迟3秒检查更新,避免影响启动速度
|
// 应用启动后延迟3秒检查更新,避免影响启动速度
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -43,6 +46,31 @@ const themes = [
|
|||||||
{ name: 'orange', label: '橙色', color: '#fb9458' }
|
{ 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 loadSavedTheme = () => {
|
||||||
const savedTheme = localStorage.getItem('selected-theme')
|
const savedTheme = localStorage.getItem('selected-theme')
|
||||||
const savedDarkMode = localStorage.getItem('dark-mode')
|
const savedDarkMode = localStorage.getItem('dark-mode')
|
||||||
@@ -86,6 +114,9 @@ const applyTheme = (themeName, darkMode = false) => {
|
|||||||
// 保存到本地存储
|
// 保存到本地存储
|
||||||
localStorage.setItem('selected-theme', themeName)
|
localStorage.setItem('selected-theme', themeName)
|
||||||
localStorage.setItem('dark-mode', darkMode.toString())
|
localStorage.setItem('dark-mode', darkMode.toString())
|
||||||
|
|
||||||
|
// 同步 Naive UI 主题
|
||||||
|
syncNaiveTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测系统主题偏好
|
// 检测系统主题偏好
|
||||||
@@ -113,6 +144,8 @@ const setupSystemThemeListener = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
|
||||||
|
<NGlobalStyle />
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -127,6 +160,7 @@ const setupSystemThemeListener = () => {
|
|||||||
<PluginNoticeDialog />
|
<PluginNoticeDialog />
|
||||||
<UpdateProgress />
|
<UpdateProgress />
|
||||||
</div>
|
</div>
|
||||||
|
</NConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
<style>
|
<style>
|
||||||
.pagesApp {
|
.pagesApp {
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ const applyTheme = (themeName: string, darkMode: boolean = false) => {
|
|||||||
// 保存到本地存储
|
// 保存到本地存储
|
||||||
localStorage.setItem('selected-theme', themeName)
|
localStorage.setItem('selected-theme', themeName)
|
||||||
localStorage.setItem('dark-mode', darkMode.toString())
|
localStorage.setItem('dark-mode', darkMode.toString())
|
||||||
|
|
||||||
|
// 通知全局(App.vue)同步 Naive UI 主题
|
||||||
|
window.dispatchEvent(new CustomEvent('theme-changed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择主题
|
// 选择主题
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||||
|
import SearchSuggest from '@renderer/components/search/searchSuggest.vue'
|
||||||
import { SearchIcon } from 'tdesign-icons-vue-next'
|
import { SearchIcon } from 'tdesign-icons-vue-next'
|
||||||
import { onMounted, ref, watchEffect, computed } from 'vue'
|
import { onMounted, ref, watchEffect, computed } from 'vue'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { searchValue } from '@renderer/store/search'
|
import { useSearchStore } from '@renderer/store'
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const LocalUserDetail = LocalUserDetailStore()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
@@ -128,18 +129,17 @@ const goForward = (): void => {
|
|||||||
|
|
||||||
// 搜索相关
|
// 搜索相关
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const SearchStore = useSearchStore()
|
||||||
|
const inputRef = ref<any>(null)
|
||||||
|
|
||||||
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||||
// const searchType = ref(1)
|
// const searchType = ref(1)
|
||||||
|
|
||||||
// 处理搜索事件
|
// 处理搜索事件
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!keyword.value.trim()) return
|
if (!SearchStore.getValue.trim()) return
|
||||||
const useSearch = searchValue()
|
|
||||||
// 重新设置搜索关键字
|
// 重新设置搜索关键字
|
||||||
try {
|
try {
|
||||||
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
|
||||||
useSearch.setValue(keyword.value.trim()) // 设置搜索关键字
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/home/search'
|
path: '/home/search'
|
||||||
})
|
})
|
||||||
@@ -151,6 +151,21 @@ const handleSearch = async () => {
|
|||||||
// 处理按键事件,按下回车键时触发搜索
|
// 处理按键事件,按下回车键时触发搜索
|
||||||
const handleKeyDown = () => {
|
const handleKeyDown = () => {
|
||||||
handleSearch()
|
handleSearch()
|
||||||
|
// 回车后取消输入框焦点
|
||||||
|
inputRef.value?.blur?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听输入变化,更新SearchStore
|
||||||
|
watchEffect(() => {
|
||||||
|
SearchStore.setValue(keyword.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理搜索建议选择
|
||||||
|
const handleSuggestionSelect = (suggestion: any, _type: any) => {
|
||||||
|
console.log(111)
|
||||||
|
|
||||||
|
keyword.value = suggestion
|
||||||
|
handleSearch()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -227,10 +242,13 @@ const handleKeyDown = () => {
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<t-input
|
<t-input
|
||||||
|
ref="inputRef"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
placeholder="搜索音乐、歌手"
|
placeholder="搜索音乐、歌手"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@enter="handleKeyDown"
|
@enter="handleKeyDown"
|
||||||
|
@focus="SearchStore.setFocus(true)"
|
||||||
|
@blur="SearchStore.setFocus(false)"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<t-button
|
<t-button
|
||||||
@@ -244,6 +262,7 @@ const handleKeyDown = () => {
|
|||||||
</t-button>
|
</t-button>
|
||||||
</template>
|
</template>
|
||||||
</t-input>
|
</t-input>
|
||||||
|
<SearchSuggest @to-search="handleSuggestionSelect" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TitleBarControls></TitleBarControls>
|
<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 './assets/base.css'
|
||||||
import 'animate.css'
|
import 'animate.css'
|
||||||
|
|
||||||
// 引入组件库的少量全局样式变量
|
|
||||||
// import 'tdesign-vue-next/es/style/index.css' //tdesign 组件样式
|
|
||||||
|
|
||||||
// 引入iconfont图标样式
|
// 引入iconfont图标样式
|
||||||
import './assets/icon_font/iconfont.css'
|
import './assets/icon_font/iconfont.css'
|
||||||
import './assets/icon_font/iconfont.js'
|
import './assets/icon_font/iconfont.js'
|
||||||
|
// vue
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
import { createPinia } from 'pinia'
|
// router
|
||||||
import router from './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(router)
|
||||||
app.use(createPinia())
|
|
||||||
|
//app
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import type {
|
|||||||
* @property {string} url - 音频URL。
|
* @property {string} url - 音频URL。
|
||||||
*/
|
*/
|
||||||
let userInfo: any
|
let userInfo: any
|
||||||
export const ControlAudioStore = defineStore('controlAudio', () => {
|
export const ControlAudioStore = defineStore(
|
||||||
|
'controlAudio',
|
||||||
|
() => {
|
||||||
const Audio = reactive<ControlAudioState>({
|
const Audio = reactive<ControlAudioState>({
|
||||||
audio: null,
|
audio: null,
|
||||||
isPlay: false,
|
isPlay: false,
|
||||||
@@ -219,4 +221,8 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
|
|||||||
clearEventSubscribers,
|
clearEventSubscribers,
|
||||||
setDuration
|
setDuration
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
persist: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
|||||||
import type { SongList } from '@renderer/types/audio'
|
import type { SongList } from '@renderer/types/audio'
|
||||||
import type { UserInfo } from '@renderer/types/userInfo'
|
import type { UserInfo } from '@renderer/types/userInfo'
|
||||||
|
|
||||||
export const LocalUserDetailStore = defineStore('Local', () => {
|
export const LocalUserDetailStore = defineStore(
|
||||||
|
'Local',
|
||||||
|
() => {
|
||||||
const list = ref<SongList[]>([])
|
const list = ref<SongList[]>([])
|
||||||
const userInfo = ref<UserInfo>({})
|
const userInfo = ref<UserInfo>({})
|
||||||
const initialization = ref(false)
|
const initialization = ref(false)
|
||||||
@@ -107,4 +109,8 @@ export const LocalUserDetailStore = defineStore('Local', () => {
|
|||||||
clearList,
|
clearList,
|
||||||
userSource
|
userSource
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
persist: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export interface SettingsState {
|
|||||||
tagWriteOptions?: TagWriteOptions
|
tagWriteOptions?: TagWriteOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore(
|
||||||
|
'settings',
|
||||||
|
() => {
|
||||||
// 从本地存储加载设置,如果没有则使用默认值
|
// 从本地存储加载设置,如果没有则使用默认值
|
||||||
const loadSettings = (): SettingsState => {
|
const loadSettings = (): SettingsState => {
|
||||||
try {
|
try {
|
||||||
@@ -63,4 +65,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
updateSettings,
|
updateSettings,
|
||||||
toggleFloatBall
|
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', {
|
export const searchValue = defineStore('search', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
value: ''
|
value: '',
|
||||||
|
focus: false
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
getValue: (state) => state.value
|
getValue: (state) => state.value,
|
||||||
|
getFocus: (state) => state.focus
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
setValue(value: string) {
|
setValue(value: string) {
|
||||||
this.value = value
|
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'
|
: importPlatformType === 'kw'
|
||||||
? '支持链接或ID:http://www.kuwo.cn/playlist_detail/123456789 或 123456789'
|
? '支持链接或ID:http://www.kuwo.cn/playlist_detail/123456789 或 123456789'
|
||||||
: importPlatformType === 'kg'
|
: importPlatformType === 'kg'
|
||||||
? '支持链接或ID:https://www.kugou.com/yy/special/single/123456789 或 123456789'
|
? '手机链接或酷狗码:https://www.kugou.com/yy/special/single/123456789 或 123456789'
|
||||||
: importPlatformType === 'mg'
|
: importPlatformType === 'mg'
|
||||||
? '支持链接或ID:https://music.migu.cn/v3/music/playlist/123456789 或 123456789'
|
? '支持链接或ID:https://music.migu.cn/v3/music/playlist/123456789 或 123456789'
|
||||||
: '请输入歌单链接或ID'
|
: '请输入歌单链接或ID'
|
||||||
@@ -1369,11 +1369,11 @@ onMounted(() => {
|
|||||||
<li>其他包含ID的酷我音乐链接格式</li>
|
<li>其他包含ID的酷我音乐链接格式</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-else-if="importPlatformType === 'kg'" class="tip-list">
|
<ul v-else-if="importPlatformType === 'kg'" class="tip-list">
|
||||||
|
<li>酷狗码(推荐):123456789</li>
|
||||||
<li>完整链接:https://www.kugou.com/yy/special/single/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/songlist/gcid_3z9vj0yqz4bz00b</li>
|
||||||
<li>旧版手机链接:https://m.kugou.com/playlist?id=123456789</li>
|
<li>旧版手机链接:https://m.kugou.com/playlist?id=123456789</li>
|
||||||
<li>参数链接:https://www.kugou.com/playlist?specialid=123456789</li>
|
<li>参数链接:https://www.kugou.com/playlist?specialid=123456789</li>
|
||||||
<li>纯数字ID:123456789</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-else-if="importPlatformType === 'mg'" class="tip-list">
|
<ul v-else-if="importPlatformType === 'mg'" class="tip-list">
|
||||||
<li>完整链接:https://music.migu.cn/v3/music/playlist/123456789</li>
|
<li>完整链接:https://music.migu.cn/v3/music/playlist/123456789</li>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<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 { searchValue } from '@renderer/store/search'
|
||||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||||
import { MessagePlugin } from 'tdesign-vue-next'
|
import { MessagePlugin } from 'tdesign-vue-next'
|
||||||
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
|
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
interface MusicItem {
|
interface MusicItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -32,14 +33,22 @@ const totalItems = ref(0)
|
|||||||
const currentSong = ref<MusicItem | null>(null)
|
const currentSong = ref<MusicItem | null>(null)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const search = searchValue()
|
const search = searchValue()
|
||||||
|
const router = useRouter()
|
||||||
onMounted(async () => {
|
onActivated(async () => {
|
||||||
const localUserStore = LocalUserDetailStore()
|
const localUserStore = LocalUserDetailStore()
|
||||||
|
console.log('sqjsqj', search.getValue)
|
||||||
|
|
||||||
|
if (search.getValue.trim() === '') {
|
||||||
|
console.log('跳转')
|
||||||
|
router.push({ name: 'find' })
|
||||||
|
}
|
||||||
watch(
|
watch(
|
||||||
search,
|
search,
|
||||||
async () => {
|
async () => {
|
||||||
|
if (search.getFocus == true || search.getValue.trim() == keyword.value.trim()) return
|
||||||
keyword.value = search.getValue
|
keyword.value = search.getValue
|
||||||
|
console.log('search', search)
|
||||||
|
|
||||||
await performSearch(true)
|
await performSearch(true)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
Reference in New Issue
Block a user