Compare commits

...

22 Commits

Author SHA1 Message Date
alger
d449930a02 🌈 style: v3.9.2 2025-01-26 00:31:18 +08:00
Alger
820509dbea Merge pull request #50 from algerkong/feat/download
 feat: 弱化下载功能 默认隐藏下载列表按钮 在设置中配置打开
2025-01-26 00:22:58 +08:00
alger
1493ab7317 feat: 弱化下载功能 默认隐藏下载列表按钮 在设置中配置打开 2025-01-26 00:20:08 +08:00
alger
c6ca63ee11 🐞 fix: 修复一些下载和登录问题
fix: #49
2025-01-25 23:53:20 +08:00
alger
4fa1295b84 🐞 fix: 修复登录状态问题 修复播放退出登录的问题 2025-01-25 21:49:22 +08:00
alger
599b0251af 🌈 style: v3.9.0 2025-01-22 23:43:17 +08:00
alger
25c2180247 feat: 添加右键添加到歌单 可以创建歌单 可以在我的歌单中右键取消收藏 2025-01-22 23:37:50 +08:00
alger
a6ff0e7f5c feat: 歌曲右键 添加下一首播放功能 2025-01-22 22:22:32 +08:00
alger
2e06711600 feat: 添加自动播放 和自动保存正在播放列表功能 2025-01-22 22:16:52 +08:00
Alger
80770d6c75 Update README.md 2025-01-20 09:53:24 +08:00
Alger
1e068df2ad Update README.md 2025-01-20 09:46:26 +08:00
alger
4172ff9fc6 🐞 fix: 修复我的搜藏 查看更多跳转空白页的问题 2025-01-19 18:46:36 +08:00
alger
83a7df9fe8 Merge branch 'feat/new-update' into dev_electron 2025-01-19 15:06:16 +08:00
alger
ba95dc11fe feat: 优化歌词下一首的滚动 2025-01-19 13:35:10 +08:00
alger
93829acdab feat: 升级依赖包 升级 electron 版本 2025-01-19 13:34:31 +08:00
alger
1f0f35dd51 🌈 style: v3.8.0 2025-01-18 03:26:14 +08:00
alger
be94d6ff8e feat: 添加歌词界面样式配置功能 2025-01-18 03:25:21 +08:00
alger
1bdb8fcb4a feat: 添加字体配置功能 可配置歌词页面 或全局字体 2025-01-17 22:45:59 +08:00
alger
914e043502 feat: 去除歌曲缓存 优化播放下一首 2025-01-17 22:35:42 +08:00
alger
dfa175b8b2 feat: 应用单例模式 2025-01-17 22:35:33 +08:00
alger
a94e0efba5 feat: 优化播放 2025-01-17 22:34:49 +08:00
alger
fb0831f2eb feat: 应用更新在内部更新 自动打开安装包 2025-01-17 00:02:57 +08:00
36 changed files with 2458 additions and 628 deletions

View File

@@ -1,10 +1,11 @@
# 更新日志
## v3.7.2
### ✨ 优化
- 优化歌词缓存
- 优化音乐播放体验 如果播放失败自动播放下一首
## v3.9.2
### 🐞 修复
- 修复下载功能导致的登陆失败问题
- 优化下载功能
- 添加下载按钮显隐配置 默认隐藏(设置页面配置)
## 咖啡☕️
| 微信 | | 支付宝 |

View File

