feat: 搜索联想 fix: 网易云歌单导入数量限制1000的问题

This commit is contained in:
sqj
2025-10-06 22:54:10 +08:00
parent fdd548972c
commit 489e920b69
28 changed files with 1359 additions and 1442 deletions

View File

@@ -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**: 初始版本,支持基础的音乐数据获取功能

View File

@@ -3,7 +3,7 @@
## 基础使用
1. 默认情况下,播放 **「搜索」「歌单」** 双击中的歌曲时,会自动将该歌曲添加到 **「列表」** 中的末尾后并不会播放,这与手动将歌曲添加到 **「列表」** 等价,亦或是通过点击歌曲前面小三角播放<img src="./assets/image-20250916132248046.png" alt="image-20250916132248046" style="float:right" />可以添加到歌曲开头也可进行该歌曲添加到 **「列表」** 中的开头并播放。歌曲会按照你选择的播放顺序进行播放。
2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**<img src="./assets/image-20250916133531421.png" alt="image-20250916133531421" style="zoom: 20%;" />
2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**<img src="./assets/image-20250916133531421.png" alt="image-20250916133531421" style="zoom: 50%;" />
## 歌曲列表的导出和分享

View File

@@ -6,6 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
import wasm from 'vite-plugin-wasm'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
@@ -37,13 +38,20 @@ export default defineConfig({
library: 'vue-next'
})
],
imports: [
'vue',
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
}
],
dts: true
}),
Components({
resolvers: [
TDesignResolver({
library: 'vue-next'
})
}),
NaiveUiResolver()
],
dts: true
})

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.12",
"version": "1.3.13",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -71,6 +71,7 @@
"node-fetch": "2",
"node-id3": "^0.2.9",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
@@ -88,12 +89,14 @@
"@types/node": "^22.16.5",
"@types/node-fetch": "^2.6.13",
"@vitejs/plugin-vue": "^6.0.0",
"@vueuse/core": "^13.9.0",
"electron": "^38.1.0",
"electron-builder": "^25.1.8",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^4.0.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "^10.3.0",
"naive-ui": "^2.43.1",
"prettier": "^3.6.2",
"sass-embedded": "^1.90.0",
"scss": "^0.2.4",

View File

@@ -24,3 +24,15 @@ export function request<T extends keyof MainApi>(
}
}
ipcMain.handle('service-music-sdk-request', request)
// 处理搜索联想请求
ipcMain.handle('service-music-tip-search', async (_, source, keyword) => {
try {
if (!source) throw new Error('请配置音源')
const Api = main(source)
return await Api.tipSearch({ keyword })
} catch (error: any) {
console.error('搜索联想错误:', error)
return { result: { songs: [], order: ['songs'] } }
}
})

View File

