mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
add(home) fix(list):ui
This commit is contained in:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -2,5 +2,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -7,5 +7,6 @@
|
|||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
}
|
},
|
||||||
|
"editor.fontSize": 14
|
||||||
}
|
}
|
||||||
|
|||||||
216
docs/audio-pubsub-pattern.md
Normal file
216
docs/audio-pubsub-pattern.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# 音频发布-订阅模式使用指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
这个改进的发布-订阅模式解决了原有实现中无法单个删除订阅者的问题。新的实现提供了以下特性:
|
||||||
|
|
||||||
|
- ✅ 支持单个订阅者的精确取消
|
||||||
|
- ✅ 自动生成唯一订阅ID
|
||||||
|
- ✅ 类型安全的事件系统
|
||||||
|
- ✅ 错误处理和日志记录
|
||||||
|
- ✅ 内存泄漏防护
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 1. 精确的订阅管理
|
||||||
|
每个订阅都会返回一个取消订阅函数,调用该函数即可精确取消对应的订阅:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 订阅事件
|
||||||
|
const unsubscribe = audioStore.subscribe('ended', () => {
|
||||||
|
console.log('音频播放结束')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取消订阅
|
||||||
|
unsubscribe()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 支持的事件类型
|
||||||
|
- `ended`: 音频播放结束
|
||||||
|
- `seeked`: 音频拖拽完成
|
||||||
|
- `timeupdate`: 音频时间更新
|
||||||
|
- `play`: 音频开始播放
|
||||||
|
- `pause`: 音频暂停播放
|
||||||
|
|
||||||
|
### 3. 类型安全
|
||||||
|
所有的事件类型和回调函数都有完整的TypeScript类型定义,确保编译时类型检查。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基础订阅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||||
|
|
||||||
|
const audioStore = ControlAudioStore()
|
||||||
|
|
||||||
|
// 订阅播放结束事件
|
||||||
|
const unsubscribeEnded = audioStore.subscribe('ended', () => {
|
||||||
|
console.log('音频播放结束了')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 订阅时间更新事件
|
||||||
|
const unsubscribeTimeUpdate = audioStore.subscribe('timeupdate', () => {
|
||||||
|
console.log('当前时间:', audioStore.Audio.currentTime)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在Vue组件中使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
import type { AudioSubscribeMethod, UnsubscribeFunction } from '@renderer/types/audio'
|
||||||
|
|
||||||
|
// 注入订阅方法
|
||||||
|
const audioSubscribe = inject<AudioSubscribeMethod>('audioSubscribe')
|
||||||
|
|
||||||
|
// 存储取消订阅函数
|
||||||
|
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!audioSubscribe) return
|
||||||
|
|
||||||
|
// 订阅多个事件
|
||||||
|
unsubscribeFunctions.push(
|
||||||
|
audioSubscribe('play', () => console.log('开始播放')),
|
||||||
|
audioSubscribe('pause', () => console.log('暂停播放')),
|
||||||
|
audioSubscribe('ended', () => console.log('播放结束'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 组件卸载时取消所有订阅
|
||||||
|
unsubscribeFunctions.forEach(unsubscribe => unsubscribe())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 条件订阅和取消
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let endedUnsubscribe: UnsubscribeFunction | null = null
|
||||||
|
|
||||||
|
// 条件订阅
|
||||||
|
const subscribeToEnded = () => {
|
||||||
|
if (!endedUnsubscribe) {
|
||||||
|
endedUnsubscribe = audioStore.subscribe('ended', handleAudioEnded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 条件取消订阅
|
||||||
|
const unsubscribeFromEnded = () => {
|
||||||
|
if (endedUnsubscribe) {
|
||||||
|
endedUnsubscribe()
|
||||||
|
endedUnsubscribe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级功能
|
||||||
|
|
||||||
|
### 批量管理订阅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 清空特定事件的所有订阅者
|
||||||
|
audioStore.clearEventSubscribers('ended')
|
||||||
|
|
||||||
|
// 清空所有事件的所有订阅者
|
||||||
|
audioStore.clearAllSubscribers()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
系统内置了错误处理机制,如果某个回调函数执行出错,不会影响其他订阅者:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
audioStore.subscribe('ended', () => {
|
||||||
|
throw new Error('这个错误不会影响其他订阅者')
|
||||||
|
})
|
||||||
|
|
||||||
|
audioStore.subscribe('ended', () => {
|
||||||
|
console.log('这个回调仍然会正常执行')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 及时清理订阅
|
||||||
|
```typescript
|
||||||
|
// ✅ 好的做法:组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribeFunctions.forEach(unsubscribe => unsubscribe())
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ 不好的做法:忘记清理,可能导致内存泄漏
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用数组管理多个订阅
|
||||||
|
```typescript
|
||||||
|
// ✅ 好的做法:统一管理
|
||||||
|
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||||
|
|
||||||
|
unsubscribeFunctions.push(
|
||||||
|
audioStore.subscribe('play', handlePlay),
|
||||||
|
audioStore.subscribe('pause', handlePause)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 统一清理
|
||||||
|
unsubscribeFunctions.forEach(fn => fn())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 避免在高频事件中执行重操作
|
||||||
|
```typescript
|
||||||
|
// ❌ 不好的做法:在timeupdate中执行重操作
|
||||||
|
audioStore.subscribe('timeupdate', () => {
|
||||||
|
// 这会每秒执行多次,影响性能
|
||||||
|
updateComplexUI()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ 好的做法:使用节流或防抖
|
||||||
|
let lastUpdate = 0
|
||||||
|
audioStore.subscribe('timeupdate', () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastUpdate > 100) { // 限制更新频率
|
||||||
|
updateUI()
|
||||||
|
lastUpdate = now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 迁移指南
|
||||||
|
|
||||||
|
### 从旧版本迁移
|
||||||
|
|
||||||
|
旧版本:
|
||||||
|
```typescript
|
||||||
|
// 旧的实现方式
|
||||||
|
provide('setAudioEnd', setEndCallback)
|
||||||
|
|
||||||
|
function setEndCallback(fn: Function): void {
|
||||||
|
endCallback.push(fn)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新版本:
|
||||||
|
```typescript
|
||||||
|
// 新的实现方式
|
||||||
|
provide('audioSubscribe', audioStore.subscribe)
|
||||||
|
|
||||||
|
// 使用时
|
||||||
|
const unsubscribe = audioSubscribe('ended', () => {
|
||||||
|
// 处理播放结束
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可以精确取消
|
||||||
|
unsubscribe()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
1. **避免重复订阅**:在订阅前检查是否已经订阅
|
||||||
|
2. **及时取消订阅**:组件卸载或不再需要时立即取消
|
||||||
|
3. **合理使用事件**:避免在高频事件中执行重操作
|
||||||
|
4. **批量操作**:需要清理多个订阅时使用批量清理方法
|
||||||
|
|
||||||
|
这个改进的发布-订阅模式为Ceru Music应用提供了更加灵活和可靠的音频事件管理机制。
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
@@ -10,14 +10,16 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
externalizeDepsPlugin({
|
externalizeDepsPlugin({
|
||||||
exclude: ['@electron-toolkit/utils']
|
exclude: ['@electron-toolkit/utils']
|
||||||
})
|
}),
|
||||||
|
bytecodePlugin()
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
plugins: [
|
plugins: [
|
||||||
externalizeDepsPlugin({
|
externalizeDepsPlugin({
|
||||||
exclude: ['@electron-toolkit/preload']
|
exclude: ['@electron-toolkit/preload']
|
||||||
})
|
}),
|
||||||
|
bytecodePlugin()
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export default tseslint.config(
|
|||||||
'vue/require-default-prop': 'off',
|
'vue/require-default-prop': 'off',
|
||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
'@typescript-eslint/no-unsafe-function-return-type': 'off',
|
'@typescript-eslint/no-unsafe-function-return-type': 'off',
|
||||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'vue/block-lang': [
|
'vue/block-lang': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev --watch",
|
||||||
"build": "pnpm run typecheck && electron-vite build",
|
"build": "pnpm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@lrc-player/core": "^1.1.5",
|
"@lrc-player/core": "^1.1.5",
|
||||||
"@lrc-player/parse": "^1.0.0",
|
"@lrc-player/parse": "^1.0.0",
|
||||||
"NeteaseCloudMusicApi": "^4.27.0",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"ceru-music": "link:",
|
"ceru-music": "link:",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
|||||||
935
pnpm-lock.yaml
generated
935
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,78 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, screen, Tray, Menu } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/logo.png?asset'
|
import icon from '../../resources/logo.png?asset'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
let isQuitting = false
|
||||||
|
|
||||||
|
function createTray(): void {
|
||||||
|
// 创建系统托盘
|
||||||
|
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
|
||||||
|
tray = new Tray(trayIconPath)
|
||||||
|
|
||||||
|
// 创建托盘菜单
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '显示窗口',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '播放/暂停',
|
||||||
|
click: () => {
|
||||||
|
// 这里可以添加播放控制逻辑
|
||||||
|
console.log('播放/暂停')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
tray.setToolTip('Ceru Music')
|
||||||
|
|
||||||
|
// 双击托盘图标显示窗口
|
||||||
|
tray.on('click', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isVisible()) {
|
||||||
|
mainWindow.hide()
|
||||||
|
} else {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 1100,
|
||||||
height: 670,
|
height: 750,
|
||||||
|
minWidth: 970,
|
||||||
|
minHeight: 670,
|
||||||
show: false,
|
show: false,
|
||||||
|
center: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
// alwaysOnTop: true,
|
||||||
|
maxWidth: screen.getPrimaryDisplay()?.workAreaSize.width,
|
||||||
|
maxHeight: screen.getPrimaryDisplay()?.workAreaSize.height,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
...(process.platform === 'linux' ? { icon } : {}),
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
|
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
||||||
icon: path.join(__dirname, '../../resources/logo.png'),
|
icon: path.join(__dirname, '../../resources/logo.png'),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
@@ -19,9 +80,27 @@ function createWindow(): void {
|
|||||||
webSecurity: false
|
webSecurity: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow.show()
|
mainWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阻止窗口关闭,改为隐藏到系统托盘
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
|
||||||
|
// 显示托盘通知
|
||||||
|
if (tray) {
|
||||||
|
tray.displayBalloon({
|
||||||
|
iconType: 'info',
|
||||||
|
title: 'Ceru Music',
|
||||||
|
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
@@ -43,7 +122,7 @@ function createWindow(): void {
|
|||||||
// Some APIs can only be used after this event occurs.
|
// Some APIs can only be used after this event occurs.
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.cerulean.music')
|
||||||
|
|
||||||
// Default open or close DevTools by F12 in development
|
// Default open or close DevTools by F12 in development
|
||||||
// and ignore CommandOrControl + R in production.
|
// and ignore CommandOrControl + R in production.
|
||||||
@@ -55,7 +134,55 @@ app.whenReady().then(() => {
|
|||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
|
// 窗口控制 IPC 处理
|
||||||
|
ipcMain.on('window-minimize', () => {
|
||||||
|
const window = BrowserWindow.getFocusedWindow()
|
||||||
|
if (window) {
|
||||||
|
window.minimize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('window-maximize', () => {
|
||||||
|
const window = BrowserWindow.getFocusedWindow()
|
||||||
|
if (window) {
|
||||||
|
if (window.isMaximized()) {
|
||||||
|
window.unmaximize()
|
||||||
|
} else {
|
||||||
|
window.maximize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('window-close', () => {
|
||||||
|
const window = BrowserWindow.getFocusedWindow()
|
||||||
|
if (window) {
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mini 模式 IPC 处理 - 最小化到系统托盘
|
||||||
|
ipcMain.on('window-mini-mode', (_, isMini) => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (isMini) {
|
||||||
|
// 进入 Mini 模式:隐藏窗口到系统托盘
|
||||||
|
mainWindow.hide()
|
||||||
|
// 显示托盘通知(可选)
|
||||||
|
if (tray) {
|
||||||
|
tray.displayBalloon({
|
||||||
|
title: '澜音 Music',
|
||||||
|
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 退出 Mini 模式:显示窗口
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
createTray()
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// On macOS it's common to re-create a window in the app when the
|
||||||
@@ -64,13 +191,15 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
// 当所有窗口关闭时不退出应用,因为我们有系统托盘
|
||||||
// for applications and their menu bar to stay active until the user quits
|
|
||||||
// explicitly with Cmd + Q.
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
// 在 macOS 上,应用通常会保持活跃状态
|
||||||
app.quit()
|
// 在其他平台上,我们也保持应用运行,因为有系统托盘
|
||||||
}
|
})
|
||||||
|
|
||||||
|
// 应用退出前的清理
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true
|
||||||
})
|
})
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
|
|||||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@@ -1,8 +1,16 @@
|
|||||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
|
|
||||||
|
// 自定义 API 接口
|
||||||
|
interface CustomAPI {
|
||||||
|
minimize: () => void
|
||||||
|
maximize: () => void
|
||||||
|
close: () => void
|
||||||
|
setMiniMode: (isMini: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: unknown
|
api: CustomAPI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { contextBridge } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {}
|
const api = {
|
||||||
|
// 窗口控制方法
|
||||||
|
minimize: () => ipcRenderer.send('window-minimize'),
|
||||||
|
maximize: () => ipcRenderer.send('window-maximize'),
|
||||||
|
close: () => ipcRenderer.send('window-close'),
|
||||||
|
setMiniMode: (isMini: boolean) => ipcRenderer.send('window-mini-mode', isMini)
|
||||||
|
}
|
||||||
|
|
||||||
// Use `contextBridge` APIs to expose Electron APIs to
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
// renderer only if context isolation is enabled, otherwise
|
// renderer only if context isolation is enabled, otherwise
|
||||||
|
|||||||
4
src/renderer/auto-imports.d.ts
vendored
4
src/renderer/auto-imports.d.ts
vendored
@@ -5,4 +5,6 @@
|
|||||||
// Generated by unplugin-auto-import
|
// Generated by unplugin-auto-import
|
||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {}
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
9
src/renderer/components.d.ts
vendored
9
src/renderer/components.d.ts
vendored
@@ -12,17 +12,14 @@ declare module 'vue' {
|
|||||||
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
|
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SearchComponent: typeof import('./src/components/Search/SearchComponent.vue')['default']
|
||||||
TAside: typeof import('tdesign-vue-next')['Aside']
|
TAside: typeof import('tdesign-vue-next')['Aside']
|
||||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
|
||||||
TButton: typeof import('tdesign-vue-next')['Button']
|
TButton: typeof import('tdesign-vue-next')['Button']
|
||||||
TContent: typeof import('tdesign-vue-next')['Content']
|
TContent: typeof import('tdesign-vue-next')['Content']
|
||||||
TFooter: typeof import('tdesign-vue-next')['Footer']
|
|
||||||
TIcon: typeof import('tdesign-vue-next')['Icon']
|
|
||||||
TInput: typeof import('tdesign-vue-next')['Input']
|
TInput: typeof import('tdesign-vue-next')['Input']
|
||||||
|
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
||||||
TLayout: typeof import('tdesign-vue-next')['Layout']
|
TLayout: typeof import('tdesign-vue-next')['Layout']
|
||||||
TMenu: typeof import('tdesign-vue-next')['Menu']
|
TLoading: typeof import('tdesign-vue-next')['Loading']
|
||||||
TMenuItem: typeof import('tdesign-vue-next')['MenuItem']
|
|
||||||
TTag: typeof import('tdesign-vue-next')['Tag']
|
|
||||||
Versions: typeof import('./src/components/Versions.vue')['default']
|
Versions: typeof import('./src/components/Versions.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
|
import { onMounted } from 'vue'
|
||||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
import GlobalAudio from './components/Play/GlobalAudio.vue'
|
||||||
const Audio = ControlAudioStore()
|
|
||||||
Audio.Audio.url =
|
onMounted(() => {
|
||||||
'https://m801.music.126.net/20250814224734/9822eac7d8f043a33883fe30796fa0c6/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/32407718767/f58f/b1e1/cf73/d70b39ba746ca874e3d1bd0466ec1227.mp3'
|
// 设置测试音频URL
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
<t-button @click="Audio.Audio.isPlay ? Audio.stop() : Audio.start()">play</t-button>
|
|
||||||
<GlobalAudio />
|
<GlobalAudio />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import './icon_font/iconfont.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
207
src/renderer/src/components/Play/.backup.js
Normal file
207
src/renderer/src/components/Play/.backup.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { ref, onMounted, reactive, UnwrapRef } from 'vue'
|
||||||
|
import '@lrc-player/core/dist/style.css'
|
||||||
|
import Player from '@lrc-player/core'
|
||||||
|
|
||||||
|
type userAudio = {
|
||||||
|
play: (lengthen?: boolean) => Promise<undefined>
|
||||||
|
pause: (isNeed?: boolean, lengthen?: boolean) => Promise<undefined>
|
||||||
|
} & Omit<HTMLAudioElement, 'pause' | 'play'>
|
||||||
|
|
||||||
|
export interface MusicPlayerInstanceType {
|
||||||
|
orderStatusVal: UnwrapRef<typeof orderStatusVal>
|
||||||
|
el: UnwrapRef<userAudio>
|
||||||
|
isPlay: UnwrapRef<boolean>
|
||||||
|
reset: (val: boolean) => void
|
||||||
|
pause: typeof pause
|
||||||
|
play: typeof play
|
||||||
|
time: number
|
||||||
|
oldTime: number
|
||||||
|
transitionIsPlay: UnwrapRef<boolean>
|
||||||
|
addListener: (listener: ListenerName) => void
|
||||||
|
cutSongHandler: () => void
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
ids?: number[]
|
||||||
|
songs: GetMusicDetailData
|
||||||
|
}
|
||||||
|
const store = useUserInfo()
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits(['playEnd', 'cutSong'])
|
||||||
|
const isPlay = ref(false)
|
||||||
|
// 1列表循环 2随机播放 3单曲循环
|
||||||
|
const orderStatusVal = ref<1 | 2 | 3>(1)
|
||||||
|
const audio = ref<userAudio>()
|
||||||
|
const music = useMusicAction()
|
||||||
|
const flags = useFlags()
|
||||||
|
const transitionIsPlay = ref(false)
|
||||||
|
const { addListener, executeListener, pauseSomethingListener } = useListener(audio)
|
||||||
|
const { getPlayListDetailFn } = usePlayList()
|
||||||
|
const player = new Player({
|
||||||
|
click
|
||||||
|
})
|
||||||
|
function click(time: number, index: number) {
|
||||||
|
console.log(time, index)
|
||||||
|
audio.value!.currentTime = time
|
||||||
|
}
|
||||||
|
function seeked() {
|
||||||
|
player.syncIndex()
|
||||||
|
}
|
||||||
|
let originPlay: HTMLMediaElement['play']
|
||||||
|
let originPause: HTMLMediaElement['pause']
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
player.mount(document.querySelector('.lyric-container') as HTMLDivElement, audio.value)
|
||||||
|
originPlay = audio.value!.play as HTMLMediaElement['play']
|
||||||
|
originPause = audio.value!.pause as HTMLMediaElement['pause']
|
||||||
|
// 播放,音量过渡提高
|
||||||
|
audio.value!.play = play
|
||||||
|
// 音量过渡减少为0,然后暂停
|
||||||
|
audio.value!.pause = pause
|
||||||
|
|
||||||
|
audio.value?.addEventListener('error', (event: any) => {
|
||||||
|
// console.log('event.target.error', event.target.error)
|
||||||
|
if (event.target.error.code === 4) {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
function play(lengthen: boolean = false) {
|
||||||
|
let volume = store.volume
|
||||||
|
player.play()
|
||||||
|
audio.value!.volume = 0
|
||||||
|
originPlay.call(audio.value).catch((err) => {
|
||||||
|
console.error('调用origin.play方法时抛出了错误:', err)
|
||||||
|
})
|
||||||
|
isPlay.value = true
|
||||||
|
timeState.stop = false
|
||||||
|
|
||||||
|
// 开始时直接改变就可以,让逐字歌词跟得上
|
||||||
|
transitionIsPlay.value = true
|
||||||
|
return transitionVolume(volume, true, lengthen).then(() => {})
|
||||||
|
}
|
||||||
|
function pause(isNeed: boolean = true, lengthen: boolean = false) {
|
||||||
|
let volume = store.volume
|
||||||
|
// 是否需要更新暂停标识, 什么时候不需要,就比如切换下一首歌的时候:
|
||||||
|
// 这个时候会先调用pause暂停上一首进行过渡,然后在调用play播放,这个时候就不需要更新暂停标识
|
||||||
|
isNeed && (isPlay.value = false)
|
||||||
|
return transitionVolume(volume, false, lengthen).then(() => {
|
||||||
|
player.pause()
|
||||||
|
// 暂停时应该等待音量过渡完成在改变,让逐字歌词也有一个暂停过渡效果
|
||||||
|
transitionIsPlay.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let timer: NodeJS.Timer
|
||||||
|
// 当过渡完成时会返回Promise
|
||||||
|
function transitionVolume(
|
||||||
|
volume: number,
|
||||||
|
target: boolean = true,
|
||||||
|
lengthen: boolean = false
|
||||||
|
): Promise<undefined> {
|
||||||
|
clearInterval(timer)
|
||||||
|
const playVolume = lengthen ? 40 : 15
|
||||||
|
const pauseVolume = lengthen ? 20 : 10
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (target) {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
audio.value!.volume = Math.min(audio.value!.volume + volume / playVolume, volume)
|
||||||
|
if (audio.value!.volume >= volume) {
|
||||||
|
resolve(undefined)
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setInterval(() => {
|
||||||
|
audio.value!.volume = Math.max(audio.value!.volume - volume / pauseVolume, 0)
|
||||||
|
if (audio.value!.volume <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
originPause.call(audio.value)
|
||||||
|
audio.value!.volume = volume
|
||||||
|
resolve(undefined)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeState = reactive({
|
||||||
|
stop: false,
|
||||||
|
previousTime: 0 // 新增属性来保存旧的 currentTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeupdate = () => {
|
||||||
|
if (timeState.stop || isNaN($audio.el.duration)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 在更新 currentTime 之前,保存旧的值
|
||||||
|
timeState.previousTime = music.state.currentTime
|
||||||
|
music.state.currentTime = $audio.time
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = (val: boolean) => {
|
||||||
|
music.state.currentTime = 0
|
||||||
|
isPlay.value = val
|
||||||
|
transitionIsPlay.value = val
|
||||||
|
// 这里需要停止timeupdate的事件监视,因为在暂停音乐时会过渡结束(就相当于还是在播放一段时间),
|
||||||
|
// 这样会导致进度条进度重置不及时
|
||||||
|
timeState.stop = true // 在每次play方法时都会重置stop值
|
||||||
|
}
|
||||||
|
const end = () => {
|
||||||
|
emit('playEnd')
|
||||||
|
}
|
||||||
|
const setOrderHandler = () => {
|
||||||
|
const runtimeList = music.state.runtimeList
|
||||||
|
|
||||||
|
let newValue = (orderStatusVal.value + 1) % orderStatus.length
|
||||||
|
|
||||||
|
// 如果上一次是心动模式并且当前播放的列表是”我喜欢的“,这次切换为其他,则重新获取”我喜欢的“列表,并更新进行时列表
|
||||||
|
if (runtimeList?.specialType === 5 && orderStatusVal.value === 0 && newValue !== 0) {
|
||||||
|
getPlayListDetailFn(runtimeList.id, '', false)
|
||||||
|
music.updateTracks(
|
||||||
|
playListState.playList,
|
||||||
|
playListState.playList.map((item) => item.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前播放歌单不是”我喜欢的“列表,则心动模式不可用
|
||||||
|
orderStatusVal.value =
|
||||||
|
newValue === 0 && runtimeList?.specialType !== 5 ? 1 : (newValue as typeof orderStatusVal.value)
|
||||||
|
|
||||||
|
music.getIntelliganceListHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行切换事件,随后暂停time监听器,等待歌曲加载完成后会打开
|
||||||
|
const cutSongHandler = () => {
|
||||||
|
console.log('music.state.lyric', music.state.lyric)
|
||||||
|
const type = music.state.lrcMode === 1 ? 'lrc' : 'yrc'
|
||||||
|
player.updateAudioLrc(music.state.lyric, type)
|
||||||
|
executeListener('cutSong')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposeObj = {
|
||||||
|
el: audio,
|
||||||
|
orderStatusVal,
|
||||||
|
isPlay,
|
||||||
|
reset,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
transitionIsPlay,
|
||||||
|
addListener,
|
||||||
|
cutSongHandler
|
||||||
|
}
|
||||||
|
Object.defineProperty(exposeObj, 'time', {
|
||||||
|
get(): number {
|
||||||
|
return audio.value!.currentTime
|
||||||
|
},
|
||||||
|
set(time: number) {
|
||||||
|
try {
|
||||||
|
audio.value!.currentTime = time
|
||||||
|
} catch (e) {
|
||||||
|
console.error('设置time时出现了错误: ', e, ',time: ', time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Object.defineProperty(exposeObj, 'oldTime', {
|
||||||
|
get(): number {
|
||||||
|
return timeState.previousTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defineExpose(exposeObj)
|
||||||
@@ -1,43 +1,56 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
||||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||||
const Audio = ControlAudioStore()
|
|
||||||
provide('setAudioEnd', setEndCallback)
|
const audioStore = ControlAudioStore()
|
||||||
provide('setAudioSeeked', setSeekedCallback)
|
|
||||||
const timeupdate = () => {}
|
|
||||||
let endCallback: Function[] = []
|
|
||||||
let seekedCallback: Function[] = []
|
|
||||||
const audioMeta = ref<HTMLAudioElement>()
|
const audioMeta = ref<HTMLAudioElement>()
|
||||||
|
|
||||||
|
// 提供订阅方法给子组件使用
|
||||||
|
provide('audioSubscribe', audioStore.subscribe)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
Audio.init(audioMeta.value)
|
audioStore.init(audioMeta.value)
|
||||||
console.log('init', audioMeta, '1111')
|
console.log('音频组件初始化完成')
|
||||||
})
|
})
|
||||||
|
|
||||||
function setEndCallback(fn: Function): void {
|
// 音频事件处理函数
|
||||||
if (typeof endCallback !== 'function') {
|
const handleTimeUpdate = (): void => {
|
||||||
endCallback.push(fn)
|
if (audioMeta.value) {
|
||||||
} else {
|
audioStore.setCurrentTime(audioMeta.value.currentTime)
|
||||||
throw new Error('Callback must be a function')
|
audioStore.Audio.duration = audioMeta.value.duration || 0
|
||||||
}
|
|
||||||
}
|
|
||||||
function setSeekedCallback(fn: Function): void {
|
|
||||||
if (typeof seekedCallback !== 'function') {
|
|
||||||
seekedCallback.push(fn)
|
|
||||||
} else {
|
|
||||||
throw new Error('Callback must be a function')
|
|
||||||
}
|
}
|
||||||
|
audioStore.publish('timeupdate')
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = (): void => {
|
const handleEnded = (): void => {
|
||||||
endCallback?.forEach((fn) => fn)
|
audioStore.Audio.isPlay = false
|
||||||
|
audioStore.publish('ended')
|
||||||
}
|
}
|
||||||
const seeked = (): void => {
|
|
||||||
seekedCallback?.forEach((fn) => fn)
|
const handleSeeked = (): void => {
|
||||||
|
audioStore.publish('seeked')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePlay = (): void => {
|
||||||
|
audioStore.Audio.isPlay = true
|
||||||
|
audioStore.publish('play')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePause = (): void => {
|
||||||
|
audioStore.Audio.isPlay = false
|
||||||
|
audioStore.publish('pause')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (event: Event): void => {
|
||||||
|
const target = event.target as HTMLAudioElement
|
||||||
|
console.error('音频加载错误:', target.error)
|
||||||
|
audioStore.Audio.isPlay = false
|
||||||
|
audioStore.publish('error')
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
endCallback = []
|
// 组件卸载时清空所有订阅者
|
||||||
seekedCallback = []
|
audioStore.clearAllSubscribers()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,10 +59,13 @@ onUnmounted(() => {
|
|||||||
<audio
|
<audio
|
||||||
ref="audioMeta"
|
ref="audioMeta"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
:src="Audio.Audio.url"
|
:src="audioStore.Audio.url"
|
||||||
@timeupdate="timeupdate"
|
@timeupdate="handleTimeUpdate"
|
||||||
@ended="end"
|
@ended="handleEnded"
|
||||||
@seeked="seeked"
|
@seeked="handleSeeked"
|
||||||
|
@play="handlePlay"
|
||||||
|
@pause="handlePause"
|
||||||
|
@error="handleError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,233 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
import { ref, onMounted, reactive, UnwrapRef } from 'vue'
|
|
||||||
import '@lrc-player/core/dist/style.css'
|
|
||||||
import Player from '@lrc-player/core'
|
|
||||||
|
|
||||||
type userAudio = {
|
|
||||||
play: (lengthen?: boolean) => Promise<undefined>
|
|
||||||
pause: (isNeed?: boolean, lengthen?: boolean) => Promise<undefined>
|
|
||||||
} & Omit<HTMLAudioElement, 'pause' | 'play'>
|
|
||||||
|
|
||||||
export interface MusicPlayerInstanceType {
|
|
||||||
orderStatusVal: UnwrapRef<typeof orderStatusVal>
|
|
||||||
el: UnwrapRef<userAudio>
|
|
||||||
isPlay: UnwrapRef<boolean>
|
|
||||||
reset: (val: boolean) => void
|
|
||||||
pause: typeof pause
|
|
||||||
play: typeof play
|
|
||||||
time: number
|
|
||||||
oldTime: number
|
|
||||||
transitionIsPlay: UnwrapRef<boolean>
|
|
||||||
addListener: (listener: ListenerName) => void
|
|
||||||
cutSongHandler: () => void
|
|
||||||
}
|
|
||||||
interface Props {
|
|
||||||
ids?: number[]
|
|
||||||
songs: GetMusicDetailData
|
|
||||||
}
|
|
||||||
const store = useUserInfo()
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits(['playEnd', 'cutSong'])
|
|
||||||
const isPlay = ref(false)
|
|
||||||
// 1列表循环 2随机播放 3单曲循环
|
|
||||||
const orderStatusVal = ref<1 | 2 | 3>(1)
|
|
||||||
const audio = ref<userAudio>()
|
|
||||||
const music = useMusicAction()
|
|
||||||
const flags = useFlags()
|
|
||||||
const transitionIsPlay = ref(false)
|
|
||||||
const { addListener, executeListener, pauseSomethingListener } = useListener(audio)
|
|
||||||
const { getPlayListDetailFn } = usePlayList()
|
|
||||||
const player = new Player({
|
|
||||||
click
|
|
||||||
})
|
|
||||||
function click(time: number, index: number) {
|
|
||||||
console.log(time, index)
|
|
||||||
audio.value!.currentTime = time
|
|
||||||
}
|
|
||||||
function seeked() {
|
|
||||||
player.syncIndex()
|
|
||||||
}
|
|
||||||
let originPlay: HTMLMediaElement['play']
|
|
||||||
let originPause: HTMLMediaElement['pause']
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
player.mount(document.querySelector('.lyric-container') as HTMLDivElement, audio.value)
|
|
||||||
originPlay = audio.value!.play as HTMLMediaElement['play']
|
|
||||||
originPause = audio.value!.pause as HTMLMediaElement['pause']
|
|
||||||
// 播放,音量过渡提高
|
|
||||||
audio.value!.play = play
|
|
||||||
// 音量过渡减少为0,然后暂停
|
|
||||||
audio.value!.pause = pause
|
|
||||||
|
|
||||||
audio.value?.addEventListener('error', (event: any) => {
|
|
||||||
// console.log('event.target.error', event.target.error)
|
|
||||||
if (event.target.error.code === 4) {
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
function play(lengthen: boolean = false) {
|
|
||||||
let volume = store.volume
|
|
||||||
player.play()
|
|
||||||
audio.value!.volume = 0
|
|
||||||
originPlay.call(audio.value).catch((err) => {
|
|
||||||
console.error('调用origin.play方法时抛出了错误:', err)
|
|
||||||
})
|
|
||||||
isPlay.value = true
|
|
||||||
timeState.stop = false
|
|
||||||
|
|
||||||
// 开始时直接改变就可以,让逐字歌词跟得上
|
|
||||||
transitionIsPlay.value = true
|
|
||||||
return transitionVolume(volume, true, lengthen).then(() => {})
|
|
||||||
}
|
|
||||||
function pause(isNeed: boolean = true, lengthen: boolean = false) {
|
|
||||||
let volume = store.volume
|
|
||||||
// 是否需要更新暂停标识, 什么时候不需要,就比如切换下一首歌的时候:
|
|
||||||
// 这个时候会先调用pause暂停上一首进行过渡,然后在调用play播放,这个时候就不需要更新暂停标识
|
|
||||||
isNeed && (isPlay.value = false)
|
|
||||||
return transitionVolume(volume, false, lengthen).then(() => {
|
|
||||||
player.pause()
|
|
||||||
// 暂停时应该等待音量过渡完成在改变,让逐字歌词也有一个暂停过渡效果
|
|
||||||
transitionIsPlay.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let timer: NodeJS.Timer
|
|
||||||
// 当过渡完成时会返回Promise
|
|
||||||
function transitionVolume(
|
|
||||||
volume: number,
|
|
||||||
target: boolean = true,
|
|
||||||
lengthen: boolean = false
|
|
||||||
): Promise<undefined> {
|
|
||||||
clearInterval(timer)
|
|
||||||
const playVolume = lengthen ? 40 : 15
|
|
||||||
const pauseVolume = lengthen ? 20 : 10
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (target) {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
audio.value!.volume = Math.min(audio.value!.volume + volume / playVolume, volume)
|
|
||||||
if (audio.value!.volume >= volume) {
|
|
||||||
resolve(undefined)
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
}, 50)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer = setInterval(() => {
|
|
||||||
audio.value!.volume = Math.max(audio.value!.volume - volume / pauseVolume, 0)
|
|
||||||
if (audio.value!.volume <= 0) {
|
|
||||||
clearInterval(timer)
|
|
||||||
originPause.call(audio.value)
|
|
||||||
audio.value!.volume = volume
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
}, 50)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeState = reactive({
|
|
||||||
stop: false,
|
|
||||||
previousTime: 0 // 新增属性来保存旧的 currentTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeupdate = () => {
|
|
||||||
if (timeState.stop || isNaN($audio.el.duration)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 在更新 currentTime 之前,保存旧的值
|
|
||||||
timeState.previousTime = music.state.currentTime
|
|
||||||
music.state.currentTime = $audio.time
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = (val: boolean) => {
|
|
||||||
music.state.currentTime = 0
|
|
||||||
isPlay.value = val
|
|
||||||
transitionIsPlay.value = val
|
|
||||||
// 这里需要停止timeupdate的事件监视,因为在暂停音乐时会过渡结束(就相当于还是在播放一段时间),
|
|
||||||
// 这样会导致进度条进度重置不及时
|
|
||||||
timeState.stop = true // 在每次play方法时都会重置stop值
|
|
||||||
}
|
|
||||||
const end = () => {
|
|
||||||
emit('playEnd')
|
|
||||||
}
|
|
||||||
const setOrderHandler = () => {
|
|
||||||
const runtimeList = music.state.runtimeList
|
|
||||||
|
|
||||||
let newValue = (orderStatusVal.value + 1) % orderStatus.length
|
|
||||||
|
|
||||||
// 如果上一次是心动模式并且当前播放的列表是”我喜欢的“,这次切换为其他,则重新获取”我喜欢的“列表,并更新进行时列表
|
|
||||||
if (runtimeList?.specialType === 5 && orderStatusVal.value === 0 && newValue !== 0) {
|
|
||||||
getPlayListDetailFn(runtimeList.id, '', false)
|
|
||||||
music.updateTracks(
|
|
||||||
playListState.playList,
|
|
||||||
playListState.playList.map((item) => item.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果当前播放歌单不是”我喜欢的“列表,则心动模式不可用
|
|
||||||
orderStatusVal.value =
|
|
||||||
newValue === 0 && runtimeList?.specialType !== 5 ? 1 : (newValue as typeof orderStatusVal.value)
|
|
||||||
|
|
||||||
music.getIntelliganceListHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行切换事件,随后暂停time监听器,等待歌曲加载完成后会打开
|
|
||||||
const cutSongHandler = () => {
|
|
||||||
console.log('music.state.lyric', music.state.lyric)
|
|
||||||
const type = music.state.lrcMode === 1 ? 'lrc' : 'yrc'
|
|
||||||
player.updateAudioLrc(music.state.lyric, type)
|
|
||||||
executeListener('cutSong')
|
|
||||||
}
|
|
||||||
|
|
||||||
const exposeObj = {
|
|
||||||
el: audio,
|
|
||||||
orderStatusVal,
|
|
||||||
isPlay,
|
|
||||||
reset,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
transitionIsPlay,
|
|
||||||
addListener,
|
|
||||||
cutSongHandler
|
|
||||||
}
|
|
||||||
Object.defineProperty(exposeObj, 'time', {
|
|
||||||
get(): number {
|
|
||||||
return audio.value!.currentTime
|
|
||||||
},
|
|
||||||
set(time: number) {
|
|
||||||
try {
|
|
||||||
audio.value!.currentTime = time
|
|
||||||
} catch (e) {
|
|
||||||
console.error('设置time时出现了错误: ', e, ',time: ', time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Object.defineProperty(exposeObj, 'oldTime', {
|
|
||||||
get(): number {
|
|
||||||
return timeState.previousTime
|
|
||||||
}
|
|
||||||
})
|
|
||||||
defineExpose(exposeObj)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bottom-container"></div>
|
<div class="bottom-container">
|
||||||
|
<div>aaa</div>
|
||||||
|
<div>aaa</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.plan-container {
|
// .plan-container {
|
||||||
display: flex;
|
// display: flex;
|
||||||
align-items: center;
|
// align-items: center;
|
||||||
height: 15px;
|
// height: 15px;
|
||||||
position: absolute;
|
// position: absolute;
|
||||||
top: -8.5px;
|
// top: -8.5px;
|
||||||
width: 100%;
|
// width: 100%;
|
||||||
}
|
// }
|
||||||
.el-overlay {
|
|
||||||
.music-drawer {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:deep(.el-drawer) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.bottom-container {
|
.bottom-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
86
src/renderer/src/components/Search/SearchComponent.vue
Normal file
86
src/renderer/src/components/Search/SearchComponent.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const keyword = ref('')
|
||||||
|
const isSearching = ref(false)
|
||||||
|
|
||||||
|
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||||
|
|
||||||
|
// 处理搜索事件
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!keyword.value.trim()) return
|
||||||
|
|
||||||
|
isSearching.value = true
|
||||||
|
try {
|
||||||
|
// 调用搜索API
|
||||||
|
|
||||||
|
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
||||||
|
router.push({
|
||||||
|
path: '/home/search',
|
||||||
|
query: { keyword: keyword.value },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error)
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理按键事件,按下回车键时触发搜索
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-component">
|
||||||
|
<t-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索音乐、歌手"
|
||||||
|
:loading="isSearching"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
shape="square"
|
||||||
|
:disabled="isSearching"
|
||||||
|
@click="handleSearch"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-sousuo"></i>
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-component {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.t-input) {
|
||||||
|
border-radius: 0rem !important;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-input__suffix) {
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
src/renderer/src/components/TitleBarControls.vue
Normal file
245
src/renderer/src/components/TitleBarControls.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
// 定义窗口控制按钮风格类型
|
||||||
|
type ControlStyle = 'traffic-light' | 'windows'
|
||||||
|
|
||||||
|
// 组件属性
|
||||||
|
interface Props {
|
||||||
|
controlStyle?: ControlStyle
|
||||||
|
showSettings?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
controlStyle: 'windows',
|
||||||
|
showSettings: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mini 模式现在是直接隐藏到系统托盘,不需要状态跟踪
|
||||||
|
|
||||||
|
// 计算样式类名
|
||||||
|
const controlsClass = computed(() => {
|
||||||
|
return `title-controls ${props.controlStyle}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 窗口控制方法
|
||||||
|
const handleMinimize = (): void => {
|
||||||
|
window.api?.minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaximize = (): void => {
|
||||||
|
window.api?.maximize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
window.api?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMiniMode = (): void => {
|
||||||
|
// 直接最小化到系统托盘
|
||||||
|
window.api?.setMiniMode(true)
|
||||||
|
console.log('最小化到系统托盘')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由实例
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleSettings = (): void => {
|
||||||
|
// 跳转到设置页面
|
||||||
|
router.push('/settings')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="controlsClass">
|
||||||
|
<!-- 设置按钮 -->
|
||||||
|
<t-button
|
||||||
|
v-if="showSettings"
|
||||||
|
shape="circle"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
class="control-btn settings-btn"
|
||||||
|
@click="handleSettings"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-shezhi"></i>
|
||||||
|
</t-button>
|
||||||
|
|
||||||
|
<!-- 窗口控制按钮组 -->
|
||||||
|
<div class="window-controls">
|
||||||
|
<!-- Mini 模式按钮 -->
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
class="control-btn mini-btn"
|
||||||
|
title="最小化到系统托盘"
|
||||||
|
@click="handleMiniMode"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-dibu"></i>
|
||||||
|
</t-button>
|
||||||
|
|
||||||
|
<!-- 最小化按钮 -->
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
class="control-btn minimize-btn"
|
||||||
|
title="最小化"
|
||||||
|
@click="handleMinimize"
|
||||||
|
>
|
||||||
|
<i v-if="controlStyle === 'windows'" class="iconfont icon-zuixiaohua"></i>
|
||||||
|
<div v-else class="traffic-light minimize"></div>
|
||||||
|
</t-button>
|
||||||
|
|
||||||
|
<!-- 最大化按钮 -->
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
class="control-btn maximize-btn"
|
||||||
|
title="最大化"
|
||||||
|
@click="handleMaximize"
|
||||||
|
>
|
||||||
|
<i v-if="controlStyle === 'windows'" class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
<div v-else class="traffic-light maximize"></div>
|
||||||
|
</t-button>
|
||||||
|
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
class="control-btn close-btn"
|
||||||
|
title="关闭"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i v-if="controlStyle === 'windows'" class="iconfont icon-a-quxiaoguanbi"></i>
|
||||||
|
<div v-else class="traffic-light close"></div>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.title-controls {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .iconfont {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
&.active .iconfont {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-btn:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maximize-btn:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 风格样式
|
||||||
|
.title-controls.windows {
|
||||||
|
.control-btn {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 红绿灯风格样式
|
||||||
|
.title-controls.traffic-light {
|
||||||
|
.control-btn {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.close {
|
||||||
|
background-color: #ff5f57;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ff3b30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minimize {
|
||||||
|
background-color: #ffbd2e;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ff9500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maximize {
|
||||||
|
background-color: #28ca42;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #30d158;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-btn:hover,
|
||||||
|
.maximize-btn:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,15 +8,39 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home',
|
path: '/home',
|
||||||
name: 'home',
|
redirect: '/home/find',
|
||||||
component: () => import('@renderer/views/home/index.vue'),
|
component: () => import('@renderer/views/home/index.vue'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'music',
|
redirect: '/home/find'
|
||||||
component: () => import('@renderer/views/music/list.vue')
|
},
|
||||||
|
{
|
||||||
|
path: 'find',
|
||||||
|
name: 'find',
|
||||||
|
component: () => import('@renderer/views/music/find.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'local',
|
||||||
|
name: 'local',
|
||||||
|
component: () => import('@renderer/views/music/local.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'recent',
|
||||||
|
name: 'recent',
|
||||||
|
component: () => import('@renderer/views/music/recent.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('@renderer/views/music/search.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@renderer/views/settings/index.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const option: RouterOptions = {
|
const option: RouterOptions = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LoadingPlugin, NotifyPlugin } from 'tdesign-vue-next'
|
import { LoadingPlugin, NotifyPlugin } from 'tdesign-vue-next'
|
||||||
|
import type { LoadingInstance } from 'tdesign-vue-next'
|
||||||
|
|
||||||
import { MusicServiceBase } from './service-base'
|
import { MusicServiceBase } from './service-base'
|
||||||
|
|
||||||
@@ -31,6 +32,170 @@ type GetLyricArgs = {
|
|||||||
yv?: boolean
|
yv?: boolean
|
||||||
tv?: boolean
|
tv?: boolean
|
||||||
}
|
}
|
||||||
|
interface Artist {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
picUrl: string | null
|
||||||
|
alias: string[]
|
||||||
|
albumSize: number
|
||||||
|
picId: number
|
||||||
|
fansGroup: null
|
||||||
|
img1v1Url: string
|
||||||
|
img1v1: number
|
||||||
|
trans: null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
artist: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
picUrl: string | null
|
||||||
|
alias: string[]
|
||||||
|
albumSize: number
|
||||||
|
picId: number
|
||||||
|
fansGroup: null
|
||||||
|
img1v1Url: string
|
||||||
|
img1v1: number
|
||||||
|
trans: null
|
||||||
|
}
|
||||||
|
publishTime: number
|
||||||
|
size: number
|
||||||
|
copyrightId: number
|
||||||
|
status: number
|
||||||
|
picId: number
|
||||||
|
alia?: string[]
|
||||||
|
mark: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
artists: Artist[]
|
||||||
|
album: Album
|
||||||
|
duration: number
|
||||||
|
copyrightId: number
|
||||||
|
status: number
|
||||||
|
alias: string[]
|
||||||
|
rtype: number
|
||||||
|
ftype: number
|
||||||
|
mvid: number
|
||||||
|
fee: number
|
||||||
|
rUrl: null
|
||||||
|
mark: number
|
||||||
|
transNames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongResponse {
|
||||||
|
songs: Song[]
|
||||||
|
songCount: number
|
||||||
|
}
|
||||||
|
interface AlbumDetail {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
picId: number
|
||||||
|
blurPicUrl: string
|
||||||
|
companyId: number
|
||||||
|
pic: number
|
||||||
|
picUrl: string
|
||||||
|
publishTime: number
|
||||||
|
description: string
|
||||||
|
tags: string
|
||||||
|
company: string
|
||||||
|
briefDesc: string
|
||||||
|
artist: {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
picId: number
|
||||||
|
img1v1Id: number
|
||||||
|
briefDesc: string
|
||||||
|
picUrl: string
|
||||||
|
img1v1Url: string
|
||||||
|
albumSize: number
|
||||||
|
alias: string[]
|
||||||
|
trans: string
|
||||||
|
musicSize: number
|
||||||
|
topicPerson: number
|
||||||
|
}
|
||||||
|
songs: any[]
|
||||||
|
alias: string[]
|
||||||
|
status: number
|
||||||
|
copyrightId: number
|
||||||
|
commentThreadId: string
|
||||||
|
artists: Artist[]
|
||||||
|
subType: string
|
||||||
|
transName: null
|
||||||
|
onSale: boolean
|
||||||
|
mark: number
|
||||||
|
gapless: number
|
||||||
|
dolbyMark: number
|
||||||
|
}
|
||||||
|
interface MusicQuality {
|
||||||
|
name: null
|
||||||
|
id: number
|
||||||
|
size: number
|
||||||
|
extension: string
|
||||||
|
sr: number
|
||||||
|
dfsId: number
|
||||||
|
bitrate: number
|
||||||
|
playTime: number
|
||||||
|
volumeDelta: number
|
||||||
|
}
|
||||||
|
interface SongDetail {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
position: number
|
||||||
|
alias: string[]
|
||||||
|
status: number
|
||||||
|
fee: number
|
||||||
|
copyrightId: number
|
||||||
|
disc: string
|
||||||
|
no: number
|
||||||
|
artists: Artist[]
|
||||||
|
album: AlbumDetail
|
||||||
|
starred: boolean
|
||||||
|
popularity: number
|
||||||
|
score: number
|
||||||
|
starredNum: number
|
||||||
|
duration: number
|
||||||
|
playedNum: number
|
||||||
|
dayPlays: number
|
||||||
|
hearTime: number
|
||||||
|
sqMusic: MusicQuality
|
||||||
|
hrMusic: null
|
||||||
|
ringtone: null
|
||||||
|
crbt: null
|
||||||
|
audition: null
|
||||||
|
copyFrom: string
|
||||||
|
commentThreadId: string
|
||||||
|
rtUrl: null
|
||||||
|
ftype: number
|
||||||
|
rtUrls: any[]
|
||||||
|
copyright: number
|
||||||
|
transName: null
|
||||||
|
sign: null
|
||||||
|
mark: number
|
||||||
|
originCoverType: number
|
||||||
|
originSongSimpleData: null
|
||||||
|
single: number
|
||||||
|
noCopyrightRcmd: null
|
||||||
|
hMusic: MusicQuality
|
||||||
|
mMusic: MusicQuality
|
||||||
|
lMusic: MusicQuality
|
||||||
|
bMusic: MusicQuality
|
||||||
|
mvid: number
|
||||||
|
mp3Url: null
|
||||||
|
rtype: number
|
||||||
|
rurl: null
|
||||||
|
}
|
||||||
|
export interface SongDetailResponse {
|
||||||
|
songs: SongDetail[]
|
||||||
|
equalizers: Record<string, unknown>
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
// 使用函数重载定义不同的调用方式
|
// 使用函数重载定义不同的调用方式
|
||||||
async function request(
|
async function request(
|
||||||
@@ -38,13 +203,13 @@ async function request(
|
|||||||
args: SearchArgs,
|
args: SearchArgs,
|
||||||
isLoading?: boolean,
|
isLoading?: boolean,
|
||||||
showError?: boolean
|
showError?: boolean
|
||||||
): Promise<any>
|
): Promise<SongResponse>
|
||||||
async function request(
|
async function request(
|
||||||
api: 'getSongDetail',
|
api: 'getSongDetail',
|
||||||
args: GetSongDetailArgs,
|
args: GetSongDetailArgs,
|
||||||
isLoading?: boolean,
|
isLoading?: boolean,
|
||||||
showError?: boolean
|
showError?: boolean
|
||||||
): Promise<any>
|
): Promise<SongDetailResponse['songs']>
|
||||||
async function request(
|
async function request(
|
||||||
api: 'getSongUrl',
|
api: 'getSongUrl',
|
||||||
args: GetSongUrlArgs,
|
args: GetSongUrlArgs,
|
||||||
@@ -63,8 +228,9 @@ async function request(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
showError = true
|
showError = true
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
let instance: LoadingInstance | null = null
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
|
instance = LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -91,6 +257,8 @@ async function request(
|
|||||||
|
|
||||||
console.error('请求失败: ', error)
|
console.error('请求失败: ', error)
|
||||||
throw new Error(error.message)
|
throw new Error(error.message)
|
||||||
|
} finally {
|
||||||
|
instance?.hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { axiosClient, mobileHeaders, MusicServiceBase } from './service-base'
|
import { axiosClient, MusicServiceBase } from './service-base'
|
||||||
import { fieldsSelector } from '@renderer/utils/object'
|
import { fieldsSelector } from '@renderer/utils/object'
|
||||||
|
|
||||||
const baseUrl: string = 'https://music.163.com'
|
const baseUrl: string = 'https://music.163.com'
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { transitionVolume } from '@renderer/utils/volume'
|
import { transitionVolume } from '@renderer/utils/volume'
|
||||||
|
import type {
|
||||||
|
AudioEventCallback,
|
||||||
|
AudioEventType,
|
||||||
|
AudioSubscriber,
|
||||||
|
UnsubscribeFunction,
|
||||||
|
ControlAudioState
|
||||||
|
} from '@renderer/types/audio'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频控制状态接口。
|
||||||
|
* @property {HTMLAudioElement | null | undefined} audio - 音频元素实例。
|
||||||
|
* @property {boolean} isPlay - 是否正在播放。
|
||||||
|
* @property {number} currentTime - 当前播放时间(秒)。
|
||||||
|
* @property {number} duration - 音频总时长(秒)。
|
||||||
|
* @property {number} volume - 音量(0-100)。
|
||||||
|
* @property {string} url - 音频URL。
|
||||||
|
*/
|
||||||
|
|
||||||
type ControlAudioState = {
|
|
||||||
audio: HTMLAudioElement | null | undefined
|
|
||||||
isPlay: boolean
|
|
||||||
currentTime: number
|
|
||||||
duration: number
|
|
||||||
volume: number
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
export const ControlAudioStore = defineStore('controlAudio', () => {
|
export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||||
const Audio = reactive<ControlAudioState>({
|
const Audio = reactive<ControlAudioState>({
|
||||||
audio: null,
|
audio: null,
|
||||||
@@ -20,44 +29,170 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
|
|||||||
url: ''
|
url: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||||
|
// 事件订阅者映射表
|
||||||
|
/**
|
||||||
|
* 音频事件订阅与发布逻辑。
|
||||||
|
* @property {Record<AudioEventType, AudioSubscriber[]>} subscribers - 事件订阅者映射表。
|
||||||
|
*/
|
||||||
|
const subscribers = reactive<Record<AudioEventType, AudioSubscriber[]>>({
|
||||||
|
ended: [],
|
||||||
|
seeked: [],
|
||||||
|
timeupdate: [],
|
||||||
|
play: [],
|
||||||
|
pause: [],
|
||||||
|
error: []
|
||||||
|
})
|
||||||
|
// 生成唯一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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有订阅者
|
||||||
|
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']) => {
|
const init = (el: ControlAudioState['audio']) => {
|
||||||
console.log(el, 'init2')
|
console.log(el, '全局音频挂载初始化success')
|
||||||
Audio.audio = el
|
Audio.audio = el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前播放时间。
|
||||||
|
* @param {number} time - 播放时间(秒)。
|
||||||
|
* @throws {Error} 如果时间不是数字类型。
|
||||||
|
*/
|
||||||
const setCurrentTime = (time: number) => {
|
const setCurrentTime = (time: number) => {
|
||||||
if (typeof time === 'number') {
|
if (typeof time === 'number') {
|
||||||
Audio.currentTime = time
|
Audio.currentTime = time
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error('the time is not number')
|
throw new Error('时间必须是数字类型')
|
||||||
}
|
}
|
||||||
const start = () => {
|
/**
|
||||||
const volume = Audio.volume
|
* 设置音量。
|
||||||
console.log(1)
|
* @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 = volume / 100
|
||||||
|
}
|
||||||
|
Audio.volume = volume
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
console.log('开始播放音频')
|
||||||
if (Audio.audio) {
|
if (Audio.audio) {
|
||||||
console.log(Audio)
|
|
||||||
Audio.audio.volume = 0
|
Audio.audio.volume = 0
|
||||||
Audio.audio.play()
|
try {
|
||||||
|
await Audio.audio.play()
|
||||||
Audio.isPlay = true
|
Audio.isPlay = true
|
||||||
return transitionVolume(Audio.audio, volume / 100, true)
|
return transitionVolume(Audio.audio, volume / 100, true)
|
||||||
|
} catch (error) {
|
||||||
|
Audio.audio.volume = volume
|
||||||
|
console.error('音频播放失败:', error)
|
||||||
|
Audio.isPlay = false
|
||||||
|
throw new Error('音频播放失败,请检查音频URL是否有效')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (Audio.audio) {
|
if (Audio.audio) {
|
||||||
Audio.isPlay = false
|
|
||||||
return transitionVolume(Audio.audio, Audio.volume / 100, false).then(() => {
|
return transitionVolume(Audio.audio, Audio.volume / 100, false).then(() => {
|
||||||
|
Audio.isPlay = false
|
||||||
Audio.audio?.pause()
|
Audio.audio?.pause()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// const
|
|
||||||
return {
|
return {
|
||||||
Audio,
|
Audio,
|
||||||
init,
|
init,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
|
setVolume,
|
||||||
|
setUrl,
|
||||||
start,
|
start,
|
||||||
stop
|
stop,
|
||||||
|
subscribe,
|
||||||
|
publish,
|
||||||
|
clearAllSubscribers,
|
||||||
|
clearEventSubscribers
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
15
src/renderer/src/store/search.ts
Normal file
15
src/renderer/src/store/search.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const searchValue = defineStore("search", {
|
||||||
|
state: () => ({
|
||||||
|
value: "",
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
getValue: (state) => state.value,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setValue(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
31
src/renderer/src/types/audio.ts
Normal file
31
src/renderer/src/types/audio.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// 音频事件相关类型定义
|
||||||
|
|
||||||
|
// 事件回调函数类型定义
|
||||||
|
export type AudioEventCallback = () => void
|
||||||
|
|
||||||
|
// 音频事件类型
|
||||||
|
export type AudioEventType = 'ended' | 'seeked' | 'timeupdate' | 'play' | 'pause' | 'error'
|
||||||
|
|
||||||
|
// 订阅者接口
|
||||||
|
export interface AudioSubscriber {
|
||||||
|
id: string
|
||||||
|
callback: AudioEventCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消订阅函数类型
|
||||||
|
export type UnsubscribeFunction = () => void
|
||||||
|
|
||||||
|
// 音频订阅方法类型
|
||||||
|
export type AudioSubscribeMethod = (
|
||||||
|
eventType: AudioEventType,
|
||||||
|
callback: AudioEventCallback
|
||||||
|
) => UnsubscribeFunction
|
||||||
|
|
||||||
|
export type ControlAudioState = {
|
||||||
|
audio: HTMLAudioElement | null | undefined
|
||||||
|
isPlay: boolean
|
||||||
|
currentTime: number
|
||||||
|
duration: number
|
||||||
|
volume: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
let timer: any
|
let timer: any
|
||||||
import type { Ref } from 'vue'
|
|
||||||
// 当过渡完成时会返回Promise
|
|
||||||
|
|
||||||
export function transitionVolume(
|
export function transitionVolume(
|
||||||
audio: HTMLAudioElement,
|
audio: HTMLAudioElement,
|
||||||
@@ -9,7 +7,7 @@ export function transitionVolume(
|
|||||||
lengthen: boolean = false
|
lengthen: boolean = false
|
||||||
): Promise<undefined> {
|
): Promise<undefined> {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
const playVolume = lengthen ? 40 : 15
|
const playVolume = lengthen ? 40 : 20
|
||||||
const pauseVolume = lengthen ? 20 : 10
|
const pauseVolume = lengthen ? 20 : 10
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (target) {
|
if (target) {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PlayMusic from '@renderer/components/Play/PlayMusic.vue'
|
// import PlayMusic from '@renderer/components/Play/PlayMusic.vue'
|
||||||
|
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { SearchIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { searchValue } from '@renderer/store/search'
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
const osType = ref(1)
|
||||||
const menuList: MenuItem[] = [
|
const menuList: MenuItem[] = [
|
||||||
{
|
{
|
||||||
name: '发现',
|
name: '发现',
|
||||||
@@ -25,8 +30,48 @@ const menuList: MenuItem[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
const menuActive = ref(0)
|
const menuActive = ref(0)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const handleClick = (index: number): void => {
|
const handleClick = (index: number): void => {
|
||||||
menuActive.value = index
|
menuActive.value = index
|
||||||
|
router.push(menuList[index].path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航历史前进后退功能
|
||||||
|
const goBack = (): void => {
|
||||||
|
router.go(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goForward = (): void => {
|
||||||
|
router.go(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索相关
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||||
|
// const searchType = ref(1)
|
||||||
|
|
||||||
|
// 处理搜索事件
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!keyword.value.trim()) return
|
||||||
|
const useSearch = searchValue()
|
||||||
|
// 重新设置搜索关键字
|
||||||
|
try {
|
||||||
|
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
||||||
|
useSearch.setValue(keyword.value.trim()) // 设置搜索关键字
|
||||||
|
router.push({
|
||||||
|
path: '/home/search'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error)
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理按键事件,按下回车键时触发搜索
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
handleSearch()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,8 +85,10 @@ const handleClick = (index: number): void => {
|
|||||||
<i class="iconfont icon-music"></i>
|
<i class="iconfont icon-music"></i>
|
||||||
</div>
|
</div>
|
||||||
<p class="app-title">
|
<p class="app-title">
|
||||||
Such Music
|
<span style="
|
||||||
<span>PC</span>
|
color:#000;
|
||||||
|
font-weight: 800;
|
||||||
|
">Ceru Music</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,10 +114,10 @@ const handleClick = (index: number): void => {
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<t-button shape="circle" theme="default" class="nav-btn">
|
<t-button shape="circle" theme="default" class="nav-btn" @click="goBack">
|
||||||
<i class="iconfont icon-xiangzuo"></i>
|
<i class="iconfont icon-xiangzuo"></i>
|
||||||
</t-button>
|
</t-button>
|
||||||
<t-button shape="circle" theme="default" class="nav-btn">
|
<t-button shape="circle" theme="default" class="nav-btn" @click="goForward">
|
||||||
<i class="iconfont icon-xiangyou"></i>
|
<i class="iconfont icon-xiangyou"></i>
|
||||||
</t-button>
|
</t-button>
|
||||||
|
|
||||||
@@ -82,15 +129,36 @@ const handleClick = (index: number): void => {
|
|||||||
"
|
"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<t-input placeholder="搜索音乐、歌手" />
|
<t-input
|
||||||
</div>
|
v-model="keyword"
|
||||||
<t-button shape="circle" theme="default" class="settings-btn">
|
placeholder="搜索音乐、歌手"
|
||||||
<i class="iconfont icon-shezhi"></i>
|
:loading="isSearching"
|
||||||
|
style="width: 100%"
|
||||||
|
@enter="handleKeyDown"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
shape="circle"
|
||||||
|
:disabled="isSearching"
|
||||||
|
style="display: flex; align-items: center; justify-content: center"
|
||||||
|
@click="handleSearch"
|
||||||
|
>
|
||||||
|
<SearchIcon style="font-size: 16px; color: #000" />
|
||||||
</t-button>
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
<TitleBarControls
|
||||||
|
:control-style="osType === 0 ? 'windows' : 'traffic-light'"
|
||||||
|
></TitleBarControls>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-view />
|
<div class="mainContent">
|
||||||
|
<router-view v-if="true" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t-content>
|
</t-content>
|
||||||
</t-layout>
|
</t-layout>
|
||||||
@@ -118,6 +186,7 @@ const handleClick = (index: number): void => {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
.logo-section {
|
.logo-section {
|
||||||
|
-webkit-app-region: drag;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -193,19 +262,22 @@ const handleClick = (index: number): void => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: #f6f6f6;
|
background: #f6f6f6;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
-webkit-app-region: drag;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #3d4043;
|
color: #3d4043;
|
||||||
@@ -220,8 +292,9 @@ const handleClick = (index: number): void => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
justify-content: space-between;
|
||||||
.search-input {
|
.search-input {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
@@ -250,5 +323,35 @@ const handleClick = (index: number): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mainContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 0; /* 确保flex子元素能够正确计算高度 */
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox 滚动条样式 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
302
src/renderer/src/views/music/find.vue
Normal file
302
src/renderer/src/views/music/find.vue
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 推荐歌单数据
|
||||||
|
const recommendPlaylists = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '热门流行',
|
||||||
|
description: '最新最热的流行音乐',
|
||||||
|
cover: 'https://via.placeholder.com/200x200/f97316/ffffff?text=热门流行',
|
||||||
|
playCount: '1.2万'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '经典老歌',
|
||||||
|
description: '怀旧经典,永恒旋律',
|
||||||
|
cover: 'https://via.placeholder.com/200x200/3b82f6/ffffff?text=经典老歌',
|
||||||
|
playCount: '8.5万'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '轻音乐',
|
||||||
|
description: '放松心情的轻柔音乐',
|
||||||
|
cover: 'https://via.placeholder.com/200x200/10b981/ffffff?text=轻音乐',
|
||||||
|
playCount: '3.7万'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '摇滚精选',
|
||||||
|
description: '激情澎湃的摇滚乐',
|
||||||
|
cover: 'https://via.placeholder.com/200x200/ef4444/ffffff?text=摇滚精选',
|
||||||
|
playCount: '2.1万'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// 热门歌曲数据
|
||||||
|
const hotSongs = ref([
|
||||||
|
{ id: 1, title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', duration: '3:37' },
|
||||||
|
{ id: 2, title: '青花瓷', artist: '周杰伦', album: '我很忙', duration: '3:58' },
|
||||||
|
{ id: 3, title: '稻香', artist: '周杰伦', album: '魔杰座', duration: '3:43' },
|
||||||
|
{ id: 4, title: '告白气球', artist: '周杰伦', album: '周杰伦的床边故事', duration: '3:34' },
|
||||||
|
{ id: 5, title: '七里香', artist: '周杰伦', album: '七里香', duration: '4:05' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const playPlaylist = (playlist: any): void => {
|
||||||
|
console.log('播放歌单:', playlist.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playSong = (song: any): void => {
|
||||||
|
console.log('播放歌曲:', song.title)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="find-container">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>发现音乐</h2>
|
||||||
|
<p>探索最新最热的音乐内容</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 推荐歌单 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">推荐歌单</h3>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<div
|
||||||
|
v-for="playlist in recommendPlaylists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-card"
|
||||||
|
@click="playPlaylist(playlist)"
|
||||||
|
>
|
||||||
|
<div class="playlist-cover">
|
||||||
|
<img :src="playlist.cover" :alt="playlist.title" />
|
||||||
|
<div class="play-overlay">
|
||||||
|
<t-button shape="circle" theme="primary" size="large">
|
||||||
|
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-info">
|
||||||
|
<h4 class="playlist-title">{{ playlist.title }}</h4>
|
||||||
|
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||||
|
<span class="play-count">
|
||||||
|
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
{{ playlist.playCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热门歌曲 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">热门歌曲</h3>
|
||||||
|
<div class="song-list">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in hotSongs"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-item"
|
||||||
|
@click="playSong(song)"
|
||||||
|
>
|
||||||
|
<div class="song-index">{{ index + 1 }}</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-title">{{ song.title }}</div>
|
||||||
|
<div class="song-artist">{{ song.artist }} - {{ song.album }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-duration">{{ song.duration }}</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<t-button shape="circle" theme="default" variant="text" size="small">
|
||||||
|
<i class="iconfont icon-gengduo"></i>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.find-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-cover {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-info {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.playlist-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-index {
|
||||||
|
width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 1rem;
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-duration {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .song-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
368
src/renderer/src/views/music/local.vue
Normal file
368
src/renderer/src/views/music/local.vue
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 本地音乐数据
|
||||||
|
const localSongs = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '十一月的萧邦',
|
||||||
|
duration: '3:37',
|
||||||
|
path: '/music/夜曲.mp3',
|
||||||
|
size: '8.5 MB',
|
||||||
|
format: 'MP3',
|
||||||
|
bitrate: '320 kbps'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '青花瓷',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '我很忙',
|
||||||
|
duration: '3:58',
|
||||||
|
path: '/music/青花瓷.mp3',
|
||||||
|
size: '9.2 MB',
|
||||||
|
format: 'MP3',
|
||||||
|
bitrate: '320 kbps'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '稻香',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '魔杰座',
|
||||||
|
duration: '3:43',
|
||||||
|
path: '/music/稻香.mp3',
|
||||||
|
size: '8.8 MB',
|
||||||
|
format: 'MP3',
|
||||||
|
bitrate: '320 kbps'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '告白气球',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '周杰伦的床边故事',
|
||||||
|
duration: '3:34',
|
||||||
|
path: '/music/告白气球.mp3',
|
||||||
|
size: '8.4 MB',
|
||||||
|
format: 'MP3',
|
||||||
|
bitrate: '320 kbps'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '七里香',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '七里香',
|
||||||
|
duration: '4:05',
|
||||||
|
path: '/music/七里香.mp3',
|
||||||
|
size: '9.6 MB',
|
||||||
|
format: 'MP3',
|
||||||
|
bitrate: '320 kbps'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const stats = ref({
|
||||||
|
totalSongs: localSongs.value.length,
|
||||||
|
totalDuration: '19:17',
|
||||||
|
totalSize: '44.5 MB'
|
||||||
|
})
|
||||||
|
|
||||||
|
const playSong = (song: any): void => {
|
||||||
|
console.log('播放本地歌曲:', song.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const importMusic = (): void => {
|
||||||
|
console.log('导入音乐文件')
|
||||||
|
// 这里可以调用 Electron 的文件选择对话框
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMusicFolder = (): void => {
|
||||||
|
console.log('打开音乐文件夹')
|
||||||
|
// 这里可以调用 Electron 的文件夹打开功能
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSong = (song: any): void => {
|
||||||
|
console.log('删除歌曲:', song.title)
|
||||||
|
// 这里可以添加删除确认和实际删除逻辑
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="local-container">
|
||||||
|
<!-- 页面标题和操作 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h2>本地音乐</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<span>{{ stats.totalSongs }} 首歌曲</span>
|
||||||
|
<span>总时长 {{ stats.totalDuration }}</span>
|
||||||
|
<span>总大小 {{ stats.totalSize }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<t-button theme="default" @click="openMusicFolder">
|
||||||
|
<i class="iconfont icon-shouye"></i>
|
||||||
|
打开文件夹
|
||||||
|
</t-button>
|
||||||
|
<t-button theme="primary" @click="importMusic">
|
||||||
|
<i class="iconfont icon-zengjia"></i>
|
||||||
|
导入音乐
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音乐列表 -->
|
||||||
|
<div class="music-list">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="header-item index">#</div>
|
||||||
|
<div class="header-item title">标题</div>
|
||||||
|
<div class="header-item artist">艺术家</div>
|
||||||
|
<div class="header-item album">专辑</div>
|
||||||
|
<div class="header-item duration">时长</div>
|
||||||
|
<div class="header-item size">大小</div>
|
||||||
|
<div class="header-item format">格式</div>
|
||||||
|
<div class="header-item actions">操作</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-body">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in localSongs"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-row"
|
||||||
|
@dblclick="playSong(song)"
|
||||||
|
>
|
||||||
|
<div class="row-item index">{{ index + 1 }}</div>
|
||||||
|
<div class="row-item title">
|
||||||
|
<div class="song-title">{{ song.title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-item artist">{{ song.artist }}</div>
|
||||||
|
<div class="row-item album">{{ song.album }}</div>
|
||||||
|
<div class="row-item duration">{{ song.duration }}</div>
|
||||||
|
<div class="row-item size">{{ song.size }}</div>
|
||||||
|
<div class="row-item format">
|
||||||
|
<span class="format-badge">{{ song.format }}</span>
|
||||||
|
<span class="bitrate">{{ song.bitrate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-item actions">
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="播放"
|
||||||
|
@click="playSong(song)"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||||
|
<i class="iconfont icon-gengduo"></i>
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="danger"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="删除"
|
||||||
|
@click="deleteSong(song)"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-shanchu"></i>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="localSongs.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="iconfont icon-music"></i>
|
||||||
|
</div>
|
||||||
|
<h3>暂无本地音乐</h3>
|
||||||
|
<p>点击"导入音乐"按钮添加您的音乐文件</p>
|
||||||
|
<t-button theme="primary" @click="importMusic">
|
||||||
|
<i class="iconfont icon-zengjia"></i>
|
||||||
|
导入音乐
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.local-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
h2 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
span {
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '•';
|
||||||
|
margin-left: 1rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.header-item {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-body {
|
||||||
|
.song-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&.index {
|
||||||
|
justify-content: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.title {
|
||||||
|
.song-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.artist,
|
||||||
|
&.album {
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.duration,
|
||||||
|
&.size {
|
||||||
|
color: #6b7280;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.format {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
|
|
||||||
|
.format-badge {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitrate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.actions {
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
445
src/renderer/src/views/music/recent.vue
Normal file
445
src/renderer/src/views/music/recent.vue
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 最近播放数据
|
||||||
|
const recentSongs = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '十一月的萧邦',
|
||||||
|
duration: '3:37',
|
||||||
|
playTime: '2024-01-15 14:30',
|
||||||
|
playCount: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '青花瓷',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '我很忙',
|
||||||
|
duration: '3:58',
|
||||||
|
playTime: '2024-01-15 13:45',
|
||||||
|
playCount: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '稻香',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '魔杰座',
|
||||||
|
duration: '3:43',
|
||||||
|
playTime: '2024-01-15 12:20',
|
||||||
|
playCount: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '告白气球',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '周杰伦的床边故事',
|
||||||
|
duration: '3:34',
|
||||||
|
playTime: '2024-01-14 20:15',
|
||||||
|
playCount: 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '七里香',
|
||||||
|
artist: '周杰伦',
|
||||||
|
album: '七里香',
|
||||||
|
duration: '4:05',
|
||||||
|
playTime: '2024-01-14 19:30',
|
||||||
|
playCount: 20
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// 最近播放的歌单
|
||||||
|
const recentPlaylists = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '我的收藏',
|
||||||
|
description: '收藏的精选歌曲',
|
||||||
|
cover: 'https://via.placeholder.com/120x120/f97316/ffffff?text=收藏',
|
||||||
|
songCount: 25,
|
||||||
|
playTime: '2024-01-15 14:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '工作音乐',
|
||||||
|
description: '适合工作时听的音乐',
|
||||||
|
cover: 'https://via.placeholder.com/120x120/3b82f6/ffffff?text=工作',
|
||||||
|
songCount: 18,
|
||||||
|
playTime: '2024-01-15 09:30'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '放松时光',
|
||||||
|
description: '轻松愉快的音乐',
|
||||||
|
cover: 'https://via.placeholder.com/120x120/10b981/ffffff?text=放松',
|
||||||
|
songCount: 32,
|
||||||
|
playTime: '2024-01-14 21:00'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const playSong = (song: any): void => {
|
||||||
|
console.log('播放歌曲:', song.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playPlaylist = (playlist: any): void => {
|
||||||
|
console.log('播放歌单:', playlist.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = (): void => {
|
||||||
|
console.log('清空播放历史')
|
||||||
|
// 这里可以添加确认对话框和清空逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPlayTime = (timeStr: string): string => {
|
||||||
|
const playTime = new Date(timeStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - playTime.getTime()
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffHours < 1) {
|
||||||
|
return '刚刚'
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
return `${diffHours} 小时前`
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays} 天前`
|
||||||
|
} else {
|
||||||
|
return playTime.toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="recent-container">
|
||||||
|
<!-- 页面标题和操作 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h2>最近播放</h2>
|
||||||
|
<p>您最近听过的音乐和歌单</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<t-button theme="default" variant="outline" @click="clearHistory">
|
||||||
|
<i class="iconfont icon-shanchu"></i>
|
||||||
|
清空历史
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近播放的歌单 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">最近播放的歌单</h3>
|
||||||
|
<div class="playlist-list">
|
||||||
|
<div
|
||||||
|
v-for="playlist in recentPlaylists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-item"
|
||||||
|
@click="playPlaylist(playlist)"
|
||||||
|
>
|
||||||
|
<div class="playlist-cover">
|
||||||
|
<img :src="playlist.cover" :alt="playlist.title" />
|
||||||
|
<div class="play-overlay">
|
||||||
|
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-info">
|
||||||
|
<h4 class="playlist-title">{{ playlist.title }}</h4>
|
||||||
|
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||||
|
<div class="playlist-meta">
|
||||||
|
<span>{{ playlist.songCount }} 首歌曲</span>
|
||||||
|
<span>{{ formatPlayTime(playlist.playTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近播放的歌曲 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">最近播放的歌曲</h3>
|
||||||
|
<div class="song-list">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in recentSongs"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-item"
|
||||||
|
@click="playSong(song)"
|
||||||
|
>
|
||||||
|
<div class="song-index">{{ index + 1 }}</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-title">{{ song.title }}</div>
|
||||||
|
<div class="song-artist">{{ song.artist }} - {{ song.album }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-stats">
|
||||||
|
<div class="play-count">播放 {{ song.playCount }} 次</div>
|
||||||
|
<div class="play-time">{{ formatPlayTime(song.playTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-duration">{{ song.duration }}</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<t-button
|
||||||
|
shape="circle"
|
||||||
|
theme="primary"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="播放"
|
||||||
|
@click.stop="playSong(song)"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||||
|
</t-button>
|
||||||
|
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||||
|
<i class="iconfont icon-gengduo"></i>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="recentSongs.length === 0 && recentPlaylists.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="iconfont icon-shijian"></i>
|
||||||
|
</div>
|
||||||
|
<h3>暂无播放历史</h3>
|
||||||
|
<p>开始播放音乐后,您的播放记录将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.recent-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
h2 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 1rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.playlist-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
|
||||||
|
span {
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '•';
|
||||||
|
margin-left: 1rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
|
||||||
|
.song-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-index {
|
||||||
|
width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 1rem;
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-stats {
|
||||||
|
margin-right: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
.play-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-duration {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
650
src/renderer/src/views/music/search.vue
Normal file
650
src/renderer/src/views/music/search.vue
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, nextTick, onUnmounted, watch } from 'vue'
|
||||||
|
import request from '@renderer/services/music'
|
||||||
|
import { PlayIcon, HeartIcon, DownloadIcon, MoreIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { searchValue } from '@renderer/store/search'
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const searchResults = ref<any>([])
|
||||||
|
const hoveredSong = ref<any>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentOffset = ref(0)
|
||||||
|
const pageSize = 50
|
||||||
|
|
||||||
|
// 虚拟滚动配置
|
||||||
|
const virtualScrollConfig = ref({
|
||||||
|
containerHeight: 0, // 动态计算容器高度
|
||||||
|
itemHeight: 64, // 每个项目的高度
|
||||||
|
buffer: 5 // 缓冲区项目数量
|
||||||
|
})
|
||||||
|
|
||||||
|
// 虚拟滚动状态
|
||||||
|
const scrollContainer = ref<HTMLElement>()
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const visibleStartIndex = ref(0)
|
||||||
|
const visibleEndIndex = ref(0)
|
||||||
|
const visibleItems = ref<any[]>([])
|
||||||
|
|
||||||
|
// 计算容器高度
|
||||||
|
const calculateContainerHeight = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const headerHeight = 120 // 搜索标题区域高度
|
||||||
|
const listHeaderHeight = 40 // 表头高度
|
||||||
|
const padding = 80 // 容器内边距
|
||||||
|
const availableHeight = window.innerHeight - headerHeight - listHeaderHeight - padding
|
||||||
|
virtualScrollConfig.value.containerHeight = Math.max(400, availableHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算虚拟滚动的可见项目
|
||||||
|
const updateVisibleItems = () => {
|
||||||
|
const { containerHeight, itemHeight, buffer } = virtualScrollConfig.value
|
||||||
|
const totalItems = searchResults.value.length
|
||||||
|
|
||||||
|
if (totalItems === 0) {
|
||||||
|
visibleItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = Math.ceil(containerHeight / itemHeight)
|
||||||
|
const startIndex = Math.floor(scrollTop.value / itemHeight)
|
||||||
|
const endIndex = Math.min(startIndex + visibleCount + buffer * 2, totalItems)
|
||||||
|
|
||||||
|
visibleStartIndex.value = Math.max(0, startIndex - buffer)
|
||||||
|
visibleEndIndex.value = endIndex
|
||||||
|
|
||||||
|
visibleItems.value = searchResults.value.slice(visibleStartIndex.value, visibleEndIndex.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理滚动事件
|
||||||
|
const handleScroll = (event: Event) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
scrollTop.value = target.scrollTop
|
||||||
|
updateVisibleItems()
|
||||||
|
|
||||||
|
// 检查是否需要加载更多
|
||||||
|
const { scrollTop: currentScrollTop, scrollHeight, clientHeight } = target
|
||||||
|
if (scrollHeight - currentScrollTop - clientHeight < 100) {
|
||||||
|
onScrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算总高度
|
||||||
|
const totalHeight = computed(
|
||||||
|
() => searchResults.value.length * virtualScrollConfig.value.itemHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算偏移量
|
||||||
|
const offsetY = computed(() => visibleStartIndex.value * virtualScrollConfig.value.itemHeight)
|
||||||
|
const search = searchValue()
|
||||||
|
// 从路由参数中获取搜索关键词和初始结果
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 计算容器高度
|
||||||
|
watch(search,async () => {
|
||||||
|
keyword.value = search.getValue
|
||||||
|
await performSearch(true)
|
||||||
|
// 确保初始渲染显示内容
|
||||||
|
await nextTick()
|
||||||
|
updateVisibleItems()
|
||||||
|
},{ immediate: true })
|
||||||
|
calculateContainerHeight()
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', calculateContainerHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (scrollContainer.value) {
|
||||||
|
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', calculateContainerHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
const performSearch = async (reset = false) => {
|
||||||
|
if (loading.value || !keyword.value.trim()) return
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
currentOffset.value = 0
|
||||||
|
searchResults.value = []
|
||||||
|
hasMore.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result: Record<string, any> = await request('search', {
|
||||||
|
type: 1,
|
||||||
|
keyword: keyword.value,
|
||||||
|
offset: currentOffset.value,
|
||||||
|
limit: pageSize
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取歌曲详情
|
||||||
|
if (result.songs && result.songs.length > 0) {
|
||||||
|
const songIds = result.songs.map((song) => song.id.toString())
|
||||||
|
const detailResult = await request('getSongDetail', {
|
||||||
|
ids: songIds
|
||||||
|
})
|
||||||
|
if (detailResult && detailResult.length > 0) {
|
||||||
|
for (let i = 0; i < result.songs.length; i++) {
|
||||||
|
result.songs[i].detail = detailResult.find((song) => song.id === result.songs[i].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
searchResults.value = result.songs || []
|
||||||
|
} else {
|
||||||
|
searchResults.value = [...searchResults.value, ...(result.songs || [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset.value += pageSize
|
||||||
|
hasMore.value = (result.songs?.length || 0) >= pageSize
|
||||||
|
|
||||||
|
// 更新虚拟滚动
|
||||||
|
await nextTick()
|
||||||
|
updateVisibleItems()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 虚拟滚动触底加载更多
|
||||||
|
const onScrollToBottom = async () => {
|
||||||
|
if (!loading.value && hasMore.value) {
|
||||||
|
await performSearch(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间显示
|
||||||
|
const formatDuration = (duration: number): string => {
|
||||||
|
const minutes = Math.floor(duration / 60000)
|
||||||
|
const seconds = Math.floor((duration % 60000) / 1000)
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算是否有搜索结果
|
||||||
|
const hasResults = computed(() => searchResults.value && searchResults.value.length > 0)
|
||||||
|
|
||||||
|
// 播放歌曲
|
||||||
|
const playSong = (song: any): void => {
|
||||||
|
console.log('播放歌曲:', song.name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-container">
|
||||||
|
<!-- 搜索结果标题 -->
|
||||||
|
<div class="search-header">
|
||||||
|
<h2 class="search-title">
|
||||||
|
搜索"<span class="keyword">{{ keyword }}</span
|
||||||
|
>"
|
||||||
|
</h2>
|
||||||
|
<div v-if="hasResults" class="result-info">找到 {{ searchResults.length }} 首单曲</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 歌曲列表 -->
|
||||||
|
<div v-if="hasResults" class="song-list-wrapper">
|
||||||
|
<!-- 表头 -->
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="col-index"></div>
|
||||||
|
<div class="col-title">标题</div>
|
||||||
|
<div class="col-album">专辑</div>
|
||||||
|
<div class="col-like">喜欢</div>
|
||||||
|
<div class="col-duration">时长</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 虚拟滚动列表 -->
|
||||||
|
<div
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="virtual-scroll-container"
|
||||||
|
:style="{ height: virtualScrollConfig.containerHeight + 'px' }"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<div class="virtual-scroll-spacer" :style="{ height: totalHeight + 'px' }">
|
||||||
|
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in visibleItems"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-item"
|
||||||
|
:class="{ 'is-playing': false, 'is-hovered': hoveredSong === song.id }"
|
||||||
|
@mouseenter="hoveredSong = song.id"
|
||||||
|
@mouseleave="hoveredSong = null"
|
||||||
|
@dblclick="playSong(song)"
|
||||||
|
>
|
||||||
|
<!-- 序号/播放按钮 -->
|
||||||
|
<div class="col-index">
|
||||||
|
<span v-if="hoveredSong !== song.id" class="track-number">
|
||||||
|
{{ String(visibleStartIndex + index + 1).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
<t-button
|
||||||
|
v-else
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="play-btn"
|
||||||
|
@click="playSong(song)"
|
||||||
|
>
|
||||||
|
<play-icon size="30" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 歌曲信息 -->
|
||||||
|
<div class="col-title">
|
||||||
|
<div
|
||||||
|
v-if="song?.detail?.album?.blurPicUrl || song?.detail?.album?.picUrl"
|
||||||
|
class="song-cover"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
(song.detail?.album?.blurPicUrl || song.detail?.album?.picUrl) +
|
||||||
|
'?param=50y50'
|
||||||
|
"
|
||||||
|
loading="lazy"
|
||||||
|
alt="封面"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-name" :title="song.name">{{ song.name }}</div>
|
||||||
|
<div
|
||||||
|
class="artist-name"
|
||||||
|
:title="song.artists ? song.artists.map((a) => a.name).join(' / ') : ''"
|
||||||
|
>
|
||||||
|
<template v-if="song.artists">
|
||||||
|
<span
|
||||||
|
v-for="(artist, idx) in song.artists"
|
||||||
|
:key="artist.id"
|
||||||
|
class="artist-link"
|
||||||
|
>
|
||||||
|
{{ artist.name }}<span v-if="idx < song.artists.length - 1"> / </span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专辑 -->
|
||||||
|
<div class="col-album">
|
||||||
|
<span class="album-name" :title="song.album?.name">
|
||||||
|
{{ song.album?.name || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 喜欢按钮 -->
|
||||||
|
<div class="col-like">
|
||||||
|
<t-button variant="text" size="small" class="action-btn like-btn" @click.stop>
|
||||||
|
<heart-icon size="16" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时长和更多操作 -->
|
||||||
|
<div class="col-duration">
|
||||||
|
<div class="duration-wrapper">
|
||||||
|
<span v-if="hoveredSong !== song.id" class="duration">{{ formatDuration(song.duration) }}</span>
|
||||||
|
<div v-else class="action-buttons">
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="action-btn"
|
||||||
|
title="下载"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<download-icon size="16" />
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="action-btn"
|
||||||
|
title="更多"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<more-icon size="16" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="!loading" class="empty-state">
|
||||||
|
<div class="empty-content">
|
||||||
|
<div class="empty-icon">🔍</div>
|
||||||
|
<h3>未找到相关歌曲</h3>
|
||||||
|
<p>请尝试其他关键词</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-else class="loading-state">
|
||||||
|
<t-loading size="large" text="搜索中..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-container {
|
||||||
|
background: #fafafa;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
|
||||||
|
.keyword {
|
||||||
|
color: #507daf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e9e9e9;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.col-index {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-title {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-like {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-duration {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroll-container {
|
||||||
|
background: #fff;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.virtual-scroll-spacer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-scroll-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
height: 64px; /* 固定高度,与虚拟滚动配置一致 */
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-hovered {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-playing {
|
||||||
|
background: #f0f7ff;
|
||||||
|
color: #507daf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-index {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
color: #507daf;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3a5d8f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.song-cover {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #507daf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
.artist-link {
|
||||||
|
&:hover {
|
||||||
|
color: #507daf;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-album {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.album-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #507daf;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-like {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.like-btn {
|
||||||
|
color: #ccc;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #507daf;
|
||||||
|
background: rgba(80, 125, 175, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.duration-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
color: #ccc;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #507daf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #f5f5f5;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.load-more-tip,
|
||||||
|
.load-complete {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
|
||||||
|
.empty-content {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header,
|
||||||
|
.song-item {
|
||||||
|
grid-template-columns: 50px 1fr 50px 60px;
|
||||||
|
|
||||||
|
.col-album {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-title {
|
||||||
|
.song-cover {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
src/renderer/src/views/settings/index.vue
Normal file
234
src/renderer/src/views/settings/index.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 当前选择的控制风格
|
||||||
|
const currentStyle = ref<'windows' | 'traffic-light'>('windows')
|
||||||
|
|
||||||
|
// 切换风格
|
||||||
|
const switchStyle = (style: 'windows' | 'traffic-light'): void => {
|
||||||
|
currentStyle.value = style
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>标题栏控制组件演示</h2>
|
||||||
|
<p>这里展示了两种不同风格的标题栏控制按钮</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>风格选择</h3>
|
||||||
|
<div class="style-buttons">
|
||||||
|
<t-button
|
||||||
|
:theme="currentStyle === 'windows' ? 'primary' : 'default'"
|
||||||
|
@click="switchStyle('windows')"
|
||||||
|
>
|
||||||
|
Windows 风格
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
:theme="currentStyle === 'traffic-light' ? 'primary' : 'default'"
|
||||||
|
@click="switchStyle('traffic-light')"
|
||||||
|
>
|
||||||
|
红绿灯风格
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>当前风格预览</h3>
|
||||||
|
<div class="preview-container">
|
||||||
|
<div class="mock-titlebar">
|
||||||
|
<div class="mock-title">Ceru Music - 设置</div>
|
||||||
|
<TitleBarControls :control-style="currentStyle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>两种风格对比</h3>
|
||||||
|
<div class="comparison-container">
|
||||||
|
<div class="style-demo">
|
||||||
|
<h4>Windows 风格</h4>
|
||||||
|
<div class="mock-titlebar">
|
||||||
|
<div class="mock-title">Windows 风格标题栏</div>
|
||||||
|
<TitleBarControls control-style="windows" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="style-demo">
|
||||||
|
<h4>红绿灯风格 (macOS)</h4>
|
||||||
|
<div class="mock-titlebar">
|
||||||
|
<div class="mock-title">红绿灯风格标题栏</div>
|
||||||
|
<TitleBarControls control-style="traffic-light" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>功能说明</h3>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature-item">
|
||||||
|
<i class="iconfont icon-shezhi"></i>
|
||||||
|
<div>
|
||||||
|
<strong>设置按钮</strong>
|
||||||
|
<p>位于控制按钮最左侧,用于打开应用设置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<i class="iconfont icon-dibu"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Mini 模式</strong>
|
||||||
|
<p>切换到迷你播放模式,节省桌面空间</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<i class="iconfont icon-zuixiaohua"></i>
|
||||||
|
<div>
|
||||||
|
<strong>最小化</strong>
|
||||||
|
<p>将窗口最小化到任务栏</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<i class="iconfont icon-zengjia"></i>
|
||||||
|
<div>
|
||||||
|
<strong>最大化</strong>
|
||||||
|
<p>切换窗口最大化/还原状态</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-item">
|
||||||
|
<i class="iconfont icon-a-quxiaoguanbi"></i>
|
||||||
|
<div>
|
||||||
|
<strong>关闭</strong>
|
||||||
|
<p>关闭应用程序</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.settings-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container,
|
||||||
|
.comparison-container {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-demo {
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f6f6f6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #f97316;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user