Compare commits

...

5 Commits

Author SHA1 Message Date
sqj
f02264c80c 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-27 00:14:45 +08:00
sqj
d0d5f918bd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:48:21 +08:00
sqj
761d265d18 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:27:50 +08:00
sqj
204df64535 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:25:22 +08:00
sqj
cc814eddbd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:08:18 +08:00
17 changed files with 696 additions and 360 deletions

View File

@@ -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 项目中,可以在本地音乐页面体验完整功能。通过测试页面可以验证各种边界情况和高级功能的表现。

View File

@@ -41,6 +41,9 @@ export default defineConfig({
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
]
},{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,8 @@
# 赞助名单
## 鸣谢
| 昵称 | 赞助金额 |
| :------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |

View File

@@ -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. 歌单
- 新增右键移除歌曲

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="编辑歌单">

View File

@@ -146,7 +146,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
downloadSingleSong(song)
}

View File

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