@@ -7,427 +7,13 @@ import {
PlaylistResult,
GetSongListDetailsArg,
PlaylistDetailResult,
DownloadSingleSongArgs
DownloadSingleSongArgs,
TipSearchResult
} from './type'
import pluginService from '../plugin/index'
import musicSdk from '../../utils/musicSdk/index'
import { musicCacheService } from '../musicCache'
import path from 'node:path'
import fs from 'fs'
import fsPromise from 'fs/promises'
import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
import { configManager } from '../ConfigManager'
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
const fileLock: Record<string, boolean> = {}
/**
* 转换LRC格式
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
* @param lrcContent 原始LRC内容
* @returns 转换后的LRC内容
*/
function convertLrcFormat(lrcContent: string): string {
if (!lrcContent) return ''
const lines = lrcContent.split('\n')
const convertedLines: string[] = []
for (const line of lines) {
// 跳过空行
if (!line.trim()) {
convertedLines.push(line)
continue
}
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
if (newFormatMatch) {
const [, startTimeMs, , content] = newFormatMatch
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
convertedLines.push(convertedLine)
continue
}
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
if (oldFormatMatch) {
const [, timestamp, content] = oldFormatMatch
// 如果内容中没有位置信息,直接返回原行
if (!content.includes('(') || !content.includes(')')) {
convertedLines.push(line)
continue
}
const convertedLine = convertOldFormat(timestamp, content)
convertedLines.push(convertedLine)
continue
}
// 其他行直接保留
convertedLines.push(line)
}
return convertedLines.join('\n')
}
/**
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
* @param timeMs 毫秒时间戳
* @returns 格式化的时间字符串
*/
function formatTimestamp(timeMs: number): string {
const minutes = Math.floor(timeMs / 60000)
const seconds = Math.floor((timeMs % 60000) / 1000)
const milliseconds = timeMs % 1000
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
function convertNewFormat(baseTimeMs: number, content: string): string {
const baseTimestamp = formatTimestamp(baseTimeMs)
let convertedContent = `<${baseTimestamp}>`
// 匹配模式:(开始时间,字符持续时间,0)字符
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
let match
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [, charStartMs, , , char] = match
const charTimeMs = parseInt(charStartMs)
const charTimestamp = formatTimestamp(charTimeMs)
if (isFirstChar) {
// 第一个字符直接添加
convertedContent += char.trim()
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char.trim()}`
}
}
return `[${baseTimestamp}]${convertedContent}`
}
/**
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
*/
function convertOldFormat(timestamp: string, content: string): string {
// 解析基础时间戳(毫秒)
const [minutes, seconds] = timestamp.split(':')
const [sec, ms] = seconds.split('.')
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
let convertedContent = `<${timestamp}>`
// 匹配所有字符(偏移,持续时间)的模式
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
let match
let lastIndex = 0
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [fullMatch, char, offsetMs, _durationMs] = match
const charTimeMs = baseTimeMs + parseInt(offsetMs)
const charTimestamp = formatTimestamp(charTimeMs)
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const beforeText = content.substring(lastIndex, match.index)
if (beforeText.trim()) {
convertedContent += beforeText
}
}
// 添加带时间戳的字符
if (isFirstChar) {
// 第一个字符直接添加,不需要额外的时间戳
convertedContent += char
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char}`
}
lastIndex = match.index + fullMatch.length
}
// 添加剩余的普通文本
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex)
if (remainingText.trim()) {
convertedContent += remainingText
}
}
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
// 获取文件扩展名来判断格式
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
console.log('文件格式:', fileExtension)
// 根据文件格式选择不同的标签写入方法
if (fileExtension === 'mp3') {
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
} else {
console.warn('不支持的音频格式:', fileExtension)
// 尝试使用 NodeID3 作为后备方案
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
// MP3 格式标签写入
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('MP3音频标签写入成功:', filePath)
} else {
console.warn('MP3音频标签写入失败:', filePath)
}
}
}
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入 FLAC 标签:', filePath)
// 准备新的标签数据
const newTags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
if (songInfo.name) newTags.TITLE = songInfo.name
if (songInfo.singer) newTags.ARTIST = songInfo.singer
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
if (songInfo.year) newTags.DATE = songInfo.year.toString()
if (songInfo.genre) newTags.GENRE = songInfo.genre
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
newTags.LYRICS = convertedLrc
}
console.log('准备写入的标签:', newTags)
// 使用 ffmpeg-static 写入 FLAC 标签
if (path.extname(filePath).toLowerCase() === '.flac') {
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
} else {
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
}
} catch (error) {
console.error('写入 Vorbis Comment 标签失败:', error)
throw error
}
}
// 使用 fluent-ffmpeg 写入 FLAC 标签
async function writeFLACTagsWithFFmpeg(
filePath: string,
tags: any,
songInfo: any,
tagWriteOptions: any
) {
let tempOutputPath: string | null = null
let tempCoverPath: string | null = null
try {
if (!ffmpegStatic) {
throw new Error('ffmpeg-static 不可用')
}
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
// 创建临时输出文件
tempOutputPath = filePath + '.temp.flac'
// 创建 fluent-ffmpeg 实例
let command = ffmpeg(filePath)
.audioCodec('copy') // 复制音频编解码器,不重新编码
.output(tempOutputPath)
// 添加元数据标签
for (const [key, value] of Object.entries(tags)) {
if (value) {
// fluent-ffmpeg 会自动处理特殊字符转义
command = command.outputOptions(['-metadata', `${key}=${value}`])
}
}
// 处理封面
if (tagWriteOptions.cover && songInfo.img) {
try {
console.log('开始下载封面:', songInfo.img)
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
// 保存临时封面文件
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
// 添加封面作为输入
command = command.input(tempCoverPath).outputOptions([
'-map',
'0:a', // 映射原始文件的音频流
'-map',
'1:v', // 映射封面的视频流
'-c:v',
'copy', // 复制视频编解码器
'-disposition:v:0',
'attached_pic' // 设置为附加图片
])
console.log('封面已添加到命令中')
}
} catch (coverError) {
console.warn(
'下载封面失败,跳过封面写入:',
coverError instanceof Error ? coverError.message : coverError
)
}
}
// 执行 ffmpeg 命令
await new Promise<void>((resolve, reject) => {
command
.on('start', () => {
console.log('执行 ffmpeg 命令')
})
.on('progress', (progress) => {
if (progress.percent) {
console.log('处理进度:', Math.round(progress.percent) + '%')
}
})
.on('end', () => {
console.log('ffmpeg 处理完成')
resolve()
})
.on('error', (err, _, stderr) => {
console.error('ffmpeg 错误:', err.message)
if (stderr) {
console.error('ffmpeg stderr:', stderr)
}
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
})
.run()
})
// 检查临时文件是否创建成功
if (!fs.existsSync(tempOutputPath)) {
throw new Error('ffmpeg 未能创建输出文件')
}
// 替换原文件
await fsPromise.rename(tempOutputPath, filePath)
tempOutputPath = null // 标记已处理,避免重复清理
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
} catch (error) {
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
throw error
} finally {
// 清理所有临时文件
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
for (const tempFile of filesToClean) {
try {
if (fs.existsSync(tempFile)) {
await fsPromise.unlink(tempFile)
console.log('已清理临时文件:', tempFile)
}
} catch (cleanupError) {
console.warn(
'清理临时文件失败:',
tempFile,
cleanupError instanceof Error ? cleanupError.message : cleanupError
)
}
}
}
}
// MP4/M4A 格式标签写入
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
// 可以使用 ffmpeg 或其他工具实现
}
import download from '../../utils/downloadSongs'
function main(source: string) {
const Api = musicSdk[source]
@@ -436,6 +22,14 @@ function main(source: string) {
return (await Api.musicSearch.search(keyword, page, limit)) as Promise<SearchResult>
},
async tipSearch({ keyword }: { keyword: string }) {
if (!Api.tipSearch?.tipSearchBySong) {
// 如果音乐源没有实现tipSearch方法返回空结果
return [] as TipSearchResult
}
return (await Api.tipSearch.search(keyword)) as Promise<TipSearchResult>
},
async getMusicUrl({ pluginId, songInfo, quality }: GetMusicUrlArg) {
try {
const usePlugin = pluginService.getPluginById(pluginId)
@@ -506,93 +100,7 @@ function main(source: string) {
}: DownloadSingleSongArgs) {
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
// 使用配置管理服务获取下载目录
const directories = configManager.getDirectories()
return directories.downloadDir
}
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
// 验证是否为常见的音频格式
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
if (validExtensions.includes(extension)) {
return extension
}
}
} catch (error) {
console.warn('解析URL失败使用默认扩展名:', error)
}
return 'mp3' // 默认扩展名
}
const fileExtension = getFileExtension(url)
const downloadDir = getDownloadDirectory()
const songPath = path.join(
downloadDir,
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.trim()
)
if (fileLock[songPath]) {
throw new Error('歌曲正在下载中')
} else {
fileLock[songPath] = true
}
try {
if (fs.existsSync(songPath)) {
return {
message: '歌曲已存在'
}
}
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
if (url.startsWith('file://')) {
const filePath = fileURLToPath(url)
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(songPath)
await pipeline(readStream, writeStream)
} else {
const songDataRes = await axios({
method: 'GET',
url: url,
responseType: 'stream'
})
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
}
} finally {
delete fileLock[songPath]
}
// 写入标签信息
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
}
}
return {
message: '下载成功',
path: songPath
}
return await download(url, songInfo, tagWriteOptions)
},
async parsePlaylistId({ url }: { url: string }) {

View File

@@ -100,3 +100,6 @@ export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}
// 搜索联想结果的类型定义
export type TipSearchResult = string[]

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

