mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-04 15:00:49 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573023600a | ||
|
|
041aad31b4 | ||
|
|
f652932d71 | ||
|
|
7efeb9492b | ||
|
|
055536eb5c | ||
|
|
14852fc8d3 | ||
|
|
9866e772df | ||
|
|
87ca0836b1 | ||
|
|
fa07c5a40c | ||
|
|
5dbfea240b | ||
|
|
c1344393c3 | ||
|
|
426888f77c | ||
|
|
45cbc15c0f | ||
|
|
072025a543 | ||
|
|
c6427aa3e1 | ||
|
|
632cdb1239 | ||
|
|
8ffe472605 | ||
|
|
8e86d378d0 | ||
|
|
744fd53fb1 | ||
|
|
3c64473dbb |
@@ -103,6 +103,7 @@ module.exports = {
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
rules: {
|
||||
'max-classes-per-file': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'dot-notation': 'off',
|
||||
'constructor-super': 'off',
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,4 +19,6 @@ bun.lockb
|
||||
|
||||
.env.*.local
|
||||
|
||||
out
|
||||
out
|
||||
|
||||
.cursorrules
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,8 +1,12 @@
|
||||
# 更新日志
|
||||
|
||||
## [v3.5.0] - 2025-01-12
|
||||
## v3.7.2
|
||||
### ✨ 优化
|
||||
- 优化歌词缓存
|
||||
- 优化音乐播放体验 如果播放失败自动播放下一首
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加下载管理 进度显示 播放下载的音乐
|
||||
- 添加缓存管理 清理缓存
|
||||
- 优化下载格式问题 支持下载其他格式 ps:其实之前只是后缀名问题
|
||||
|
||||
## 咖啡☕️
|
||||
| 微信 | | 支付宝 |
|
||||
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
|
||||
| <img src="https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/src/renderer/assets/wechat.png" alt="WeChat QRcode" width=200>| | <img src="https://www.ghproxy.cn/https://raw.githubusercontent.com/algerkong/AlgerMusicPlayer/dev_electron/src/renderer/assets/alipay.png" alt="Wechat QRcode" width=200> |
|
||||
32
README.md
32
README.md
@@ -1,17 +1,24 @@
|
||||
# Alger Music Player
|
||||
主要功能如下
|
||||
|
||||
- 音乐推荐
|
||||
- 网易云登录
|
||||
- 播放历史 歌曲收藏
|
||||
- 桌面歌词
|
||||
- 歌单 mv 搜索 专辑等功能
|
||||
- 识别无法播放歌曲 并解析播放
|
||||
- 主题切换 更新检测
|
||||
- 本地服务 不依赖线上服务
|
||||
- 可听周杰伦(搜索专辑)
|
||||
- 支持歌曲下载(歌曲右键)
|
||||
- 支持音质选择(网易云VIP)
|
||||
- 🎵 音乐推荐
|
||||
- 🔐 网易云账号登录与同步
|
||||
- 📝 功能
|
||||
- 播放历史记录
|
||||
- 歌曲收藏管理
|
||||
- 自定义快捷键配置
|
||||
- 🎨 界面与交互
|
||||
- 沉浸式歌词显示(点击左下角封面进入)
|
||||
- 独立桌面歌词窗口
|
||||
- 明暗主题切换
|
||||
- 🎼 音乐功能
|
||||
- 支持歌单、MV、专辑等完整音乐服务
|
||||
- 灰色音乐资源解析(基于 @unblockneteasemusic/server)
|
||||
- 高品质音乐试听(需网易云VIP)
|
||||
- 音乐文件下载(支持右键下载和批量下载)
|
||||
- 🚀 技术特性
|
||||
- 本地化服务,无需依赖在线API (基于 netease-cloud-music-api)
|
||||
- 自动更新检测
|
||||
- 全平台适配(Desktop & Web & Mobile Web)
|
||||
|
||||
## 项目简介
|
||||
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
@@ -26,6 +33,7 @@ QQ群:789288579
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
BIN
docs/image4.png
Normal file
BIN
docs/image4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "AlgerMusicPlayer",
|
||||
"version": "3.5.0",
|
||||
"version": "3.7.2",
|
||||
"description": "Alger Music Player",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -62,7 +62,7 @@
|
||||
"howler": "^2.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^15.0.4",
|
||||
"naive-ui": "^2.39.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.2",
|
||||
"remixicon": "^4.2.0",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||
import { app, globalShortcut, ipcMain, nativeImage } from 'electron';
|
||||
import { app, ipcMain, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
import { loadLyricWindow } from './lyric';
|
||||
import { initializeConfig } from './modules/config';
|
||||
import { initializeFileManager } from './modules/fileManager';
|
||||
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
|
||||
import { initializeTray } from './modules/tray';
|
||||
import { createMainWindow, initializeWindowManager } from './modules/window';
|
||||
import { startMusicApi } from './server';
|
||||
@@ -23,16 +24,16 @@ let mainWindow: Electron.BrowserWindow;
|
||||
|
||||
// 初始化应用
|
||||
function initialize() {
|
||||
// 初始化各个模块
|
||||
// 初始化配置管理
|
||||
initializeConfig();
|
||||
// 初始化文件管理
|
||||
initializeFileManager();
|
||||
// 初始化窗口管理
|
||||
initializeWindowManager();
|
||||
|
||||
// 创建主窗口
|
||||
mainWindow = createMainWindow(icon);
|
||||
|
||||
// 初始化窗口管理
|
||||
initializeWindowManager();
|
||||
|
||||
// 初始化托盘
|
||||
initializeTray(iconPath, mainWindow);
|
||||
|
||||
@@ -41,6 +42,9 @@ function initialize() {
|
||||
|
||||
// 加载歌词窗口
|
||||
loadLyricWindow(ipcMain, mainWindow);
|
||||
|
||||
// 初始化快捷键
|
||||
initializeShortcuts(mainWindow);
|
||||
}
|
||||
|
||||
// 应用程序准备就绪时的处理
|
||||
@@ -57,20 +61,14 @@ app.whenReady().then(() => {
|
||||
initialize();
|
||||
|
||||
// macOS 激活应用时的处理
|
||||
app.on('activate', function () {
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) initialize();
|
||||
});
|
||||
});
|
||||
|
||||
// 应用程序准备就绪后的快捷键设置
|
||||
app.on('ready', () => {
|
||||
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
// 监听快捷键更新
|
||||
ipcMain.on('update-shortcuts', () => {
|
||||
registerShortcuts(mainWindow);
|
||||
});
|
||||
|
||||
// 所有窗口关闭时的处理
|
||||
|
||||
89
src/main/modules/cache.ts
Normal file
89
src/main/modules/cache.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
interface LyricData {
|
||||
id: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StoreSchema {
|
||||
lyrics: Record<number, LyricData>;
|
||||
}
|
||||
|
||||
class CacheManager {
|
||||
private store: Store<StoreSchema>;
|
||||
|
||||
constructor() {
|
||||
this.store = new Store<StoreSchema>({
|
||||
name: 'lyrics',
|
||||
defaults: {
|
||||
lyrics: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async cacheLyric(id: number, data: any) {
|
||||
try {
|
||||
const lyrics = this.store.get('lyrics');
|
||||
lyrics[id] = {
|
||||
id,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.store.set('lyrics', lyrics);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error caching lyric:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedLyric(id: number) {
|
||||
try {
|
||||
const lyrics = this.store.get('lyrics');
|
||||
const result = lyrics[id];
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
// 检查缓存是否过期(24小时)
|
||||
if (Date.now() - result.timestamp > 24 * 60 * 60 * 1000) {
|
||||
delete lyrics[id];
|
||||
this.store.set('lyrics', lyrics);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting cached lyric:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async clearLyricCache() {
|
||||
try {
|
||||
this.store.set('lyrics', {});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error clearing lyric cache:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheManager = new CacheManager();
|
||||
|
||||
export function initializeCacheManager() {
|
||||
// 添加歌词缓存相关的 IPC 处理
|
||||
ipcMain.handle('cache-lyric', async (_, id: number, lyricData: any) => {
|
||||
return await cacheManager.cacheLyric(id, lyricData);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-cached-lyric', async (_, id: number) => {
|
||||
return await cacheManager.getCachedLyric(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-lyric-cache', async () => {
|
||||
return await cacheManager.clearLyricCache();
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { app, ipcMain } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
import set from '../set.json';
|
||||
import { defaultShortcuts } from './shortcuts';
|
||||
|
||||
interface StoreType {
|
||||
set: {
|
||||
@@ -12,6 +13,7 @@ interface StoreType {
|
||||
authorUrl: string;
|
||||
musicApiPort: number;
|
||||
};
|
||||
shortcuts: typeof defaultShortcuts;
|
||||
}
|
||||
|
||||
let store: Store<StoreType>;
|
||||
@@ -23,7 +25,8 @@ export function initializeConfig() {
|
||||
store = new Store<StoreType>({
|
||||
name: 'config',
|
||||
defaults: {
|
||||
set
|
||||
set,
|
||||
shortcuts: defaultShortcuts
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,3 +44,7 @@ export function initializeConfig() {
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export function getStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -317,8 +317,8 @@ async function downloadMusic(
|
||||
|
||||
// 等待下载完成
|
||||
await new Promise((resolve, reject) => {
|
||||
writer!.on('finish', resolve);
|
||||
writer!.on('error', reject);
|
||||
writer!.on('finish', () => resolve(undefined));
|
||||
writer!.on('error', (error) => reject(error));
|
||||
response.data.pipe(writer!);
|
||||
});
|
||||
|
||||
|
||||
88
src/main/modules/shortcuts.ts
Normal file
88
src/main/modules/shortcuts.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { globalShortcut, ipcMain } from 'electron';
|
||||
|
||||
import { getStore } from './config';
|
||||
|
||||
// 添加获取平台信息的 IPC 处理程序
|
||||
ipcMain.on('get-platform', (event) => {
|
||||
event.returnValue = process.platform;
|
||||
});
|
||||
|
||||
// 定义默认快捷键
|
||||
export const defaultShortcuts = {
|
||||
togglePlay: 'CommandOrControl+Alt+P',
|
||||
prevPlay: 'CommandOrControl+Alt+Left',
|
||||
nextPlay: 'CommandOrControl+Alt+Right',
|
||||
volumeUp: 'CommandOrControl+Alt+Up',
|
||||
volumeDown: 'CommandOrControl+Alt+Down',
|
||||
toggleFavorite: 'CommandOrControl+Alt+L',
|
||||
toggleWindow: 'CommandOrControl+Alt+Shift+M'
|
||||
};
|
||||
|
||||
let mainWindowRef: Electron.BrowserWindow | null = null;
|
||||
|
||||
// 注册快捷键
|
||||
export function registerShortcuts(mainWindow: Electron.BrowserWindow) {
|
||||
mainWindowRef = mainWindow;
|
||||
const store = getStore();
|
||||
const shortcuts = store.get('shortcuts');
|
||||
|
||||
// 注销所有已注册的快捷键
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
// 显示/隐藏主窗口
|
||||
globalShortcut.register(shortcuts.toggleWindow, () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
// 播放/暂停
|
||||
globalShortcut.register(shortcuts.togglePlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'togglePlay');
|
||||
});
|
||||
|
||||
// 上一首
|
||||
globalShortcut.register(shortcuts.prevPlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'prevPlay');
|
||||
});
|
||||
|
||||
// 下一首
|
||||
globalShortcut.register(shortcuts.nextPlay, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'nextPlay');
|
||||
});
|
||||
|
||||
// 音量增加
|
||||
globalShortcut.register(shortcuts.volumeUp, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'volumeUp');
|
||||
});
|
||||
|
||||
// 音量减少
|
||||
globalShortcut.register(shortcuts.volumeDown, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'volumeDown');
|
||||
});
|
||||
|
||||
// 收藏当前歌曲
|
||||
globalShortcut.register(shortcuts.toggleFavorite, () => {
|
||||
mainWindow.webContents.send('global-shortcut', 'toggleFavorite');
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化快捷键
|
||||
export function initializeShortcuts(mainWindow: Electron.BrowserWindow) {
|
||||
mainWindowRef = mainWindow;
|
||||
registerShortcuts(mainWindow);
|
||||
|
||||
// 监听禁用快捷键事件
|
||||
ipcMain.on('disable-shortcuts', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
// 监听启用快捷键事件
|
||||
ipcMain.on('enable-shortcuts', () => {
|
||||
if (mainWindowRef) {
|
||||
registerShortcuts(mainWindowRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,162 @@
|
||||
import match from '@unblockneteasemusic/server';
|
||||
import Store from 'electron-store';
|
||||
|
||||
const unblockMusic = async (id: any, songData: any): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'], songData)
|
||||
.then((data) => {
|
||||
resolve({
|
||||
data: {
|
||||
data,
|
||||
params: {
|
||||
id,
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube';
|
||||
|
||||
interface SongData {
|
||||
name: string;
|
||||
artists: Array<{ name: string }>;
|
||||
album?: { name: string };
|
||||
}
|
||||
|
||||
interface ResponseData {
|
||||
url: string;
|
||||
br: number;
|
||||
size: number;
|
||||
md5?: string;
|
||||
platform?: Platform;
|
||||
gain?: number;
|
||||
}
|
||||
|
||||
interface UnblockResult {
|
||||
data: {
|
||||
data: ResponseData;
|
||||
params: {
|
||||
id: number;
|
||||
type: 'song';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheData extends UnblockResult {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CacheStore {
|
||||
[key: string]: CacheData;
|
||||
}
|
||||
|
||||
// 初始化缓存存储
|
||||
const store = new Store<CacheStore>({
|
||||
name: 'unblock-cache'
|
||||
});
|
||||
|
||||
// 缓存过期时间(24小时)
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
* @param cacheData 缓存数据
|
||||
* @returns boolean
|
||||
*/
|
||||
const isCacheValid = (cacheData: CacheData | null): boolean => {
|
||||
if (!cacheData) return false;
|
||||
const now = Date.now();
|
||||
return now - cacheData.timestamp < CACHE_EXPIRY;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从缓存中获取数据
|
||||
* @param id 歌曲ID
|
||||
* @returns CacheData | null
|
||||
*/
|
||||
const getFromCache = (id: string | number): CacheData | null => {
|
||||
const cacheData = store.get(String(id)) as CacheData | null;
|
||||
if (isCacheValid(cacheData)) {
|
||||
return cacheData;
|
||||
}
|
||||
// 清除过期缓存
|
||||
store.delete(String(id));
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将数据存入缓存
|
||||
* @param id 歌曲ID
|
||||
* @param data 解析结果
|
||||
*/
|
||||
const saveToCache = (id: string | number, data: UnblockResult): void => {
|
||||
const cacheData: CacheData = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
store.set(String(id), cacheData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
const cleanExpiredCache = (): void => {
|
||||
const allData = store.store;
|
||||
Object.entries(allData).forEach(([id, data]) => {
|
||||
if (!isCacheValid(data)) {
|
||||
store.delete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { unblockMusic };
|
||||
/**
|
||||
* 音乐解析函数
|
||||
* @param id 歌曲ID
|
||||
* @param songData 歌曲信息
|
||||
* @param retryCount 重试次数
|
||||
* @returns Promise<UnblockResult>
|
||||
*/
|
||||
const unblockMusic = async (
|
||||
id: number | string,
|
||||
songData: SongData,
|
||||
retryCount = 3
|
||||
): Promise<UnblockResult> => {
|
||||
// 检查缓存
|
||||
const cachedData = getFromCache(id);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// 所有可用平台
|
||||
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube'];
|
||||
|
||||
const retry = async (attempt: number): Promise<UnblockResult> => {
|
||||
try {
|
||||
const data = await match(parseInt(String(id), 10), platforms, songData);
|
||||
const result: UnblockResult = {
|
||||
data: {
|
||||
data,
|
||||
params: {
|
||||
id: parseInt(String(id), 10),
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存到缓存
|
||||
saveToCache(id, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (attempt < retryCount) {
|
||||
// 延迟重试,每次重试增加延迟时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
return retry(attempt + 1);
|
||||
}
|
||||
|
||||
// 所有重试都失败后,抛出详细错误
|
||||
throw new Error(
|
||||
`音乐解析失败 (ID: ${id}): ${err instanceof Error ? err.message : '未知错误'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return retry(1);
|
||||
};
|
||||
|
||||
// 定期清理过期缓存(每小时执行一次)
|
||||
setInterval(cleanExpiredCache, 60 * 60 * 1000);
|
||||
|
||||
export {
|
||||
cleanExpiredCache, // 导出清理缓存函数,以便手动调用
|
||||
type Platform,
|
||||
type ResponseData,
|
||||
type SongData,
|
||||
unblockMusic,
|
||||
type UnblockResult
|
||||
};
|
||||
|
||||
1
src/preload/index.d.ts
vendored
1
src/preload/index.d.ts
vendored
@@ -13,6 +13,7 @@ declare global {
|
||||
miniTray: () => void;
|
||||
restart: () => void;
|
||||
unblockMusic: (id: number, data: any) => Promise<any>;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
};
|
||||
$message: any;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ const api = {
|
||||
restart: () => ipcRenderer.send('restart'),
|
||||
openLyric: () => ipcRenderer.send('open-lyric'),
|
||||
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
||||
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id)
|
||||
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id),
|
||||
// 歌词缓存相关
|
||||
invoke: (channel: string, ...args: any[]) => {
|
||||
const validChannels = ['get-cached-lyric', 'cache-lyric', 'clear-lyric-cache'];
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
return Promise.reject(new Error(`Invalid channel: ${channel}`));
|
||||
}
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
21
src/renderer/api/artist.ts
Normal file
21
src/renderer/api/artist.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 获取歌手详情
|
||||
export const getArtistDetail = (id) => {
|
||||
return request.get('/artist/detail', { params: { id } });
|
||||
};
|
||||
|
||||
// 获取歌手热门歌曲
|
||||
export const getArtistTopSongs = (params) => {
|
||||
return request.get('/artist/songs', {
|
||||
params: {
|
||||
...params,
|
||||
order: 'hot'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 获取歌手专辑
|
||||
export const getArtistAlbums = (params) => {
|
||||
return request.get('/artist/album', { params });
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import { musicDB } from '@/hooks/MusicHook';
|
||||
import store from '@/store';
|
||||
import { ILyric } from '@/type/lyric';
|
||||
import type { ILyric } from '@/type/lyric';
|
||||
import { isElectron } from '@/utils';
|
||||
import request from '@/utils/request';
|
||||
import requestMusic from '@/utils/request_music';
|
||||
|
||||
const { addData, getData, deleteData } = musicDB;
|
||||
|
||||
// 获取音乐音质详情
|
||||
export const getMusicQualityDetail = (id: number) => {
|
||||
return request.get('/song/music/detail', { params: { id } });
|
||||
@@ -36,8 +39,32 @@ export const getMusicDetail = (ids: Array<number>) => {
|
||||
};
|
||||
|
||||
// 根据音乐Id获取音乐歌词
|
||||
export const getMusicLrc = (id: number) => {
|
||||
return request.get<ILyric>('/lyric', { params: { id } });
|
||||
export const getMusicLrc = async (id: number) => {
|
||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000; // 10天的毫秒数
|
||||
|
||||
try {
|
||||
// 尝试获取缓存的歌词
|
||||
const cachedLyric = await getData('music_lyric', id);
|
||||
if (cachedLyric?.createTime && Date.now() - cachedLyric.createTime < TEN_DAYS_MS) {
|
||||
return { ...cachedLyric };
|
||||
}
|
||||
|
||||
// 获取新的歌词数据
|
||||
const res = await request.get<ILyric>('/lyric', { params: { id } });
|
||||
|
||||
// 只有在成功获取新数据后才删除旧缓存并添加新缓存
|
||||
if (res?.data) {
|
||||
if (cachedLyric) {
|
||||
await deleteData('music_lyric', id);
|
||||
}
|
||||
addData('music_lyric', { id, data: res.data, createTime: Date.now() });
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('获取歌词失败:', error);
|
||||
throw error; // 向上抛出错误,让调用者处理
|
||||
}
|
||||
};
|
||||
|
||||
export const getParsingMusicUrl = (id: number, data: any) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import request from '@/utils/request';
|
||||
interface IParams {
|
||||
keywords: string;
|
||||
type: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
// 搜索内容
|
||||
export const getSearch = (params: IParams) => {
|
||||
|
||||
3687
src/renderer/assets/css/animate.css
vendored
3687
src/renderer/assets/css/animate.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,7 @@ body {
|
||||
.n-popover:has(.music-play) {
|
||||
border-radius: 1.5rem !important;
|
||||
}
|
||||
.n-popover {
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
2
src/renderer/components.d.ts
vendored
2
src/renderer/components.d.ts
vendored
@@ -11,7 +11,6 @@ declare module 'vue' {
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
@@ -23,6 +22,7 @@ declare module 'vue' {
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
mask-closable
|
||||
:style="{ backgroundColor: 'transparent' }"
|
||||
:to="`#layout-main`"
|
||||
:z-index="zIndex"
|
||||
@mask-click="close"
|
||||
>
|
||||
<div class="music-page">
|
||||
@@ -17,7 +18,7 @@
|
||||
</div>
|
||||
</n-ellipsis>
|
||||
<div class="music-close">
|
||||
<i class="icon iconfont icon-icon_error" @click="close"></i>
|
||||
<i class="icon iconfont ri-close-line" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
@@ -88,6 +89,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
zIndex?: number;
|
||||
songList: any[];
|
||||
loading?: boolean;
|
||||
listInfo?: {
|
||||
@@ -98,7 +100,8 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
cover: true
|
||||
cover: true,
|
||||
zIndex: 9996
|
||||
}
|
||||
);
|
||||
|
||||
@@ -234,7 +237,7 @@ watch(
|
||||
}
|
||||
|
||||
&-close {
|
||||
@apply cursor-pointer text-gray-900 dark:text-white flex gap-2 items-center;
|
||||
@apply cursor-pointer text-gray-500 dark:text-white hover:text-gray-900 dark:hover:text-gray-300 flex gap-2 items-center transition;
|
||||
.icon {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
91
src/renderer/components/ShortcutToast.vue
Normal file
91
src/renderer/components/ShortcutToast.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<transition name="shortcut-toast">
|
||||
<div v-if="visible" class="shortcut-toast">
|
||||
<div class="shortcut-toast-content">
|
||||
<div class="shortcut-toast-icon">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
<div class="shortcut-toast-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
const visible = ref(false);
|
||||
const text = ref('');
|
||||
const icon = ref('');
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const show = (message: string, iconName: string) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
text.value = message;
|
||||
icon.value = iconName;
|
||||
visible.value = true;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
visible.value = false;
|
||||
// 在动画结束后触发销毁事件
|
||||
setTimeout(() => {
|
||||
emit('destroy');
|
||||
}, 300);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['destroy']);
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
show
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shortcut-toast {
|
||||
@apply fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[9999];
|
||||
@apply flex items-center justify-center;
|
||||
|
||||
&-content {
|
||||
@apply flex flex-col items-center gap-2 p-4 rounded-lg;
|
||||
@apply bg-light-200 bg-opacity-70 dark:bg-dark-200 dark:bg-opacity-90;
|
||||
@apply text-dark-100 dark:text-light-100;
|
||||
@apply shadow-lg backdrop-blur-sm;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
&-text {
|
||||
@apply text-sm font-medium text-center;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-toast-enter-active,
|
||||
.shortcut-toast-leave-active {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.shortcut-toast-enter-from,
|
||||
.shortcut-toast-leave-to {
|
||||
@apply opacity-0 scale-90;
|
||||
}
|
||||
|
||||
.shortcut-toast-enter-to,
|
||||
.shortcut-toast-leave-from {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
</style>
|
||||
317
src/renderer/components/common/ArtistDrawer.vue
Normal file
317
src/renderer/components/common/ArtistDrawer.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
v-model:show="modelValue"
|
||||
:width="800"
|
||||
placement="right"
|
||||
:mask-closable="true"
|
||||
:z-index="9997"
|
||||
>
|
||||
<div v-loading="loading" class="artist-drawer">
|
||||
<div class="close-btn">
|
||||
<i class="ri-close-line" @click="modelValue = false"></i>
|
||||
</div>
|
||||
|
||||
<!-- 歌手信息头部 -->
|
||||
<div class="artist-header">
|
||||
<div class="artist-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(artistInfo?.avatar, '300y300')"
|
||||
class="w-48 h-48 rounded-2xl object-cover"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
<h1 class="artist-name">{{ artistInfo?.name }}</h1>
|
||||
<div v-if="artistInfo?.alias?.length" class="artist-alias">
|
||||
{{ artistInfo.alias.join(' / ') }}
|
||||
</div>
|
||||
<div v-if="artistInfo?.briefDesc" class="artist-desc">
|
||||
{{ artistInfo.briefDesc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页切换 -->
|
||||
<n-tabs v-model:value="activeTab" class="flex-1" type="line" animated>
|
||||
<n-tab-pane name="songs" tab="热门歌曲">
|
||||
<div ref="songListRef" class="songs-list">
|
||||
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleSongScroll">
|
||||
<div class="song-list-content">
|
||||
<song-item
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
:item="song"
|
||||
:list="true"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
<div v-if="songLoading" class="loading-more">加载中...</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="albums" tab="专辑">
|
||||
<div ref="albumListRef" class="albums-list">
|
||||
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleAlbumScroll">
|
||||
<div class="albums-grid">
|
||||
<search-item
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
shape="square"
|
||||
:z-index="9998"
|
||||
:item="{
|
||||
id: album.id,
|
||||
picUrl: album.picUrl,
|
||||
name: album.name,
|
||||
desc: formatPublishTime(album.publishTime),
|
||||
size: album.size,
|
||||
type: '专辑'
|
||||
}"
|
||||
/>
|
||||
<div v-if="albumLoading" class="loading-more">加载中...</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="about" tab="艺人介绍">
|
||||
<div class="artist-description">
|
||||
<n-scrollbar style="max-height: 60vh">
|
||||
<div class="description-content" v-html="artistInfo?.briefDesc"></div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDateFormat } from '@vueuse/core';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SearchItem from '@/components/common/SearchItem.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { IArtist } from '@/type/artist';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
import PlayBottom from './PlayBottom.vue';
|
||||
|
||||
const modelValue = defineModel<boolean>('show', { required: true });
|
||||
|
||||
const store = useStore();
|
||||
const activeTab = ref('songs');
|
||||
const currentArtistId = ref<number>();
|
||||
|
||||
// 歌手信息
|
||||
const artistInfo = ref<IArtist>();
|
||||
const songs = ref<any[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
|
||||
// 加载状态
|
||||
const songLoading = ref(false);
|
||||
const albumLoading = ref(false);
|
||||
|
||||
// 分页参数
|
||||
const songPage = ref({
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
hasMore: true
|
||||
});
|
||||
|
||||
const albumPage = ref({
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
hasMore: true
|
||||
});
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
store.commit('setShowArtistDrawer', newVal);
|
||||
});
|
||||
const loading = ref(false);
|
||||
// 加载歌手信息
|
||||
const loadArtistInfo = async (id: number) => {
|
||||
if (currentArtistId.value === id) return;
|
||||
activeTab.value = 'songs';
|
||||
loading.value = true;
|
||||
currentArtistId.value = id;
|
||||
try {
|
||||
const info = await getArtistDetail(id);
|
||||
if (info.data?.data?.artist) {
|
||||
artistInfo.value = info.data.data.artist;
|
||||
}
|
||||
// 重置分页并加载初始数据
|
||||
resetPagination();
|
||||
await Promise.all([loadSongs(), loadAlbums()]);
|
||||
} catch (error) {
|
||||
console.error('加载歌手信息失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置分页
|
||||
const resetPagination = () => {
|
||||
songPage.value = {
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
hasMore: true
|
||||
};
|
||||
albumPage.value = {
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
hasMore: true
|
||||
};
|
||||
songs.value = [];
|
||||
albums.value = [];
|
||||
};
|
||||
|
||||
// 加载歌曲
|
||||
const loadSongs = async () => {
|
||||
if (!currentArtistId.value || !songPage.value.hasMore || songLoading.value) return;
|
||||
|
||||
try {
|
||||
songLoading.value = true;
|
||||
const { page, pageSize } = songPage.value;
|
||||
const res = await getArtistTopSongs({
|
||||
id: currentArtistId.value,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
const ids = res.data.songs.map((item) => item.id);
|
||||
const songsDetail = await getMusicDetail(ids);
|
||||
|
||||
if (songsDetail.data?.songs) {
|
||||
const newSongs = songsDetail.data.songs.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
picUrl: item.al.picUrl,
|
||||
song: {
|
||||
artists: item.ar,
|
||||
name: item.name,
|
||||
id: item.id
|
||||
}
|
||||
};
|
||||
});
|
||||
songs.value = page === 1 ? newSongs : [...songs.value, ...newSongs];
|
||||
songPage.value.hasMore = newSongs.length === pageSize;
|
||||
songPage.value.page++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌曲失败:', error);
|
||||
} finally {
|
||||
songLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载专辑
|
||||
const loadAlbums = async () => {
|
||||
if (!currentArtistId.value || !albumPage.value.hasMore || albumLoading.value) return;
|
||||
|
||||
try {
|
||||
albumLoading.value = true;
|
||||
const { page, pageSize } = albumPage.value;
|
||||
const res = await getArtistAlbums({
|
||||
id: currentArtistId.value,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
if (res.data?.hotAlbums) {
|
||||
const newAlbums = res.data.hotAlbums;
|
||||
albums.value = page === 1 ? newAlbums : [...albums.value, ...newAlbums];
|
||||
albumPage.value.hasMore = newAlbums.length === pageSize;
|
||||
albumPage.value.page++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载专辑失败:', error);
|
||||
} finally {
|
||||
albumLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动加载
|
||||
const handleSongScroll = (e: { target: any }) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
||||
loadSongs();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlbumScroll = (e: { target: any }) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
||||
loadAlbums();
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化发布时间
|
||||
const formatPublishTime = (time: number) => {
|
||||
return useDateFormat(time, 'YYYY-MM-DD').value;
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit(
|
||||
'setPlayList',
|
||||
songs.value.map((item) => ({
|
||||
...item,
|
||||
picUrl: item.al.picUrl
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
loadArtistInfo
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.artist-drawer {
|
||||
@apply h-full bg-light dark:bg-dark px-6 overflow-hidden flex flex-col;
|
||||
|
||||
.close-btn {
|
||||
@apply absolute top-4 right-4 text-gray-500 dark:text-gray-400 hover:text-green-500 text-2xl cursor-pointer p-2;
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
@apply flex gap-6 pt-6;
|
||||
|
||||
.artist-info {
|
||||
@apply flex-1;
|
||||
|
||||
.artist-name {
|
||||
@apply text-4xl font-bold mb-2;
|
||||
}
|
||||
|
||||
.artist-alias {
|
||||
@apply text-gray-500 dark:text-gray-400 mb-2;
|
||||
}
|
||||
|
||||
.artist-desc {
|
||||
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.albums-grid {
|
||||
@apply grid gap-4 grid-cols-5;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center py-4 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.artist-description {
|
||||
.description-content {
|
||||
@apply text-sm leading-relaxed whitespace-pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -77,6 +77,10 @@
|
||||
</div>
|
||||
|
||||
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
|
||||
<div class="description text-center text-sm text-gray-700 dark:text-gray-200">
|
||||
<p>您的捐赠将用于支持开发和维护工作,包括但不限于服务器维护、域名续费等。</p>
|
||||
<p>留言时可留下您的邮箱或 github名称。</p>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
@@ -102,8 +106,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'animate.css';
|
||||
|
||||
import axios from 'axios';
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
const props = defineProps({
|
||||
showPop: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const musicFullClass = computed(() => {
|
||||
if (props.showPop) {
|
||||
return setAnimationClass('animate__fadeInUp');
|
||||
}
|
||||
return setAnimationClass('animate__fadeOutDown');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
|
||||
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
|
||||
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pop-page {
|
||||
height: 800px;
|
||||
@apply absolute top-4 left-0 w-full;
|
||||
background-color: #000000f0;
|
||||
.close {
|
||||
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="search-item" :class="item.type" @click="handleClick">
|
||||
<div class="search-item" :class="[item.type, shape]" @click="handleClick">
|
||||
<div class="search-item-img">
|
||||
<n-image
|
||||
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '100y100')"
|
||||
class="w-full h-full"
|
||||
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
@@ -15,6 +16,11 @@
|
||||
<p class="search-item-artist">{{ item.desc }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="item.type === '专辑'" class="search-item-size">
|
||||
<i class="ri-music-2-line"></i>
|
||||
<span>{{ item.size }}</span>
|
||||
</div>
|
||||
|
||||
<music-list
|
||||
v-if="['专辑', 'playlist'].includes(item.type)"
|
||||
v-model:show="showPop"
|
||||
@@ -22,6 +28,7 @@
|
||||
:song-list="songList"
|
||||
:list-info="listInfo"
|
||||
:cover="false"
|
||||
:z-index="zIndex"
|
||||
/>
|
||||
<mv-player
|
||||
v-if="item.type === 'mv'"
|
||||
@@ -43,15 +50,22 @@ import { getImgUrl } from '@/utils';
|
||||
|
||||
import MusicList from '../MusicList.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
item: {
|
||||
picUrl: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
shape?: 'square' | 'rectangle';
|
||||
zIndex?: number;
|
||||
item: {
|
||||
picUrl: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}>(),
|
||||
{
|
||||
shape: 'rectangle'
|
||||
}
|
||||
);
|
||||
|
||||
const songList = ref<any[]>([]);
|
||||
|
||||
@@ -104,11 +118,45 @@ const handleClick = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-item {
|
||||
@apply rounded-3xl p-3 flex items-center hover:bg-light-200 dark:hover:bg-gray-800 transition cursor-pointer;
|
||||
margin: 0 10px;
|
||||
.search-item-img {
|
||||
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
|
||||
@apply rounded-lg p-0 flex items-center hover:bg-transparent transition cursor-pointer border-none;
|
||||
|
||||
&.square {
|
||||
@apply flex-col relative;
|
||||
|
||||
.search-item-img {
|
||||
@apply w-full aspect-square mb-2 mr-0 rounded-lg overflow-hidden hover:shadow-xl transition-all duration-300 shadow-sm shadow-black/20 dark:shadow-white/20;
|
||||
img {
|
||||
@apply object-cover w-full h-full transition-transform duration-500;
|
||||
}
|
||||
}
|
||||
|
||||
.search-item-info {
|
||||
@apply w-full text-left px-0;
|
||||
|
||||
.search-item-name {
|
||||
@apply truncate mb-1 font-medium text-base text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.search-item-artist {
|
||||
@apply truncate text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.search-item-size {
|
||||
@apply absolute top-2 right-2 text-xs text-white px-2 py-1 rounded-full bg-black/30 backdrop-blur-sm;
|
||||
i {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.rectangle {
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200 p-3;
|
||||
.search-item-img {
|
||||
@apply w-12 h-12 mr-4 rounded-lg overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.search-item-info {
|
||||
@apply flex-1 overflow-hidden;
|
||||
&-name {
|
||||
@@ -138,4 +186,8 @@ const handleClick = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-item-size {
|
||||
@apply flex items-center gap-2 text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,10 +25,14 @@
|
||||
}}</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
<template v-for="(artist, index) in artists" :key="index">
|
||||
<span
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click.stop="handleArtistClick(artist.id)"
|
||||
>{{ artist.name }}</span
|
||||
>
|
||||
<span v-if="index < artists.length - 1"> / </span>
|
||||
</template>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -37,12 +41,14 @@
|
||||
</div>
|
||||
<div class="song-item-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<span
|
||||
v-for="(artists, artistsindex) in item.ar || item.song.artists"
|
||||
:key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
<template v-for="(artist, index) in artists" :key="index">
|
||||
<span
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click.stop="handleArtistClick(artist.id)"
|
||||
>{{ artist.name }}</span
|
||||
>
|
||||
<span v-if="index < artists.length - 1"> / </span>
|
||||
</template>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</template>
|
||||
@@ -266,6 +272,15 @@ const toggleFavorite = async (e: Event) => {
|
||||
const toggleSelect = () => {
|
||||
emits('select', props.item.id, !props.selected);
|
||||
};
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
store.commit('setCurrentArtistId', id);
|
||||
};
|
||||
|
||||
// 获取歌手列表(最多显示5个)
|
||||
const artists = computed(() => {
|
||||
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -313,11 +328,11 @@ const toggleSelect = () => {
|
||||
}
|
||||
|
||||
&-like {
|
||||
@apply mr-2 cursor-pointer ml-4;
|
||||
@apply mr-2 cursor-pointer ml-4 transition-all;
|
||||
}
|
||||
|
||||
.like-active {
|
||||
@apply text-red-500;
|
||||
@apply text-red-500 dark:text-red-500;
|
||||
}
|
||||
|
||||
&-play {
|
||||
|
||||
380
src/renderer/components/settings/ShortcutSettings.vue
Normal file
380
src/renderer/components/settings/ShortcutSettings.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
preset="dialog"
|
||||
title="快捷键设置"
|
||||
:show-icon="false"
|
||||
style="width: 600px"
|
||||
@after-leave="handleAfterLeave"
|
||||
>
|
||||
<div class="shortcut-settings">
|
||||
<div class="shortcut-card">
|
||||
<div class="shortcut-content">
|
||||
<n-scrollbar>
|
||||
<n-space vertical>
|
||||
<div v-for="(shortcut, key) in tempShortcuts" :key="key" class="shortcut-item">
|
||||
<div class="shortcut-info">
|
||||
<span class="shortcut-label">{{ getShortcutLabel(key) }}</span>
|
||||
</div>
|
||||
<div class="shortcut-input">
|
||||
<n-input
|
||||
:value="formatShortcut(shortcut)"
|
||||
:status="duplicateKeys[key] ? 'error' : undefined"
|
||||
placeholder="点击输入快捷键"
|
||||
readonly
|
||||
@keydown="(e) => handleKeyDown(e, key)"
|
||||
@focus="() => startRecording(key)"
|
||||
@blur="stopRecording"
|
||||
/>
|
||||
<n-tooltip v-if="duplicateKeys[key]" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon class="error-icon" size="18">
|
||||
<i class="ri-error-warning-line"></i>
|
||||
</n-icon>
|
||||
</template>
|
||||
快捷键冲突
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="shortcut-footer">
|
||||
<n-space justify="end">
|
||||
<n-button size="small" @click="handleCancel">取消</n-button>
|
||||
<n-button size="small" @click="resetShortcuts">恢复默认</n-button>
|
||||
<n-button type="primary" size="small" :disabled="hasConflict" @click="handleSave">
|
||||
保存
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
interface Shortcuts {
|
||||
togglePlay: string;
|
||||
prevPlay: string;
|
||||
nextPlay: string;
|
||||
volumeUp: string;
|
||||
volumeDown: string;
|
||||
toggleFavorite: string;
|
||||
toggleWindow: string;
|
||||
}
|
||||
|
||||
const defaultShortcuts: Shortcuts = {
|
||||
togglePlay: 'CommandOrControl+Alt+P',
|
||||
prevPlay: 'Alt+Left',
|
||||
nextPlay: 'Alt+Right',
|
||||
volumeUp: 'Alt+Up',
|
||||
volumeDown: 'Alt+Down',
|
||||
toggleFavorite: 'CommandOrControl+Alt+L',
|
||||
toggleWindow: 'CommandOrControl+Alt+Shift+M'
|
||||
};
|
||||
|
||||
const shortcuts = ref<Shortcuts>(
|
||||
isElectron
|
||||
? window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts') || defaultShortcuts
|
||||
: { ...defaultShortcuts }
|
||||
);
|
||||
|
||||
// 临时存储编辑中的快捷键
|
||||
const tempShortcuts = ref<Shortcuts>({ ...shortcuts.value });
|
||||
|
||||
// 监听快捷键更新
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.on('shortcuts-updated', () => {
|
||||
const newShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
|
||||
if (newShortcuts) {
|
||||
shortcuts.value = newShortcuts;
|
||||
tempShortcuts.value = { ...newShortcuts };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 组件挂载时禁用快捷键
|
||||
onMounted(() => {
|
||||
if (isElectron) {
|
||||
// 禁用全局快捷键
|
||||
window.electron.ipcRenderer.send('disable-shortcuts');
|
||||
|
||||
const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
|
||||
console.log('storedShortcuts', storedShortcuts);
|
||||
if (storedShortcuts) {
|
||||
shortcuts.value = storedShortcuts;
|
||||
tempShortcuts.value = { ...storedShortcuts };
|
||||
} else {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
tempShortcuts.value = { ...defaultShortcuts };
|
||||
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', defaultShortcuts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const shortcutLabels: Record<keyof Shortcuts, string> = {
|
||||
togglePlay: '播放/暂停',
|
||||
prevPlay: '上一首',
|
||||
nextPlay: '下一首',
|
||||
volumeUp: '音量增加',
|
||||
volumeDown: '音量减少',
|
||||
toggleFavorite: '收藏/取消收藏',
|
||||
toggleWindow: '显示/隐藏窗口'
|
||||
};
|
||||
|
||||
const getShortcutLabel = (key: keyof Shortcuts) => shortcutLabels[key];
|
||||
|
||||
const isRecording = ref(false);
|
||||
const currentKey = ref<keyof Shortcuts | ''>('');
|
||||
const message = useMessage();
|
||||
|
||||
// 检查快捷键冲突
|
||||
const duplicateKeys = computed(() => {
|
||||
const result: Record<string, boolean> = {};
|
||||
const usedShortcuts = new Set<string>();
|
||||
|
||||
Object.entries(tempShortcuts.value).forEach(([key, shortcut]) => {
|
||||
if (usedShortcuts.has(shortcut)) {
|
||||
result[key] = true;
|
||||
} else {
|
||||
usedShortcuts.add(shortcut);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// 是否存在冲突
|
||||
const hasConflict = computed(() => Object.keys(duplicateKeys.value).length > 0);
|
||||
|
||||
const startRecording = (key: keyof Shortcuts) => {
|
||||
isRecording.value = true;
|
||||
currentKey.value = key;
|
||||
// 禁用全局快捷键
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send('disable-shortcuts');
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
isRecording.value = false;
|
||||
currentKey.value = '';
|
||||
// 重新启用全局快捷键
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send('enable-shortcuts');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent, key: keyof Shortcuts) => {
|
||||
if (!isRecording.value || currentKey.value !== key) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const modifiers: string[] = [];
|
||||
|
||||
// 统一使用 CommandOrControl
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
modifiers.push('CommandOrControl');
|
||||
}
|
||||
if (e.altKey) modifiers.push('Alt');
|
||||
if (e.shiftKey) modifiers.push('Shift');
|
||||
|
||||
let keyName = e.key;
|
||||
|
||||
// 特殊按键处理
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
keyName = 'Left';
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
keyName = 'Right';
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
keyName = 'Up';
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
keyName = 'Down';
|
||||
break;
|
||||
case 'Control':
|
||||
case 'Alt':
|
||||
case 'Shift':
|
||||
case 'Meta':
|
||||
case 'Command':
|
||||
return; // 忽略单独的修饰键
|
||||
default:
|
||||
keyName = e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
}
|
||||
|
||||
if (!['Control', 'Alt', 'Shift', 'Meta', 'Command'].includes(keyName)) {
|
||||
tempShortcuts.value[key] = [...modifiers, keyName].join('+');
|
||||
}
|
||||
};
|
||||
|
||||
const resetShortcuts = () => {
|
||||
tempShortcuts.value = { ...defaultShortcuts };
|
||||
message.success('已恢复默认快捷键,请记得保存');
|
||||
};
|
||||
|
||||
const saveShortcuts = () => {
|
||||
if (hasConflict.value) {
|
||||
message.error('存在冲突的快捷键,请重新设置');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个新的 Shortcuts 对象
|
||||
const shortcutsToSave = cloneDeep(tempShortcuts.value);
|
||||
|
||||
shortcuts.value = shortcutsToSave;
|
||||
|
||||
if (isElectron) {
|
||||
try {
|
||||
// 先保存到 store
|
||||
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', shortcutsToSave);
|
||||
// 然后更新快捷键
|
||||
window.electron.ipcRenderer.send('update-shortcuts');
|
||||
message.success('快捷键设置已保存');
|
||||
} catch (error) {
|
||||
console.error('保存快捷键失败:', error);
|
||||
message.error('保存快捷键失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
tempShortcuts.value = { ...shortcuts.value };
|
||||
message.info('已取消修改');
|
||||
emit('update:show', false);
|
||||
};
|
||||
|
||||
// 组件卸载时确保快捷键被重新启用
|
||||
onUnmounted(() => {
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send('enable-shortcuts');
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化快捷键显示
|
||||
const formatShortcut = (shortcut: string) => {
|
||||
const isMac = isElectron
|
||||
? window.electron.ipcRenderer.sendSync('get-platform') === 'darwin'
|
||||
: false;
|
||||
return shortcut
|
||||
.replace(/CommandOrControl/g, isMac ? '⌘' : 'Ctrl')
|
||||
.replace(/\+/g, ' + ')
|
||||
.replace(/Meta/g, isMac ? '⌘' : 'Win')
|
||||
.replace(/Control/g, isMac ? '⌃' : 'Ctrl')
|
||||
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
|
||||
.replace(/Shift/g, isMac ? '⇧' : 'Shift')
|
||||
.replace(/ArrowUp/g, '↑')
|
||||
.replace(/ArrowDown/g, '↓')
|
||||
.replace(/ArrowLeft/g, '←')
|
||||
.replace(/ArrowRight/g, '→');
|
||||
};
|
||||
|
||||
const visible = ref(false);
|
||||
const emit = defineEmits(['update:show', 'change']);
|
||||
|
||||
// 接收外部的 show 属性
|
||||
const props = defineProps<{
|
||||
show?: boolean;
|
||||
}>();
|
||||
|
||||
// 监听 show 属性变化
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听内部 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
});
|
||||
|
||||
// 处理弹窗关闭后的事件
|
||||
const handleAfterLeave = () => {
|
||||
// 重置临时数据
|
||||
tempShortcuts.value = { ...shortcuts.value };
|
||||
};
|
||||
|
||||
// 处理取消按钮点击
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
cancelEdit();
|
||||
};
|
||||
|
||||
// 处理保存按钮点击
|
||||
const handleSave = () => {
|
||||
saveShortcuts();
|
||||
visible.value = false;
|
||||
emit('change', shortcuts.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shortcut-settings {
|
||||
height: 500px;
|
||||
|
||||
.shortcut-card {
|
||||
@apply flex flex-col h-full;
|
||||
|
||||
.shortcut-footer {
|
||||
@apply p-4 border-t border-gray-100 dark:border-gray-800;
|
||||
}
|
||||
|
||||
.shortcut-content {
|
||||
@apply flex-1 overflow-hidden;
|
||||
|
||||
:deep(.n-scrollbar) {
|
||||
@apply h-full;
|
||||
|
||||
.n-scrollbar-content {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
@apply flex items-center justify-between p-3 rounded-lg transition-all mb-3;
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shortcut-info {
|
||||
@apply flex flex-col;
|
||||
|
||||
.shortcut-label {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
@apply flex items-center gap-2;
|
||||
min-width: 200px;
|
||||
|
||||
:deep(.n-input) {
|
||||
.n-input__input-el {
|
||||
@apply text-center font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,29 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 定义表配置的泛型接口
|
||||
export interface StoreConfig<T extends string> {
|
||||
name: T;
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
// 创建一个使用 IndexedDB 的组合函数
|
||||
const useIndexedDB = () => {
|
||||
const db = ref<IDBDatabase | null>(null); // 数据库引用
|
||||
const useIndexedDB = async <T extends string, S extends Record<T, Record<string, any>>>(
|
||||
dbName: string,
|
||||
stores: StoreConfig<T>[],
|
||||
version: number = 1
|
||||
) => {
|
||||
const db = ref<IDBDatabase | null>(null);
|
||||
|
||||
// 打开数据库并创建表
|
||||
const initDB = (
|
||||
dbName: string,
|
||||
version: number,
|
||||
stores: { name: string; keyPath?: string }[]
|
||||
) => {
|
||||
const initDB = () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, version); // 打开数据库请求
|
||||
const request = indexedDB.open(dbName, version);
|
||||
|
||||
request.onupgradeneeded = (event: any) => {
|
||||
const db = event.target.result; // 获取数据库实例
|
||||
const db = event.target.result;
|
||||
stores.forEach((store) => {
|
||||
if (!db.objectStoreNames.contains(store.name)) {
|
||||
// 确保对象存储(表)创建
|
||||
db.createObjectStore(store.name, {
|
||||
keyPath: store.keyPath || 'id',
|
||||
autoIncrement: true
|
||||
@@ -28,39 +33,41 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
db.value = event.target.result; // 保存数据库实例
|
||||
resolve(); // 成功时解析 Promise
|
||||
db.value = event.target.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
reject(event.target.error); // 失败时拒绝 Promise
|
||||
reject(event.target.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用新增数据
|
||||
const addData = (storeName: string, value: any) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
|
||||
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
|
||||
const store = tx.objectStore(storeName); // 获取对象存储
|
||||
await initDB();
|
||||
|
||||
const request = store.add(value); // 添加数据请求
|
||||
// 通用新增数据
|
||||
const addData = <K extends T>(storeName: K, value: S[K]) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
const store = tx.objectStore(storeName);
|
||||
|
||||
const request = store.add(value);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('成功'); // 成功时输出
|
||||
resolve(); // 解析 Promise
|
||||
console.log('成功');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
|
||||
reject((event.target as IDBRequest).error); // 拒绝 Promise
|
||||
console.error('新增失败:', (event.target as IDBRequest).error);
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用保存数据(新增或更新)
|
||||
const saveData = (storeName: string, value: any) => {
|
||||
const saveData = <K extends T>(storeName: K, value: S[K]) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
@@ -79,8 +86,8 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 通用获取数据
|
||||
const getData = (storeName: string, key: string | number) => {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const getData = <K extends T>(storeName: K, key: string | number) => {
|
||||
return new Promise<S[K]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
@@ -101,7 +108,7 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const deleteData = (storeName: string, key: string | number) => {
|
||||
const deleteData = <K extends T>(storeName: K, key: string | number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
@@ -120,8 +127,8 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 查询所有数据
|
||||
const getAllData = (storeName: string) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const getAllData = <K extends T>(storeName: K) => {
|
||||
return new Promise<S[K][]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
@@ -142,29 +149,29 @@ const useIndexedDB = () => {
|
||||
};
|
||||
|
||||
// 分页查询数据
|
||||
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const getDataWithPagination = <K extends T>(storeName: K, page: number, pageSize: number) => {
|
||||
return new Promise<S[K][]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.openCursor(); // 打开游标请求
|
||||
const results: any[] = []; // 存储结果的数组
|
||||
let index = 0; // 当前索引
|
||||
const skip = (page - 1) * pageSize; // 计算跳过的数量
|
||||
const request = store.openCursor();
|
||||
const results: S[K][] = [];
|
||||
let index = 0;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
const cursor = event.target.result; // 获取游标
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
resolve(results); // 如果没有更多数据,解析结果
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= skip && results.length < pageSize) {
|
||||
results.push(cursor.value); // 添加当前游标值到结果
|
||||
results.push(cursor.value);
|
||||
}
|
||||
|
||||
index++; // 增加索引
|
||||
cursor.continue(); // 继续游标
|
||||
index++;
|
||||
cursor.continue();
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import useIndexedDB from '@/hooks/IndexDBHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import store from '@/store';
|
||||
import type { ILyricText, SongResult } from '@/type/music';
|
||||
import type { Artist, ILyricText, SongResult } from '@/type/music';
|
||||
import { isElectron } from '@/utils';
|
||||
import { getTextColors } from '@/utils/linearColor';
|
||||
|
||||
@@ -19,6 +20,15 @@ export const playMusic = computed(() => store.state.playMusic as SongResult); //
|
||||
export const sound = ref<Howl | null>(audioService.getCurrentSound());
|
||||
export const isLyricWindowOpen = ref(false); // 新增状态
|
||||
export const textColors = ref<any>(getTextColors());
|
||||
export const artistList = computed(
|
||||
() => (store.state.playMusic.ar || store.state.playMusic?.song?.artists) as Artist[]
|
||||
);
|
||||
|
||||
export const musicDB = await useIndexedDB('musicDB', [
|
||||
{ name: 'music', keyPath: 'id' },
|
||||
{ name: 'music_lyric', keyPath: 'id' },
|
||||
{ name: 'api_cache', keyPath: 'id' }
|
||||
]);
|
||||
|
||||
document.onkeyup = (e) => {
|
||||
// 检查事件目标是否是输入框元素
|
||||
@@ -43,11 +53,18 @@ document.onkeyup = (e) => {
|
||||
|
||||
watch(
|
||||
() => store.state.playMusicUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
audioService.play(newVal);
|
||||
sound.value = audioService.getCurrentSound();
|
||||
audioServiceOn(audioService);
|
||||
async (newVal) => {
|
||||
if (newVal && playMusic.value) {
|
||||
try {
|
||||
const newSound = await audioService.play(newVal, playMusic.value);
|
||||
sound.value = newSound as Howl;
|
||||
setupAudioListeners();
|
||||
} catch (error) {
|
||||
console.error('播放音频失败:', error);
|
||||
store.commit('setPlayMusic', false);
|
||||
// 下一首
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -70,59 +87,97 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
export const audioServiceOn = (audio: typeof audioService) => {
|
||||
const setupAudioListeners = () => {
|
||||
let interval: any = null;
|
||||
|
||||
const clearInterval = () => {
|
||||
if (interval) {
|
||||
window.clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 清理所有事件监听器
|
||||
audioService.clearAllListeners();
|
||||
|
||||
// 监听播放
|
||||
audio.onPlay(() => {
|
||||
audioService.on('play', () => {
|
||||
store.commit('setPlayMusic', true);
|
||||
interval = setInterval(() => {
|
||||
nowTime.value = sound.value?.seek() as number;
|
||||
allTime.value = sound.value?.duration() as number;
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
nowIndex.value = newIndex;
|
||||
currentLrcProgress.value = 0;
|
||||
// 当歌词索引更新时,发送歌词数据
|
||||
clearInterval();
|
||||
interval = window.setInterval(() => {
|
||||
try {
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound || typeof currentSound.seek !== 'function') {
|
||||
console.error('Invalid sound object or seek function');
|
||||
clearInterval();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = currentSound.seek() as number;
|
||||
if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) {
|
||||
console.error('Invalid current time:', currentTime);
|
||||
clearInterval();
|
||||
return;
|
||||
}
|
||||
|
||||
nowTime.value = currentTime;
|
||||
allTime.value = currentSound.duration() as number;
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
nowIndex.value = newIndex;
|
||||
currentLrcProgress.value = 0;
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
// 定期发送歌词数据更新
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
} catch (error) {
|
||||
console.error('Error in interval:', error);
|
||||
clearInterval();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// 监听暂停
|
||||
audio.onPause(() => {
|
||||
audioService.on('pause', () => {
|
||||
store.commit('setPlayMusic', false);
|
||||
clearInterval(interval);
|
||||
// 暂停时也发送一次状态更新
|
||||
clearInterval();
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听结束
|
||||
audio.onEnd(() => {
|
||||
audioService.on('end', () => {
|
||||
clearInterval();
|
||||
|
||||
if (store.state.playMode === 1) {
|
||||
// 单曲循环模式
|
||||
audio.getCurrentSound()?.play();
|
||||
if (sound.value) {
|
||||
sound.value.seek(0);
|
||||
try {
|
||||
sound.value.play();
|
||||
} catch (error) {
|
||||
console.error('Error replaying song:', error);
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
}
|
||||
} else if (store.state.playMode === 2) {
|
||||
// 随机播放模式
|
||||
const { playList } = store.state;
|
||||
if (playList.length <= 1) {
|
||||
// 如果播放列表只有一首歌或为空,则重新播放当前歌曲
|
||||
audio.getCurrentSound()?.play();
|
||||
try {
|
||||
sound.value?.play();
|
||||
} catch (error) {
|
||||
console.error('Error replaying song:', error);
|
||||
}
|
||||
} else {
|
||||
// 随机选择一首不同的歌
|
||||
let randomIndex;
|
||||
do {
|
||||
randomIndex = Math.floor(Math.random() * playList.length);
|
||||
} while (randomIndex === store.state.playListIndex && playList.length > 1);
|
||||
|
||||
store.state.playListIndex = randomIndex;
|
||||
store.commit('setPlay', playList[randomIndex]);
|
||||
}
|
||||
@@ -131,6 +186,8 @@ export const audioServiceOn = (audio: typeof audioService) => {
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
});
|
||||
|
||||
return clearInterval;
|
||||
};
|
||||
|
||||
export const play = () => {
|
||||
@@ -195,20 +252,47 @@ export const useLyricProgress = () => {
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!isPlaying.value) return;
|
||||
if (!isPlaying.value) {
|
||||
stopProgressAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
if (!currentSound || typeof currentSound.seek !== 'function') {
|
||||
console.error('Invalid sound object or seek function');
|
||||
stopProgressAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
const duration = end - start;
|
||||
const elapsed = (currentSound.seek() as number) - start;
|
||||
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
|
||||
try {
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
if (typeof start !== 'number' || typeof end !== 'number' || start === end) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = currentSound.seek() as number;
|
||||
if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) {
|
||||
console.error('Invalid current time:', currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - start;
|
||||
const duration = end - start;
|
||||
const progress = (elapsed / duration) * 100;
|
||||
|
||||
// 确保进度在 0-100 之间
|
||||
currentLrcProgress.value = Math.min(Math.max(progress, 0), 100);
|
||||
} catch (error) {
|
||||
console.error('Error updating progress:', error);
|
||||
}
|
||||
|
||||
// 继续下一帧更新
|
||||
animationFrameId = requestAnimationFrame(updateProgress);
|
||||
};
|
||||
|
||||
const startProgressAnimation = () => {
|
||||
if (!animationFrameId && isPlaying.value) {
|
||||
stopProgressAnimation(); // 先停止之前的动画
|
||||
if (isPlaying.value) {
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
@@ -220,8 +304,30 @@ export const useLyricProgress = () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(isPlaying, (newIsPlaying) => {
|
||||
if (newIsPlaying) {
|
||||
// 监听播放状态变化
|
||||
watch(
|
||||
isPlaying,
|
||||
(newIsPlaying) => {
|
||||
if (newIsPlaying) {
|
||||
startProgressAnimation();
|
||||
} else {
|
||||
stopProgressAnimation();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听当前歌词索引变化
|
||||
watch(nowIndex, () => {
|
||||
currentLrcProgress.value = 0;
|
||||
if (isPlaying.value) {
|
||||
startProgressAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听音频对象变化
|
||||
watch(sound, (newSound) => {
|
||||
if (newSound && isPlaying.value) {
|
||||
startProgressAnimation();
|
||||
} else {
|
||||
stopProgressAnimation();
|
||||
@@ -357,3 +463,16 @@ if (isElectron) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 在组件挂载时设置监听器
|
||||
onMounted(() => {
|
||||
const clearIntervalFn = setupAudioListeners();
|
||||
useLyricProgress(); // 直接调用,不需要解构返回值
|
||||
|
||||
// 在组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
clearIntervalFn();
|
||||
audioService.stop();
|
||||
audioService.clearAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Howl } from 'howler';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
@@ -13,13 +14,11 @@ const musicHistory = useMusicHistory();
|
||||
// 获取歌曲url
|
||||
export const getSongUrl = async (id: number, songData: any, isDownloaded: boolean = false) => {
|
||||
const { data } = await getMusicUrl(id);
|
||||
console.log('data', data);
|
||||
let url = '';
|
||||
let songDetail = null;
|
||||
try {
|
||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||
const res = await getParsingMusicUrl(id, songData);
|
||||
console.log('res', res);
|
||||
url = res.data.data.url;
|
||||
songDetail = res.data.data;
|
||||
} else {
|
||||
@@ -52,7 +51,6 @@ const getSongDetail = async (playMusic: SongResult) => {
|
||||
export const useMusicListHook = () => {
|
||||
const handlePlayMusic = async (state: any, playMusic: SongResult) => {
|
||||
const updatedPlayMusic = await getSongDetail(playMusic);
|
||||
console.log('updatedPlayMusic', updatedPlayMusic);
|
||||
state.playMusic = updatedPlayMusic;
|
||||
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
|
||||
state.play = true;
|
||||
@@ -66,43 +64,91 @@ export const useMusicListHook = () => {
|
||||
fetchSongs(state, playListIndex + 1, playListIndex + 6);
|
||||
};
|
||||
|
||||
const preloadingSounds = ref<Howl[]>([]);
|
||||
|
||||
// 用于预加载下一首歌曲的 MP3 数据
|
||||
const preloadNextSong = (nextSongUrl: string) => {
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false
|
||||
});
|
||||
return sound;
|
||||
try {
|
||||
// 限制同时预加载的数量
|
||||
if (preloadingSounds.value.length >= 2) {
|
||||
const oldestSound = preloadingSounds.value.shift();
|
||||
if (oldestSound) {
|
||||
oldestSound.unload();
|
||||
}
|
||||
}
|
||||
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false
|
||||
});
|
||||
|
||||
preloadingSounds.value.push(sound);
|
||||
|
||||
// 添加加载错误处理
|
||||
sound.on('loaderror', () => {
|
||||
console.error('预加载音频失败:', nextSongUrl);
|
||||
const index = preloadingSounds.value.indexOf(sound);
|
||||
if (index > -1) {
|
||||
preloadingSounds.value.splice(index, 1);
|
||||
}
|
||||
sound.unload();
|
||||
});
|
||||
|
||||
return sound;
|
||||
} catch (error) {
|
||||
console.error('预加载音频出错:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
|
||||
const songs = state.playList.slice(
|
||||
Math.max(0, startIndex),
|
||||
Math.min(endIndex, state.playList.length)
|
||||
);
|
||||
try {
|
||||
const songs = state.playList.slice(
|
||||
Math.max(0, startIndex),
|
||||
Math.min(endIndex, state.playList.length)
|
||||
);
|
||||
|
||||
const detailedSongs = await Promise.all(
|
||||
songs.map(async (song: SongResult) => {
|
||||
// 如果歌曲详情已经存在,就不重复请求
|
||||
if (!song.playMusicUrl) {
|
||||
return await getSongDetail(song);
|
||||
const detailedSongs = await Promise.all(
|
||||
songs.map(async (song: SongResult) => {
|
||||
try {
|
||||
// 如果歌曲详情已经存在,就不重复请求
|
||||
if (!song.playMusicUrl) {
|
||||
return await getSongDetail(song);
|
||||
}
|
||||
return song;
|
||||
} catch (error) {
|
||||
console.error('获取歌曲详情失败:', error);
|
||||
return song;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 加载下一首的歌词
|
||||
const nextSong = detailedSongs[0];
|
||||
if (nextSong && !(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
||||
try {
|
||||
nextSong.lyric = await loadLrc(nextSong.id);
|
||||
} catch (error) {
|
||||
console.error('加载歌词失败:', error);
|
||||
}
|
||||
return song;
|
||||
})
|
||||
);
|
||||
// 加载下一首的歌词
|
||||
const nextSong = detailedSongs[0];
|
||||
if (!(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
||||
nextSong.lyric = await loadLrc(nextSong.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放列表中的歌曲详情
|
||||
detailedSongs.forEach((song, index) => {
|
||||
state.playList[startIndex + index] = song;
|
||||
});
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
// 更新播放列表中的歌曲详情
|
||||
detailedSongs.forEach((song, index) => {
|
||||
if (song && startIndex + index < state.playList.length) {
|
||||
state.playList[startIndex + index] = song;
|
||||
}
|
||||
});
|
||||
|
||||
// 只预加载下一首歌曲
|
||||
if (nextSong && nextSong.playMusicUrl) {
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌曲列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const nextPlay = async (state: any) => {
|
||||
@@ -156,7 +202,7 @@ export const useMusicListHook = () => {
|
||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
||||
const tlyric: Record<string, string> = {};
|
||||
|
||||
if (data.tlyric.lyric) {
|
||||
if (data.tlyric && data.tlyric.lyric) {
|
||||
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
||||
tLyrics.forEach((lyric, index) => {
|
||||
tlyric[tTimes[index].toString()] = lyric.text;
|
||||
@@ -196,6 +242,12 @@ export const useMusicListHook = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
// 在组件卸载时清理预加载的音频
|
||||
onUnmounted(() => {
|
||||
preloadingSounds.value.forEach((sound) => sound.unload());
|
||||
preloadingSounds.value = [];
|
||||
});
|
||||
|
||||
return {
|
||||
handlePlayMusic,
|
||||
nextPlay,
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
}
|
||||
|
||||
.n-slider-handle-indicator--top {
|
||||
@apply bg-transparent dark:text-[#ffffffdd] text-[#000000dd] text-2xl px-2 py-1 shadow-none mb-0 !important;
|
||||
@apply bg-transparent text-2xl px-2 py-1 shadow-none mb-0 dark:text-[#ffffffdd] text-[#000000dd] !important;
|
||||
mix-blend-mode: difference !important;
|
||||
}
|
||||
|
||||
.v-binder-follower-container:has(.n-slider-handle-indicator--top) {
|
||||
z-index: 999999999 !important;
|
||||
}
|
||||
|
||||
.text-el {
|
||||
@@ -56,3 +61,11 @@
|
||||
--text-color-300: #3d3d3d;
|
||||
--primary-color: #22c55e;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-color: #000000dd;
|
||||
}
|
||||
|
||||
:root[class='dark'] {
|
||||
--text-color: #ffffffdd;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
<meta name="apple-mobile-web-app-title" content="网抑云音乐" />
|
||||
<!-- 资源预加载 -->
|
||||
<link rel="preload" href="./assets/icon/iconfont.css" as="style" />
|
||||
<link rel="preload" href="./assets/css/animate.css" as="style" />
|
||||
<link rel="preload" href="./assets/css/base.css" as="style" />
|
||||
|
||||
<!-- 样式表 -->
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
</div>
|
||||
<install-app-modal v-if="!isElectron"></install-app-modal>
|
||||
<update-modal v-if="isElectron" />
|
||||
<artist-drawer ref="artistDrawerRef" :show="artistDrawerShow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted } from 'vue';
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
@@ -61,6 +62,8 @@ const PlayBar = defineAsyncComponent(() => import('./components/PlayBar.vue'));
|
||||
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
|
||||
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
|
||||
|
||||
const ArtistDrawer = defineAsyncComponent(() => import('@/components/common/ArtistDrawer.vue'));
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const isPlay = computed(() => store.state.isPlay as boolean);
|
||||
@@ -71,6 +74,25 @@ onMounted(() => {
|
||||
store.dispatch('initializeSettings');
|
||||
store.dispatch('initializeTheme');
|
||||
});
|
||||
|
||||
const artistDrawerRef = ref<InstanceType<typeof ArtistDrawer>>();
|
||||
const artistDrawerShow = computed({
|
||||
get: () => store.state.showArtistDrawer,
|
||||
set: (val) => store.commit('setShowArtistDrawer', val)
|
||||
});
|
||||
|
||||
// 监听歌手ID变化
|
||||
watch(
|
||||
() => store.state.currentArtistId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
artistDrawerShow.value = true;
|
||||
nextTick(() => {
|
||||
artistDrawerRef.value?.loadArtistInfo(newId);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="musicFull"
|
||||
v-model:show="isVisible"
|
||||
height="100%"
|
||||
placement="bottom"
|
||||
:style="{ background: currentBackground || background }"
|
||||
:to="`#layout-main`"
|
||||
:z-index="9998"
|
||||
>
|
||||
<div id="drawer-target">
|
||||
<div class="drawer-back"></div>
|
||||
@@ -22,10 +23,24 @@
|
||||
<div>
|
||||
<div class="music-content-name">{{ playMusic.name }}</div>
|
||||
<div class="music-content-singer">
|
||||
<span v-for="(item, index) in playMusic.ar || playMusic.song.artists" :key="index">
|
||||
{{ item.name
|
||||
}}{{ index < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
<n-ellipsis
|
||||
class="text-ellipsis"
|
||||
line-clamp="2"
|
||||
:tooltip="{
|
||||
contentStyle: { maxWidth: '600px' },
|
||||
zIndex: 99999
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in artistList"
|
||||
:key="index"
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click="handleArtistClick(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
{{ index < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,9 +84,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import {
|
||||
artistList,
|
||||
lrcArray,
|
||||
nowIndex,
|
||||
playMusic,
|
||||
@@ -91,7 +108,7 @@ const animationFrame = ref<number | null>(null);
|
||||
const isDark = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
musicFull: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
@@ -101,15 +118,26 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const isVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
// 歌词滚动方法
|
||||
const lrcScroll = (behavior = 'smooth') => {
|
||||
const lrcScroll = (behavior = 'smooth', top: null | number = null) => {
|
||||
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
|
||||
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
|
||||
const containerRect = lrcContainer.value.getBoundingClientRect();
|
||||
const nowElRect = nowEl.getBoundingClientRect();
|
||||
const relativeTop = nowElRect.top - containerRect.top;
|
||||
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
|
||||
lrcSider.value.scrollTo({ top: scrollTop, behavior });
|
||||
if (isVisible.value && !isMouse.value && nowEl && lrcContainer.value) {
|
||||
if (top !== null) {
|
||||
lrcSider.value.scrollTo({ top, behavior });
|
||||
} else {
|
||||
const containerRect = lrcContainer.value.getBoundingClientRect();
|
||||
const nowElRect = nowEl.getBoundingClientRect();
|
||||
const relativeTop = nowElRect.top - containerRect.top;
|
||||
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
|
||||
lrcSider.value.scrollTo({ top: scrollTop, behavior });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,9 +164,9 @@ watch(nowIndex, () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.musicFull,
|
||||
() => isVisible.value,
|
||||
() => {
|
||||
if (props.musicFull) {
|
||||
if (isVisible.value) {
|
||||
nextTick(() => {
|
||||
lrcScroll('instant');
|
||||
});
|
||||
@@ -219,6 +247,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const handleArtistClick = (id: number) => {
|
||||
isVisible.value = false;
|
||||
store.commit('setCurrentArtistId', id);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
lrcScroll
|
||||
});
|
||||
@@ -263,7 +297,7 @@ defineExpose({
|
||||
@apply flex flex-col justify-center items-center relative;
|
||||
|
||||
&-name {
|
||||
@apply font-bold text-xl pb-1 pt-4;
|
||||
@apply font-bold text-2xl pb-1 pt-4;
|
||||
}
|
||||
|
||||
&-singer {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<template>
|
||||
<!-- 展开全屏 -->
|
||||
<!-- 底部播放栏 -->
|
||||
|
||||
<div
|
||||
class="music-play-bar"
|
||||
:class="
|
||||
@@ -51,15 +48,22 @@
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<div class="music-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<n-ellipsis
|
||||
class="text-ellipsis"
|
||||
line-clamp="1"
|
||||
:tooltip="{
|
||||
contentStyle: { maxWidth: '600px' },
|
||||
zIndex: 99999
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists"
|
||||
v-for="(artists, artistsindex) in artistList"
|
||||
:key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{
|
||||
artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : ''
|
||||
}}</span
|
||||
class="cursor-pointer hover:text-green-500"
|
||||
@click="handleArtistClick(artists.id)"
|
||||
>
|
||||
{{ artists.name }}{{ artistsindex < artistList.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,37 +144,39 @@
|
||||
</n-popover>
|
||||
</div>
|
||||
<!-- 播放音乐 -->
|
||||
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
|
||||
<music-full ref="MusicFullRef" v-model="musicFullVisible" :background="background" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import {
|
||||
allTime,
|
||||
artistList,
|
||||
isLyricWindowOpen,
|
||||
nowTime,
|
||||
openLyric,
|
||||
playMusic,
|
||||
sound,
|
||||
textColors
|
||||
} from '@/hooks/MusicHook';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
|
||||
import { showShortcutToast } from '@/utils/shortcutToast';
|
||||
|
||||
import MusicFull from './MusicFull.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// 播放的音乐信息
|
||||
const playMusic = computed(() => store.state.playMusic as SongResult);
|
||||
// 是否播放
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
// 播放列表
|
||||
const playList = computed(() => store.state.playList as SongResult[]);
|
||||
|
||||
// 背景颜色
|
||||
const background = ref('#000');
|
||||
|
||||
watch(
|
||||
@@ -181,7 +187,7 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 使用 useThrottleFn 创建节流版本的 seek 函数
|
||||
// 节流版本的 seek 函数
|
||||
const throttledSeek = useThrottleFn((value: number) => {
|
||||
if (!sound.value) return;
|
||||
sound.value.seek(value);
|
||||
@@ -295,6 +301,9 @@ const musicFullVisible = ref(false);
|
||||
const setMusicFull = () => {
|
||||
musicFullVisible.value = !musicFullVisible.value;
|
||||
store.commit('setMusicFull', musicFullVisible.value);
|
||||
if (musicFullVisible.value) {
|
||||
store.commit('setShowArtistDrawer', false);
|
||||
}
|
||||
};
|
||||
|
||||
const palyListRef = useTemplateRef('palyListRef');
|
||||
@@ -322,6 +331,57 @@ const toggleFavorite = async (e: Event) => {
|
||||
const openLyricWindow = () => {
|
||||
openLyric();
|
||||
};
|
||||
|
||||
const handleArtistClick = (id: number) => {
|
||||
musicFullVisible.value = false;
|
||||
store.commit('setCurrentArtistId', id);
|
||||
};
|
||||
|
||||
// 添加全局快捷键处理
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.on('global-shortcut', (_, action: string) => {
|
||||
console.log('action', action);
|
||||
switch (action) {
|
||||
case 'togglePlay':
|
||||
playMusicEvent();
|
||||
showShortcutToast(
|
||||
store.state.play ? '开始播放' : '暂停播放',
|
||||
store.state.play ? 'ri-pause-circle-line' : 'ri-play-circle-line'
|
||||
);
|
||||
break;
|
||||
case 'prevPlay':
|
||||
handlePrev();
|
||||
showShortcutToast('上一首', 'ri-skip-back-line');
|
||||
break;
|
||||
case 'nextPlay':
|
||||
handleNext();
|
||||
showShortcutToast('下一首', 'ri-skip-forward-line');
|
||||
break;
|
||||
case 'volumeUp':
|
||||
if (volumeSlider.value < 100) {
|
||||
volumeSlider.value = Math.min(volumeSlider.value + 10, 100);
|
||||
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-up-line');
|
||||
}
|
||||
break;
|
||||
case 'volumeDown':
|
||||
if (volumeSlider.value > 0) {
|
||||
volumeSlider.value = Math.max(volumeSlider.value - 10, 0);
|
||||
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-down-line');
|
||||
}
|
||||
break;
|
||||
case 'toggleFavorite':
|
||||
toggleFavorite(new Event('click'));
|
||||
showShortcutToast(
|
||||
isFavorite.value ? `已收藏${playMusic.value.name}` : `已取消收藏${playMusic.value.name}`,
|
||||
isFavorite.value ? 'ri-heart-fill' : 'ri-heart-line'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log('未知的快捷键动作:', action);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -379,7 +439,7 @@ const openLyricWindow = () => {
|
||||
|
||||
&-play {
|
||||
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 transition text-gray-500;
|
||||
@apply bg-gray-100 bg-opacity-60 hover:bg-gray-200;
|
||||
@apply bg-gray-100 bg-opacity-60 dark:bg-gray-800 dark:bg-opacity-60 hover:bg-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,12 @@
|
||||
<i class="iconfont ri-restart-line"></i>
|
||||
<span>重启</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="toGithubRelease">
|
||||
<div class="menu-item" @click="selectItem('refresh')">
|
||||
<i class="iconfont ri-refresh-line"></i>
|
||||
<span>刷新</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="toGithubRelease">
|
||||
<i class="iconfont ri-github-fill"></i>
|
||||
<span>当前版本</span>
|
||||
<div class="version-info">
|
||||
<span class="version-number">{{ updateInfo.currentVersion }}</span>
|
||||
@@ -212,6 +216,9 @@ const selectItem = async (key: string) => {
|
||||
case 'user':
|
||||
router.push('/user');
|
||||
break;
|
||||
case 'refresh':
|
||||
window.location.reload();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
@@ -286,7 +293,7 @@ const toGithubRelease = () => {
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
|
||||
@apply min-w-[220px] p-0 rounded-xl overflow-hidden;
|
||||
@apply bg-light dark:bg-black;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -303,7 +310,7 @@ const toGithubRelease = () => {
|
||||
@apply py-1;
|
||||
|
||||
.menu-item {
|
||||
@apply flex items-center px-3 py-2 text-sm cursor-pointer;
|
||||
@apply flex items-center px-3 py-1 text-sm cursor-pointer;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div id="title-bar" @mousedown="drag">
|
||||
<div id="title">Alger Music</div>
|
||||
<div id="buttons">
|
||||
<button @click="minimize">
|
||||
<div class="button" @click="minimize">
|
||||
<i class="iconfont icon-minisize"></i>
|
||||
</button>
|
||||
<button @click="close">
|
||||
</div>
|
||||
<div class="button" @click="close">
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ const drag = (event: MouseEvent) => {
|
||||
-webkit-app-region: drag;
|
||||
@apply flex justify-between px-6 py-2 select-none relative;
|
||||
@apply text-dark dark:text-white;
|
||||
z-index: 9999999;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
#buttons {
|
||||
@@ -107,7 +107,7 @@ const drag = (event: MouseEvent) => {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
button {
|
||||
.button {
|
||||
@apply text-gray-600 dark:text-gray-400 hover:text-green-500;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,238 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
class AudioService {
|
||||
private currentSound: Howl | null = null;
|
||||
|
||||
play(url: string) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.unload();
|
||||
private currentTrack: SongResult | null = null;
|
||||
|
||||
constructor() {
|
||||
if ('mediaSession' in navigator) {
|
||||
this.initMediaSession();
|
||||
}
|
||||
this.currentSound = null;
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: localStorage.getItem('volume')
|
||||
? parseFloat(localStorage.getItem('volume') as string)
|
||||
: 1
|
||||
}
|
||||
|
||||
private initMediaSession() {
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
this.currentSound?.play();
|
||||
});
|
||||
|
||||
return this.currentSound;
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
this.currentSound?.pause();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('stop', () => {
|
||||
this.stop();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', (event) => {
|
||||
if (event.seekTime && this.currentSound) {
|
||||
this.currentSound.seek(event.seekTime);
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekbackward', (event) => {
|
||||
if (this.currentSound) {
|
||||
const currentTime = this.currentSound.seek() as number;
|
||||
this.currentSound.seek(currentTime - (event.seekOffset || 10));
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekforward', (event) => {
|
||||
if (this.currentSound) {
|
||||
const currentTime = this.currentSound.seek() as number;
|
||||
this.currentSound.seek(currentTime + (event.seekOffset || 10));
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
// 这里需要通过回调通知外部
|
||||
this.emit('previoustrack');
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
// 这里需要通过回调通知外部
|
||||
this.emit('nexttrack');
|
||||
});
|
||||
}
|
||||
|
||||
private updateMediaSessionMetadata(track: SongResult) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const artists = track.ar ? track.ar.map((a) => a.name) : track.song.artists?.map((a) => a.name);
|
||||
const album = track.al ? track.al.name : track.song.album.name;
|
||||
const artwork = ['96', '128', '192', '256', '384', '512'].map((size) => ({
|
||||
src: `${track.picUrl}?param=${size}y${size}`,
|
||||
type: 'image/jpg',
|
||||
sizes: `${size}x${size}`
|
||||
}));
|
||||
const metadata = {
|
||||
title: track.name || '',
|
||||
artist: artists ? artists.join(',') : '',
|
||||
album: album || '',
|
||||
artwork
|
||||
};
|
||||
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata(metadata);
|
||||
}
|
||||
|
||||
private updateMediaSessionState(isPlaying: boolean) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
|
||||
private updateMediaSessionPositionState() {
|
||||
if (!this.currentSound || !('mediaSession' in navigator)) return;
|
||||
|
||||
if ('setPositionState' in navigator.mediaSession) {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: this.currentSound.duration(),
|
||||
playbackRate: 1.0,
|
||||
position: this.currentSound.seek() as number
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理相关
|
||||
private callbacks: { [key: string]: Function[] } = {};
|
||||
|
||||
private emit(event: string, ...args: any[]) {
|
||||
const eventCallbacks = this.callbacks[event];
|
||||
if (eventCallbacks) {
|
||||
eventCallbacks.forEach((callback) => callback(...args));
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.callbacks[event]) {
|
||||
this.callbacks[event] = [];
|
||||
}
|
||||
this.callbacks[event].push(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: Function) {
|
||||
const eventCallbacks = this.callbacks[event];
|
||||
if (eventCallbacks) {
|
||||
this.callbacks[event] = eventCallbacks.filter((cb) => cb !== callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 播放控制相关
|
||||
play(url: string, track: SongResult): Promise<Howl> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
const tryPlay = () => {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.unload();
|
||||
}
|
||||
this.currentSound = null;
|
||||
this.currentTrack = track;
|
||||
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: localStorage.getItem('volume')
|
||||
? parseFloat(localStorage.getItem('volume') as string)
|
||||
: 1,
|
||||
onloaderror: () => {
|
||||
console.error('Audio load error');
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
reject(new Error('音频加载失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
},
|
||||
onplayerror: () => {
|
||||
console.error('Audio play error');
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
|
||||
setTimeout(tryPlay, 1000 * retryCount);
|
||||
} else {
|
||||
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 更新媒体会话元数据
|
||||
this.updateMediaSessionMetadata(track);
|
||||
|
||||
// 设置音频事件监听
|
||||
this.currentSound.on('play', () => {
|
||||
this.updateMediaSessionState(true);
|
||||
this.emit('play');
|
||||
});
|
||||
|
||||
this.currentSound.on('pause', () => {
|
||||
this.updateMediaSessionState(false);
|
||||
this.emit('pause');
|
||||
});
|
||||
|
||||
this.currentSound.on('end', () => {
|
||||
this.emit('end');
|
||||
});
|
||||
|
||||
this.currentSound.on('seek', () => {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('seek');
|
||||
});
|
||||
|
||||
this.currentSound.on('load', () => {
|
||||
this.updateMediaSessionPositionState();
|
||||
this.emit('load');
|
||||
resolve(this.currentSound as Howl);
|
||||
});
|
||||
};
|
||||
|
||||
tryPlay();
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentSound() {
|
||||
return this.currentSound;
|
||||
}
|
||||
|
||||
getCurrentTrack() {
|
||||
return this.currentTrack;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.unload();
|
||||
this.currentSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听播放
|
||||
onPlay(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('play', callback);
|
||||
this.currentTrack = null;
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 监听暂停
|
||||
onPause(callback: () => void) {
|
||||
setVolume(volume: number) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('pause', callback);
|
||||
this.currentSound.volume(volume);
|
||||
localStorage.setItem('volume', volume.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// 监听结束
|
||||
onEnd(callback: () => void) {
|
||||
seek(time: number) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('end', callback);
|
||||
this.currentSound.seek(time);
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
}
|
||||
|
||||
clearAllListeners() {
|
||||
this.callbacks = {};
|
||||
}
|
||||
}
|
||||
|
||||
export const audioService = new AudioService();
|
||||
|
||||
@@ -16,7 +16,7 @@ function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
}
|
||||
|
||||
interface State {
|
||||
export interface State {
|
||||
menus: any[];
|
||||
play: boolean;
|
||||
isPlay: boolean;
|
||||
@@ -35,6 +35,8 @@ interface State {
|
||||
theme: ThemeType;
|
||||
musicFull: boolean;
|
||||
showUpdateModal: boolean;
|
||||
showArtistDrawer: boolean;
|
||||
currentArtistId: number | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -55,7 +57,9 @@ const state: State = {
|
||||
playMode: getLocalStorageItem('playMode', 0),
|
||||
theme: getCurrentTheme(),
|
||||
musicFull: false,
|
||||
showUpdateModal: false
|
||||
showUpdateModal: false,
|
||||
showArtistDrawer: false,
|
||||
currentArtistId: null
|
||||
};
|
||||
|
||||
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
|
||||
@@ -147,6 +151,15 @@ const mutations = {
|
||||
state.user = null;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
setShowArtistDrawer(state, show: boolean) {
|
||||
state.showArtistDrawer = show;
|
||||
if (!show) {
|
||||
state.currentArtistId = null;
|
||||
}
|
||||
},
|
||||
setCurrentArtistId(state, id: number) {
|
||||
state.currentArtistId = id;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,6 +210,9 @@ const actions = {
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
|
||||
},
|
||||
showArtist({ commit }, id: number) {
|
||||
commit('setCurrentArtistId', id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
110
src/renderer/type/artist.ts
Normal file
110
src/renderer/type/artist.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface IArtistDetail {
|
||||
videoCount: number;
|
||||
vipRights: VipRights;
|
||||
identify: Identify;
|
||||
artist: IArtist;
|
||||
blacklist: boolean;
|
||||
preferShow: number;
|
||||
showPriMsg: boolean;
|
||||
secondaryExpertIdentiy: SecondaryExpertIdentiy[];
|
||||
eventCount: number;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface User {
|
||||
backgroundUrl: string;
|
||||
birthday: number;
|
||||
detailDescription: string;
|
||||
authenticated: boolean;
|
||||
gender: number;
|
||||
city: number;
|
||||
signature: null;
|
||||
description: string;
|
||||
remarkName: null;
|
||||
shortUserName: string;
|
||||
accountStatus: number;
|
||||
locationStatus: number;
|
||||
avatarImgId: number;
|
||||
defaultAvatar: boolean;
|
||||
province: number;
|
||||
nickname: string;
|
||||
expertTags: null;
|
||||
djStatus: number;
|
||||
avatarUrl: string;
|
||||
accountType: number;
|
||||
authStatus: number;
|
||||
vipType: number;
|
||||
userName: string;
|
||||
followed: boolean;
|
||||
userId: number;
|
||||
lastLoginIP: string;
|
||||
lastLoginTime: number;
|
||||
authenticationTypes: number;
|
||||
mutual: boolean;
|
||||
createTime: number;
|
||||
anchor: boolean;
|
||||
authority: number;
|
||||
backgroundImgId: number;
|
||||
userType: number;
|
||||
experts: null;
|
||||
avatarDetail: AvatarDetail;
|
||||
}
|
||||
|
||||
interface AvatarDetail {
|
||||
userType: number;
|
||||
identityLevel: number;
|
||||
identityIconUrl: string;
|
||||
}
|
||||
|
||||
interface SecondaryExpertIdentiy {
|
||||
expertIdentiyId: number;
|
||||
expertIdentiyName: string;
|
||||
expertIdentiyCount: number;
|
||||
}
|
||||
|
||||
export interface IArtist {
|
||||
id: number;
|
||||
cover: string;
|
||||
avatar: string;
|
||||
name: string;
|
||||
transNames: any[];
|
||||
alias: any[];
|
||||
identities: any[];
|
||||
identifyTag: string[];
|
||||
briefDesc: string;
|
||||
rank: Rank;
|
||||
albumSize: number;
|
||||
musicSize: number;
|
||||
mvSize: number;
|
||||
}
|
||||
|
||||
interface Rank {
|
||||
rank: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
interface Identify {
|
||||
imageUrl: string;
|
||||
imageDesc: string;
|
||||
actionUrl: string;
|
||||
}
|
||||
|
||||
interface VipRights {
|
||||
rightsInfoDetailDtoList: RightsInfoDetailDtoList[];
|
||||
oldProtocol: boolean;
|
||||
redVipAnnualCount: number;
|
||||
redVipLevel: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
interface RightsInfoDetailDtoList {
|
||||
vipCode: number;
|
||||
expireTime: number;
|
||||
iconUrl: null;
|
||||
dynamicIconUrl: null;
|
||||
vipLevel: number;
|
||||
signIap: boolean;
|
||||
signDeduct: boolean;
|
||||
signIapDeduct: boolean;
|
||||
sign: boolean;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ interface Album {
|
||||
picId_str: string;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
export interface Artist {
|
||||
name: string;
|
||||
id: number;
|
||||
picId: number;
|
||||
|
||||
@@ -23,6 +23,9 @@ export const setAnimationClass = (type: String) => {
|
||||
};
|
||||
// 设置动画延时
|
||||
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
||||
if (store.state.setData?.noAnimate) {
|
||||
return '';
|
||||
}
|
||||
const speed = store.state.setData?.animationSpeed || 1;
|
||||
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
||||
};
|
||||
|
||||
40
src/renderer/utils/shortcutToast.ts
Normal file
40
src/renderer/utils/shortcutToast.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createVNode, render } from 'vue';
|
||||
|
||||
import ShortcutToast from '@/components/ShortcutToast.vue';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let toastInstance: any = null;
|
||||
|
||||
export function showShortcutToast(message: string, iconName: string) {
|
||||
// 如果容器不存在,创建一个新的容器
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// 如果已经有实例,先销毁它
|
||||
if (toastInstance) {
|
||||
render(null, container);
|
||||
toastInstance = null;
|
||||
}
|
||||
|
||||
// 创建新的 toast 实例
|
||||
const vnode = createVNode(ShortcutToast, {
|
||||
onDestroy: () => {
|
||||
if (container) {
|
||||
render(null, container);
|
||||
document.body.removeChild(container);
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染 toast
|
||||
render(vnode, container);
|
||||
toastInstance = vnode.component?.exposed;
|
||||
|
||||
// 显示 toast
|
||||
if (toastInstance) {
|
||||
toastInstance.show(message, iconName);
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,17 @@
|
||||
<n-layout
|
||||
v-if="isMobile ? searchDetail : true"
|
||||
class="search-list"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:class="setAnimationClass('animate__fadeInDown')"
|
||||
:native-scrollbar="false"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="title">{{ hotKeyword }}</div>
|
||||
<div v-if="searchDetail" class="title">
|
||||
<i
|
||||
class="ri-arrow-left-s-line mr-1 cursor-pointer hover:text-gray-500 hover:scale-110"
|
||||
@click="searchDetail = null"
|
||||
></i>
|
||||
{{ hotKeyword }}
|
||||
</div>
|
||||
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||
<template v-if="searchDetail">
|
||||
<div
|
||||
@@ -46,6 +53,7 @@
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
class="mb-3"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
@@ -53,6 +61,41 @@
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">加载中...</span>
|
||||
</div>
|
||||
<div v-if="!hasMore && searchDetail" class="no-more">没有更多了</div>
|
||||
</template>
|
||||
<!-- 搜索历史 -->
|
||||
<template v-else>
|
||||
<div class="search-history">
|
||||
<div class="search-history-header title">
|
||||
<span>搜索历史</span>
|
||||
<n-button text type="error" @click="clearSearchHistory">
|
||||
<template #icon>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</template>
|
||||
清空
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="search-history-list">
|
||||
<n-tag
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
class="search-history-item"
|
||||
round
|
||||
closable
|
||||
@click="handleSearchHistory(item)"
|
||||
@close="handleCloseSearchHistory(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
@@ -82,6 +125,51 @@ const store = useStore();
|
||||
const searchDetail = ref<any>();
|
||||
const searchType = computed(() => store.state.searchType as number);
|
||||
const searchDetailLoading = ref(false);
|
||||
const searchHistory = ref<string[]>([]);
|
||||
|
||||
// 添加分页相关的状态
|
||||
const ITEMS_PER_PAGE = 30; // 每页数量
|
||||
const page = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
const currentKeyword = ref('');
|
||||
|
||||
// 从 localStorage 加载搜索历史
|
||||
const loadSearchHistory = () => {
|
||||
const history = localStorage.getItem('searchHistory');
|
||||
searchHistory.value = history ? JSON.parse(history) : [];
|
||||
};
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = (keyword: string) => {
|
||||
if (!keyword) return;
|
||||
const history = searchHistory.value;
|
||||
// 移除重复的关键词
|
||||
const index = history.indexOf(keyword);
|
||||
if (index > -1) {
|
||||
history.splice(index, 1);
|
||||
}
|
||||
// 添加到开头
|
||||
history.unshift(keyword);
|
||||
// 只保留最近的20条记录
|
||||
if (history.length > 20) {
|
||||
history.pop();
|
||||
}
|
||||
searchHistory.value = history;
|
||||
localStorage.setItem('searchHistory', JSON.stringify(history));
|
||||
};
|
||||
|
||||
// 清空搜索历史
|
||||
const clearSearchHistory = () => {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem('searchHistory');
|
||||
};
|
||||
|
||||
// 删除搜索历史
|
||||
const handleCloseSearchHistory = (keyword: string) => {
|
||||
searchHistory.value = searchHistory.value.filter((item) => item !== keyword);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value));
|
||||
};
|
||||
|
||||
// 热搜列表
|
||||
const hotSearchData = ref<IHotSearch>();
|
||||
@@ -92,6 +180,7 @@ const loadHotSearch = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearch();
|
||||
loadSearchHistory();
|
||||
loadSearch(route.query.keyword);
|
||||
});
|
||||
|
||||
@@ -114,48 +203,103 @@ watch(
|
||||
);
|
||||
|
||||
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
||||
const loadSearch = async (keywords: any, type: any = null) => {
|
||||
hotKeyword.value = keywords;
|
||||
searchDetail.value = undefined;
|
||||
const loadSearch = async (keywords: any, type: any = null, isLoadMore = false) => {
|
||||
if (!keywords) return;
|
||||
|
||||
searchDetailLoading.value = true;
|
||||
const { data } = await getSearch({ keywords, type: type || searchType.value });
|
||||
if (!isLoadMore) {
|
||||
hotKeyword.value = keywords;
|
||||
searchDetail.value = undefined;
|
||||
page.value = 0;
|
||||
hasMore.value = true;
|
||||
currentKeyword.value = keywords;
|
||||
} else if (isLoadingMore.value || !hasMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const songs = data.result.songs || [];
|
||||
const albums = data.result.albums || [];
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.cover,
|
||||
playCount: item.playCount,
|
||||
desc: item.artists.map((artist: any) => artist.name).join('/'),
|
||||
type: 'mv'
|
||||
}));
|
||||
// 保存搜索历史
|
||||
if (!isLoadMore) {
|
||||
saveSearchHistory(keywords);
|
||||
}
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator.nickname,
|
||||
type: 'playlist'
|
||||
}));
|
||||
if (isLoadMore) {
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
searchDetailLoading.value = true;
|
||||
}
|
||||
|
||||
// songs map 替换属性
|
||||
songs.forEach((item: any) => {
|
||||
item.picUrl = item.al.picUrl;
|
||||
item.artists = item.ar;
|
||||
});
|
||||
albums.forEach((item: any) => {
|
||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
||||
});
|
||||
searchDetail.value = {
|
||||
songs,
|
||||
albums,
|
||||
mvs,
|
||||
playlists
|
||||
};
|
||||
try {
|
||||
const { data } = await getSearch({
|
||||
keywords: currentKeyword.value,
|
||||
type: type || searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: page.value * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
searchDetailLoading.value = false;
|
||||
const songs = data.result.songs || [];
|
||||
const albums = data.result.albums || [];
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.cover,
|
||||
playCount: item.playCount,
|
||||
desc: item.artists.map((artist: any) => artist.name).join('/'),
|
||||
type: 'mv'
|
||||
}));
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator.nickname,
|
||||
type: 'playlist'
|
||||
}));
|
||||
|
||||
// songs map 替换属性
|
||||
songs.forEach((item: any) => {
|
||||
item.picUrl = item.al.picUrl;
|
||||
item.artists = item.ar;
|
||||
});
|
||||
albums.forEach((item: any) => {
|
||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
||||
});
|
||||
|
||||
if (isLoadMore && searchDetail.value) {
|
||||
// 合并数据
|
||||
searchDetail.value.songs = [...searchDetail.value.songs, ...songs];
|
||||
searchDetail.value.albums = [...searchDetail.value.albums, ...albums];
|
||||
searchDetail.value.mvs = [...searchDetail.value.mvs, ...mvs];
|
||||
searchDetail.value.playlists = [...searchDetail.value.playlists, ...playlists];
|
||||
} else {
|
||||
searchDetail.value = {
|
||||
songs,
|
||||
albums,
|
||||
mvs,
|
||||
playlists
|
||||
};
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
hasMore.value =
|
||||
songs.length === ITEMS_PER_PAGE ||
|
||||
albums.length === ITEMS_PER_PAGE ||
|
||||
mvs.length === ITEMS_PER_PAGE ||
|
||||
playlists.length === ITEMS_PER_PAGE;
|
||||
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
} finally {
|
||||
searchDetailLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加滚动处理函数
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||
loadSearch(currentKeyword.value, null, true);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -171,6 +315,11 @@ const handlePlay = () => {
|
||||
const tracks = searchDetail.value?.songs || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
};
|
||||
|
||||
// 点击搜索历史
|
||||
const handleSearchHistory = (keyword: string) => {
|
||||
loadSearch(keyword, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -214,6 +363,7 @@ const handlePlay = () => {
|
||||
@apply flex-1 rounded-xl;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
height: 100%;
|
||||
animation-duration: 0.2s;
|
||||
|
||||
&-box {
|
||||
@apply pb-28;
|
||||
@@ -225,9 +375,35 @@ const handlePlay = () => {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.search-history {
|
||||
&-header {
|
||||
@apply flex justify-between items-center mb-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply flex flex-wrap gap-2 px-4;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply cursor-pointer;
|
||||
animation-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.hot-search {
|
||||
@apply mr-0 w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex justify-center items-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,268 +1,289 @@
|
||||
<template>
|
||||
<n-scrollbar>
|
||||
<div class="set-page">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">主题模式</div>
|
||||
<div class="set-item-content">切换日间/夜间主题</div>
|
||||
</div>
|
||||
<n-switch v-model:value="isDarkTheme">
|
||||
<template #checked>
|
||||
<i class="ri-moon-line"></i>
|
||||
</template>
|
||||
<template #unchecked>
|
||||
<i class="ri-sun-line"></i>
|
||||
</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
<!-- <div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理</div>
|
||||
<div class="set-item-content">无法听音乐时打开</div>
|
||||
</div>
|
||||
<n-switch v-model:value="setData.isProxy" />
|
||||
</div> -->
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">音乐API端口</div>
|
||||
<div class="set-item-content">修改后需要重启应用</div>
|
||||
</div>
|
||||
<n-input-number v-model:value="setData.musicApiPort" />
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">动画速度</div>
|
||||
<div class="set-item-content">调节动画播放速度</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||
<div class="w-40">
|
||||
<n-slider
|
||||
v-model:value="setData.animationSpeed"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:marks="{
|
||||
0.1: '极慢',
|
||||
1: '正常',
|
||||
3: '极快'
|
||||
}"
|
||||
:disabled="setData.noAnimate"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">下载目录</div>
|
||||
<div class="set-item-content">
|
||||
{{ setData.downloadPath || '默认下载目录' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button size="small" @click="openDownloadPath">打开目录</n-button>
|
||||
<n-button size="small" @click="selectDownloadPath">修改目录</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">版本</div>
|
||||
<div class="set-item-content">
|
||||
{{ updateInfo.currentVersion }}
|
||||
<template v-if="updateInfo.hasUpdate">
|
||||
<n-tag type="success" class="ml-2">发现新版本 {{ updateInfo.latestVersion }}</n-tag>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button
|
||||
:type="updateInfo.hasUpdate ? 'primary' : 'default'"
|
||||
size="small"
|
||||
:loading="checking"
|
||||
@click="checkForUpdates(true)"
|
||||
>
|
||||
{{ checking ? '检查中...' : '检查更新' }}
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="updateInfo.hasUpdate"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="openReleasePage"
|
||||
>
|
||||
前往更新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-container">
|
||||
<!-- 左侧导航栏 -->
|
||||
<div class="settings-nav">
|
||||
<div
|
||||
class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all"
|
||||
@click="openAuthor"
|
||||
v-for="section in settingSections"
|
||||
:key="section.id"
|
||||
class="nav-item"
|
||||
:class="{ active: currentSection === section.id }"
|
||||
@click="scrollToSection(section.id)"
|
||||
>
|
||||
<div>
|
||||
<coffee>
|
||||
<div>
|
||||
<div class="set-item-title">作者</div>
|
||||
<div class="set-item-content">algerkong 点个star🌟呗</div>
|
||||
</div>
|
||||
</coffee>
|
||||
</div>
|
||||
<div>
|
||||
<n-button size="small" type="primary" @click="openAuthor"
|
||||
><i class="ri-github-line"></i>前往github</n-button
|
||||
>
|
||||
</div>
|
||||
{{ section.title }}
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">音质设置</div>
|
||||
<div class="set-item-content">选择音乐播放音质(VIP)</div>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="setData.musicQuality"
|
||||
:options="[
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '较高', value: 'higher' },
|
||||
{ label: '极高', value: 'exhigh' },
|
||||
{ label: '无损', value: 'lossless' },
|
||||
{ label: 'Hi-Res', value: 'hires' },
|
||||
{ label: '高清环绕声', value: 'jyeffect' },
|
||||
{ label: '沉浸环绕声', value: 'sky' },
|
||||
{ label: '杜比全景声', value: 'dolby' },
|
||||
{ label: '超清母带', value: 'jymaster' }
|
||||
]"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">关闭行为</div>
|
||||
<div class="set-item-content">
|
||||
{{ closeActionLabels[setData.closeAction] || '每次询问' }}
|
||||
</div>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="setData.closeAction"
|
||||
:options="[
|
||||
{ label: '每次询问', value: 'ask' },
|
||||
{ label: '最小化到托盘', value: 'minimize' },
|
||||
{ label: '直接退出', value: 'close' }
|
||||
]"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">重启</div>
|
||||
<div class="set-item-content">重启应用</div>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="restartApp">重启</n-button>
|
||||
</div>
|
||||
<!-- 缓存管理 -->
|
||||
<!-- <n-card class="set-card" title="缓存管理">
|
||||
<n-space vertical>
|
||||
<n-button type="primary" @click="showClearCacheModal = true"> 清除缓存 </n-button>
|
||||
</n-space>
|
||||
</n-card> -->
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">缓存管理</div>
|
||||
<div class="set-item-content">清除缓存</div>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="showClearCacheModal = true">
|
||||
清除缓存
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理设置</div>
|
||||
<div class="set-item-content">无法访问音乐时可以开启代理</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.proxyConfig.enable">
|
||||
<template #checked>开启</template>
|
||||
<template #unchecked>关闭</template>
|
||||
</n-switch>
|
||||
<n-button size="small" @click="showProxyModal = true">配置</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">realIP</div>
|
||||
<div class="set-item-content">
|
||||
由于限制,此项目在国外使用会受到限制可使用realIP参数,传进国内IP解决,如:116.25.146.177
|
||||
即可解决
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.enableRealIP">
|
||||
<template #checked>开启</template>
|
||||
<template #unchecked>关闭</template>
|
||||
</n-switch>
|
||||
<n-input
|
||||
v-if="setData.enableRealIP"
|
||||
v-model:value="setData.realIP"
|
||||
placeholder="realIP"
|
||||
style="width: 200px"
|
||||
@blur="validateAndSaveRealIP"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 bg-light dark:bg-dark rounded-lg mb-4 border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="set-item-title">捐赠支持</div>
|
||||
<div class="set-item-content">感谢您的支持,让我有动力能够持续改进</div>
|
||||
</div>
|
||||
<n-button text @click="toggleDonationList">
|
||||
<template #icon>
|
||||
<i :class="isDonationListVisible ? 'ri-eye-line' : 'ri-eye-off-line'" />
|
||||
</template>
|
||||
{{ isDonationListVisible ? '隐藏列表' : '显示列表' }}
|
||||
</n-button>
|
||||
</div>
|
||||
<donation-list v-if="isDonationListVisible" />
|
||||
</div>
|
||||
|
||||
<!-- 清除缓存弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showClearCacheModal"
|
||||
preset="dialog"
|
||||
title="清除缓存"
|
||||
positive-text="确认"
|
||||
negative-text="取消"
|
||||
@positive-click="clearCache"
|
||||
@negative-click="
|
||||
() => {
|
||||
selectedCacheTypes = [];
|
||||
}
|
||||
"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>请选择要清除的缓存类型:</p>
|
||||
<n-checkbox-group v-model:value="selectedCacheTypes">
|
||||
<n-space vertical>
|
||||
<n-checkbox
|
||||
v-for="option in clearCacheOptions"
|
||||
:key="option.key"
|
||||
:value="option.key"
|
||||
:label="option.label"
|
||||
>
|
||||
<template #default>
|
||||
<div>
|
||||
<div>{{ option.label }}</div>
|
||||
<div class="text-gray-400 text-sm">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
<play-bottom />
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<n-scrollbar ref="scrollbarRef" class="settings-content" @scroll="handleScroll">
|
||||
<div class="set-page">
|
||||
<!-- 基础设置 -->
|
||||
<div id="basic" ref="basicRef" class="settings-section">
|
||||
<div class="settings-section-title">基础设置</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">主题模式</div>
|
||||
<div class="set-item-content">切换日间/夜间主题</div>
|
||||
</div>
|
||||
<n-switch v-model:value="isDarkTheme">
|
||||
<template #checked><i class="ri-moon-line"></i></template>
|
||||
<template #unchecked><i class="ri-sun-line"></i></template>
|
||||
</n-switch>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">动画速度</div>
|
||||
<div class="set-item-content">
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.noAnimate">
|
||||
<template #checked>关闭</template>
|
||||
<template #unchecked>开启</template>
|
||||
</n-switch>
|
||||
<span>是否开启动画</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||
<div class="w-40">
|
||||
<n-slider
|
||||
v-model:value="setData.animationSpeed"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:marks="{
|
||||
0.1: '极慢',
|
||||
1: '正常',
|
||||
3: '极快'
|
||||
}"
|
||||
:disabled="setData.noAnimate"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放设置 -->
|
||||
<div id="playback" ref="playbackRef" class="settings-section">
|
||||
<div class="settings-section-title">播放设置</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">音质设置</div>
|
||||
<div class="set-item-content">选择音乐播放音质(VIP)</div>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="setData.musicQuality"
|
||||
:options="[
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '较高', value: 'higher' },
|
||||
{ label: '极高', value: 'exhigh' },
|
||||
{ label: '无损', value: 'lossless' },
|
||||
{ label: 'Hi-Res', value: 'hires' },
|
||||
{ label: '高清环绕声', value: 'jyeffect' },
|
||||
{ label: '沉浸环绕声', value: 'sky' },
|
||||
{ label: '杜比全景声', value: 'dolby' },
|
||||
{ label: '超清母带', value: 'jymaster' }
|
||||
]"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用设置 -->
|
||||
<div v-if="isElectron" id="application" ref="applicationRef" class="settings-section">
|
||||
<div class="settings-section-title">应用设置</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">关闭行为</div>
|
||||
<div class="set-item-content">
|
||||
{{ closeActionLabels[setData.closeAction] || '每次询问' }}
|
||||
</div>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="setData.closeAction"
|
||||
:options="[
|
||||
{ label: '每次询问', value: 'ask' },
|
||||
{ label: '最小化到托盘', value: 'minimize' },
|
||||
{ label: '直接退出', value: 'close' }
|
||||
]"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">快捷键设置</div>
|
||||
<div class="set-item-content">自定义全局快捷键</div>
|
||||
</div>
|
||||
<n-button size="small" @click="showShortcutModal = true">配置</n-button>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">下载目录</div>
|
||||
<div class="set-item-content">
|
||||
{{ setData.downloadPath || '默认下载目录' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button size="small" @click="openDownloadPath">打开目录</n-button>
|
||||
<n-button size="small" @click="selectDownloadPath">修改目录</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络设置 -->
|
||||
<div v-if="isElectron" id="network" ref="networkRef" class="settings-section">
|
||||
<div class="settings-section-title">网络设置</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">音乐API端口</div>
|
||||
<div class="set-item-content">修改后需要重启应用</div>
|
||||
</div>
|
||||
<n-input-number v-model:value="setData.musicApiPort" />
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理设置</div>
|
||||
<div class="set-item-content">无法访问音乐时可以开启代理</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.proxyConfig.enable">
|
||||
<template #checked>开启</template>
|
||||
<template #unchecked>关闭</template>
|
||||
</n-switch>
|
||||
<n-button size="small" @click="showProxyModal = true">配置</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">realIP</div>
|
||||
<div class="set-item-content">
|
||||
由于限制,此项目在国外使用会受到限制可使用realIP参数,传进国内IP解决,如:116.25.146.177
|
||||
即可解决
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.enableRealIP">
|
||||
<template #checked>开启</template>
|
||||
<template #unchecked>关闭</template>
|
||||
</n-switch>
|
||||
<n-input
|
||||
v-if="setData.enableRealIP"
|
||||
v-model:value="setData.realIP"
|
||||
placeholder="realIP"
|
||||
style="width: 200px"
|
||||
@blur="validateAndSaveRealIP"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统管理 -->
|
||||
<div v-if="isElectron" id="system" ref="systemRef" class="settings-section">
|
||||
<div class="settings-section-title">系统管理</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">缓存管理</div>
|
||||
<div class="set-item-content">清除缓存</div>
|
||||
</div>
|
||||
<n-button size="small" @click="showClearCacheModal = true"> 清除缓存 </n-button>
|
||||
</div>
|
||||
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">重启</div>
|
||||
<div class="set-item-content">重启应用</div>
|
||||
</div>
|
||||
<n-button size="small" @click="restartApp">重启</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于 -->
|
||||
<div id="about" ref="aboutRef" class="settings-section">
|
||||
<div class="settings-section-title">关于</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">版本</div>
|
||||
<div class="set-item-content">
|
||||
{{ updateInfo.currentVersion }}
|
||||
<template v-if="updateInfo.hasUpdate">
|
||||
<n-tag type="success" class="ml-2"
|
||||
>发现新版本 {{ updateInfo.latestVersion }}</n-tag
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button size="small" :loading="checking" @click="checkForUpdates(true)">
|
||||
{{ checking ? '检查中...' : '检查更新' }}
|
||||
</n-button>
|
||||
<n-button v-if="updateInfo.hasUpdate" size="small" @click="openReleasePage">
|
||||
前往更新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all"
|
||||
@click="openAuthor"
|
||||
>
|
||||
<coffee>
|
||||
<div>
|
||||
<div class="set-item-title">作者</div>
|
||||
<div class="set-item-content">algerkong 点个star🌟呗</div>
|
||||
</div>
|
||||
</coffee>
|
||||
<div>
|
||||
<n-button size="small" @click="openAuthor">
|
||||
<i class="ri-github-line"></i>前往github
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 捐赠支持 -->
|
||||
<div id="donation" ref="donationRef" class="settings-section">
|
||||
<div class="settings-section-title">捐赠支持</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">捐赠支持</div>
|
||||
<div class="set-item-content">感谢您的支持,让我有动力能够持续改进</div>
|
||||
</div>
|
||||
<n-button text @click="toggleDonationList">
|
||||
<template #icon>
|
||||
<i :class="isDonationListVisible ? 'ri-eye-line' : 'ri-eye-off-line'" />
|
||||
</template>
|
||||
{{ isDonationListVisible ? '隐藏列表' : '显示列表' }}
|
||||
</n-button>
|
||||
</div>
|
||||
<donation-list v-if="isDonationListVisible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- 快捷键设置弹窗 -->
|
||||
<shortcut-settings v-model:show="showShortcutModal" @change="handleShortcutsChange" />
|
||||
|
||||
<!-- 代理设置弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showProxyModal"
|
||||
preset="dialog"
|
||||
@@ -304,19 +325,55 @@
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</n-scrollbar>
|
||||
<!-- 清除缓存弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showClearCacheModal"
|
||||
preset="dialog"
|
||||
title="清除缓存"
|
||||
positive-text="确认"
|
||||
negative-text="取消"
|
||||
@positive-click="clearCache"
|
||||
@negative-click="
|
||||
() => {
|
||||
selectedCacheTypes = [];
|
||||
}
|
||||
"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>请选择要清除的缓存类型:</p>
|
||||
<n-checkbox-group v-model:value="selectedCacheTypes">
|
||||
<n-space vertical>
|
||||
<n-checkbox
|
||||
v-for="option in clearCacheOptions"
|
||||
:key="option.key"
|
||||
:value="option.key"
|
||||
:label="option.label"
|
||||
>
|
||||
<template #default>
|
||||
<div>
|
||||
<div>{{ option.label }}</div>
|
||||
<div class="text-gray-400 text-sm">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormRules } from 'naive-ui';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import localData from '@/../main/set.json';
|
||||
import Coffee from '@/components/Coffee.vue';
|
||||
import DonationList from '@/components/common/DonationList.vue';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import ShortcutSettings from '@/components/settings/ShortcutSettings.vue';
|
||||
import { isElectron } from '@/utils';
|
||||
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
|
||||
import { checkUpdate, UpdateResult } from '@/utils/update';
|
||||
@@ -552,7 +609,8 @@ const clearCacheOptions = ref([
|
||||
{ label: '用户数据', key: 'user', description: '清除登录信息和用户相关数据' },
|
||||
{ label: '应用设置', key: 'settings', description: '清除应用的所有自定义设置' },
|
||||
{ label: '下载记录', key: 'downloads', description: '清除下载历史记录(不会删除已下载的文件)' },
|
||||
{ label: '音乐资源', key: 'resources', description: '清除已加载的音乐文件、歌词等资源缓存' }
|
||||
{ label: '音乐资源', key: 'resources', description: '清除已加载的音乐文件、歌词等资源缓存' },
|
||||
{ label: '歌词资源', key: 'lyrics', description: '清除已加载的歌词资源缓存' }
|
||||
]);
|
||||
|
||||
const selectedCacheTypes = ref<string[]>([]);
|
||||
@@ -609,6 +667,9 @@ const clearCache = async () => {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'lyrics':
|
||||
window.api.invoke('clear-lyrics-cache');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -619,15 +680,137 @@ const clearCache = async () => {
|
||||
showClearCacheModal.value = false;
|
||||
selectedCacheTypes.value = [];
|
||||
};
|
||||
|
||||
const showShortcutModal = ref(false);
|
||||
|
||||
const handleShortcutsChange = (shortcuts: any) => {
|
||||
console.log('快捷键已更新:', shortcuts);
|
||||
};
|
||||
|
||||
// 定义设置分类
|
||||
const settingSections = [
|
||||
{ id: 'basic', title: '基础设置' },
|
||||
{ id: 'playback', title: '播放设置' },
|
||||
{ id: 'application', title: '应用设置', electron: true },
|
||||
{ id: 'network', title: '网络设置', electron: true },
|
||||
{ id: 'system', title: '系统管理', electron: true },
|
||||
{ id: 'about', title: '关于' },
|
||||
{ id: 'donation', title: '捐赠支持' }
|
||||
];
|
||||
|
||||
// 当前激活的分类
|
||||
const currentSection = ref('basic');
|
||||
const scrollbarRef = ref();
|
||||
|
||||
// 各个分类的ref
|
||||
const basicRef = ref();
|
||||
const playbackRef = ref();
|
||||
const applicationRef = ref();
|
||||
const networkRef = ref();
|
||||
const systemRef = ref();
|
||||
const aboutRef = ref();
|
||||
const donationRef = ref();
|
||||
|
||||
// 滚动到指定分类
|
||||
const scrollToSection = async (sectionId: string) => {
|
||||
currentSection.value = sectionId;
|
||||
const sectionRef = {
|
||||
basic: basicRef,
|
||||
playback: playbackRef,
|
||||
application: applicationRef,
|
||||
network: networkRef,
|
||||
system: systemRef,
|
||||
about: aboutRef,
|
||||
donation: donationRef
|
||||
}[sectionId];
|
||||
|
||||
if (sectionRef?.value) {
|
||||
await nextTick();
|
||||
scrollbarRef.value?.scrollTo({
|
||||
top: sectionRef.value.offsetTop - 20,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动,更新当前激活的分类
|
||||
const handleScroll = () => {
|
||||
const scrollTop = scrollbarRef.value?.containerRef.scrollTop;
|
||||
const sections = [
|
||||
{ id: 'basic', ref: basicRef },
|
||||
{ id: 'playback', ref: playbackRef },
|
||||
{ id: 'application', ref: applicationRef },
|
||||
{ id: 'network', ref: networkRef },
|
||||
{ id: 'system', ref: systemRef },
|
||||
{ id: 'about', ref: aboutRef },
|
||||
{ id: 'donation', ref: donationRef }
|
||||
];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.ref?.value) {
|
||||
const { offsetTop } = section.ref.value;
|
||||
const offsetBottom = offsetTop + section.ref.value.offsetHeight;
|
||||
|
||||
if (scrollTop >= offsetTop - 100 && scrollTop < offsetBottom) {
|
||||
currentSection.value = section.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-container {
|
||||
@apply flex h-full;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@apply w-32 h-full flex-shrink-0 border-r border-gray-200 dark:border-gray-700;
|
||||
@apply bg-light dark:bg-dark;
|
||||
|
||||
.nav-item {
|
||||
@apply px-4 py-2.5 cursor-pointer text-sm;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply transition-colors duration-200;
|
||||
@apply border-l-2 border-transparent;
|
||||
|
||||
&:hover {
|
||||
@apply text-primary dark:text-white bg-gray-50 dark:bg-dark-100;
|
||||
@apply border-l-2 border-gray-200 dark:border-gray-200;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply text-primary dark:text-white bg-gray-50 dark:bg-dark-100;
|
||||
@apply border-l-2 border-gray-200 dark:border-gray-200;
|
||||
@apply font-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
@apply flex-1 h-full;
|
||||
}
|
||||
|
||||
.set-page {
|
||||
@apply p-4 bg-light dark:bg-dark pb-20;
|
||||
@apply p-4 pb-20;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@apply mb-6 scroll-mt-4;
|
||||
|
||||
&-title {
|
||||
@apply text-base font-medium mb-4;
|
||||
@apply text-gray-600 dark:text-white;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply space-y-4;
|
||||
}
|
||||
}
|
||||
|
||||
.set-item {
|
||||
@apply flex items-center justify-between p-4 rounded-lg mb-4 transition-all;
|
||||
@apply flex items-center justify-between p-4 rounded-lg transition-all;
|
||||
@apply bg-light dark:bg-dark text-gray-900 dark:text-white;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
|
||||
|
||||
@@ -207,7 +207,6 @@ watch(
|
||||
// 页面挂载时检查登录状态
|
||||
onMounted(() => {
|
||||
checkLoginStatus();
|
||||
loadPage();
|
||||
});
|
||||
|
||||
// 展示歌单
|
||||
@@ -275,7 +274,7 @@ const handlePlay = () => {
|
||||
.record-list {
|
||||
@apply rounded-2xl;
|
||||
@apply bg-light dark:bg-black;
|
||||
height: calc(100% - 3.75rem);
|
||||
height: calc(100% - 100px);
|
||||
|
||||
.record-item {
|
||||
@apply flex items-center px-4;
|
||||
|
||||
Reference in New Issue
Block a user