@@ -46,6 +46,9 @@ QQ群:789288579
## 咖啡☕️
[<img src="https://api.gitsponsors.com/api/badge/img?id=710867462" height="90">](https://api.gitsponsors.com/api/badge/link?p=GTUHmTNQ9W5XzPhaLd8cPBm26uhtP/QOon9hexaWh9gnfaDT3ivj1ID0uKScVHL61jTFrK1fRWyigScIYvcLh/no+3zgtdW3TK0+vN0TVs84Mt3RibhEqAgBHSd8KhNLxaMd4vMIY37P5gOA2/QYcw==)
| 微信 | 支付宝 |
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
| <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> |

View File

@@ -1,6 +1,6 @@
{
"name": "AlgerMusicPlayer",
"version": "3.7.2",
"version": "3.9.2",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "./out/main/index.js",
@@ -26,6 +26,7 @@
"@unblockneteasemusic/server": "^0.27.8-patch.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"font-list": "^1.5.1",
"netease-cloud-music-api-alger": "^4.25.0"
},
"devDependencies": {
@@ -36,6 +37,7 @@
"@tailwindcss/postcss7-compat": "^2.2.4",
"@types/howler": "^2.2.12",
"@types/node": "^20.14.8",
"@types/tinycolor2": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitejs/plugin-vue": "^5.0.5",
@@ -45,11 +47,12 @@
"@vue/runtime-core": "^3.5.0",
"@vueuse/core": "^11.0.3",
"@vueuse/electron": "^11.0.3",
"animate.css": "^4.1.1",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"cross-env": "^7.0.3",
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron": "^34.0.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
@@ -66,8 +69,9 @@
"postcss": "^8.4.49",
"prettier": "^3.3.2",
"remixicon": "^4.2.0",
"sass": "^1.82.0",
"tailwindcss": "^3.4.15",
"sass": "^1.83.4",
"tailwindcss": "^3.4.17",
"tinycolor2": "^1.6.0",
"typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
@@ -75,11 +79,10 @@
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.4.0",
"vue": "^3.4.30",
"vue-router": "^4.4.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-tsc": "^2.0.22",
"vuex": "^4.1.0",
"animate.css": "^4.1.1"
"vuex": "^4.1.0"
},
"build": {
"appId": "com.alger.music",

View File

@@ -5,8 +5,10 @@ import { join } from 'path';
import { loadLyricWindow } from './lyric';
import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeTray } from './modules/tray';
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server';
@@ -30,6 +32,8 @@ function initialize() {
initializeFileManager();
// 初始化窗口管理
initializeWindowManager();
// 初始化字体管理
initializeFonts();
// 创建主窗口
mainWindow = createMainWindow(icon);
@@ -45,46 +49,67 @@ function initialize() {
// 初始化快捷键
initializeShortcuts(mainWindow);
// 初始化更新处理程序
setupUpdateHandlers(mainWindow);
}
// 应用程序准备就绪时的处理
app.whenReady().then(() => {
// 设置应用ID
electronApp.setAppUserModelId('com.alger.music');
// 检查是否为第一个实例
const isSingleInstance = app.requestSingleInstanceLock();
// 监听窗口创建事件
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
if (!isSingleInstance) {
app.quit();
} else {
// 当第二个实例启动时,将焦点转移到第一个实例的窗口
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
}
});
// 初始化应用
initialize();
// 应用程序准备就绪时的处理
app.whenReady().then(() => {
// 设置应用ID
electronApp.setAppUserModelId('com.alger.music');
// macOS 激活应用时的处理
app.on('activate', () => {
if (mainWindow === null) initialize();
// 监听窗口创建事件
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// 初始化应用
initialize();
// macOS 激活应用时的处理
app.on('activate', () => {
if (mainWindow === null) initialize();
});
});
});
// 监听快捷键更新
ipcMain.on('update-shortcuts', () => {
registerShortcuts(mainWindow);
});
// 监听快捷键更新
ipcMain.on('update-shortcuts', () => {
registerShortcuts(mainWindow);
});
// 所有窗口关闭时的处理
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// 所有窗口关闭时的处理
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// 重启应用
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
// 重启应用
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
// 获取系统架构信息
ipcMain.on('get-arch', (event) => {
event.returnValue = process.arch;
});
// 获取系统架构信息
ipcMain.on('get-arch', (event) => {
event.returnValue = process.arch;
});
}

View File

@@ -4,15 +4,28 @@ import Store from 'electron-store';
import set from '../set.json';
import { defaultShortcuts } from './shortcuts';
interface StoreType {
set: {
isProxy: boolean;
noAnimate: boolean;
animationSpeed: number;
author: string;
authorUrl: string;
musicApiPort: number;
type SetConfig = {
isProxy: boolean;
proxyConfig: {
enable: boolean;
protocol: string;
host: string;
port: number;
};
enableRealIP: boolean;
realIP: string;
noAnimate: boolean;
animationSpeed: number;
author: string;
authorUrl: string;
musicApiPort: number;
closeAction: 'ask' | 'minimize' | 'close';
musicQuality: string;
fontFamily: string;
fontScope: 'global' | 'lyric';
};
interface StoreType {
set: SetConfig;
shortcuts: typeof defaultShortcuts;
}
@@ -25,7 +38,7 @@ export function initializeConfig() {
store = new Store<StoreType>({
name: 'config',
defaults: {
set,
set: set as SetConfig,
shortcuts: defaultShortcuts
}
});

View File

@@ -60,13 +60,22 @@ export function initializeFileManager() {
// 通用的打开目录处理
ipcMain.on('open-directory', (_, filePath) => {
try {
if (fs.statSync(filePath).isDirectory()) {
shell.openPath(filePath);
// 验证文件路径
if (!filePath) {
console.error('无效的文件路径: 路径为空');
return;
}
// 统一处理路径分隔符
const normalizedPath = path.normalize(filePath);
if (fs.statSync(normalizedPath).isDirectory()) {
shell.openPath(normalizedPath);
} else {
shell.showItemInFolder(filePath);
shell.showItemInFolder(normalizedPath);
}
} catch (error) {
console.error('Error opening path:', error);
console.error('打开路径失败:', error);
}
});

42
src/main/modules/fonts.ts Normal file
View File

@@ -0,0 +1,42 @@
import { ipcMain } from 'electron';
import { getFonts } from 'font-list';
/**
* 清理字体名称
* @param fontName 原始字体名称
* @returns 清理后的字体名称
*/
function cleanFontName(fontName: string): string {
return fontName
.trim()
.replace(/^["']|["']$/g, '') // 移除首尾的引号
.replace(/\s+/g, ' '); // 将多个空格替换为单个空格
}
/**
* 获取系统字体列表
*/
async function getSystemFonts(): Promise<string[]> {
try {
// 使用 font-list 获取系统字体
const fonts = await getFonts();
// 清理字体名称并去重
const cleanedFonts = [...new Set(fonts.map(cleanFontName))];
// 添加系统默认字体并排序
return ['system-ui', ...cleanedFonts].sort();
} catch (error) {
console.error('获取系统字体失败:', error);
// 如果获取失败,至少返回系统默认字体
return ['system-ui'];
}
}
/**
* 初始化字体管理模块
*/
export function initializeFonts() {
// 添加获取系统字体的 IPC 处理
ipcMain.handle('get-system-fonts', async () => {
return await getSystemFonts();
});
}

View File

@@ -0,0 +1,90 @@
import axios from 'axios';
import { exec } from 'child_process';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
export function setupUpdateHandlers(_mainWindow: BrowserWindow) {
ipcMain.on('start-download', async (event, url: string) => {
try {
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
onDownloadProgress: (progressEvent: { loaded: number; total?: number }) => {
if (!progressEvent.total) return;
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
const downloaded = (progressEvent.loaded / 1024 / 1024).toFixed(2);
const total = (progressEvent.total / 1024 / 1024).toFixed(2);
event.sender.send('download-progress', percent, `已下载 ${downloaded}MB / ${total}MB`);
}
});
const fileName = url.split('/').pop() || 'update.exe';
const downloadPath = path.join(app.getPath('downloads'), fileName);
// 创建写入流
const writer = fs.createWriteStream(downloadPath);
// 将响应流写入文件
response.data.pipe(writer);
// 处理写入完成
writer.on('finish', () => {
event.sender.send('download-complete', true, downloadPath);
});
// 处理写入错误
writer.on('error', (error) => {
console.error('Write file error:', error);
event.sender.send('download-complete', false, '');
});
} catch (error) {
console.error('Download failed:', error);
event.sender.send('download-complete', false, '');
}
});
ipcMain.on('install-update', (_event, filePath: string) => {
if (!fs.existsSync(filePath)) {
console.error('Installation file not found:', filePath);
return;
}
const { platform } = process;
// 关闭当前应用
app.quit();
// 根据不同平台执行安装
if (platform === 'win32') {
exec(`"${filePath}"`, (error) => {
if (error) {
console.error('Error starting installer:', error);
}
});
} else if (platform === 'darwin') {
// 挂载 DMG 文件
exec(`open "${filePath}"`, (error) => {
if (error) {
console.error('Error opening DMG:', error);
}
});
} else if (platform === 'linux') {
const ext = path.extname(filePath);
if (ext === '.AppImage') {
exec(`chmod +x "${filePath}" && "${filePath}"`, (error) => {
if (error) {
console.error('Error running AppImage:', error);
}
});
} else if (ext === '.deb') {
exec(`pkexec dpkg -i "${filePath}"`, (error) => {
if (error) {
console.error('Error installing deb package:', error);
}
});
}
}
});
}

View File

@@ -14,5 +14,10 @@
"authorUrl": "https://github.com/algerkong",
"musicApiPort": 30488,
"closeAction": "ask",
"musicQuality": "higher"
"musicQuality": "higher",
"fontFamily": "system-ui",
"fontScope": "global",
"autoPlay": false,
"downloadPath": "",
"alwaysShowDownloadButton": false
}

View File

@@ -1,5 +1,4 @@
import match from '@unblockneteasemusic/server';
import Store from 'electron-store';
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube';
@@ -28,73 +27,6 @@ interface UnblockResult {
};
}
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);
}
});
};
/**
* 音乐解析函数
* @param id 歌曲ID
@@ -107,12 +39,6 @@ const unblockMusic = async (
songData: SongData,
retryCount = 3
): Promise<UnblockResult> => {
// 检查缓存
const cachedData = getFromCache(id);
if (cachedData) {
return cachedData;
}
// 所有可用平台
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube'];
@@ -128,9 +54,6 @@ const unblockMusic = async (
}
}
};
// 保存到缓存
saveToCache(id, result);
return result;
} catch (err) {
if (attempt < retryCount) {
@@ -149,14 +72,4 @@ const unblockMusic = async (
return retry(1);
};
// 定期清理过期缓存(每小时执行一次)
setInterval(cleanExpiredCache, 60 * 60 * 1000);
export {
cleanExpiredCache, // 导出清理缓存函数,以便手动调用
type Platform,
type ResponseData,
type SongData,
unblockMusic,
type UnblockResult
};
export { type Platform, type ResponseData, type SongData, unblockMusic, type UnblockResult };

View File

@@ -13,6 +13,10 @@ declare global {
miniTray: () => void;
restart: () => void;
unblockMusic: (id: number, data: any) => Promise<any>;
startDownload: (url: string) => void;
onDownloadProgress: (callback: (progress: number, status: string) => void) => void;
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void;
removeDownloadListeners: () => void;
invoke: (channel: string, ...args: any[]) => Promise<any>;
};
$message: any;

View File

@@ -12,13 +12,32 @@ const api = {
openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id),
// 更新相关
startDownload: (url: string) => ipcRenderer.send('start-download', url),
onDownloadProgress: (callback: (progress: number, status: string) => void) => {
ipcRenderer.on('download-progress', (_event, progress, status) => callback(progress, status));
},
onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => {
ipcRenderer.on('download-complete', (_event, success, filePath) => callback(success, filePath));
},
removeDownloadListeners: () => {
ipcRenderer.removeAllListeners('download-progress');
ipcRenderer.removeAllListeners('download-complete');
},
// 歌词缓存相关
invoke: (channel: string, ...args: any[]) => {
const validChannels = ['get-cached-lyric', 'cache-lyric', 'clear-lyric-cache'];
const validChannels = [
'get-lyrics',
'clear-lyrics-cache',
'get-system-fonts',
'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}`));
return Promise.reject(new Error(`未授权的 IPC 通道: ${channel}`));
}
};

View File

@@ -12,7 +12,7 @@
<script setup lang="ts">
import { darkTheme, lightTheme } from 'naive-ui';
import { onMounted } from 'vue';
import { computed, onMounted, watch } from 'vue';
import homeRouter from '@/router/home';
import store from '@/store';
@@ -24,9 +24,46 @@ const theme = computed(() => {
return store.state.theme;
});
// 监听字体变化并应用
watch(
() => [store.state.setData.fontFamily, store.state.setData.fontScope],
([newFont, fontScope]) => {
const appElement = document.body;
if (!appElement) return;
const defaultFonts =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
// 只有在全局模式下才应用字体
if (fontScope !== 'global') {
appElement.style.fontFamily = defaultFonts;
return;
}
if (newFont === 'system-ui') {
appElement.style.fontFamily = defaultFonts;
} else {
// 处理多个字体,确保每个字体名都被正确引用
const fontList = newFont.split(',').map((font) => {
const trimmedFont = font.trim();
// 如果字体名包含空格或特殊字符,添加引号(如果还没有引号的话)
return /[\s'"()]/.test(trimmedFont) && !/^['"].*['"]$/.test(trimmedFont)
? `"${trimmedFont}"`
: trimmedFont;
});
// 将选择的字体和默认字体组合
appElement.style.fontFamily = `${fontList.join(', ')}, ${defaultFonts}`;
}
},
{ immediate: true }
);
onMounted(() => {
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
store.dispatch('initializeSystemFonts');
store.dispatch('initializePlayState');
if (isMobile.value) {
store.commit(
'setMenus',

View File

@@ -13,16 +13,25 @@ export const getMusicQualityDetail = (id: number) => {
};
// 根据音乐Id获取音乐播放URl
export const getMusicUrl = async (id: number) => {
const res = await request.get('/song/download/url/v1', {
params: {
id,
level: store.state.setData.musicQuality || 'higher'
}
});
export const getMusicUrl = async (id: number, isDownloaded: boolean = false) => {
// 判断是否登录
try {
if (store.state.user && isDownloaded && store.state.user.vipType !== 0) {
const url = '/song/download/url/v1';
const res = await request.get(url, {
params: {
id,
level: store.state.setData.musicQuality || 'higher',
cookie: `${localStorage.getItem('token')} os=pc;`
}
});
if (res.data.data.url) {
return { data: { data: [{ ...res.data.data }] } };
if (res.data.data.url) {
return { data: { data: [{ ...res.data.data }] } };
}
}
} catch (error) {
console.error('error', error);
}
return await request.get('/song/url/v1', {
@@ -80,6 +89,22 @@ export const likeSong = (id: number, like: boolean = true) => {
};
// 获取用户喜欢的音乐列表
export const getLikedList = () => {
return request.get('/likelist');
export const getLikedList = (uid: number) => {
return request.get('/likelist', {
params: { uid }
});
};
// 创建歌单
export const createPlaylist = (params: { name: string; privacy: number }) => {
return request.post('/playlist/create', params);
};
// 添加或删除歌单歌曲
export const updatePlaylistTracks = (params: {
op: 'add' | 'del';
pid: number;
tracks: string;
}) => {
return request.get('/playlist/tracks', { params });
};

View File

@@ -9,3 +9,7 @@ body {
border-radius: 0.5rem !important;
overflow: hidden !important;
}
.n-popover:has(.transparent-popover ) {
background-color: transparent !important;
padding: 0 !important;
}

View File

@@ -31,6 +31,8 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider']

View File

@@ -59,7 +59,12 @@
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
<song-item
:item="formatDetail(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="(id) => emit('remove-song', id)"
/>
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom />
@@ -97,15 +102,17 @@ const props = withDefaults(
[key: string]: any;
};
cover?: boolean;
canRemove?: boolean;
}>(),
{
loading: false,
cover: true,
zIndex: 9996
zIndex: 9996,
canRemove: false
}
);
const emit = defineEmits(['update:show', 'update:loading']);
const emit = defineEmits(['update:show', 'update:loading', 'remove-song']);
const page = ref(0);
const pageSize = 20;

View File

@@ -1,7 +1,7 @@
<template>
<div class="download-drawer-trigger">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="showDrawer = true">
<n-button circle @click="store.commit('setShowDownloadDrawer', true)">
<template #icon>
<i class="iconfont ri-download-cloud-2-line"></i>
</template>
@@ -9,7 +9,12 @@
</n-badge>
</div>
<n-drawer v-model:show="showDrawer" :height="'80%'" placement="bottom">
<n-drawer
v-model:show="showDrawer"
:height="'80%'"
placement="bottom"
@after-leave="handleDrawerClose"
>
<n-drawer-content title="下载管理" closable :native-scrollbar="false">
<div class="drawer-container">
<n-tabs type="line" animated class="h-full">
@@ -85,7 +90,7 @@
</div>
<div v-else class="downloaded-content">
<div class="downloaded-items">
<div v-for="item in downloadedList" :key="item.path" class="downloaded-item">
<div v-for="item in downList" :key="item.path" class="downloaded-item">
<div class="downloaded-item-content">
<div class="downloaded-item-cover">
<n-image
@@ -105,11 +110,11 @@
<div class="downloaded-item-size">{{ formatSize(item.size) }}</div>
</div>
<div class="downloaded-item-actions">
<n-button text type="primary" size="large" @click="handlePlayMusic(item)">
<!-- <n-button text type="primary" size="large" @click="handlePlayMusic(item)">
<template #icon>
<i class="iconfont ri-play-circle-line text-xl"></i>
</template>
</n-button>
</n-button> -->
<n-button
text
type="primary"
@@ -162,7 +167,7 @@ import { computed, onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import { audioService } from '@/services/audioService';
// import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils';
interface DownloadItem {
@@ -185,15 +190,26 @@ interface DownloadedItem {
ar: { name: string }[];
}
const store = useStore();
const message = useMessage();
const showDrawer = ref(false);
const store = useStore();
const showDrawer = computed({
get: () => store.state.showDownloadDrawer,
set: (val) => store.commit('setShowDownloadDrawer', val)
});
const downloadList = ref<DownloadItem[]>([]);
const downloadedList = ref<DownloadedItem[]>([]);
const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]')
);
const downList = computed(() => {
return (downloadedList.value as DownloadedItem[]).reverse();
});
// 获取播放状态
const play = computed(() => store.state.play as boolean);
const currentMusic = computed(() => store.state.playMusic);
// const play = computed(() => store.state.play as boolean);
// const currentMusic = computed(() => store.state.playMusic);
// 计算下载中的任务数量
const downloadingCount = computed(() => {
@@ -264,8 +280,7 @@ const formatSize = (bytes: number) => {
// 打开目录
const openDirectory = (path: string) => {
const directory = path.substring(0, path.lastIndexOf('/'));
window.electron.ipcRenderer.send('open-directory', directory);
window.electron.ipcRenderer.send('open-directory', path);
};
// 删除相关
@@ -288,6 +303,14 @@ const confirmDelete = async () => {
itemToDelete.value.path
);
if (success) {
localStorage.setItem(
'downloadedList',
JSON.stringify(
downloadedList.value.filter(
(item) => item.id !== (itemToDelete.value as DownloadedItem).id
)
)
);
await refreshDownloadedList();
message.success('删除成功');
} else {
@@ -303,58 +326,59 @@ const confirmDelete = async () => {
};
// 播放音乐
const handlePlayMusic = async (item: DownloadedItem) => {
// 确保路径正确编码
const encodedPath = encodeURIComponent(item.path);
const localUrl = `local://${encodedPath}`;
// const handlePlayMusic = async (item: DownloadedItem) => {
// // 确保路径正确编码
// const encodedPath = encodeURIComponent(item.path);
// const localUrl = `local://${encodedPath}`;
const musicInfo = {
name: item.filename,
id: item.id,
url: localUrl,
playMusicUrl: localUrl,
picUrl: item.picUrl,
ar: item.ar || [{ name: '本地音乐' }],
song: {
artists: item.ar || [{ name: '本地音乐' }]
},
al: {
picUrl: item.picUrl || '/images/default_cover.png'
}
};
// const musicInfo = {
// name: item.filename,
// id: item.id,
// url: localUrl,
// playMusicUrl: localUrl,
// picUrl: item.picUrl,
// ar: item.ar || [{ name: '本地音乐' }],
// song: {
// artists: item.ar || [{ name: '本地音乐' }]
// },
// al: {
// picUrl: item.picUrl || '/images/default_cover.png'
// }
// };
// 如果是当前播放的音乐,则切换播放状态
if (currentMusic.value?.id === item.id) {
if (play.value) {
audioService.getCurrentSound()?.pause();
store.commit('setPlayMusic', false);
} else {
audioService.getCurrentSound()?.play();
store.commit('setPlayMusic', true);
}
return;
}
// // 如果是当前播放的音乐,则切换播放状态
// if (currentMusic.value?.id === item.id) {
// if (play.value) {
// audioService.getCurrentSound()?.pause();
// store.commit('setPlayMusic', false);
// } else {
// audioService.getCurrentSound()?.play();
// store.commit('setPlayMusic', true);
// }
// return;
// }
// 播放新的音乐
store.commit('setPlay', musicInfo);
store.commit('setPlayMusic', true);
store.commit('setIsPlay', true);
// // 播放新的音乐
// store.commit('setPlay', musicInfo);
// store.commit('setPlayMusic', true);
// store.commit('setIsPlay', true);
store.commit(
'setPlayList',
downloadedList.value.map((item) => ({
...item,
playMusicUrl: `local://${encodeURIComponent(item.path)}`
}))
);
};
// store.commit(
// 'setPlayList',
// downloadedList.value.map((item) => ({
// ...item,
// playMusicUrl: `local://${encodeURIComponent(item.path)}`
// }))
// );
// };
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
try {
let saveList: any = [];
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
if (!Array.isArray(list) || list.length === 0) {
downloadedList.value = [];
saveList = [];
return;
}
@@ -369,7 +393,7 @@ const refreshDownloadedList = async () => {
return acc;
}, {});
downloadedList.value = list.map((item) => {
saveList = list.map((item) => {
const songDetail = songDetails[item.id];
return {
...item,
@@ -379,17 +403,29 @@ const refreshDownloadedList = async () => {
});
} catch (detailError) {
console.error('Failed to get music details:', detailError);
downloadedList.value = list;
saveList = list;
}
} else {
downloadedList.value = list;
saveList = list;
}
setLocalDownloadedList(saveList);
} catch (error) {
console.error('Failed to get downloaded music list:', error);
downloadedList.value = [];
}
};
const setLocalDownloadedList = (list: DownloadedItem[]) => {
const localList = localStorage.getItem('downloadedList');
// 合并 去重
const saveList = [...(localList ? JSON.parse(localList) : []), ...list];
const uniqueList = saveList.filter(
(item, index, self) => index === self.findIndex((t) => t.id === item.id)
);
localStorage.setItem('downloadedList', JSON.stringify(uniqueList));
downloadedList.value = uniqueList;
};
// 监听抽屉显示状态
watch(
() => showDrawer.value,
@@ -465,6 +501,10 @@ onMounted(() => {
}
});
});
const handleDrawerClose = () => {
store.commit('setShowDownloadDrawer', false);
};
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,355 @@
<template>
<n-drawer
:show="modelValue"
:width="400"
placement="right"
@update:show="$emit('update:modelValue', $event)"
>
<n-drawer-content title="添加到歌单" class="mac-style-drawer">
<n-scrollbar class="h-full">
<div class="playlist-drawer">
<!-- 创建新歌单按钮和表单 -->
<div class="create-playlist-section">
<div
class="create-playlist-button"
:class="{ 'is-expanded': isCreating }"
@click="toggleCreateForm"
>
<div class="create-playlist-icon">
<i class="iconfont" :class="isCreating ? 'ri-close-line' : 'ri-add-line'"></i>
</div>
<div class="create-playlist-text">{{ isCreating ? '取消创建' : '创建新歌单' }}</div>
</div>
<!-- 创建歌单表单 -->
<div class="create-playlist-form" :class="{ 'is-visible': isCreating }">
<n-input
v-model:value="formValue.name"
placeholder="歌单名称"
maxlength="40"
class="mac-style-input"
:status="inputError ? 'error' : undefined"
>
<template #prefix>
<i class="iconfont ri-music-2-line"></i>
</template>
</n-input>
<div class="privacy-switch">
<div class="privacy-label">
<i
class="iconfont"
:class="formValue.privacy ? 'ri-lock-line' : 'ri-earth-line'"
></i>
<span>{{ formValue.privacy ? '私密歌单' : '公开歌单' }}</span>
</div>
<n-switch v-model:value="formValue.privacy" class="mac-style-switch">
<template #checked>私密</template>
<template #unchecked>公开</template>
</n-switch>
</div>
<div class="form-actions">
<n-button
type="primary"
quaternary
class="mac-style-button"
:loading="creating"
:disabled="!formValue.name"
@click="handleCreatePlaylist"
>
创建歌单
</n-button>
</div>
</div>
</div>
<!-- 歌单列表 -->
<div class="playlist-list">
<div
v-for="playlist in playlists"
:key="playlist.id"
class="playlist-item"
@click="handleAddToPlaylist(playlist)"
>
<n-image
:src="getImgUrl(playlist.coverImgUrl || playlist.picUrl, '100y100')"
class="playlist-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
/>
<div class="playlist-item-info">
<div class="playlist-item-name">{{ playlist.name }}</div>
<div class="playlist-item-count">{{ playlist.trackCount }}首歌曲</div>
</div>
<div class="playlist-item-action">
<i class="iconfont ri-add-line"></i>
</div>
</div>
</div>
</div>
</n-scrollbar>
</n-drawer-content>
</n-drawer>
</template>
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { computed, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { createPlaylist, updatePlaylistTracks } from '@/api/music';
import { getUserPlaylist } from '@/api/user';
import { getImgUrl } from '@/utils';
const props = defineProps<{
modelValue: boolean;
songId?: number;
}>();
const emit = defineEmits(['update:modelValue']);
const message = useMessage();
const playlists = ref<any[]>([]);
const creating = ref(false);
const store = useStore();
const isCreating = ref(false);
const formValue = ref({
name: '',
privacy: false
});
const inputError = computed(() => {
return isCreating.value && !formValue.value.name;
});
const toggleCreateForm = () => {
if (creating.value) return;
isCreating.value = !isCreating.value;
if (!isCreating.value) {
formValue.value.name = '';
formValue.value.privacy = false;
}
};
// 获取用户歌单
const fetchUserPlaylists = async () => {
try {
const { user } = store.state;
if (!user?.userId) {
message.error('请先登录');
emit('update:modelValue', false);
return;
}
const res = await getUserPlaylist(user.userId);
if (res.data?.playlist) {
playlists.value = res.data.playlist;
}
} catch (error) {
console.error('获取歌单失败:', error);
message.error('获取歌单失败');
}
};
// 添加到歌单
const handleAddToPlaylist = async (playlist: any) => {
if (!props.songId) return;
try {
const res = await updatePlaylistTracks({
op: 'add',
pid: playlist.id,
tracks: props.songId.toString()
});
console.log('res.data', res.data);
if (res.status === 200) {
message.success('添加成功');
emit('update:modelValue', false);
} else {
throw new Error(res.data?.msg || '添加失败');
}
} catch (error: any) {
console.error('添加到歌单失败:', error);
message.error(error.message || '添加到歌单失败');
}
};
// 创建歌单
const handleCreatePlaylist = async () => {
if (!formValue.value.name) {
message.error('请输入歌单名称');
return;
}
try {
creating.value = true;
const res = await createPlaylist({
name: formValue.value.name,
privacy: formValue.value.privacy ? 10 : 0
});
if (res.data?.id) {
message.success('创建成功');
isCreating.value = false;
formValue.value.name = '';
formValue.value.privacy = false;
await fetchUserPlaylists();
}
} catch (error) {
console.error('创建歌单失败:', error);
message.error('创建歌单失败');
} finally {
creating.value = false;
}
};
// 监听显示状态变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
fetchUserPlaylists();
}
}
);
</script>
<style lang="scss" scoped>
.mac-style-drawer {
@apply h-full;
:deep(.n-drawer-header__main) {
@apply text-base font-medium;
}
:deep(.n-drawer-content) {
@apply h-full;
}
:deep(.n-drawer-content-wrapper) {
@apply h-full;
}
:deep(.n-scrollbar-rail) {
@apply right-0.5;
}
}
.playlist-drawer {
@apply flex flex-col gap-6;
}
.create-playlist-section {
@apply flex flex-col;
}
.create-playlist-button {
@apply flex items-center gap-4 p-3 rounded-xl cursor-pointer transition-all duration-200
bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700;
&.is-expanded {
@apply bg-gray-100 dark:bg-gray-700;
.create-playlist-icon {
transform: rotate(45deg);
}
}
&-icon {
@apply w-10 h-10 rounded-xl bg-green-500 flex items-center justify-center text-white
transition-all duration-300;
.iconfont {
@apply text-xl transition-transform duration-300;
}
}
&-text {
@apply text-sm font-medium transition-colors duration-300;
}
}
.create-playlist-form {
@apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out opacity-0;
&.is-visible {
@apply max-h-[200px] mt-4 opacity-100;
}
.mac-style-input {
@apply rounded-lg;
:deep(.n-input-wrapper) {
@apply bg-gray-50 dark:bg-gray-800 border-0;
}
:deep(.n-input__input) {
@apply text-sm;
}
:deep(.n-input__prefix) {
@apply text-gray-400;
}
}
.form-actions {
@apply mt-4;
.mac-style-button {
@apply w-full rounded-lg text-sm py-2 bg-green-500 hover:bg-green-600 text-white;
}
}
}
.privacy-switch {
@apply flex items-center justify-between mt-4 px-2;
.privacy-label {
@apply flex items-center gap-2;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400;
}
span {
@apply text-sm;
}
}
:deep(.n-switch) {
@apply h-5 min-w-[40px];
}
}
.playlist-list {
@apply flex flex-col gap-2;
}
.playlist-item {
@apply flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all duration-200
hover:bg-gray-50 dark:hover:bg-gray-800;
&-img {
@apply w-10 h-10 rounded-xl;
}
&-info {
@apply flex-1 min-w-0;
}
&-name {
@apply text-sm font-medium truncate;
}
&-count {
@apply text-xs text-gray-500 dark:text-gray-400;
}
&-action {
@apply w-8 h-8 rounded-lg flex items-center justify-center
text-gray-400 hover:text-green-500 transition-colors duration-200;
.iconfont {
@apply text-xl;
}
}
}
</style>

View File

@@ -73,9 +73,10 @@
<n-dropdown
v-if="isElectron"
:show="showDropdown"
:options="dropdownOptions"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
:z-index="99999"
placement="bottom-start"
@clickoutside="showDropdown = false"
@select="handleSelect"
@@ -86,8 +87,8 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash';
import type { MenuOption } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, h, ref, useTemplateRef } from 'vue';
import { NImage, NText, useMessage } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import { getSongUrl } from '@/hooks/MusicListHook';
@@ -104,13 +105,15 @@ const props = withDefaults(
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true,
selectable: false,
selected: false
selected: false,
canRemove: false
}
);
@@ -132,14 +135,114 @@ const dropdownY = ref(0);
const isDownloading = ref(false);
const dropdownOptions = computed<MenuOption[]>(() => [
{
label: isDownloading.value ? '下载中...' : `下载 ${props.item.name}`,
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' }),
disabled: isDownloading.value
const openPlaylistDrawer = inject<(songId: number) => void>('openPlaylistDrawer');
const renderSongPreview = () => {
return h(
'div',
{
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
},
[
h(NImage, {
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
class: 'w-10 h-10 rounded-lg flex-shrink-0',
previewDisabled: true,
imgProps: {
crossorigin: 'anonymous'
}
}),
h(
'div',
{
class: 'flex-1 min-w-0 py-1'
},
[
h(
'div',
{
class: 'mb-1'
},
[
h(
NText,
{
depth: 1,
class: 'text-sm font-medium'
},
{
default: () => props.item.name
}
)
]
)
]
)
]
);
};
const dropdownOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{
key: 'header',
type: 'render',
render: renderSongPreview
},
{
key: 'divider1',
type: 'divider'
},
{
label: '播放',
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: '下一首播放',
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
{
type: 'divider',
key: 'd1'
},
{
label: '下载歌曲',
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: '添加到歌单',
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: isFavorite.value ? '取消喜欢' : '喜欢',
key: 'favorite',
icon: () =>
h('i', {
class: `iconfont ${isFavorite.value ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
})
}
];
if (props.canRemove) {
options.push(
{
type: 'divider',
key: 'd2'
},
{
label: '从歌单中删除',
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
);
}
]);
return options;
});
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
@@ -152,6 +255,16 @@ const handleSelect = (key: string | number) => {
showDropdown.value = false;
if (key === 'download') {
downloadMusic();
} else if (key === 'playNext') {
handlePlayNext();
} else if (key === 'addToPlaylist') {
openPlaylistDrawer?.(props.item.id);
} else if (key === 'favorite') {
toggleFavorite(new Event('click'));
} else if (key === 'play') {
playMusicEvent(props.item);
} else if (key === 'remove') {
emits('remove-song', props.item.id);
}
};
@@ -222,7 +335,7 @@ const downloadMusic = async () => {
}
};
const emits = defineEmits(['play', 'select']);
const emits = defineEmits(['play', 'select', 'remove-song']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
@@ -281,6 +394,12 @@ const handleArtistClick = (id: number) => {
const artists = computed(() => {
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
});
// 添加到下一首播放
const handlePlayNext = () => {
store.commit('addToNextPlay', props.item);
message.success('已添加到下一首播放');
};
</script>
<style lang="scss" scoped>
@@ -450,4 +569,56 @@ const artists = computed(() => {
}
}
}
:deep(.n-dropdown-menu) {
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
.n-dropdown-option {
@apply h-9 text-sm;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
.n-dropdown-option-body {
@apply h-full;
.n-dropdown-option-body__prefix {
@apply w-8 flex justify-center items-center;
.iconfont {
@apply text-base;
}
}
}
}
.n-dropdown-divider {
@apply my-1;
}
}
:deep(.song-preview) {
@apply flex items-center gap-3 p-3 border-b dark:border-gray-800;
.n-image {
@apply w-12 h-12 rounded-lg flex-shrink-0;
}
.song-preview-info {
@apply flex-1 min-w-0 py-1;
.song-preview-name {
@apply text-sm font-medium truncate mb-1;
}
.song-preview-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
}
}
:deep(.n-dropdown-option-body--render) {
@apply p-0;
}
</style>

View File

@@ -3,7 +3,8 @@
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
:mask-closable="!downloading"
:closable="!downloading"
class="update-app-modal"
style="width: 800px; max-width: 90vw"
>
@@ -15,7 +16,6 @@
<div class="app-info">
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="update-info">
@@ -23,11 +23,35 @@
<div class="update-body" v-html="parsedReleaseNotes"></div>
</n-scrollbar>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不更新</n-button>
<n-button type="primary" class="update-btn" @click="handleUpdate">立即更新</n-button>
<div v-if="downloading" class="download-status mt-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-500">{{ downloadStatus }}</span>
<span class="text-sm font-medium">{{ downloadProgress }}%</span>
</div>
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: `${downloadProgress}%` }"></div>
</div>
</div>
<div class="modal-desc mt-4 text-center">
<div class="modal-actions" :class="{ 'mt-6': !downloading }">
<n-button
class="cancel-btn"
:disabled="downloading"
:loading="downloading"
@click="closeModal"
>
{{ '暂不更新' }}
</n-button>
<n-button
type="primary"
class="update-btn"
:loading="downloading"
:disabled="downloading"
@click="handleUpdate"
>
{{ downloadBtnText }}
</n-button>
</div>
<div v-if="!downloading" class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
@@ -45,7 +69,7 @@
<script setup lang="ts">
import { marked } from 'marked';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { checkUpdate, UpdateResult } from '@/utils/update';
@@ -59,7 +83,6 @@ marked.setOptions({
});
const showModal = ref(false);
const noPrompt = ref(false);
const updateInfo = ref<UpdateResult>({
hasUpdate: false,
latestVersion: '',
@@ -102,9 +125,6 @@ const parsedReleaseNotes = computed(() => {
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('updatePromptDismissed', 'true');
}
};
const checkForUpdates = async () => {
@@ -112,21 +132,54 @@ const checkForUpdates = async () => {
const result = await checkUpdate(config.version);
if (result) {
updateInfo.value = result;
if (localStorage.getItem('updatePromptDismissed') !== 'true') {
showModal.value = true;
}
showModal.value = true;
}
} catch (error) {
console.error('检查更新失败:', error);
}
};
const downloading = ref(false);
const downloadProgress = ref(0);
const downloadStatus = ref('准备下载...');
const downloadBtnText = computed(() => {
if (downloading.value) return '下载中...';
return '立即更新';
});
// 处理下载状态更新
const handleDownloadProgress = (_event: any, progress: number, status: string) => {
downloadProgress.value = progress;
downloadStatus.value = status;
};
// 处理下载完成
const handleDownloadComplete = (_event: any, success: boolean, filePath: string) => {
downloading.value = false;
if (success) {
window.electron.ipcRenderer.send('install-update', filePath);
} else {
window.$message.error('下载失败,请重试或手动下载');
}
};
// 监听下载事件
onMounted(() => {
checkForUpdates();
window.electron.ipcRenderer.on('download-progress', handleDownloadProgress);
window.electron.ipcRenderer.on('download-complete', handleDownloadComplete);
});
// 清理事件监听
onUnmounted(() => {
window.electron.ipcRenderer.removeListener('download-progress', handleDownloadProgress);
window.electron.ipcRenderer.removeListener('download-complete', handleDownloadComplete);
});
const handleUpdate = async () => {
const assets = updateInfo.value.releaseInfo?.assets || [];
const { platform } = window.electron.process;
const arch = window.electron.ipcRenderer.sendSync('get-arch');
console.log('arch', arch);
console.log('platform', platform);
const version = updateInfo.value.latestVersion;
const downUrls = {
win32: {
@@ -170,16 +223,20 @@ const handleUpdate = async () => {
}
if (downloadUrl) {
window.open(`https://www.ghproxy.cn/${downloadUrl}`, '_blank');
try {
downloading.value = true;
downloadStatus.value = '准备下载...';
window.electron.ipcRenderer.send('start-download', downloadUrl);
} catch (error) {
downloading.value = false;
window.$message.error('启动下载失败,请重试或手动下载');
console.error('下载失败:', error);
}
} else {
// 如果没有找到对应的安装包,跳转到 release 页面
window.$message.error('未找到适合当前系统的安装包,请手动下载');
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
}
};
onMounted(() => {
checkForUpdates();
});
</script>
<style lang="scss" scoped>
@@ -266,8 +323,18 @@ onMounted(() => {
}
}
}
.download-status {
@apply p-2;
.progress-bar-wrapper {
@apply w-full h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden;
.progress-bar {
@apply h-full bg-green-500 rounded-full transition-all duration-300 ease-out;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.5);
}
}
}
.modal-actions {
@apply flex gap-4 mt-6;
@apply flex gap-4;
.n-button {
@apply flex-1 text-base py-2;
}
@@ -276,12 +343,18 @@ onMounted(() => {
&:hover {
@apply bg-gray-700;
}
&:disabled {
@apply opacity-50 cursor-not-allowed;
}
}
.update-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
&:disabled {
@apply opacity-50 cursor-not-allowed;
}
}
}
}

View File

@@ -0,0 +1,192 @@
<template>
<div class="settings-panel transparent-popover">
<div class="settings-title">页面设置</div>
<div class="settings-content">
<div class="settings-item">
<span>纯净模式</span>
<n-switch v-model:value="config.pureModeEnabled" />
</div>
<div class="settings-item">
<span>隐藏封面</span>
<n-switch v-model:value="config.hideCover" />
</div>
<div class="settings-item">
<span>居中显示</span>
<n-switch v-model:value="config.centerLyrics" />
</div>
<div class="settings-item">
<span>显示翻译</span>
<n-switch v-model:value="config.showTranslation" />
</div>
<div class="settings-item">
<span>隐藏播放栏</span>
<n-switch v-model:value="config.hidePlayBar" />
</div>
<div class="settings-slider">
<span>字体大小</span>
<n-slider
v-model:value="config.fontSize"
:step="1"
:min="12"
:max="32"
:marks="{
12: '小',
22: '中',
32: '大'
}"
/>
</div>
<div class="settings-slider">
<span>文字间距</span>
<n-slider
v-model:value="config.letterSpacing"
:step="0.2"
:min="-2"
:max="10"
:marks="{
'-2': '紧凑',
0: '默认',
10: '宽松'
}"
/>
</div>
<div class="settings-slider">
<span>行高</span>
<n-slider
v-model:value="config.lineHeight"
:step="0.1"
:min="1"
:max="3"
:marks="{
1: '紧凑',
1.5: '默认',
3: '宽松'
}"
/>
</div>
<div class="settings-item">
<span>背景主题</span>
<n-radio-group v-model:value="config.theme" name="theme">
<n-radio value="default">默认</n-radio>
<n-radio value="light">亮色</n-radio>
<n-radio value="dark">暗色</n-radio>
</n-radio-group>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
interface LyricConfig {
hideCover: boolean;
centerLyrics: boolean;
fontSize: number;
letterSpacing: number;
lineHeight: number;
showTranslation: boolean;
theme: 'default' | 'light' | 'dark';
hidePlayBar: boolean;
pureModeEnabled: boolean;
}
const config = ref<LyricConfig>({
hideCover: false,
centerLyrics: false,
fontSize: 22,
letterSpacing: 0,
lineHeight: 2,
showTranslation: true,
theme: 'default',
hidePlayBar: false,
pureModeEnabled: false
});
const emit = defineEmits(['themeChange']);
// 监听配置变化并保存到本地存储
watch(
() => config.value,
(newConfig) => {
updateCSSVariables(newConfig);
},
{ deep: true }
);
// 监听主题变化
watch(
() => config.value.theme,
(newTheme) => {
emit('themeChange', newTheme);
}
);
// 更新 CSS 变量
const updateCSSVariables = (config: LyricConfig) => {
document.documentElement.style.setProperty('--lyric-font-size', `${config.fontSize}px`);
document.documentElement.style.setProperty('--lyric-letter-spacing', `${config.letterSpacing}px`);
document.documentElement.style.setProperty('--lyric-line-height', config.lineHeight.toString());
};
// 加载保存的配置
onMounted(() => {
const savedConfig = localStorage.getItem('music-full-config');
if (savedConfig) {
config.value = { ...config.value, ...JSON.parse(savedConfig) };
updateCSSVariables(config.value);
}
});
defineExpose({
config
});
</script>
<style scoped lang="scss">
.settings-panel {
@apply p-4 w-72 rounded-lg relative overflow-hidden backdrop-blur-lg bg-black/10;
.settings-title {
@apply text-base font-bold mb-4;
color: var(--text-color-active);
}
.settings-content {
@apply space-y-4;
}
.settings-item {
@apply flex items-center justify-between;
span {
@apply text-sm;
color: var(--text-color-primary);
}
}
.settings-slider {
@apply space-y-2;
@apply mb-10 !important;
span {
@apply text-sm;
color: var(--text-color-primary);
}
}
}
// 修改 slider 字体颜色
:deep(.n-slider-mark) {
color: var(--text-color-primary) !important;
}
// 修改 radio 字体颜色
:deep(.n-radio__label) {
color: var(--text-color-active) !important;
}
</style>

View File

@@ -6,7 +6,7 @@ import store from '@/store';
import type { Artist, ILyricText, SongResult } from '@/type/music';
import { isElectron } from '@/utils';
import { getTextColors } from '@/utils/linearColor';
import { createDiscreteApi } from 'naive-ui';
const windowData = window as any;
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
@@ -51,6 +51,8 @@ document.onkeyup = (e) => {
}
};
const { message } = createDiscreteApi(['message']);
watch(
() => store.state.playMusicUrl,
async (newVal) => {
@@ -60,8 +62,10 @@ watch(
sound.value = newSound as Howl;
setupAudioListeners();
} catch (error) {
console.error('播放音频失败:', error);
store.commit('setPlayMusic', false);
message.error('当前歌曲播放失败,播放下一首');
// 下一首
store.commit('nextPlay');
}
@@ -149,6 +153,30 @@ const setupAudioListeners = () => {
}
});
const replayMusic = async () => {
try {
// 如果当前有音频实例,先停止并销毁
if (sound.value) {
sound.value.stop();
sound.value.unload();
sound.value = null;
}
// 重新播放当前歌曲
if (store.state.playMusicUrl && playMusic.value) {
const newSound = await audioService.play(store.state.playMusicUrl, playMusic.value);
sound.value = newSound as Howl;
setupAudioListeners();
} else {
console.error('No music URL or playMusic data available');
store.commit('nextPlay');
}
} catch (error) {
console.error('Error replaying song:', error);
store.commit('nextPlay');
}
};
// 监听结束
audioService.on('end', () => {
clearInterval();
@@ -156,23 +184,13 @@ const setupAudioListeners = () => {
if (store.state.playMode === 1) {
// 单曲循环模式
if (sound.value) {
sound.value.seek(0);
try {
sound.value.play();
} catch (error) {
console.error('Error replaying song:', error);
store.commit('nextPlay');
}
replayMusic();
}
} else if (store.state.playMode === 2) {
// 随机播放模式
const { playList } = store.state;
if (playList.length <= 1) {
try {
sound.value?.play();
} catch (error) {
console.error('Error replaying song:', error);
}
replayMusic();
} else {
let randomIndex;
do {
@@ -466,13 +484,6 @@ if (isElectron) {
// 在组件挂载时设置监听器
onMounted(() => {
const clearIntervalFn = setupAudioListeners();
setupAudioListeners();
useLyricProgress(); // 直接调用,不需要解构返回值
// 在组件卸载时清理
onUnmounted(() => {
clearIntervalFn();
audioService.stop();
audioService.clearAllListeners();
});
});

View File

@@ -13,7 +13,7 @@ const musicHistory = useMusicHistory();
// 获取歌曲url
export const getSongUrl = async (id: number, songData: any, isDownloaded: boolean = false) => {
const { data } = await getMusicUrl(id);
const { data } = await getMusicUrl(id, isDownloaded);
let url = '';
let songDetail = null;
try {

View File

@@ -25,18 +25,26 @@
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" :style="isMobile && store.state.musicFull ? 'bottom: 0;' : ''" />
<play-bar v-show="isPlay" :style="isMobile && store.state.musicFull ? 'bottom: 0;' : ''" />
<!-- 下载管理抽屉 -->
<download-drawer v-if="isElectron" />
<download-drawer
v-if="
isElectron &&
(store.state.setData?.alwaysShowDownloadButton ||
store.state.showDownloadDrawer ||
store.state.hasDownloadingTasks)
"
/>
</div>
<install-app-modal v-if="!isElectron"></install-app-modal>
<update-modal v-if="isElectron" />
<artist-drawer ref="artistDrawerRef" :show="artistDrawerShow" />
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, nextTick, onMounted, provide, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
@@ -63,6 +71,7 @@ const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const ArtistDrawer = defineAsyncComponent(() => import('@/components/common/ArtistDrawer.vue'));
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
const store = useStore();
@@ -93,6 +102,18 @@ watch(
}
}
);
const showPlaylistDrawer = ref(false);
const currentSongId = ref<number | undefined>();
// 提供一个方法来打开歌单抽屉
const openPlaylistDrawer = (songId: number) => {
currentSongId.value = songId;
showPlaylistDrawer.value = true;
};
// 将方法提供给全局
provide('openPlaylistDrawer', openPlaylistDrawer);
</script>
<style lang="scss" scoped>

View File

@@ -7,9 +7,29 @@
:to="`#layout-main`"
:z-index="9998"
>
<div id="drawer-target">
<div class="drawer-back"></div>
<div id="drawer-target" :class="[config.theme]">
<div
class="control-btn absolute top-8 left-8"
:class="{ 'pure-mode': config.pureModeEnabled }"
@click="isVisible = false"
>
<i class="ri-arrow-down-s-line"></i>
</div>
<n-popover trigger="click" placement="bottom">
<template #trigger>
<div
class="control-btn absolute top-8 right-8"
:class="{ 'pure-mode': config.pureModeEnabled }"
>
<i class="ri-settings-3-line"></i>
</div>
</template>
<lyric-settings ref="lyricSettingsRef" />
</n-popover>
<div
v-show="!config.hideCover"
class="music-img"
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
>
@@ -44,16 +64,39 @@
</div>
</div>
</div>
<div class="music-content">
<div class="music-content" :class="{ center: config.centerLyrics && config.hideCover }">
<n-layout
ref="lrcSider"
class="music-lrc"
style="height: 60vh"
:style="{
height: config.hidePlayBar ? '85vh' : '65vh',
width: config.hideCover ? '50vw' : '500px'
}"
:native-scrollbar="false"
@mouseover="mouseOverLayout"
@mouseleave="mouseLeaveLayout"
>
<div ref="lrcContainer">
<!-- 歌曲信息 -->
<div ref="lrcContainer" class="music-lrc-container">
<div
v-if="config.hideCover"
class="music-info-header"
:style="{ textAlign: config.centerLyrics ? 'center' : 'left' }"
>
<div class="music-info-name">{{ playMusic.name }}</div>
<div class="music-info-singer">
<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>
</div>
</div>
<div
v-for="(item, index) in lrcArray"
:id="`music-lrc-text-${index}`"
@@ -63,7 +106,9 @@
@click="setAudioTime(index)"
>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
<div v-show="config.showTranslation" class="music-lrc-text-tr">
{{ item.trText }}
</div>
</div>
<!-- 无歌词 -->
@@ -84,9 +129,10 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import LyricSettings from '@/components/lyric/LyricSettings.vue';
import {
artistList,
lrcArray,
@@ -106,6 +152,56 @@ const lrcContainer = ref<HTMLElement | null>(null);
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const showStickyHeader = ref(false);
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
interface LyricConfig {
hideCover: boolean;
centerLyrics: boolean;
fontSize: number;
letterSpacing: number;
lineHeight: number;
showTranslation: boolean;
theme: 'default' | 'light' | 'dark';
hidePlayBar: boolean;
pureModeEnabled: boolean;
}
// 移除 computed 配置
const config = ref<LyricConfig>({
hideCover: false,
centerLyrics: false,
fontSize: 22,
letterSpacing: 0,
lineHeight: 1.5,
showTranslation: true,
theme: 'default',
hidePlayBar: false,
pureModeEnabled: false
});
// 监听设置组件的配置变化
watch(
() => lyricSettingsRef.value?.config,
(newConfig) => {
if (newConfig) {
config.value = newConfig;
}
},
{ deep: true, immediate: true }
);
// 监听本地配置变化,保存到 localStorage
watch(
() => config.value,
(newConfig) => {
localStorage.setItem('music-full-config', JSON.stringify(newConfig));
if (lyricSettingsRef.value) {
lyricSettingsRef.value.config = newConfig;
}
},
{ deep: true }
);
const props = defineProps({
modelValue: {
@@ -118,6 +214,11 @@ const props = defineProps({
}
});
const themeMusic = {
light: 'linear-gradient(to bottom, #ffffff, #f5f5f5)',
dark: 'linear-gradient(to bottom, #1a1a1a, #000000)'
};
const emit = defineEmits(['update:modelValue']);
const isVisible = computed({
@@ -126,18 +227,29 @@ const isVisible = computed({
});
// 歌词滚动方法
const lrcScroll = (behavior = 'smooth', top: null | number = null) => {
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
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 });
}
const lrcScroll = (behavior: ScrollBehavior = 'smooth', forceTop: boolean = false) => {
if (!isVisible.value || !lrcSider.value) return;
if (forceTop) {
lrcSider.value.scrollTo({
top: 0,
behavior
});
return;
}
if (isMouse.value) return;
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`) as HTMLElement;
if (nowEl) {
const containerHeight = lrcSider.value.$el.clientHeight;
const elementTop = nowEl.offsetTop;
const scrollTop = elementTop - containerHeight / 2 + nowEl.clientHeight / 2;
lrcSider.value.scrollTo({
top: scrollTop,
behavior
});
}
};
@@ -149,6 +261,7 @@ const mouseOverLayout = () => {
}
isMouse.value = true;
};
const mouseLeaveLayout = () => {
if (isMobile.value) {
return;
@@ -174,41 +287,51 @@ watch(
}
);
const setTextColors = (background: string) => {
if (!background) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
// 更新文字颜色
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
// 处理背景颜色动画
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
// 监听背景变化
watch(
() => props.background,
(newBg) => {
if (!newBg) {
textColors.value = getTextColors();
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(false)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
animationFrame.value = animateGradient(currentBackground.value, newBg, (gradient) => {
currentBackground.value = gradient;
});
if (config.value.theme === 'default') {
setTextColors(newBg);
} else {
currentBackground.value = newBg;
setTextColors(themeMusic[config.value.theme] || props.background);
}
textColors.value = getTextColors(newBg);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
},
{ immediate: true }
);
@@ -253,8 +376,131 @@ const handleArtistClick = (id: number) => {
store.commit('setCurrentArtistId', id);
};
const setData = computed(() => store.state.setData);
// 监听字体变化并更新 CSS 变量
watch(
() => [setData.value.fontFamily, setData.value.fontScope],
([newFont, fontScope]) => {
const defaultFonts =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
// 如果不是歌词模式或全局模式,使用默认字体
if (fontScope !== 'lyric' && fontScope !== 'global') {
document.documentElement.style.setProperty('--current-font-family', defaultFonts);
return;
}
if (newFont === 'system-ui') {
document.documentElement.style.setProperty('--current-font-family', defaultFonts);
} else {
// 处理多个字体,确保每个字体名都被正确引用
const fontList = newFont.split(',').map((font) => {
const trimmedFont = font.trim();
// 如果字体名包含空格或特殊字符,添加引号(如果还没有引号的话)
return /[\s'"()]/.test(trimmedFont) && !/^['"].*['"]$/.test(trimmedFont)
? `"${trimmedFont}"`
: trimmedFont;
});
// 将选择的字体和默认字体组合
document.documentElement.style.setProperty(
'--current-font-family',
`${fontList.join(', ')}, ${defaultFonts}`
);
}
},
{ immediate: true }
);
// 监听配置变化并保存到本地存储
watch(
() => config.value,
(newConfig) => {
localStorage.setItem('music-full-config', JSON.stringify(newConfig));
},
{ deep: true }
);
// 监听滚动事件
const handleScroll = () => {
if (!lrcSider.value || !config.value.hideCover) return;
const { scrollTop } = lrcSider.value.$el;
showStickyHeader.value = scrollTop > 100;
};
// 添加滚动监听
onMounted(() => {
if (lrcSider.value?.$el) {
lrcSider.value.$el.addEventListener('scroll', handleScroll);
}
});
// 移除滚动监听
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
if (lrcSider.value?.$el) {
lrcSider.value.$el.removeEventListener('scroll', handleScroll);
}
});
// 监听字体大小变化
watch(
() => config.value.fontSize,
(newSize) => {
document.documentElement.style.setProperty('--lyric-font-size', `${newSize}px`);
}
);
// 监听主题变化
watch(
() => config.value.theme,
(newTheme) => {
const newBackground = themeMusic[newTheme] || props.background;
setTextColors(newBackground);
},
{ immediate: true }
);
// 添加文字间距监听
watch(
() => config.value.letterSpacing,
(newSpacing) => {
document.documentElement.style.setProperty('--lyric-letter-spacing', `${newSpacing}px`);
}
);
// 添加行高监听
watch(
() => config.value.lineHeight,
(newLineHeight) => {
document.documentElement.style.setProperty('--lyric-line-height', newLineHeight.toString());
}
);
// 加载保存的配置
onMounted(() => {
const savedConfig = localStorage.getItem('music-full-config');
if (savedConfig) {
config.value = { ...config.value, ...JSON.parse(savedConfig) };
}
if (lrcSider.value?.$el) {
lrcSider.value.$el.addEventListener('scroll', handleScroll);
}
});
// 添加对 playMusic 的监听
watch(playMusic, () => {
nextTick(() => {
lrcScroll('instant', true);
});
});
defineExpose({
lrcScroll
lrcScroll,
config
});
</script>
@@ -281,7 +527,7 @@ defineExpose({
}
#drawer-target {
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full py-8;
animation-duration: 300ms;
.music-img {
@@ -289,12 +535,23 @@ defineExpose({
max-width: 360px;
max-height: 360px;
.img {
@apply rounded-xl w-full h-full shadow-2xl;
@apply rounded-xl w-full h-full shadow-2xl;
}
}
.music-content {
@apply flex flex-col justify-center items-center relative;
width: 500px;
&.center {
@apply w-full;
.music-lrc {
@apply w-full max-w-3xl mx-auto;
}
.music-lrc-text {
@apply text-center;
}
}
&-name {
@apply font-bold text-2xl pb-1 pt-4;
@@ -309,15 +566,46 @@ defineExpose({
display: none;
@apply flex justify-center items-center;
}
.music-lrc-container {
padding-top: 30vh;
}
.music-lrc {
background-color: inherit;
width: 500px;
height: 550px;
position: relative;
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 10%,
black 90%,
transparent 100%
);
.music-info-header {
@apply mb-8;
.music-info-name {
@apply text-4xl font-bold mb-2;
color: var(--text-color-active);
}
.music-info-singer {
@apply text-base;
color: var(--text-color-primary);
}
}
&-text {
@apply text-2xl cursor-pointer font-bold px-2 py-4;
transition: all 0.3s ease;
background-color: transparent;
font-size: var(--lyric-font-size, 22px) !important;
letter-spacing: var(--lyric-letter-spacing, 0) !important;
line-height: var(--lyric-line-height, 2) !important;
span {
background-clip: text !important;
@@ -370,4 +658,57 @@ defineExpose({
.music-drawer {
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
}
// 添加全局字体样式
:root {
--current-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
#drawer-target {
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full py-8;
animation-duration: 300ms;
.music-lrc-text {
font-family: var(--current-font-family);
}
}
.close-btn {
opacity: 0.3;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
.control-btn {
@apply w-9 h-9 flex items-center justify-center rounded cursor-pointer transition-all duration-300;
background: rgba(142, 142, 142, 0.192);
backdrop-filter: blur(12px);
i {
@apply text-xl;
color: var(--text-color-active);
}
&.pure-mode {
background: transparent;
backdrop-filter: none;
&:not(:hover) {
i {
opacity: 0;
}
}
}
&:hover {
background: rgba(126, 121, 121, 0.2);
i {
opacity: 1;
}
}
}
</style>

View File

@@ -1,9 +1,13 @@
<template>
<div
class="music-play-bar"
:class="
setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')
"
:class="[
setAnimationClass('animate__bounceInUp'),
musicFullVisible ? 'play-bar-opcity' : '',
musicFullVisible && MusicFullRef?.config?.hidePlayBar
? 'animate__animated animate__slideOutDown'
: ''
]"
:style="{
color: musicFullVisible
? textColors.theme === 'dark'
@@ -25,7 +29,7 @@
</div>
<div class="play-bar-img-wrapper" @click="setMusicFull">
<n-image
:src="getImgUrl(playMusic?.picUrl, '500y500')"
:src="getImgUrl(playMusic?.picUrl, '100y100')"
class="play-bar-img"
lazy
preview-disabled
@@ -164,6 +168,7 @@ import {
sound,
textColors
} from '@/hooks/MusicHook';
import { audioService } from '@/services/audioService';
import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron, isMobile, secondToMinute, setAnimationClass } from '@/utils';
import { showShortcutToast } from '@/utils/shortcutToast';
@@ -282,16 +287,34 @@ const MusicFullRef = ref<any>(null);
// 播放暂停按钮事件
const playMusicEvent = async () => {
if (play.value) {
if (sound.value) {
sound.value.pause();
try {
// 检查是否有有效的音乐对象和 URL
if (!playMusic.value?.id || !store.state.playMusicUrl) {
console.warn('No valid music or URL available');
store.commit('setPlay', playMusic.value);
return;
}
store.commit('setPlayMusic', false);
} else {
if (sound.value) {
sound.value.play();
if (play.value) {
// 暂停播放
if (audioService.getCurrentSound()) {
audioService.pause();
store.commit('setPlayMusic', false);
}
} else {
// 开始播放
if (audioService.getCurrentSound()) {
// 如果已经有音频实例,直接播放
audioService.play();
} else {
// 如果没有音频实例,重新创建并播放
await audioService.play(store.state.playMusicUrl, playMusic.value);
}
store.commit('setPlayMusic', true);
}
store.commit('setPlayMusic', true);
} catch (error) {
console.error('播放出错:', error);
store.commit('nextPlay');
}
};
@@ -382,6 +405,16 @@ if (isElectron) {
}
});
}
// 监听播放栏显示状态
watch(
() => MusicFullRef.value?.config?.hidePlayBar,
(newVal) => {
if (newVal && musicFullVisible.value) {
// 使用 animate.css 动画,不需要手动设置样式
}
}
);
</script>
<style lang="scss" scoped>
@@ -395,6 +428,16 @@ if (isElectron) {
z-index: 9999;
animation-duration: 0.5s !important;
&.play-bar-opcity {
@apply bg-transparent !important;
box-shadow: 0 0 20px 5px #0000001d;
}
&.animate__slideOutDown {
animation-duration: 0.3s !important;
pointer-events: none;
}
.music-content {
width: 160px;
@apply ml-4;
@@ -409,11 +452,6 @@ if (isElectron) {
}
}
.play-bar-opcity {
@apply bg-transparent !important;
box-shadow: 0 0 20px 5px #0000001d;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}

View File

@@ -105,7 +105,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import { getUserDetail } from '@/api/login';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue';
@@ -132,8 +132,10 @@ const loadPage = async () => {
const token = localStorage.getItem('token');
if (!token) return;
const { data } = await getUserDetail();
store.state.user = data.profile;
localStorage.setItem('user', JSON.stringify(data.profile));
console.log('data', data);
store.state.user =
data.profile || store.state.user || JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify(store.state.user));
};
loadPage();
@@ -202,10 +204,7 @@ const selectItem = async (key: string) => {
// switch 判断
switch (key) {
case 'logout':
logout().then(() => {
store.commit('logout');
router.push('/login');
});
store.commit('logout');
break;
case 'login':
router.push('/login');

View File

@@ -121,75 +121,95 @@ class AudioService {
}
// 播放控制相关
play(url: string, track: SongResult): Promise<Howl> {
play(url?: string, track?: SongResult): Promise<Howl> {
// 如果没有提供新的 URL 和 track且当前有音频实例则继续播放
if (this.currentSound && !url && !track) {
this.currentSound.play();
return Promise.resolve(this.currentSound);
}
// 如果没有提供必要的参数,返回错误
if (!url || !track) {
return Promise.reject(new Error('Missing required parameters: url and track'));
}
return new Promise((resolve, reject) => {
let retryCount = 0;
const maxRetries = 3;
const maxRetries = 1;
const tryPlay = () => {
// 清理现有的音频实例
if (this.currentSound) {
this.currentSound.unload();
this.currentSound = null;
}
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('音频播放失败,请尝试切换其他歌曲'));
try {
this.currentTrack = track;
this.currentSound = new Howl({
src: [url],
html5: true,
autoplay: true,
volume: localStorage.getItem('volume')
? parseFloat(localStorage.getItem('volume') as string)
: 1,
format: ['mp3', 'aac'],
onloaderror: (_, error) => {
console.error('Audio load error:', error);
if (retryCount < maxRetries) {
retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount);
} else {
reject(new Error('音频加载失败,请尝试切换其他歌曲'));
}
},
onplayerror: (_, error) => {
console.error('Audio play error:', error);
if (retryCount < maxRetries) {
retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount);
} else {
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
}
},
onload: () => {
// 音频加载成功后更新媒体会话
if (track && this.currentSound) {
this.updateMediaSessionMetadata(track);
this.updateMediaSessionPositionState();
this.emit('load');
resolve(this.currentSound);
}
}
});
// 设置音频事件监听
if (this.currentSound) {
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.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);
});
} catch (error) {
console.error('Error creating audio instance:', error);
reject(error);
}
};
tryPlay();
@@ -206,8 +226,12 @@ class AudioService {
stop() {
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.unload();
try {
this.currentSound.stop();
this.currentSound.unload();
} catch (error) {
console.error('Error stopping audio:', error);
}
this.currentSound = null;
}
this.currentTrack = null;
@@ -230,6 +254,16 @@ class AudioService {
}
}
pause() {
if (this.currentSound) {
try {
this.currentSound.pause();
} catch (error) {
console.error('Error pausing audio:', error);
}
}
}
clearAllListeners() {
this.callbacks = {};
}

View File

@@ -1,6 +1,7 @@
import { createStore } from 'vuex';
import setData from '@/../main/set.json';
import { logout } from '@/api/login';
import { getLikedList, likeSong } from '@/api/music';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
@@ -11,9 +12,63 @@ import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
// 默认设置
const defaultSettings = setData;
function isValidUrl(urlString: string): boolean {
try {
return Boolean(new URL(urlString));
} catch (e) {
return false;
}
}
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
try {
const item = localStorage.getItem(key);
if (!item) return defaultValue;
// 尝试解析 JSON
const parsedItem = JSON.parse(item);
// 对于音乐 URL检查是否是有效的 URL 格式或本地文件路径
if (key === 'currentPlayMusicUrl' && typeof parsedItem === 'string') {
if (!parsedItem.startsWith('local://') && !isValidUrl(parsedItem)) {
console.warn(`Invalid URL in localStorage for key ${key}, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
// 对于播放列表,检查是否是数组且每个项都有必要的字段
if (key === 'playList') {
if (!Array.isArray(parsedItem)) {
console.warn(`Invalid playList format in localStorage, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
// 检查每个歌曲对象是否有必要的字段
const isValid = parsedItem.every((item) => item && typeof item === 'object' && 'id' in item);
if (!isValid) {
console.warn(`Invalid song objects in playList, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
// 对于当前播放音乐,检查是否是对象且包含必要的字段
if (key === 'currentPlayMusic') {
if (!parsedItem || typeof parsedItem !== 'object' || !('id' in parsedItem)) {
console.warn(`Invalid currentPlayMusic format in localStorage, using default value`);
localStorage.removeItem(key);
return defaultValue;
}
}
return parsedItem;
} catch (error) {
console.warn(`Error parsing localStorage item for key ${key}:`, error);
// 如果解析失败,删除可能损坏的数据
localStorage.removeItem(key);
return defaultValue;
}
}
export interface State {
@@ -25,7 +80,7 @@ export interface State {
user: any;
playList: SongResult[];
playListIndex: number;
setData: any;
setData: typeof defaultSettings;
lyric: any;
isMobile: boolean;
searchValue: string;
@@ -37,17 +92,19 @@ export interface State {
showUpdateModal: boolean;
showArtistDrawer: boolean;
currentArtistId: number | null;
systemFonts: { label: string; value: string }[];
showDownloadDrawer: boolean;
}
const state: State = {
menus: homeRouter,
play: false,
isPlay: false,
playMusic: {} as SongResult,
playMusicUrl: '',
playMusic: getLocalStorageItem('currentPlayMusic', {} as SongResult),
playMusicUrl: getLocalStorageItem('currentPlayMusicUrl', ''),
user: getLocalStorageItem('user', null),
playList: [],
playListIndex: 0,
playList: getLocalStorageItem('playList', []),
playListIndex: getLocalStorageItem('playListIndex', 0),
setData: defaultSettings,
lyric: {},
isMobile: false,
@@ -59,7 +116,9 @@ const state: State = {
musicFull: false,
showUpdateModal: false,
showArtistDrawer: false,
currentArtistId: null
currentArtistId: null,
systemFonts: [{ label: '系统默认', value: 'system-ui' }],
showDownloadDrawer: false
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -70,12 +129,16 @@ const mutations = {
},
async setPlay(state: State, playMusic: SongResult) {
await handlePlayMusic(state, playMusic);
localStorage.setItem('currentPlayMusic', JSON.stringify(state.playMusic));
localStorage.setItem('currentPlayMusicUrl', state.playMusicUrl);
},
setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay;
localStorage.setItem('isPlaying', isPlay.toString());
},
setPlayMusic(state: State, play: boolean) {
async setPlayMusic(state: State, play: boolean) {
state.play = play;
localStorage.setItem('isPlaying', play.toString());
},
setMusicFull(state: State, musicFull: boolean) {
state.musicFull = musicFull;
@@ -83,6 +146,8 @@ const mutations = {
setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
state.playList = playList;
localStorage.setItem('playList', JSON.stringify(playList));
localStorage.setItem('playListIndex', state.playListIndex.toString());
},
async nextPlay(state: State) {
await nextPlay(state);
@@ -90,6 +155,27 @@ const mutations = {
async prevPlay(state: State) {
await prevPlay(state);
},
// 添加到下一首播放
addToNextPlay(state: State, song: SongResult) {
const playList = [...state.playList];
const currentIndex = state.playListIndex;
// 检查歌曲是否已经在播放列表中
const existingIndex = playList.findIndex((item) => item.id === song.id);
if (existingIndex !== -1) {
// 如果歌曲已经在列表中,将其移动到当前播放歌曲的下一个位置
playList.splice(existingIndex, 1);
}
// 在当前播放歌曲后插入新歌曲
playList.splice(currentIndex + 1, 0, song);
// 更新播放列表
state.playList = playList;
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
localStorage.setItem('playList', JSON.stringify(playList));
localStorage.setItem('playListIndex', state.playListIndex.toString());
},
setSetData(state: State, setData: any) {
state.setData = setData;
if (isElectron) {
@@ -148,9 +234,11 @@ const mutations = {
state.showUpdateModal = value;
},
logout(state: State) {
state.user = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
logout().then(() => {
state.user = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
});
},
setShowArtistDrawer(state, show: boolean) {
state.showArtistDrawer = show;
@@ -160,6 +248,18 @@ const mutations = {
},
setCurrentArtistId(state, id: number) {
state.currentArtistId = id;
},
setSystemFonts(state, fonts: string[]) {
state.systemFonts = [
{ label: '系统默认', value: 'system-ui' },
...fonts.map((font) => ({
label: font,
value: font
}))
];
},
setShowDownloadDrawer(state: State, show: boolean) {
state.showDownloadDrawer = show;
}
};
@@ -167,7 +267,10 @@ const actions = {
initializeSettings({ commit }: { commit: any }) {
if (isElectron) {
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
commit('setSetData', setData || defaultSettings);
commit('setSetData', {
...defaultSettings,
...setData
});
} else {
const savedSettings = localStorage.getItem('appSettings');
if (savedSettings) {
@@ -189,9 +292,9 @@ const actions = {
const localList: number[] = localFavoriteList ? JSON.parse(localFavoriteList) : [];
// 如果用户已登录,尝试获取服务器收藏列表并合并
if (state.user && localStorage.getItem('token')) {
if (state.user && state.user.userId) {
try {
const res = await getLikedList();
const res = await getLikedList(state.user.userId);
if (res.data?.ids) {
// 合并本地和服务器的收藏列表,去重
const serverList = res.data.ids.reverse();
@@ -213,6 +316,50 @@ const actions = {
},
showArtist({ commit }, id: number) {
commit('setCurrentArtistId', id);
},
async initializeSystemFonts({ commit, state }) {
// 如果已经有字体列表(不只是默认字体),则不重复获取
if (state.systemFonts.length > 1) return;
try {
const fonts = await window.api.invoke('get-system-fonts');
commit('setSystemFonts', fonts);
} catch (error) {
console.error('获取系统字体失败:', error);
}
},
async initializePlayState({ state, commit }: { state: State; commit: any }) {
const savedPlayList = getLocalStorageItem('playList', []);
const savedPlayMusic = getLocalStorageItem('currentPlayMusic', null);
if (savedPlayList.length > 0) {
commit('setPlayList', savedPlayList);
}
if (savedPlayMusic && Object.keys(savedPlayMusic).length > 0) {
// 不直接使用保存的 URL而是重新获取
try {
// 使用 handlePlayMusic 来重新获取音乐 URL
// 根据自动播放设置决定是否恢复播放状态
const shouldAutoPlay = state.setData.autoPlay;
if (shouldAutoPlay) {
await handlePlayMusic(state, savedPlayMusic);
}
state.play = shouldAutoPlay;
state.isPlay = true;
} catch (error) {
console.error('重新获取音乐链接失败:', error);
// 清除无效的播放状态
state.play = false;
state.isPlay = false;
state.playMusic = {} as SongResult;
state.playMusicUrl = '';
localStorage.removeItem('currentPlayMusic');
localStorage.removeItem('currentPlayMusicUrl');
localStorage.removeItem('isPlaying');
}
}
}
};

View File

@@ -1,8 +1,17 @@
import { useDebounceFn } from '@vueuse/core';
import tinycolor from 'tinycolor2';
interface IColor {
backgroundColor: string;
primaryColor: string;
}
interface ITextColors {
primary: string;
active: string;
theme: string;
}
export const getImageLinearBackground = async (imageSrc: string): Promise<IColor> => {
try {
const primaryColor = await getImagePrimaryColor(imageSrc);
@@ -96,126 +105,43 @@ const getAverageColor = (data: Uint8ClampedArray): number[] => {
};
const generateGradientBackground = (color: string): string => {
const [r, g, b] = color.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [h, s, l] = rgbToHsl(r, g, b);
const tc = tinycolor(color);
const hsl = tc.toHsl();
// 增加亮度和暗度的差异
const lightL = Math.min(l + 0.2, 0.95);
const darkL = Math.max(l - 0.3, 0.05);
const midL = (lightL + darkL) / 2;
const lightColor = tinycolor({ h: hsl.h, s: hsl.s * 0.8, l: Math.min(hsl.l + 0.2, 0.95) });
const midColor = tinycolor({ h: hsl.h, s: hsl.s, l: hsl.l });
const darkColor = tinycolor({
h: hsl.h,
s: Math.min(hsl.s * 1.2, 1),
l: Math.max(hsl.l - 0.3, 0.05)
});
// 调整饱和度以增强效果
const lightS = Math.min(s * 0.8, 1);
const darkS = Math.min(s * 1.2, 1);
const [lightR, lightG, lightB] = hslToRgb(h, lightS, lightL);
const [midR, midG, midB] = hslToRgb(h, s, midL);
const [darkR, darkG, darkB] = hslToRgb(h, darkS, darkL);
const lightColor = `rgb(${lightR}, ${lightG}, ${lightB})`;
const midColor = `rgb(${midR}, ${midG}, ${midB})`;
const darkColor = `rgb(${darkR}, ${darkG}, ${darkB})`;
// 使用三个颜色点创建更丰富的渐变
return `linear-gradient(to bottom, ${lightColor} 0%, ${midColor} 50%, ${darkColor} 100%)`;
};
// Helper functions (unchanged)
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
// 添加新的接口
interface ITextColors {
primary: string;
active: string;
theme: string;
}
// 添加新的函数
export const calculateBrightness = (r: number, g: number, b: number): number => {
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return `linear-gradient(to bottom, ${lightColor.toRgbString()} 0%, ${midColor.toRgbString()} 50%, ${darkColor.toRgbString()} 100%)`;
};
export const parseGradient = (gradientStr: string) => {
const matches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
if (!matches) return [];
return matches.map((rgb) => {
const [r, g, b] = rgb.match(/\d+/g)!.map(Number);
return { r, g, b };
if (!gradientStr) return [];
// 处理非渐变色
if (!gradientStr.startsWith('linear-gradient')) {
const color = tinycolor(gradientStr);
if (color.isValid()) {
const rgb = color.toRgb();
return [{ r: rgb.r, g: rgb.g, b: rgb.b }];
}
return [];
}
// 处理渐变色,支持 rgb、rgba 和十六进制颜色
const colorMatches = gradientStr.match(/(?:(?:rgb|rgba)\([^)]+\)|#[0-9a-fA-F]{3,8})/g) || [];
return colorMatches.map((color) => {
const tc = tinycolor(color);
const rgb = tc.toRgb();
return { r: rgb.r, g: rgb.g, b: rgb.b };
});
};
export const interpolateRGB = (start: number, end: number, progress: number) => {
return Math.round(start + (end - start) * progress);
};
export const createGradientString = (
colors: { r: number; g: number; b: number }[],
percentages = [0, 50, 100]
) => {
return `linear-gradient(to bottom, ${colors
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
.join(', ')})`;
};
export const getTextColors = (gradient: string = ''): ITextColors => {
const defaultColors = {
primary: 'rgba(255, 255, 255, 0.54)',
@@ -228,9 +154,9 @@ export const getTextColors = (gradient: string = ''): ITextColors => {
const colors = parseGradient(gradient);
if (!colors.length) return defaultColors;
const mainColor = colors[1] || colors[0];
const brightness = calculateBrightness(mainColor.r, mainColor.g, mainColor.b);
const isDark = brightness > 0.6;
const mainColor = colors.length === 1 ? colors[0] : colors[1] || colors[0];
const tc = tinycolor(mainColor);
const isDark = tc.getBrightness() > 155; // tinycolor 的亮度范围是 0-255
return {
primary: isDark ? 'rgba(0, 0, 0, 0.54)' : 'rgba(255, 255, 255, 0.54)',
@@ -243,35 +169,130 @@ export const getHoverBackgroundColor = (isDark: boolean): string => {
return isDark ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.08)';
};
export const animateGradient = (
oldGradient: string,
newGradient: string,
onUpdate: (gradient: string) => void,
duration = 1000
) => {
const startColors = parseGradient(oldGradient);
const endColors = parseGradient(newGradient);
if (startColors.length !== endColors.length) return null;
export const animateGradient = (() => {
let currentAnimation: number | null = null;
let isAnimating = false;
let lastProgress = 0;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentColors = startColors.map((startColor, i) => ({
r: interpolateRGB(startColor.r, endColors[i].r, progress),
g: interpolateRGB(startColor.g, endColors[i].g, progress),
b: interpolateRGB(startColor.b, endColors[i].b, progress)
}));
onUpdate(createGradientString(currentColors));
if (progress < 1) {
return requestAnimationFrame(animate);
}
return null;
const validateColors = (colors: ReturnType<typeof parseGradient>) => {
return colors.every(
(color) =>
typeof color.r === 'number' &&
typeof color.g === 'number' &&
typeof color.b === 'number' &&
!Number.isNaN(color.r) &&
!Number.isNaN(color.g) &&
!Number.isNaN(color.b)
);
};
return requestAnimationFrame(animate);
const easeInOutCubic = (x: number): number => {
return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
};
const animate = (
oldGradient: string,
newGradient: string,
onUpdate: (gradient: string) => void,
duration = 300
) => {
// 如果新旧渐变色相同,不执行动画
if (oldGradient === newGradient) {
return null;
}
// 如果正在动画中,取消当前动画
if (currentAnimation !== null) {
cancelAnimationFrame(currentAnimation);
currentAnimation = null;
}
// 解析颜色
const startColors = parseGradient(oldGradient);
const endColors = parseGradient(newGradient);
// 验证颜色数组
if (
!startColors.length ||
!endColors.length ||
!validateColors(startColors) ||
!validateColors(endColors)
) {
console.warn('Invalid color values detected');
onUpdate(newGradient); // 直接更新到目标颜色
return null;
}
// 如果颜色数量不匹配,直接更新到目标颜色
if (startColors.length !== endColors.length) {
onUpdate(newGradient);
return null;
}
isAnimating = true;
const startTime = performance.now();
const animateFrame = (currentTime: number) => {
if (!isAnimating) return null;
const elapsed = currentTime - startTime;
const rawProgress = Math.min(elapsed / duration, 1);
// 使用缓动函数使动画更平滑
const progress = easeInOutCubic(rawProgress);
try {
// 使用上一帧的进度来平滑过渡
const effectiveProgress = lastProgress + (progress - lastProgress) * 0.6;
lastProgress = effectiveProgress;
const currentColors = startColors.map((startColor, i) => {
const start = tinycolor(startColor);
const end = tinycolor(endColors[i]);
return tinycolor.mix(start, end, effectiveProgress * 100);
});
const gradientString = createGradientString(
currentColors.map((c) => {
const rgb = c.toRgb();
return { r: rgb.r, g: rgb.g, b: rgb.b };
})
);
onUpdate(gradientString);
if (rawProgress < 1) {
currentAnimation = requestAnimationFrame(animateFrame);
return currentAnimation;
}
// 确保最终颜色正确
onUpdate(newGradient);
isAnimating = false;
currentAnimation = null;
lastProgress = 0;
return null;
} catch (error) {
console.error('Animation error:', error);
onUpdate(newGradient);
isAnimating = false;
currentAnimation = null;
lastProgress = 0;
return null;
}
};
currentAnimation = requestAnimationFrame(animateFrame);
return currentAnimation;
};
// 使用更短的防抖时间
return useDebounceFn(animate, 50);
})();
export const createGradientString = (
colors: { r: number; g: number; b: number }[],
percentages = [0, 50, 100]
) => {
return `linear-gradient(to bottom, ${colors
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
.join(', ')})`;
};

View File

@@ -1,12 +1,9 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { createDiscreteApi } from 'naive-ui';
import store from '@/store';
import { isElectron } from '.';
const { notification } = createDiscreteApi(['notification']);
let setData: any = null;
const getSetData = () => {
if (window.electron) {
@@ -44,19 +41,14 @@ request.interceptors.request.use(
// 在请求发送之前做一些处理
// 在get请求params中添加timestamp
if (config.method === 'get') {
config.params = {
...config.params,
timestamp: Date.now()
};
const token = localStorage.getItem('token');
if (token) {
config.params.cookie = `${token} os=pc;`;
} else {
config.params.cookie = 'os=pc;';
}
config.params = {
...config.params,
timestamp: Date.now()
};
const token = localStorage.getItem('token');
if (token) {
config.params.cookie = config.params.cookie !== undefined ? config.params.cookie : token;
}
if (isElectron) {
const proxyConfig = setData?.proxyConfig;
if (proxyConfig?.enable && ['http', 'https'].includes(proxyConfig?.protocol)) {
@@ -75,6 +67,8 @@ request.interceptors.request.use(
}
);
const NO_RETRY_URLS = ['暂时没有'];
// 响应拦截器
request.interceptors.response.use(
(response) => {
@@ -93,28 +87,16 @@ request.interceptors.response.use(
if (error.response?.status === 301) {
// 使用 store mutation 清除用户信息
store.commit('logout');
// 如果还可以重试,则重新发起请求
if (config.retryCount === undefined || config.retryCount < MAX_RETRIES) {
config.retryCount = (config.retryCount || 1) + 1;
console.log(`301 状态码,清除登录信息后重试第 ${config.retryCount}`);
notification.error({
content: '登录状态失效,请重新登录',
meta: '请重新登录',
duration: 2500,
keepAliveOnHover: true
});
// 延迟重试
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
// 重新发起请求
return request(config);
}
console.log(`301 状态码,清除登录信息后重试第 ${config.retryCount}`);
config.retryCount = 3;
}
// 检查是否还可以重试
if (config.retryCount !== undefined && config.retryCount < MAX_RETRIES) {
if (
config.retryCount !== undefined &&
config.retryCount < MAX_RETRIES &&
!NO_RETRY_URLS.includes(config.url as string)
) {
config.retryCount++;
console.log(`请求重试第 ${config.retryCount}`);

View File

@@ -272,9 +272,9 @@ const handleScroll = (e: any) => {
}
};
onMounted(() => {
store.dispatch('initializeFavoriteList');
getFavoriteSongs();
onMounted(async () => {
await store.dispatch('initializeFavoriteList');
await getFavoriteSongs();
});
// 监听收藏列表变化
@@ -298,7 +298,7 @@ const getItemAnimationDelay = (index: number) => {
const router = useRouter();
const handleMore = () => {
router.push('/favorite');
router.push('/history');
};
// 全选相关

View File

@@ -58,7 +58,7 @@ const timerIsQr = (key: string) => {
localStorage.setItem('token', data.cookie);
const user = await getUserDetail();
store.state.user = user.data.profile;
localStorage.setItem('user', JSON.stringify(store.state.user));
localStorage.setItem('user', JSON.stringify(user.data.profile));
message.success('登录成功');
clearInterval(timer);

View File

@@ -31,6 +31,51 @@
</n-switch>
</div>
<div class="set-item">
<div>
<div class="set-item-title">字体设置</div>
<div class="set-item-content">选择字体优先使用排在前面的字体</div>
</div>
<div class="flex gap-2">
<n-radio-group v-model:value="setData.fontScope" class="mt-2">
<n-radio key="global" value="global">全局</n-radio>
<n-radio key="lyric" value="lyric">仅歌词</n-radio>
</n-radio-group>
<n-select
v-model:value="selectedFonts"
:options="systemFonts"
filterable
multiple
placeholder="选择字体"
style="width: 300px"
:render-label="renderFontLabel"
>
</n-select>
</div>
</div>
<div v-if="selectedFonts.length > 0" class="font-preview-container">
<div class="font-preview-title">字体预览</div>
<div class="font-preview" :style="{ fontFamily: setData.fontFamily }">
<div class="preview-item">
<div class="preview-label">中文</div>
<div class="preview-text">静夜思 床前明月光 疑是地上霜</div>
</div>
<div class="preview-item">
<div class="preview-label">English</div>
<div class="preview-text">The quick brown fox jumps over the lazy dog</div>
</div>
<div class="preview-item">
<div class="preview-label">日本語</div>
<div class="preview-text">あいうえお かきくけこ さしすせそ</div>
</div>
<div class="preview-item">
<div class="preview-label">한국어</div>
<div class="preview-text">가나다라마 바사아자차 카타파하</div>
</div>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">动画速度</div>
@@ -91,6 +136,17 @@
style="width: 160px"
/>
</div>
<div class="set-item">
<div>
<div class="set-item-title">自动播放</div>
<div class="set-item-content">重新打开应用时是否自动继续播放</div>
</div>
<n-switch v-model:value="setData.autoPlay">
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
</div>
</div>
</div>
@@ -124,6 +180,24 @@
<n-button size="small" @click="showShortcutModal = true">配置</n-button>
</div>
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">下载管理</div>
<div class="set-item-content">
<n-switch v-model:value="setData.alwaysShowDownloadButton" class="mr-2">
<template #checked>显示</template>
<template #unchecked>隐藏</template>
</n-switch>
是否始终显示下载列表按钮
</div>
</div>
<div class="flex items-center gap-2">
<n-button size="small" @click="store.commit('setShowDownloadDrawer', true)">
打开下载管理
</n-button>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">下载目录</div>
@@ -366,7 +440,7 @@
<script setup lang="ts">
import type { FormRules } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, h, nextTick, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import localData from '@/../main/set.json';
@@ -510,8 +584,55 @@ const proxyRules: FormRules = {
}
};
// 初始化时从store获取代理配置
onMounted(() => {
// 使用 store 中的字体列表
const systemFonts = computed(() => store.state.systemFonts);
// 已选择的字体列表
const selectedFonts = ref<string[]>([]);
// 自定义渲染函数
const renderFontLabel = (option: { label: string; value: string }) => {
return h('span', { style: { fontFamily: option.value } }, option.label);
};
// 监听字体选择变化
watch(
selectedFonts,
(newFonts) => {
// 如果没有选择任何字体,使用系统默认字体
if (newFonts.length === 0) {
store.commit('setSetData', {
...setData.value,
fontFamily: 'system-ui'
});
return;
}
// 将选择的字体组合成字体列表
store.commit('setSetData', {
...setData.value,
fontFamily: newFonts.join(',')
});
},
{ deep: true }
);
// 初始化已选择的字体
watch(
() => setData.value.fontFamily,
(newFont) => {
if (newFont) {
if (newFont === 'system-ui') {
selectedFonts.value = [];
} else {
selectedFonts.value = newFont.split(',');
}
}
},
{ immediate: true }
);
// 初始化时从store获取配置
onMounted(async () => {
checkForUpdates();
if (setData.value.proxyConfig) {
proxyForm.value = { ...setData.value.proxyConfig };
@@ -625,8 +746,6 @@ const clearCache = async () => {
localStorage.removeItem('favoriteList');
break;
case 'user':
localStorage.removeItem('user');
localStorage.removeItem('token');
store.commit('logout');
break;
case 'settings':
@@ -734,8 +853,9 @@ const scrollToSection = async (sectionId: string) => {
};
// 处理滚动,更新当前激活的分类
const handleScroll = () => {
const scrollTop = scrollbarRef.value?.containerRef.scrollTop;
const handleScroll = (e: any) => {
const { scrollTop } = e.target;
const sections = [
{ id: 'basic', ref: basicRef },
{ id: 'playback', ref: playbackRef },
@@ -746,18 +866,30 @@ const handleScroll = () => {
{ id: 'donation', ref: donationRef }
];
const activeSection = sections[0].id;
let lastValidSection = activeSection;
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;
if (scrollTop >= offsetTop - 100) {
lastValidSection = section.id;
}
}
}
if (lastValidSection !== currentSection.value) {
currentSection.value = lastValidSection;
}
};
// 初始化时设置当前激活的分类
onMounted(() => {
// 延迟一帧等待 DOM 完全渲染
nextTick(() => {
handleScroll({ target: { scrollTop: 0 } });
});
});
</script>
<style lang="scss" scoped>
@@ -830,4 +962,34 @@ const handleScroll = () => {
@apply text-green-500 bg-green-50 dark:bg-green-900;
}
}
.font-preview-container {
@apply mt-4 p-4 rounded-lg;
@apply bg-gray-50 dark:bg-dark-100;
@apply border border-gray-200 dark:border-gray-700;
.font-preview-title {
@apply text-sm font-medium mb-3;
@apply text-gray-600 dark:text-gray-300;
}
.font-preview {
@apply space-y-3;
.preview-item {
@apply flex flex-col gap-1;
.preview-label {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.preview-text {
@apply text-base text-gray-900 dark:text-gray-100;
@apply p-2 rounded;
@apply bg-white dark:bg-dark;
@apply border border-gray-200 dark:border-gray-700;
}
}
}
}
</style>

View File

@@ -84,16 +84,20 @@
:song-list="list?.tracks || []"
:list-info="list"
:loading="listLoading"
:can-remove="true"
@remove-song="handleRemoveFromPlaylist"
/>
</div>
</template>
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getListDetail } from '@/api/list';
import { updatePlaylistTracks } from '@/api/music';
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
@@ -116,6 +120,7 @@ const mounted = ref(true);
const isShowList = ref(false);
const list = ref<Playlist>();
const listLoading = ref(false);
const message = useMessage();
const user = computed(() => store.state.user);
@@ -147,6 +152,10 @@ const loadPage = async () => {
// 检查登录状态
if (!checkLoginStatus()) return;
await loadData();
};
const loadData = async () => {
try {
infoLoading.value = true;
@@ -183,8 +192,10 @@ const loadPage = async () => {
watch(
() => router.currentRoute.value.path,
(newPath) => {
console.log('newPath', newPath);
if (newPath === '/user') {
checkLoginStatus();
loadData();
}
}
);
@@ -215,11 +226,41 @@ const showPlaylist = async (id: number, name: string) => {
listLoading.value = true;
list.value = {
name
name,
id
} as Playlist;
await loadPlaylistDetail(id);
listLoading.value = false;
};
// 加载歌单详情
const loadPlaylistDetail = async (id: number) => {
const { data } = await getListDetail(id);
list.value = data.playlist;
listLoading.value = false;
};
// 从歌单中删除歌曲
const handleRemoveFromPlaylist = async (songId: number) => {
if (!list.value?.id) return;
try {
const res = await updatePlaylistTracks({
op: 'del',
pid: list.value.id,
tracks: songId.toString()
});
if (res.status === 200) {
message.success('删除成功');
// 重新加载歌单详情
await loadPlaylistDetail(list.value.id);
} else {
throw new Error(res.data?.msg || '删除失败');
}
} catch (error: any) {
console.error('删除歌曲失败:', error);
message.error(error.message || '删除失败');
}
};
const handlePlay = () => {