add(home) fix(list):ui

This commit is contained in:
sqj
2025-08-16 11:34:37 +08:00
parent a26e057bd4
commit 51c6627285
32 changed files with 3512 additions and 1267 deletions

3
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
</project>

View File

@@ -7,5 +7,6 @@
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
},
"editor.fontSize": 14
}

View 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应用提供了更加灵活和可靠的音频事件管理机制。

View File

@@ -1,5 +1,5 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
@@ -10,14 +10,16 @@ export default defineConfig({
plugins: [
externalizeDepsPlugin({
exclude: ['@electron-toolkit/utils']
})
}),
bytecodePlugin()
]
},
preload: {
plugins: [
externalizeDepsPlugin({
exclude: ['@electron-toolkit/preload']
})
}),
bytecodePlugin()
]
},
renderer: {

View File

@@ -27,7 +27,6 @@ export default tseslint.config(
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unsafe-function-return-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'vue/block-lang': [
'error',

View File

@@ -13,7 +13,7 @@
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev": "electron-vite dev --watch",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
@@ -26,7 +26,6 @@
"@electron-toolkit/utils": "^4.0.0",
"@lrc-player/core": "^1.1.5",
"@lrc-player/parse": "^1.0.0",
"NeteaseCloudMusicApi": "^4.27.0",
"axios": "^1.11.0",
"ceru-music": "link:",
"electron-updater": "^6.3.9",

935
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
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 {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
mainWindow = new BrowserWindow({
width: 1100,
height: 750,
minWidth: 970,
minHeight: 670,
show: false,
center: true,
autoHideMenuBar: true,
// alwaysOnTop: true,
maxWidth: screen.getPrimaryDisplay()?.workAreaSize.width,
maxHeight: screen.getPrimaryDisplay()?.workAreaSize.height,
titleBarStyle: 'hidden',
...(process.platform === 'linux' ? { icon } : {}),
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
icon: path.join(__dirname, '../../resources/logo.png'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -19,9 +80,27 @@ function createWindow(): void {
webSecurity: false
}
})
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
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) => {
@@ -43,7 +122,7 @@ function createWindow(): void {
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// 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
// and ignore CommandOrControl + R in production.
@@ -55,7 +134,55 @@ app.whenReady().then(() => {
// IPC test
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()
createTray()
app.on('activate', function () {
// 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', () => {
if (process.platform !== 'darwin') {
app.quit()
}
// 在 macOS 上,应用通常会保持活跃状态
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// In this file you can include the rest of your app's specific main process

View File

@@ -1,8 +1,16 @@
import { ElectronAPI } from '@electron-toolkit/preload'
// 自定义 API 接口
interface CustomAPI {
minimize: () => void
maximize: () => void
close: () => void
setMiniMode: (isMini: boolean) => void
}
declare global {
interface Window {
electron: ElectronAPI
api: unknown
api: CustomAPI
}
}

View File

@@ -1,8 +1,14 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// 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
// renderer only if context isolation is enabled, otherwise

View File

@@ -5,4 +5,6 @@
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {}
declare global {
}

View File

@@ -12,17 +12,14 @@ declare module 'vue' {
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchComponent: typeof import('./src/components/Search/SearchComponent.vue')['default']
TAside: typeof import('tdesign-vue-next')['Aside']
TBadge: typeof import('tdesign-vue-next')['Badge']
TButton: typeof import('tdesign-vue-next')['Button']
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']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TMenu: typeof import('tdesign-vue-next')['Menu']
TMenuItem: typeof import('tdesign-vue-next')['MenuItem']
TTag: typeof import('tdesign-vue-next')['Tag']
TLoading: typeof import('tdesign-vue-next')['Loading']
Versions: typeof import('./src/components/Versions.vue')['default']
}
}

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
import { ControlAudioStore } from '@renderer/store/ControlAudio'
const Audio = ControlAudioStore()
Audio.Audio.url =
'https://m801.music.126.net/20250814224734/9822eac7d8f043a33883fe30796fa0c6/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/32407718767/f58f/b1e1/cf73/d70b39ba746ca874e3d1bd0466ec1227.mp3'
import { onMounted } from 'vue'
import GlobalAudio from './components/Play/GlobalAudio.vue'
onMounted(() => {
// 设置测试音频URL
})
</script>
<template>
<router-view />
<t-button @click="Audio.Audio.isPlay ? Audio.stop() : Audio.start()">play</t-button>
<GlobalAudio />
</template>

View File

@@ -1,3 +1,5 @@
@import './icon_font/iconfont.css';
:root {
}

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

View File

@@ -1,43 +1,56 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
const Audio = ControlAudioStore()
provide('setAudioEnd', setEndCallback)
provide('setAudioSeeked', setSeekedCallback)
const timeupdate = () => {}
let endCallback: Function[] = []
let seekedCallback: Function[] = []
const audioStore = ControlAudioStore()
const audioMeta = ref<HTMLAudioElement>()
// 提供订阅方法给子组件使用
provide('audioSubscribe', audioStore.subscribe)
onMounted(() => {
Audio.init(audioMeta.value)
console.log('init', audioMeta, '1111')
audioStore.init(audioMeta.value)
console.log('音频组件初始化完成')
})
function setEndCallback(fn: Function): void {
if (typeof endCallback !== 'function') {
endCallback.push(fn)
} else {
throw new Error('Callback must be a function')
}
}
function setSeekedCallback(fn: Function): void {
if (typeof seekedCallback !== 'function') {
seekedCallback.push(fn)
} else {
throw new Error('Callback must be a function')
// 音频事件处理函数
const handleTimeUpdate = (): void => {
if (audioMeta.value) {
audioStore.setCurrentTime(audioMeta.value.currentTime)
audioStore.Audio.duration = audioMeta.value.duration || 0
}
audioStore.publish('timeupdate')
}
const end = (): void => {
endCallback?.forEach((fn) => fn)
const handleEnded = (): void => {
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(() => {
endCallback = []
seekedCallback = []
// 组件卸载时清空所有订阅者
audioStore.clearAllSubscribers()
})
</script>
@@ -46,10 +59,13 @@ onUnmounted(() => {
<audio
ref="audioMeta"
preload="auto"
:src="Audio.Audio.url"
@timeupdate="timeupdate"
@ended="end"
@seeked="seeked"
:src="audioStore.Audio.url"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
@seeked="handleSeeked"
@play="handlePlay"
@pause="handlePause"
@error="handleError"
/>
</div>
</template>

View File

@@ -1,233 +1,22 @@
<script setup lang="ts">
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>
<script setup lang="ts"></script>
<template>
<div class="bottom-container"></div>
<div class="bottom-container">
<div>aaa</div>
<div>aaa</div>
</div>
</template>
<style lang="scss">
.plan-container {
display: flex;
align-items: center;
height: 15px;
position: absolute;
top: -8.5px;
width: 100%;
}
.el-overlay {
.music-drawer {
}
}
:deep(.el-drawer) {
height: 100%;
}
// .plan-container {
// display: flex;
// align-items: center;
// height: 15px;
// position: absolute;
// top: -8.5px;
// width: 100%;
// }
.bottom-container {
display: flex;
justify-content: space-between;

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

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

View File

@@ -8,15 +8,39 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/home',
name: 'home',
redirect: '/home/find',
component: () => import('@renderer/views/home/index.vue'),
children: [
{
path: '',
name: 'music',
component: () => import('@renderer/views/music/list.vue')
redirect: '/home/find'
},
{
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 = {

View File

@@ -1,4 +1,5 @@
import { LoadingPlugin, NotifyPlugin } from 'tdesign-vue-next'
import type { LoadingInstance } from 'tdesign-vue-next'
import { MusicServiceBase } from './service-base'
@@ -31,6 +32,170 @@ type GetLyricArgs = {
yv?: 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(
@@ -38,13 +203,13 @@ async function request(
args: SearchArgs,
isLoading?: boolean,
showError?: boolean
): Promise<any>
): Promise<SongResponse>
async function request(
api: 'getSongDetail',
args: GetSongDetailArgs,
isLoading?: boolean,
showError?: boolean
): Promise<any>
): Promise<SongDetailResponse['songs']>
async function request(
api: 'getSongUrl',
args: GetSongUrlArgs,
@@ -63,8 +228,9 @@ async function request(
isLoading = false,
showError = true
): Promise<any> {
let instance: LoadingInstance | null = null
if (isLoading) {
LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
instance = LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
}
try {
@@ -91,6 +257,8 @@ async function request(
console.error('请求失败: ', error)
throw new Error(error.message)
} finally {
instance?.hide()
}
}

View File

@@ -1,4 +1,4 @@
import { axiosClient, mobileHeaders, MusicServiceBase } from './service-base'
import { axiosClient, MusicServiceBase } from './service-base'
import { fieldsSelector } from '@renderer/utils/object'
const baseUrl: string = 'https://music.163.com'

View File

@@ -1,15 +1,24 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
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', () => {
const Audio = reactive<ControlAudioState>({
audio: null,
@@ -20,44 +29,170 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
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']) => {
console.log(el, 'init2')
console.log(el, '全局音频挂载初始化success')
Audio.audio = el
}
/**
* 设置当前播放时间。
* @param {number} time - 播放时间(秒)。
* @throws {Error} 如果时间不是数字类型。
*/
const setCurrentTime = (time: number) => {
if (typeof time === 'number') {
Audio.currentTime = time
return
}
throw new Error('the time is not number')
throw new Error('时间必须是数字类型')
}
const start = () => {
/**
* 设置音量。
* @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(1)
console.log('开始播放音频')
if (Audio.audio) {
console.log(Audio)
Audio.audio.volume = 0
Audio.audio.play()
Audio.isPlay = true
return transitionVolume(Audio.audio, volume / 100, true)
try {
await Audio.audio.play()
Audio.isPlay = 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
}
const stop = () => {
if (Audio.audio) {
Audio.isPlay = false
return transitionVolume(Audio.audio, Audio.volume / 100, false).then(() => {
Audio.isPlay = false
Audio.audio?.pause()
})
}
return false
}
// const
return {
Audio,
init,
setCurrentTime,
setVolume,
setUrl,
start,
stop
stop,
subscribe,
publish,
clearAllSubscribers,
clearEventSubscribers
}
})

View 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;
},
},
});

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

View File

@@ -1,6 +1,4 @@
let timer: any
import type { Ref } from 'vue'
// 当过渡完成时会返回Promise
export function transitionVolume(
audio: HTMLAudioElement,
@@ -9,7 +7,7 @@ export function transitionVolume(
lengthen: boolean = false
): Promise<undefined> {
clearInterval(timer)
const playVolume = lengthen ? 40 : 15
const playVolume = lengthen ? 40 : 20
const pauseVolume = lengthen ? 20 : 10
return new Promise((resolve) => {
if (target) {

View File

@@ -1,12 +1,17 @@
<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 { SearchIcon } from 'tdesign-icons-vue-next'
import { useRouter } from 'vue-router'
import { searchValue } from '@renderer/store/search'
interface MenuItem {
name: string
icon: string
path: string
}
const osType = ref(1)
const menuList: MenuItem[] = [
{
name: '发现',
@@ -25,8 +30,48 @@ const menuList: MenuItem[] = [
}
]
const menuActive = ref(0)
const router = useRouter()
const handleClick = (index: number): void => {
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>
@@ -40,8 +85,10 @@ const handleClick = (index: number): void => {
<i class="iconfont icon-music"></i>
</div>
<p class="app-title">
Such Music
<span>PC</span>
<span style="
color:#000;
font-weight: 800;
">Ceru Music</span>
</p>
</div>
@@ -67,10 +114,10 @@ const handleClick = (index: number): void => {
<div class="content">
<!-- 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>
</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>
</t-button>
@@ -82,15 +129,36 @@ const handleClick = (index: number): void => {
"
></use>
</svg>
<t-input placeholder="搜索音乐、歌手" />
<t-input
v-model="keyword"
placeholder="搜索音乐、歌手"
: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>
</template>
</t-input>
</div>
<t-button shape="circle" theme="default" class="settings-btn">
<i class="iconfont icon-shezhi"></i>
</t-button>
<TitleBarControls
:control-style="osType === 0 ? 'windows' : 'traffic-light'"
></TitleBarControls>
</div>
</div>
<router-view />
<div class="mainContent">
<router-view v-if="true" />
</div>
</div>
</t-content>
</t-layout>
@@ -118,6 +186,7 @@ const handleClick = (index: number): void => {
padding: 1rem;
.logo-section {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -193,19 +262,22 @@ const handleClick = (index: number): void => {
padding: 0;
background: #f6f6f6;
height: 100vh;
display: flex;
flex-direction: column;
.header {
-webkit-app-region: drag;
display: flex;
align-items: center;
padding: 1.5rem;
.nav-btn {
-webkit-app-region: no-drag;
margin-right: 0.5rem;
&:last-of-type {
margin-right: 0.5rem;
}
.iconfont {
font-size: 1rem;
color: #3d4043;
@@ -220,8 +292,9 @@ const handleClick = (index: number): void => {
display: flex;
flex: 1;
position: relative;
justify-content: space-between;
.search-input {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
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>

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

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

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

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

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