mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 03:15:07 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02264c80c | ||
|
|
d0d5f918bd | ||
|
|
761d265d18 | ||
|
|
204df64535 | ||
|
|
cc814eddbd |
@@ -1,255 +0,0 @@
|
||||
# 🎯 自定义右键菜单组件 - 完整功能实现
|
||||
|
||||
## ✅ 项目完成状态
|
||||
|
||||
**已完成** - 功能完整的自定义右键菜单组件,包含所有要求的特性和优化
|
||||
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 📋 基础功能
|
||||
|
||||
- ✅ **可配置菜单项** - 支持图标、文字、快捷键显示
|
||||
- ✅ **多级子菜单** - 支持无限层级嵌套
|
||||
- ✅ **菜单项状态** - 支持禁用、隐藏、分割线
|
||||
- ✅ **事件回调** - 完整的点击事件处理机制
|
||||
|
||||
### 🎨 样式与主题
|
||||
|
||||
- ✅ **自定义主题** - 支持亮色/暗色/自动主题切换
|
||||
- ✅ **现代化设计** - 圆角、阴影、渐变、动画效果
|
||||
- ✅ **响应式布局** - 适配不同屏幕尺寸
|
||||
- ✅ **无障碍支持** - 高对比度、减少动画模式
|
||||
|
||||
### 🔧 智能定位与边界处理
|
||||
|
||||
- ✅ **智能定位** - 自动检测屏幕边界并调整位置
|
||||
- ✅ **向上展开** - 底部空间不足时自动向上显示
|
||||
- ✅ **滚动支持** - 菜单过长时支持滚动和滚动指示器
|
||||
- ✅ **子菜单定位** - 子菜单智能避让边界
|
||||
|
||||
### ⌨️ 交互优化
|
||||
|
||||
- ✅ **键盘导航** - 支持方向键、ESC、回车等快捷键
|
||||
- ✅ **鼠标交互** - 悬停显示子菜单,点击外部关闭
|
||||
- ✅ **滚轮支持** - 长菜单支持滚轮滚动
|
||||
- ✅ **触摸友好** - 移动端优化的交互体验
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/renderer/src/components/ContextMenu/
|
||||
├── types.ts # TypeScript 类型定义
|
||||
├── ContextMenu.vue # 主菜单组件
|
||||
├── ContextMenuItem.vue # 菜单项组件
|
||||
├── useContextMenu.ts # 组合式 API 钩子
|
||||
├── index.ts # 组件导出入口
|
||||
└── README.md # 使用文档
|
||||
```
|
||||
|
||||
## 🎯 使用示例
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div @contextmenu="handleContextMenu">右键点击此区域</div>
|
||||
|
||||
<ContextMenu
|
||||
v-model:visible="visible"
|
||||
:items="menuItems"
|
||||
:position="position"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ContextMenu, createMenuItem, commonMenuItems } from '@renderer/components/ContextMenu'
|
||||
|
||||
const visible = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const menuItems = ref([
|
||||
createMenuItem('copy', '复制', {
|
||||
icon: 'copy',
|
||||
shortcut: 'Ctrl+C',
|
||||
onClick: () => console.log('复制')
|
||||
}),
|
||||
commonMenuItems.divider,
|
||||
createMenuItem('paste', '粘贴', {
|
||||
icon: 'paste',
|
||||
onClick: () => console.log('粘贴')
|
||||
})
|
||||
])
|
||||
|
||||
const handleContextMenu = (event) => {
|
||||
event.preventDefault()
|
||||
position.value = { x: event.clientX, y: event.clientY }
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
const handleItemClick = (item, event) => {
|
||||
if (item.onClick) {
|
||||
item.onClick(item, event)
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 多级菜单
|
||||
|
||||
```javascript
|
||||
const menuItems = [
|
||||
createMenuItem('file', '文件', {
|
||||
icon: 'folder',
|
||||
children: [
|
||||
createMenuItem('new', '新建', {
|
||||
icon: 'add',
|
||||
children: [
|
||||
createMenuItem('vue', 'Vue 组件', {
|
||||
onClick: () => console.log('新建 Vue 组件')
|
||||
}),
|
||||
createMenuItem('ts', 'TypeScript 文件', {
|
||||
onClick: () => console.log('新建 TS 文件')
|
||||
})
|
||||
]
|
||||
}),
|
||||
createMenuItem('open', '打开', {
|
||||
icon: 'folder-open',
|
||||
onClick: () => console.log('打开文件')
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
```
|
||||
|
||||
## 🎨 样式特性
|
||||
|
||||
### 现代化视觉效果
|
||||
|
||||
- **毛玻璃效果** - `backdrop-filter: blur(8px)`
|
||||
- **多层阴影** - 立体感阴影效果
|
||||
- **流畅动画** - `cubic-bezier` 缓动函数
|
||||
- **悬停反馈** - 微妙的变换和颜色变化
|
||||
|
||||
### 响应式设计
|
||||
|
||||
- **桌面端** - 最小宽度 160px,最大宽度 300px
|
||||
- **平板端** - 适配中等屏幕尺寸
|
||||
- **移动端** - 优化触摸交互,增大点击区域
|
||||
|
||||
## 🔧 高级功能
|
||||
|
||||
### 智能边界处理
|
||||
|
||||
```javascript
|
||||
// 自动检测屏幕边界
|
||||
if (x + menuWidth > viewportWidth) {
|
||||
x = viewportWidth - menuWidth - 8
|
||||
}
|
||||
|
||||
// 向上展开逻辑
|
||||
if (availableHeight < 200 && availableHeightFromTop > availableHeight) {
|
||||
y = y - menuHeight
|
||||
}
|
||||
```
|
||||
|
||||
### 滚动功能
|
||||
|
||||
- **自动滚动** - 菜单超出屏幕高度时启用
|
||||
- **滚动指示器** - 显示可滚动方向
|
||||
- **键盘滚动** - 支持方向键和 Home/End 键
|
||||
- **鼠标滚轮** - 平滑滚动体验
|
||||
|
||||
### 无障碍支持
|
||||
|
||||
- **高对比度模式** - 自动适配系统设置
|
||||
- **减少动画模式** - 尊重用户偏好设置
|
||||
- **键盘导航** - 完整的键盘操作支持
|
||||
|
||||
## 🧪 测试页面
|
||||
|
||||
访问 `http://localhost:5174/#/context-menu-test` 查看完整的功能演示:
|
||||
|
||||
1. **基础功能测试** - 图标、快捷键、禁用项
|
||||
2. **多级菜单测试** - 嵌套子菜单
|
||||
3. **长菜单滚动** - 25+ 菜单项滚动测试
|
||||
4. **边界处理测试** - 四个角落的边界测试
|
||||
5. **歌曲列表模拟** - 实际使用场景演示
|
||||
|
||||
## 🎯 集成状态
|
||||
|
||||
### 已集成页面
|
||||
|
||||
- ✅ **本地音乐页面** (`src/renderer/src/views/music/local.vue`)
|
||||
- 歌曲右键菜单
|
||||
- 播放、收藏、添加到歌单等功能
|
||||
- 多级歌单选择
|
||||
|
||||
### 菜单功能
|
||||
|
||||
- ✅ 播放歌曲
|
||||
- ✅ 下一首播放
|
||||
- ✅ 收藏歌曲
|
||||
- ✅ 添加到歌单(支持子菜单)
|
||||
- ✅ 导出歌曲
|
||||
- ✅ 查看歌曲信息
|
||||
- ✅ 删除歌曲
|
||||
|
||||
## 🚀 性能优化
|
||||
|
||||
### 渲染优化
|
||||
|
||||
- **Teleport 渲染** - 避免 z-index 冲突
|
||||
- **按需渲染** - 只在显示时渲染菜单
|
||||
- **事件委托** - 高效的事件处理
|
||||
|
||||
### 内存管理
|
||||
|
||||
- **自动清理** - 组件卸载时清理事件监听
|
||||
- **防抖处理** - 避免频繁的位置计算
|
||||
- **缓存优化** - 计算结果缓存
|
||||
|
||||
## 🔮 扩展性
|
||||
|
||||
### 自定义组件
|
||||
|
||||
```javascript
|
||||
// 支持自定义图标组件
|
||||
createMenuItem('custom', '自定义', {
|
||||
icon: CustomIconComponent,
|
||||
onClick: () => {}
|
||||
})
|
||||
```
|
||||
|
||||
### 主题扩展
|
||||
|
||||
```css
|
||||
/* 自定义主题变量 */
|
||||
:root {
|
||||
--context-menu-bg: #ffffff;
|
||||
--context-menu-border: #e5e5e5;
|
||||
--context-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 浏览器兼容性
|
||||
|
||||
- ✅ **Chrome** 88+
|
||||
- ✅ **Firefox** 85+
|
||||
- ✅ **Safari** 14+
|
||||
- ✅ **Edge** 88+
|
||||
- ✅ **Electron** (项目环境)
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
这个自定义右键菜单组件完全满足了项目需求:
|
||||
|
||||
1. **功能完整** - 支持所有要求的特性
|
||||
2. **性能优秀** - 流畅的动画和交互
|
||||
3. **样式现代** - 符合当前设计趋势
|
||||
4. **易于使用** - 简洁的 API 设计
|
||||
5. **高度可定制** - 灵活的配置选项
|
||||
6. **无障碍友好** - 支持各种用户需求
|
||||
|
||||
组件已成功集成到 CeruMusic 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。
|
||||
@@ -41,6 +41,9 @@ export default defineConfig({
|
||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||
]
|
||||
},{
|
||||
text: '鸣谢名单',
|
||||
link: '/guide/sponsorship'
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8
docs/guide/sponsorship.md
Normal file
8
docs/guide/sponsorship.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 赞助名单
|
||||
|
||||
## 鸣谢
|
||||
|
||||
| 昵称 | 赞助金额 |
|
||||
| :------------------------: | :------: |
|
||||
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## 日志
|
||||
|
||||
- ###### 2025-9-22 (v1.3.7)
|
||||
- ###### 2025-9-26 (v1.3.8)
|
||||
|
||||
1. 写入歌曲tag信息
|
||||
2. 歌曲下载 选择音质
|
||||
3. 歌单 头部自动压缩
|
||||
|
||||
- ###### 2025-9-25 (v1.3.7)
|
||||
|
||||
1. 歌单
|
||||
- 新增右键移除歌曲
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// electron.vite.config.ts
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
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 topLevelAwait from 'vite-plugin-top-level-await'
|
||||
var electron_vite_config_default = defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@common': resolve('src/common')
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@common': resolve('src/common')
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
AutoImport({
|
||||
resolvers: [
|
||||
TDesignResolver({
|
||||
library: 'vue-next'
|
||||
})
|
||||
],
|
||||
dts: true
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
TDesignResolver({
|
||||
library: 'vue-next'
|
||||
})
|
||||
],
|
||||
dts: true
|
||||
})
|
||||
],
|
||||
base: './',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@assets': resolve('src/renderer/src/assets'),
|
||||
'@components': resolve('src/renderer/src/components'),
|
||||
'@services': resolve('src/renderer/src/services'),
|
||||
'@types': resolve('src/renderer/src/types'),
|
||||
'@store': resolve('src/renderer/src/store'),
|
||||
'@common': resolve('src/common')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
export { electron_vite_config_default as default }
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.3.7",
|
||||
"version": "1.3.8",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
@@ -63,7 +63,9 @@
|
||||
"mitt": "^3.0.1",
|
||||
"needle": "^3.3.1",
|
||||
"node-fetch": "2",
|
||||
"node-id3": "^0.2.9",
|
||||
"pinia": "^3.0.3",
|
||||
"tdesign-icons-vue-next": "^0.4.1",
|
||||
"tdesign-vue-next": "^1.15.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"zlib": "^1.0.5"
|
||||
|
||||
@@ -19,9 +19,221 @@ import axios from 'axios'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { configManager } from '../ConfigManager'
|
||||
import NodeID3 from 'node-id3'
|
||||
|
||||
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 {
|
||||
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) {
|
||||
// 转换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('音频标签写入成功:', filePath)
|
||||
} else {
|
||||
console.warn('音频标签写入失败:', filePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('写入音频标签时发生错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function main(source: string) {
|
||||
const Api = musicSdk[source]
|
||||
return {
|
||||
@@ -91,7 +303,12 @@ function main(source: string) {
|
||||
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
||||
},
|
||||
|
||||
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
|
||||
async downloadSingleSong({
|
||||
pluginId,
|
||||
songInfo,
|
||||
quality,
|
||||
tagWriteOptions
|
||||
}: DownloadSingleSongArgs) {
|
||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||
|
||||
@@ -167,6 +384,16 @@ function main(source: string) {
|
||||
delete fileLock[songPath]
|
||||
}
|
||||
|
||||
// 写入标签信息
|
||||
if (tagWriteOptions && fs.existsSync(songPath)) {
|
||||
try {
|
||||
await writeAudioTags(songPath, songInfo, tagWriteOptions)
|
||||
} catch (error) {
|
||||
console.warn('写入音频标签失败:', error)
|
||||
// 标签写入失败不影响下载成功的返回
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: '下载成功',
|
||||
path: songPath
|
||||
|
||||
@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
|
||||
info: PlaylistInfo
|
||||
}
|
||||
|
||||
export interface TagWriteOptions {
|
||||
basicInfo?: boolean
|
||||
cover?: boolean
|
||||
lyrics?: boolean
|
||||
}
|
||||
|
||||
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
||||
path?: string
|
||||
tagWriteOptions?: TagWriteOptions
|
||||
}
|
||||
|
||||
1
src/renderer/components.d.ts
vendored
1
src/renderer/components.d.ts
vendored
@@ -32,6 +32,7 @@ declare module 'vue' {
|
||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
||||
TButton: typeof import('tdesign-vue-next')['Button']
|
||||
TCard: typeof import('tdesign-vue-next')['Card']
|
||||
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
|
||||
TContent: typeof import('tdesign-vue-next')['Content']
|
||||
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
||||
TDivider: typeof import('tdesign-vue-next')['Divider']
|
||||
|
||||
@@ -691,8 +691,8 @@ const lightMainColor = computed(() => {
|
||||
|
||||
// bottom: max(2vw, 29px);
|
||||
|
||||
height: 200%;
|
||||
transform: translateY(-25%);
|
||||
height: 100%;
|
||||
// transform: translateY(-25%);
|
||||
|
||||
* [class^='lyricMainLine'] {
|
||||
font-weight: 600 !important;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface TagWriteOptions {
|
||||
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
|
||||
cover: boolean // 封面
|
||||
lyrics: boolean // 普通歌词
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
showFloatBall: boolean
|
||||
directories?: {
|
||||
cacheDir: string
|
||||
downloadDir: string
|
||||
}
|
||||
tagWriteOptions?: TagWriteOptions
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
// 默认设置
|
||||
return {
|
||||
showFloatBall: true
|
||||
showFloatBall: true,
|
||||
tagWriteOptions: {
|
||||
basicInfo: true,
|
||||
cover: true,
|
||||
lyrics: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NotifyPlugin, MessagePlugin } from 'tdesign-vue-next'
|
||||
import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { toRaw } from 'vue'
|
||||
import { useSettingsStore } from '@renderer/store/Settings'
|
||||
import { toRaw, h } from 'vue'
|
||||
|
||||
interface MusicItem {
|
||||
singer: string
|
||||
@@ -12,7 +13,7 @@ interface MusicItem {
|
||||
songmid: number
|
||||
img: string
|
||||
lrc: null | string
|
||||
types: string[]
|
||||
types: Array<{ type: string; size: string }>
|
||||
_types: Record<string, any>
|
||||
typeUrl: Record<string, any>
|
||||
}
|
||||
@@ -29,15 +30,166 @@ const qualityMap: Record<string, string> = {
|
||||
}
|
||||
const qualityKey = Object.keys(qualityMap)
|
||||
|
||||
// 创建音质选择弹窗
|
||||
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
|
||||
// 获取歌曲支持的音质列表
|
||||
const availableQualities = songInfo.types || []
|
||||
|
||||
// 检查用户设置的音质是否为特殊音质
|
||||
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
|
||||
|
||||
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
|
||||
const qualityOptions = [...availableQualities]
|
||||
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
|
||||
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
|
||||
if (!hasSpecialQuality) {
|
||||
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
|
||||
}
|
||||
}
|
||||
|
||||
// 按音质优先级排序
|
||||
qualityOptions.sort((a, b) => {
|
||||
const aIndex = qualityKey.indexOf(a.type)
|
||||
const bIndex = qualityKey.indexOf(b.type)
|
||||
return bIndex - aIndex // 降序排列,高音质在前
|
||||
})
|
||||
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: '选择下载音质(可滚动)',
|
||||
width: 400,
|
||||
placement: 'center',
|
||||
body: () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'quality-selector'
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'quality-list',
|
||||
style: {
|
||||
maxHeight:
|
||||
'max(calc(calc(70vh - 2 * var(--td-comp-paddingTB-xxl)) - 24px - 32px - 32px),100px)',
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
},
|
||||
qualityOptions.map((quality) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
key: quality.type,
|
||||
class: 'quality-item',
|
||||
style: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
margin: '8px 0',
|
||||
border: '1px solid #e7e7e7',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||
},
|
||||
onClick: () => {
|
||||
dialog.destroy()
|
||||
resolve(quality.type)
|
||||
},
|
||||
onMouseenter: (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.backgroundColor = '#f0f9ff'
|
||||
target.style.borderColor = '#1890ff'
|
||||
},
|
||||
onMouseleave: (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.backgroundColor =
|
||||
quality.type === userQuality ? '#e6f7ff' : '#fff'
|
||||
target.style.borderColor = '#e7e7e7'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'quality-info' }, [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
color: quality.type === userQuality ? '#1890ff' : '#333'
|
||||
}
|
||||
},
|
||||
qualityMap[quality.type] || quality.type
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
marginTop: '2px'
|
||||
}
|
||||
},
|
||||
quality.type.toUpperCase()
|
||||
)
|
||||
]),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'quality-size',
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontWeight: '500'
|
||||
}
|
||||
},
|
||||
quality.size
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
confirmBtn: null,
|
||||
cancelBtn: null,
|
||||
footer: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||
try {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
let quality = LocalUserDetail.userSource.quality as string
|
||||
const userQuality = LocalUserDetail.userSource.quality as string
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
|
||||
// 获取歌词
|
||||
const { crlyric, lyric } = await window.api.music.requestSdk('getLyric', {
|
||||
source: toRaw(songInfo.source),
|
||||
songInfo: toRaw(songInfo) as any
|
||||
})
|
||||
console.log(songInfo)
|
||||
songInfo.lrc = crlyric && songInfo.source !== 'tx' ? crlyric : lyric
|
||||
|
||||
// 显示音质选择弹窗
|
||||
const selectedQuality = await createQualityDialog(songInfo, userQuality)
|
||||
|
||||
// 如果用户取消选择,直接返回
|
||||
if (!selectedQuality) {
|
||||
return
|
||||
}
|
||||
|
||||
let quality = selectedQuality
|
||||
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
|
||||
|
||||
// 如果是特殊音质,先尝试获取对应链接
|
||||
// 如果选择的是特殊音质,先尝试下载
|
||||
if (isSpecialQuality) {
|
||||
try {
|
||||
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
|
||||
@@ -47,7 +199,8 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||
source: songInfo.source,
|
||||
quality,
|
||||
songInfo: toRaw(songInfo)
|
||||
songInfo: toRaw(songInfo) as any,
|
||||
tagWriteOptions: settingsStore.settings.tagWriteOptions
|
||||
})
|
||||
|
||||
;(await tip).close()
|
||||
@@ -65,34 +218,48 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`下载${qualityMap[quality]}音质失败,回退到标准逻辑`)
|
||||
// 如果获取特殊音质失败,继续执行原有逻辑
|
||||
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
|
||||
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||
|
||||
// 特殊音质下载失败,重新弹出选择框
|
||||
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||
if (!retryQuality) {
|
||||
return
|
||||
}
|
||||
quality = retryQuality
|
||||
} catch (specialError) {
|
||||
console.log(`下载${qualityMap[quality]}音质出错,回退到标准逻辑:`, specialError)
|
||||
// 特殊音质获取失败,继续执行原有逻辑
|
||||
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
|
||||
MessagePlugin.error('该音质下载失败,请重新选择音质')
|
||||
|
||||
// 特殊音质下载出错,重新弹出选择框
|
||||
const retryQuality = await createQualityDialog(songInfo, userQuality)
|
||||
if (!retryQuality) {
|
||||
return
|
||||
}
|
||||
quality = retryQuality
|
||||
}
|
||||
MessagePlugin.error('下载失败了,向下兼容音质')
|
||||
}
|
||||
|
||||
// 原有逻辑:检查歌曲支持的最高音质
|
||||
if (
|
||||
qualityKey.indexOf(quality) >
|
||||
qualityKey.indexOf(
|
||||
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
||||
)
|
||||
) {
|
||||
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
|
||||
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
|
||||
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
|
||||
quality = songMaxQuality
|
||||
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
|
||||
}
|
||||
|
||||
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
|
||||
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
|
||||
|
||||
const result = await window.api.music.requestSdk('downloadSingleSong', {
|
||||
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
|
||||
source: songInfo.source,
|
||||
quality,
|
||||
songInfo: toRaw(songInfo)
|
||||
songInfo: toRaw(songInfo) as any,
|
||||
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
|
||||
})
|
||||
|
||||
;(await tip).close()
|
||||
|
||||
if (!Object.hasOwn(result, 'path')) {
|
||||
MessagePlugin.info(result.message)
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, toRaw, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
@@ -190,7 +190,7 @@ const handlePause = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (song: MusicItem) => {
|
||||
const handleDownload = (song: any) => {
|
||||
downloadSingleSong(song)
|
||||
}
|
||||
|
||||
@@ -232,6 +232,12 @@ const isLocalPlaylist = computed(() => {
|
||||
// 文件选择器引用
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 滚动相关状态
|
||||
const scrollY = ref(0)
|
||||
const isHeaderCompact = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const songListRef = ref<any>(null)
|
||||
|
||||
// 点击封面修改图片(仅本地歌单)
|
||||
const handleCoverClick = () => {
|
||||
if (!isLocalPlaylist.value) return
|
||||
@@ -383,18 +389,54 @@ const handleShufflePlaylist = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
// 滚动事件处理
|
||||
const handleScroll = (event?: Event) => {
|
||||
let scrollTop = 0
|
||||
|
||||
if (event && event.target) {
|
||||
scrollTop = (event.target as HTMLElement).scrollTop
|
||||
} else if (scrollContainer.value) {
|
||||
scrollTop = scrollContainer.value.scrollTop
|
||||
}
|
||||
|
||||
scrollY.value = scrollTop
|
||||
// 当滚动超过100px时,启用紧凑模式
|
||||
isHeaderCompact.value = scrollY.value > 100
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchPlaylistSongs()
|
||||
|
||||
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
|
||||
setTimeout(() => {
|
||||
// 查找 SongVirtualList 内部的虚拟滚动容器
|
||||
const virtualListContainer = document.querySelector('.virtual-scroll-container')
|
||||
|
||||
if (virtualListContainer) {
|
||||
scrollContainer.value = virtualListContainer as HTMLElement
|
||||
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||
console.log('滚动监听器已添加到:', virtualListContainer)
|
||||
} else {
|
||||
console.warn('未找到虚拟滚动容器')
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-container">
|
||||
<!-- 固定头部区域 -->
|
||||
<div class="fixed-header">
|
||||
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
|
||||
<!-- 歌单信息 -->
|
||||
<div class="playlist-header">
|
||||
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
|
||||
<div
|
||||
class="playlist-cover"
|
||||
:class="{ clickable: isLocalPlaylist }"
|
||||
@@ -421,11 +463,15 @@ onMounted(() => {
|
||||
/>
|
||||
<div class="playlist-details">
|
||||
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
|
||||
<p class="playlist-author">by {{ playlistInfo.author }}</p>
|
||||
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
|
||||
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
|
||||
by {{ playlistInfo.author }}
|
||||
</p>
|
||||
<p class="playlist-stats" :class="{ hidden: isHeaderCompact }">
|
||||
{{ playlistInfo.total || songs.length }} 首歌曲
|
||||
</p>
|
||||
|
||||
<!-- 播放控制按钮 -->
|
||||
<div class="playlist-actions">
|
||||
<div class="playlist-actions" :class="{ compact: isHeaderCompact }">
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="medium"
|
||||
@@ -473,6 +519,7 @@ onMounted(() => {
|
||||
|
||||
<div v-else class="song-list-wrapper">
|
||||
<SongVirtualList
|
||||
ref="songListRef"
|
||||
:songs="songs"
|
||||
:current-song="currentSong"
|
||||
:is-playing="isPlaying"
|
||||
@@ -486,6 +533,7 @@ onMounted(() => {
|
||||
@download="handleDownload"
|
||||
@add-to-playlist="handleAddToPlaylist"
|
||||
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
|
||||
@scroll="handleScroll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,7 +543,7 @@ onMounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.list-container {
|
||||
box-sizing: border-box;
|
||||
background: #fafafa;
|
||||
// background: #fafafa;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
@@ -564,7 +612,17 @@ onMounted(() => {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.compact {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&.compact .playlist-cover {
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
}
|
||||
.playlist-cover {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
@@ -572,6 +630,7 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -628,39 +687,85 @@ onMounted(() => {
|
||||
|
||||
.playlist-details {
|
||||
flex: 1;
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.playlist-header.compact & {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-author {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 0.5rem 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
margin: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 1rem 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
margin: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.compact {
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.play-btn,
|
||||
.shuffle-btn {
|
||||
min-width: 120px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.playlist-actions.compact & {
|
||||
min-width: 100px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.play-icon,
|
||||
.shuffle-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.playlist-actions.compact & {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted, computed, toRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { Edit2Icon, ListIcon, PlayCircleIcon, DeleteIcon } from 'tdesign-icons-vue-next'
|
||||
import { Edit2Icon, PlayCircleIcon, DeleteIcon, ViewListIcon } from 'tdesign-icons-vue-next'
|
||||
import songListAPI from '@renderer/api/songList'
|
||||
import type { SongList, Songs } from '@common/types/songList'
|
||||
import defaultCover from '/default-cover.png'
|
||||
@@ -856,7 +856,7 @@ const contextMenuItems = computed((): ContextMenuItem[] => {
|
||||
}
|
||||
}),
|
||||
createMenuItem('view', '查看详情', {
|
||||
icon: ListIcon,
|
||||
icon: ViewListIcon,
|
||||
onClick: () => {
|
||||
if (contextMenuPlaylist.value) {
|
||||
viewPlaylist(contextMenuPlaylist.value)
|
||||
@@ -1035,7 +1035,11 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click="viewPlaylist(playlist)"
|
||||
>
|
||||
<ListIcon />
|
||||
<view-list-icon
|
||||
:fill-color="'transparent'"
|
||||
:stroke-color="'#000000'"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
</t-button>
|
||||
</t-tooltip>
|
||||
<t-tooltip content="编辑歌单">
|
||||
|
||||
@@ -146,7 +146,7 @@ const handlePause = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (song: MusicItem) => {
|
||||
const handleDownload = (song: any) => {
|
||||
downloadSingleSong(song)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,14 @@ import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettin
|
||||
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
|
||||
import Versions from '@renderer/components/Versions.vue'
|
||||
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
|
||||
import { useSettingsStore } from '@renderer/store/Settings'
|
||||
const Store = LocalUserDetailStore()
|
||||
const { userInfo } = storeToRefs(Store)
|
||||
|
||||
// 设置存储
|
||||
const settingsStore = useSettingsStore()
|
||||
const { settings } = storeToRefs(settingsStore)
|
||||
|
||||
// 当前选择的设置分类
|
||||
const activeCategory = ref<string>('appearance')
|
||||
// 应用版本号
|
||||
@@ -308,6 +313,30 @@ const getCurrentSourceName = () => {
|
||||
const openLink = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 标签写入选项
|
||||
const tagWriteOptions = ref({
|
||||
basicInfo: settings.value.tagWriteOptions?.basicInfo ?? true,
|
||||
cover: settings.value.tagWriteOptions?.cover ?? true,
|
||||
lyrics: settings.value.tagWriteOptions?.lyrics ?? true
|
||||
})
|
||||
|
||||
// 更新标签写入选项
|
||||
const updateTagWriteOptions = () => {
|
||||
settingsStore.updateSettings({
|
||||
tagWriteOptions: { ...tagWriteOptions.value }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取标签选项状态描述
|
||||
const getTagOptionsStatus = () => {
|
||||
const enabled: string[] = []
|
||||
if (tagWriteOptions.value.basicInfo) enabled.push('基础信息')
|
||||
if (tagWriteOptions.value.cover) enabled.push('封面')
|
||||
if (tagWriteOptions.value.lyrics) enabled.push('歌词')
|
||||
|
||||
return enabled.length > 0 ? enabled.join('、') : '未选择任何选项'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -582,6 +611,44 @@ const openLink = (url: string) => {
|
||||
<div style="margin-top: 20px" class="setting-group">
|
||||
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
|
||||
</div>
|
||||
|
||||
<!-- 标签写入设置 -->
|
||||
<div class="setting-group">
|
||||
<h3>下载标签写入设置</h3>
|
||||
<p>选择下载歌曲时要写入的标签信息</p>
|
||||
|
||||
<div class="tag-options">
|
||||
<div class="tag-option">
|
||||
<t-checkbox v-model="tagWriteOptions.basicInfo" @change="updateTagWriteOptions">
|
||||
基础信息
|
||||
</t-checkbox>
|
||||
<p class="option-desc">包括歌曲标题、艺术家、专辑名称等基本信息</p>
|
||||
</div>
|
||||
|
||||
<div class="tag-option">
|
||||
<t-checkbox v-model="tagWriteOptions.cover" @change="updateTagWriteOptions">
|
||||
封面
|
||||
</t-checkbox>
|
||||
<p class="option-desc">将专辑封面嵌入到音频文件中</p>
|
||||
</div>
|
||||
|
||||
<div class="tag-option">
|
||||
<t-checkbox v-model="tagWriteOptions.lyrics" @change="updateTagWriteOptions">
|
||||
普通歌词
|
||||
</t-checkbox>
|
||||
<p class="option-desc">将歌词信息写入到音频文件的标签中</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-options-status">
|
||||
<div class="status-summary">
|
||||
<span class="status-label">当前配置:</span>
|
||||
<span class="status-value">
|
||||
{{ getTagOptionsStatus() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于页面 -->
|
||||
@@ -1783,6 +1850,53 @@ const openLink = (url: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 标签写入设置样式
|
||||
.tag-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.tag-option {
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
.option-desc {
|
||||
margin: 0.5rem 0 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-options-status {
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
.status-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.status-label {
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
|
||||
Reference in New Issue
Block a user