View File

@@ -5,10 +5,10 @@ import pic from './pic'
import lyric from './lyric'
import hotSearch from './hotSearch'
import comment from './comment'
// import tipSearch from './tipSearch'
import tipSearch from './tipSearch'
const kg = {
// tipSearch,
tipSearch,
leaderboard,
songList,
musicSearch,

View File

@@ -5,10 +5,10 @@ import pic from './pic'
import lyric from './lyric'
import hotSearch from './hotSearch'
import comment from './comment'
// import tipSearch from './tipSearch'
import tipSearch from './tipSearch'
const mg = {
// tipSearch,
tipSearch,
songList,
musicSearch,
leaderboard,

View File

@@ -4,10 +4,10 @@ import songList from './songList'
import musicSearch from './musicSearch'
import hotSearch from './hotSearch'
import comment from './comment'
// import tipSearch from './tipSearch'
import tipSearch from './tipSearch'
const tx = {
// tipSearch,
tipSearch,
leaderboard,
songList,
musicSearch,

View File

@@ -5,9 +5,10 @@ import musicSearch from './musicSearch'
import songList from './songList'
import hotSearch from './hotSearch'
import comment from './comment'
import tipSearch from './tipSearch'
const wy = {
// tipSearch,
tipSearch,
leaderboard,
musicSearch,
songList,
@@ -25,4 +26,4 @@ const wy = {
}
}
export default wy
export default wy

View File

@@ -30,7 +30,7 @@ const api = {
// 音乐相关方法
music: {
requestSdk: (api: string, args: any) =>
ipcRenderer.invoke('service-music-sdk-request', api, args)
ipcRenderer.invoke('service-music-sdk-request', api, args),
},
//音源插件
plugins: {

View File

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

View File

@@ -18,6 +18,9 @@ declare module 'vue' {
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NCard: typeof import('naive-ui')['NCard']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NText: typeof import('naive-ui')['NText']
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
@@ -26,6 +29,7 @@ declare module 'vue' {
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchSuggest: typeof import('./src/components/search/searchSuggest.vue')['default']
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
TAlert: typeof import('tdesign-vue-next')['Alert']

View File

@@ -10,9 +10,10 @@
-->
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useAutoUpdate } from './composables/useAutoUpdate'
import { NConfigProvider, darkTheme, NGlobalStyle } from 'naive-ui'
const userInfo = LocalUserDetailStore()
const { checkForUpdates } = useAutoUpdate()
@@ -27,6 +28,8 @@ onMounted(() => {
userInfo.init()
setupSystemThemeListener()
loadSavedTheme()
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
@@ -43,6 +46,31 @@ const themes = [
{ name: 'orange', label: '橙色', color: '#fb9458' }
]
const naiveTheme = ref<any>(null)
const themeOverrides = ref<any>({})
function syncNaiveTheme() {
const docEl = document.documentElement
const savedDarkMode = localStorage.getItem('dark-mode')
const isDark = savedDarkMode === 'true'
naiveTheme.value = isDark ? darkTheme : null
const computed = getComputedStyle(docEl)
const primary = (computed.getPropertyValue('--td-brand-color') || '').trim()
const savedThemeName = localStorage.getItem('selected-theme') || 'default'
const fallback = themes.find((t) => t.name === savedThemeName)?.color || '#2ba55b'
const mainColor = primary || fallback
themeOverrides.value = {
common: {
primaryColor: mainColor,
primaryColorHover: mainColor,
primaryColorPressed: mainColor
}
}
}
const loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
const savedDarkMode = localStorage.getItem('dark-mode')
@@ -86,6 +114,9 @@ const applyTheme = (themeName, darkMode = false) => {
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
// 同步 Naive UI 主题
syncNaiveTheme()
}
// 检测系统主题偏好
@@ -113,20 +144,23 @@ const setupSystemThemeListener = () => {
</script>
<template>
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NGlobalStyle />
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
</NConfigProvider>
</template>
<style>
.pagesApp {

View File

@@ -64,6 +64,9 @@ const applyTheme = (themeName: string, darkMode: boolean = false) => {
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
// 通知全局App.vue同步 Naive UI 主题
window.dispatchEvent(new CustomEvent('theme-changed'))
}
// 选择主题

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
import SearchSuggest from '@renderer/components/search/searchSuggest.vue'
import { SearchIcon } from 'tdesign-icons-vue-next'
import { onMounted, ref, watchEffect, computed } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useRouter } from 'vue-router'
import { searchValue } from '@renderer/store/search'
import { useSearchStore } from '@renderer/store'
onMounted(() => {
const LocalUserDetail = LocalUserDetailStore()
@@ -128,18 +129,17 @@ const goForward = (): void => {
// 搜索相关
const keyword = ref('')
const SearchStore = useSearchStore()
const inputRef = ref<any>(null)
// 搜索类型1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
// const searchType = ref(1)
// 处理搜索事件
const handleSearch = async () => {
if (!keyword.value.trim()) return
const useSearch = searchValue()
if (!SearchStore.getValue.trim()) return
// 重新设置搜索关键字
try {
// 跳转到搜索结果页面,并传递搜索结果和关键词
useSearch.setValue(keyword.value.trim()) // 设置搜索关键字
router.push({
path: '/home/search'
})
@@ -151,6 +151,21 @@ const handleSearch = async () => {
// 处理按键事件,按下回车键时触发搜索
const handleKeyDown = () => {
handleSearch()
// 回车后取消输入框焦点
inputRef.value?.blur?.()
}
// 监听输入变化更新SearchStore
watchEffect(() => {
SearchStore.setValue(keyword.value)
})
// 处理搜索建议选择
const handleSuggestionSelect = (suggestion: any, _type: any) => {
console.log(111)
keyword.value = suggestion
handleSearch()
}
</script>
@@ -227,10 +242,13 @@ const handleKeyDown = () => {
</div>
</transition>
<t-input
ref="inputRef"
v-model="keyword"
placeholder="搜索音乐、歌手"
style="width: 100%"
@enter="handleKeyDown"
@focus="SearchStore.setFocus(true)"
@blur="SearchStore.setFocus(false)"
>
<template #suffix>
<t-button
@@ -244,6 +262,7 @@ const handleKeyDown = () => {
</t-button>
</template>
</t-input>
<SearchSuggest @to-search="handleSuggestionSelect" />
</div>
<TitleBarControls></TitleBarControls>

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

View File

@@ -1,20 +1,25 @@
// 基础样式
import './assets/base.css'
import 'animate.css'
// 引入组件库的少量全局样式变量
// import 'tdesign-vue-next/es/style/index.css' //tdesign 组件样式
// 引入iconfont图标样式
import './assets/icon_font/iconfont.css'
import './assets/icon_font/iconfont.js'
// vue
import App from './App.vue'
import { createApp } from 'vue'
const app = createApp(App)
import { createPinia } from 'pinia'
// router
import router from './router'
// pinia
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// pinia
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
//router
app.use(router)
app.use(createPinia())
//app
app.mount('#app')

View File

@@ -21,202 +21,208 @@ import type {
* @property {string} url - 音频URL。
*/
let userInfo: any
export const ControlAudioStore = defineStore('controlAudio', () => {
const Audio = reactive<ControlAudioState>({
audio: null,
isPlay: false,
currentTime: 0,
duration: 0,
volume: 80,
url: ''
})
// -------------------------------------------发布订阅逻辑------------------------------------------
// 事件订阅者映射表
/**
* 音频事件订阅与发布逻辑。
* @property {Record<AudioEventType, AudioSubscriber[]>} subscribers - 事件订阅者映射表。
*/
const subscribers = reactive<Record<AudioEventType, AudioSubscriber[]>>({
ended: [],
seeked: [],
timeupdate: [],
play: [],
pause: [],
error: [],
canplay: []
})
// 生成唯一ID
const generateId = (): string => {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 订阅事件
/**
* 订阅音频事件。
* @param {AudioEventType} eventType - 事件类型。
* @param {AudioEventCallback} callback - 事件回调函数。
* @returns {UnsubscribeFunction} 取消订阅的函数。
*/
const subscribe = (
eventType: AudioEventType,
callback: AudioEventCallback
): UnsubscribeFunction => {
const id = generateId()
const subscriber: AudioSubscriber = { id, callback }
subscribers[eventType].push(subscriber)
// 返回取消订阅函数
return () => {
const index = subscribers[eventType].findIndex((sub) => sub.id === id)
if (index > -1) {
subscribers[eventType].splice(index, 1)
}
}
}
// 发布事件
const publish = (eventType: AudioEventType): void => {
subscribers[eventType].forEach((subscriber) => {
try {
subscriber.callback()
} catch (error) {
console.error(`音频事件回调执行错误 [${eventType}]:`, error)
}
export const ControlAudioStore = defineStore(
'controlAudio',
() => {
const Audio = reactive<ControlAudioState>({
audio: null,
isPlay: false,
currentTime: 0,
duration: 0,
volume: 80,
url: ''
})
}
// 清空所有订阅者
const clearAllSubscribers = (): void => {
Object.keys(subscribers).forEach((eventType) => {
subscribers[eventType as AudioEventType] = []
// -------------------------------------------发布订阅逻辑------------------------------------------
// 事件订阅者映射表
/**
* 音频事件订阅与发布逻辑。
* @property {Record<AudioEventType, AudioSubscriber[]>} subscribers - 事件订阅者映射表。
*/
const subscribers = reactive<Record<AudioEventType, AudioSubscriber[]>>({
ended: [],
seeked: [],
timeupdate: [],
play: [],
pause: [],
error: [],
canplay: []
})
}
// 清空特定事件的所有订阅者
const clearEventSubscribers = (eventType: AudioEventType): void => {
subscribers[eventType] = []
}
// End-------------------------------------------事件订阅者映射表逻辑------------------------------------------
// 初始化
const init = (el: ControlAudioState['audio']) => {
userInfo = LocalUserDetailStore()
console.log(el, '全局音频挂载初始化success')
Audio.audio = el
}
/**
* 设置当前播放时间。
* @param {number} time - 播放时间(秒)。
* @throws {Error} 如果时间不是数字类型。
*/
const setCurrentTime = (time: number) => {
if (typeof time === 'number') {
Audio.currentTime = time
return
// 生成唯一ID
const generateId = (): string => {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
throw new Error('时间必须是数字类型')
}
const setDuration = (duration: number) => {
if (typeof duration === 'number') {
Audio.duration = duration
return
}
throw new Error('时间必须是数字类型')
}
/**
* 设置音量。
* @param {number} volume - 音量0-100
* @param {boolean} transition - 是否使用渐变。
* @throws {Error} 如果音量不在0-100之间。
*/
const setVolume = (volume: number, transition: boolean = false) => {
if (typeof volume === 'number' && volume >= 0 && volume <= 100) {
if (Audio.audio) {
if (Audio.isPlay && transition) {
transitionVolume(Audio.audio, volume / 100, Audio.volume <= volume)
} else {
Audio.audio.volume = Number((volume / 100).toFixed(2))
// 订阅事件
/**
* 订阅音频事件。
* @param {AudioEventType} eventType - 事件类型。
* @param {AudioEventCallback} callback - 事件回调函数。
* @returns {UnsubscribeFunction} 取消订阅的函数。
*/
const subscribe = (
eventType: AudioEventType,
callback: AudioEventCallback
): UnsubscribeFunction => {
const id = generateId()
const subscriber: AudioSubscriber = { id, callback }
subscribers[eventType].push(subscriber)
// 返回取消订阅函数
return () => {
const index = subscribers[eventType].findIndex((sub) => sub.id === id)
if (index > -1) {
subscribers[eventType].splice(index, 1)
}
Audio.volume = volume
userInfo.userInfo.volume = volume
}
} else {
if (typeof volume === 'number' && Audio.audio) {
if (volume <= 0) {
Audio.volume = 0
Audio.audio.volume = 0
userInfo.userInfo.volume = 0
} else {
Audio.volume = 100
Audio.audio.volume = 100
userInfo.userInfo.volume = 100
}
// 发布事件
const publish = (eventType: AudioEventType): void => {
subscribers[eventType].forEach((subscriber) => {
try {
subscriber.callback()
} catch (error) {
console.error(`音频事件回调执行错误 [${eventType}]:`, error)
}
} else {
throw new Error('音量必须是0-100之间的数字')
}
}
}
/**
* 设置音频URL。
* @param {string} url - 音频URL。
* @throws {Error} 如果URL为空或无效。
*/
const setUrl = (url: string) => {
if (typeof url !== 'string' || url.trim() === '') {
throw new Error('音频URL不能为空')
}
// 停止当前播放
if (Audio.isPlay) {
stop()
}
Audio.url = url.trim()
console.log('音频URL已设置:', Audio.url)
}
const start = async () => {
const volume = Audio.volume
if (Audio.audio) {
Audio.audio.volume = 0
try {
await Audio.audio.play()
Audio.isPlay = true
return transitionVolume(Audio.audio, volume / 100, true, true)
} catch (error) {
Audio.audio.volume = volume / 100
console.error('音频播放失败:', error)
Audio.isPlay = false
throw new Error('音频播放失败请检查音频URL是否有效')
}
}
return false
}
const stop = () => {
if (Audio.audio) {
Audio.isPlay = false
return transitionVolume(Audio.audio, Audio.volume / 100, false, true).then(() => {
Audio.audio?.pause()
})
}
return false
}
return {
Audio,
init,
setCurrentTime,
setVolume,
setUrl,
start,
stop,
subscribe,
publish,
clearAllSubscribers,
clearEventSubscribers,
setDuration
// 清空所有订阅者
const clearAllSubscribers = (): void => {
Object.keys(subscribers).forEach((eventType) => {
subscribers[eventType as AudioEventType] = []
})
}
// 清空特定事件的所有订阅者
const clearEventSubscribers = (eventType: AudioEventType): void => {
subscribers[eventType] = []
}
// End-------------------------------------------事件订阅者映射表逻辑------------------------------------------
// 初始化
const init = (el: ControlAudioState['audio']) => {
userInfo = LocalUserDetailStore()
console.log(el, '全局音频挂载初始化success')
Audio.audio = el
}
/**
* 设置当前播放时间。
* @param {number} time - 播放时间(秒)。
* @throws {Error} 如果时间不是数字类型。
*/
const setCurrentTime = (time: number) => {
if (typeof time === 'number') {
Audio.currentTime = time
return
}
throw new Error('时间必须是数字类型')
}
const setDuration = (duration: number) => {
if (typeof duration === 'number') {
Audio.duration = duration
return
}
throw new Error('时间必须是数字类型')
}
/**
* 设置音量。
* @param {number} volume - 音量0-100
* @param {boolean} transition - 是否使用渐变。
* @throws {Error} 如果音量不在0-100之间。
*/
const setVolume = (volume: number, transition: boolean = false) => {
if (typeof volume === 'number' && volume >= 0 && volume <= 100) {
if (Audio.audio) {
if (Audio.isPlay && transition) {
transitionVolume(Audio.audio, volume / 100, Audio.volume <= volume)
} else {
Audio.audio.volume = Number((volume / 100).toFixed(2))
}
Audio.volume = volume
userInfo.userInfo.volume = volume
}
} else {
if (typeof volume === 'number' && Audio.audio) {
if (volume <= 0) {
Audio.volume = 0
Audio.audio.volume = 0
userInfo.userInfo.volume = 0
} else {
Audio.volume = 100
Audio.audio.volume = 100
userInfo.userInfo.volume = 100
}
} else {
throw new Error('音量必须是0-100之间的数字')
}
}
}
/**
* 设置音频URL。
* @param {string} url - 音频URL。
* @throws {Error} 如果URL为空或无效。
*/
const setUrl = (url: string) => {
if (typeof url !== 'string' || url.trim() === '') {
throw new Error('音频URL不能为空')
}
// 停止当前播放
if (Audio.isPlay) {
stop()
}
Audio.url = url.trim()
console.log('音频URL已设置:', Audio.url)
}
const start = async () => {
const volume = Audio.volume
if (Audio.audio) {
Audio.audio.volume = 0
try {
await Audio.audio.play()
Audio.isPlay = true
return transitionVolume(Audio.audio, volume / 100, true, true)
} catch (error) {
Audio.audio.volume = volume / 100
console.error('音频播放失败:', error)
Audio.isPlay = false
throw new Error('音频播放失败请检查音频URL是否有效')
}
}
return false
}
const stop = () => {
if (Audio.audio) {
Audio.isPlay = false
return transitionVolume(Audio.audio, Audio.volume / 100, false, true).then(() => {
Audio.audio?.pause()
})
}
return false
}
return {
Audio,
init,
setCurrentTime,
setVolume,
setUrl,
start,
stop,
subscribe,
publish,
clearAllSubscribers,
clearEventSubscribers,
setDuration
}
},
{
persist: false
}
})
)

View File

@@ -5,106 +5,112 @@ import { ControlAudioStore } from '@renderer/store/ControlAudio'
import type { SongList } from '@renderer/types/audio'
import type { UserInfo } from '@renderer/types/userInfo'
export const LocalUserDetailStore = defineStore('Local', () => {
const list = ref<SongList[]>([])
const userInfo = ref<UserInfo>({})
const initialization = ref(false)
function init(): void {
const UserInfoLocal = localStorage.getItem('userInfo')
const ListLocal = localStorage.getItem('songList')
if (UserInfoLocal) {
userInfo.value = JSON.parse(UserInfoLocal) as UserInfo
} else {
userInfo.value = {
lastPlaySongId: null,
topBarStyle: false,
mainColor: '#00DAC0',
volume: 80,
currentTime: 0,
selectSources: 'wy'
export const LocalUserDetailStore = defineStore(
'Local',
() => {
const list = ref<SongList[]>([])
const userInfo = ref<UserInfo>({})
const initialization = ref(false)
function init(): void {
const UserInfoLocal = localStorage.getItem('userInfo')
const ListLocal = localStorage.getItem('songList')
if (UserInfoLocal) {
userInfo.value = JSON.parse(UserInfoLocal) as UserInfo
} else {
userInfo.value = {
lastPlaySongId: null,
topBarStyle: false,
mainColor: '#00DAC0',
volume: 80,
currentTime: 0,
selectSources: 'wy'
}
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
}
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
if (ListLocal) {
list.value = JSON.parse(ListLocal) as SongList[]
} else {
list.value = []
localStorage.setItem('songList', JSON.stringify([]))
}
console.log('init local user detail')
initialization.value = true
const Audio = ControlAudioStore()
startWatch()
Audio.setVolume(userInfo.value.volume as number)
}
if (ListLocal) {
list.value = JSON.parse(ListLocal) as SongList[]
} else {
function startWatch() {
console.log('startWatch')
watch(
list,
(newVal) => {
localStorage.setItem('songList', JSON.stringify(newVal))
},
{
deep: true
}
)
watch(
userInfo,
(newVal) => {
localStorage.setItem('userInfo', JSON.stringify(newVal))
},
{
deep: true
}
)
}
function addSong(song: SongList) {
if (!list.value.find((item) => item.songmid === song.songmid)) {
list.value.push(song)
}
return list.value
}
function addSongToFirst(song: SongList) {
const existingIndex = list.value.findIndex((item) => item.songmid === song.songmid)
if (existingIndex !== -1) {
// 如果歌曲已存在,将其移动到第一位
const existingSong = list.value.splice(existingIndex, 1)[0]
list.value.unshift(existingSong)
} else {
// 如果歌曲不存在,添加到第一位
list.value.unshift(song)
}
return list.value
}
function removeSong(songId: number | string) {
const index = list.value.findIndex((item) => item.songmid === songId)
if (index !== -1) {
list.value.splice(index, 1)
}
}
function clearList() {
list.value = []
localStorage.setItem('songList', JSON.stringify([]))
}
console.log('init local user detail')
initialization.value = true
const Audio = ControlAudioStore()
startWatch()
Audio.setVolume(userInfo.value.volume as number)
}
function startWatch() {
console.log('startWatch')
watch(
list,
(newVal) => {
localStorage.setItem('songList', JSON.stringify(newVal))
},
{
deep: true
const userSource = computed(() => {
return {
pluginId: userInfo.value.pluginId,
source: userInfo.value.selectSources,
quality: userInfo.value.selectQuality
}
)
watch(
userInfo,
(newVal) => {
localStorage.setItem('userInfo', JSON.stringify(newVal))
},
{
deep: true
}
)
}
function addSong(song: SongList) {
if (!list.value.find((item) => item.songmid === song.songmid)) {
list.value.push(song)
}
return list.value
}
function addSongToFirst(song: SongList) {
const existingIndex = list.value.findIndex((item) => item.songmid === song.songmid)
if (existingIndex !== -1) {
// 如果歌曲已存在,将其移动到第一位
const existingSong = list.value.splice(existingIndex, 1)[0]
list.value.unshift(existingSong)
} else {
// 如果歌曲不存在,添加到第一位
list.value.unshift(song)
}
return list.value
}
function removeSong(songId: number | string) {
const index = list.value.findIndex((item) => item.songmid === songId)
if (index !== -1) {
list.value.splice(index, 1)
}
}
function clearList() {
list.value = []
}
const userSource = computed(() => {
})
return {
pluginId: userInfo.value.pluginId,
source: userInfo.value.selectSources,
quality: userInfo.value.selectQuality
list,
userInfo,
initialization,
init,
addSong,
addSongToFirst,
removeSong,
clearList,
userSource
}
})
return {
list,
userInfo,
initialization,
init,
addSong,
addSongToFirst,
removeSong,
clearList,
userSource
},
{
persist: false
}
})
)

View File

@@ -16,51 +16,57 @@ export interface SettingsState {
tagWriteOptions?: TagWriteOptions
}
export const useSettingsStore = defineStore('settings', () => {
// 从本地存储加载设置,如果没有则使用默认值
const loadSettings = (): SettingsState => {
try {
const savedSettings = localStorage.getItem('appSettings')
if (savedSettings) {
return JSON.parse(savedSettings)
export const useSettingsStore = defineStore(
'settings',
() => {
// 从本地存储加载设置,如果没有则使用默认值
const loadSettings = (): SettingsState => {
try {
const savedSettings = localStorage.getItem('appSettings')
if (savedSettings) {
return JSON.parse(savedSettings)
}
} catch (error) {
console.error('加载设置失败:', error)
}
// 默认设置
return {
showFloatBall: true,
tagWriteOptions: {
basicInfo: true,
cover: true,
lyrics: true
}
}
} catch (error) {
console.error('加载设置失败:', error)
}
// 默认设置
const settings = ref<SettingsState>(loadSettings())
// 保存设置到本地存储
const saveSettings = () => {
localStorage.setItem('appSettings', JSON.stringify(settings.value))
}
// 更新设置
const updateSettings = (newSettings: Partial<SettingsState>) => {
settings.value = { ...settings.value, ...newSettings }
saveSettings()
}
// 切换悬浮球显示状态
const toggleFloatBall = () => {
settings.value.showFloatBall = !settings.value.showFloatBall
saveSettings()
}
return {
showFloatBall: true,
tagWriteOptions: {
basicInfo: true,
cover: true,
lyrics: true
}
settings,
updateSettings,
toggleFloatBall
}
},
{
persist: false
}
const settings = ref<SettingsState>(loadSettings())
// 保存设置到本地存储
const saveSettings = () => {
localStorage.setItem('appSettings', JSON.stringify(settings.value))
}
// 更新设置
const updateSettings = (newSettings: Partial<SettingsState>) => {
settings.value = { ...settings.value, ...newSettings }
saveSettings()
}
// 切换悬浮球显示状态
const toggleFloatBall = () => {
settings.value.showFloatBall = !settings.value.showFloatBall
saveSettings()
}
return {
settings,
updateSettings,
toggleFloatBall
}
})
)

View File

@@ -0,0 +1 @@
export { searchValue as useSearchStore } from './search'

View File

@@ -2,14 +2,20 @@ import { defineStore } from 'pinia'
export const searchValue = defineStore('search', {
state: () => ({
value: ''
value: '',
focus: false
}),
getters: {
getValue: (state) => state.value
getValue: (state) => state.value,
getFocus: (state) => state.focus
},
actions: {
setValue(value: string) {
this.value = value
},
setFocus(focus: boolean) {
this.focus = focus
}
}
},
persist: false
})

View File

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

View File

@@ -1320,7 +1320,7 @@ onMounted(() => {
: importPlatformType === 'kw'
? '支持链接或IDhttp://www.kuwo.cn/playlist_detail/123456789 或 123456789'
: importPlatformType === 'kg'
? '支持链接或IDhttps://www.kugou.com/yy/special/single/123456789 或 123456789'
? '手机链接或酷狗码https://www.kugou.com/yy/special/single/123456789 或 123456789'
: importPlatformType === 'mg'
? '支持链接或IDhttps://music.migu.cn/v3/music/playlist/123456789 或 123456789'
: '请输入歌单链接或ID'
@@ -1369,11 +1369,11 @@ onMounted(() => {
<li>其他包含ID的酷我音乐链接格式</li>
</ul>
<ul v-else-if="importPlatformType === 'kg'" class="tip-list">
<li>酷狗码推荐123456789</li>
<li>完整链接https://www.kugou.com/yy/special/single/123456789</li>
<li>手机版链接https://m.kugou.com/songlist/gcid_3z9vj0yqz4bz00b</li>
<li>旧版手机链接https://m.kugou.com/playlist?id=123456789</li>
<li>参数链接https://www.kugou.com/playlist?specialid=123456789</li>
<li>纯数字ID123456789</li>
</ul>
<ul v-else-if="importPlatformType === 'mg'" class="tip-list">
<li>完整链接https://music.migu.cn/v3/music/playlist/123456789</li>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch, toRaw } from 'vue'
import { ref, computed, watch, toRaw } from 'vue'
import { searchValue } from '@renderer/store/search'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { MessagePlugin } from 'tdesign-vue-next'
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
import { useRouter } from 'vue-router'
interface MusicItem {
id: number
@@ -32,14 +33,22 @@ const totalItems = ref(0)
const currentSong = ref<MusicItem | null>(null)
const isPlaying = ref(false)
const search = searchValue()
onMounted(async () => {
const router = useRouter()
onActivated(async () => {
const localUserStore = LocalUserDetailStore()
console.log('sqjsqj', search.getValue)
if (search.getValue.trim() === '') {
console.log('跳转')
router.push({ name: 'find' })
}
watch(
search,
async () => {
if (search.getFocus == true || search.getValue.trim() == keyword.value.trim()) return
keyword.value = search.getValue
console.log('search', search)
await performSearch(true)
},
{ immediate: true }