mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 23:11:00 +08:00
feat: 添加本地音乐扫描播放功能
This commit is contained in:
@@ -268,5 +268,6 @@ export default {
|
||||
mv: 'MV',
|
||||
home: 'Home',
|
||||
search: 'Search',
|
||||
album: 'Album'
|
||||
album: 'Album',
|
||||
localMusic: 'Local Music'
|
||||
};
|
||||
|
||||
13
src/i18n/lang/en-US/localMusic.ts
Normal file
13
src/i18n/lang/en-US/localMusic.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: 'Local Music',
|
||||
scanFolder: 'Scan Folder',
|
||||
removeFolder: 'Remove Folder',
|
||||
scanning: 'Scanning...',
|
||||
scanComplete: 'Scan Complete',
|
||||
playAll: 'Play All',
|
||||
search: 'Search local music',
|
||||
emptyState: 'No local music found. Please select a folder to scan.',
|
||||
fileNotFound: 'File not found or has been moved',
|
||||
rescan: 'Rescan',
|
||||
songCount: '{count} songs'
|
||||
};
|
||||
@@ -268,5 +268,6 @@ export default {
|
||||
mv: 'MV',
|
||||
home: 'ホーム',
|
||||
search: '検索',
|
||||
album: 'アルバム'
|
||||
album: 'アルバム',
|
||||
localMusic: 'ローカル音楽'
|
||||
};
|
||||
|
||||
13
src/i18n/lang/ja-JP/localMusic.ts
Normal file
13
src/i18n/lang/ja-JP/localMusic.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: 'ローカル音楽',
|
||||
scanFolder: 'フォルダをスキャン',
|
||||
removeFolder: 'フォルダを削除',
|
||||
scanning: 'スキャン中...',
|
||||
scanComplete: 'スキャン完了',
|
||||
playAll: 'すべて再生',
|
||||
search: 'ローカル音楽を検索',
|
||||
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
|
||||
fileNotFound: 'ファイルが見つからないか、移動されました',
|
||||
rescan: '再スキャン',
|
||||
songCount: '{count} 曲'
|
||||
};
|
||||
@@ -267,5 +267,6 @@ export default {
|
||||
mv: 'MV',
|
||||
home: '홈',
|
||||
search: '검색',
|
||||
album: '앨범'
|
||||
album: '앨범',
|
||||
localMusic: '로컬 음악'
|
||||
};
|
||||
|
||||
13
src/i18n/lang/ko-KR/localMusic.ts
Normal file
13
src/i18n/lang/ko-KR/localMusic.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: '로컬 음악',
|
||||
scanFolder: '폴더 스캔',
|
||||
removeFolder: '폴더 제거',
|
||||
scanning: '스캔 중...',
|
||||
scanComplete: '스캔 완료',
|
||||
playAll: '모두 재생',
|
||||
search: '로컬 음악 검색',
|
||||
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
|
||||
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
|
||||
rescan: '다시 스캔',
|
||||
songCount: '{count}곡'
|
||||
};
|
||||
@@ -261,5 +261,6 @@ export default {
|
||||
mv: 'MV',
|
||||
home: '首页',
|
||||
search: '搜索',
|
||||
album: '专辑'
|
||||
album: '专辑',
|
||||
localMusic: '本地音乐'
|
||||
};
|
||||
|
||||
13
src/i18n/lang/zh-CN/localMusic.ts
Normal file
13
src/i18n/lang/zh-CN/localMusic.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: '本地音乐',
|
||||
scanFolder: '扫描文件夹',
|
||||
removeFolder: '移除文件夹',
|
||||
scanning: '正在扫描...',
|
||||
scanComplete: '扫描完成',
|
||||
playAll: '播放全部',
|
||||
search: '搜索本地音乐',
|
||||
emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
|
||||
fileNotFound: '文件不存在或已被移动',
|
||||
rescan: '重新扫描',
|
||||
songCount: '{count} 首歌曲'
|
||||
};
|
||||
@@ -261,5 +261,6 @@ export default {
|
||||
mv: 'MV',
|
||||
home: '首頁',
|
||||
search: '搜尋',
|
||||
album: '專輯'
|
||||
album: '專輯',
|
||||
localMusic: '本地音樂'
|
||||
};
|
||||
|
||||
13
src/i18n/lang/zh-Hant/localMusic.ts
Normal file
13
src/i18n/lang/zh-Hant/localMusic.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
title: '本地音樂',
|
||||
scanFolder: '掃描資料夾',
|
||||
removeFolder: '移除資料夾',
|
||||
scanning: '正在掃描...',
|
||||
scanComplete: '掃描完成',
|
||||
playAll: '播放全部',
|
||||
search: '搜尋本地音樂',
|
||||
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
|
||||
fileNotFound: '檔案不存在或已被移動',
|
||||
rescan: '重新掃描',
|
||||
songCount: '{count} 首歌曲'
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { loadLyricWindow } from './lyric';
|
||||
import { initializeConfig } from './modules/config';
|
||||
import { initializeFileManager } from './modules/fileManager';
|
||||
import { initializeFonts } from './modules/fonts';
|
||||
import { initializeLocalMusicScanner } from './modules/localMusicScanner';
|
||||
import { initializeLoginWindow } from './modules/loginWindow';
|
||||
import { initLxMusicHttp } from './modules/lxMusicHttp';
|
||||
import { initializeOtherApi } from './modules/otherApi';
|
||||
@@ -48,6 +49,8 @@ function initialize(configStore: any) {
|
||||
initializeFonts();
|
||||
// 初始化登录窗口
|
||||
initializeLoginWindow();
|
||||
// 初始化本地音乐扫描模块
|
||||
initializeLocalMusicScanner();
|
||||
|
||||
// 创建主窗口
|
||||
mainWindow = createMainWindow(icon);
|
||||
|
||||
@@ -644,52 +644,61 @@ async function downloadMusic(
|
||||
if (songInfo?.picUrl || songInfo?.al?.picUrl) {
|
||||
const picUrl = songInfo.picUrl || songInfo.al?.picUrl;
|
||||
if (picUrl && picUrl !== '/images/default_cover.png') {
|
||||
const coverResponse = await axios({
|
||||
url: picUrl.replace('http://', 'https://'),
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const originalCoverBuffer = Buffer.from(coverResponse.data);
|
||||
const TWO_MB = 2 * 1024 * 1024;
|
||||
// 检查图片大小是否超过2MB
|
||||
if (originalCoverBuffer.length > TWO_MB) {
|
||||
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
|
||||
try {
|
||||
// 使用 Electron nativeImage 进行压缩
|
||||
const image = nativeImage.createFromBuffer(originalCoverBuffer);
|
||||
const size = image.getSize();
|
||||
|
||||
// 计算新尺寸,保持宽高比,最大1600px
|
||||
const maxSize = 1600;
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
|
||||
if (size.width > maxSize || size.height > maxSize) {
|
||||
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
|
||||
newWidth = Math.round(size.width * ratio);
|
||||
newHeight = Math.round(size.height * ratio);
|
||||
}
|
||||
|
||||
// 调整大小并转换为 JPEG 格式(质量 80)
|
||||
const resizedImage = image.resize({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
quality: 'good'
|
||||
});
|
||||
coverImageBuffer = resizedImage.toJPEG(80);
|
||||
|
||||
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
|
||||
} catch (compressionError) {
|
||||
console.error('封面图压缩失败,将使用原图:', compressionError);
|
||||
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
|
||||
// 处理 base64 Data URL(本地音乐扫描提取的封面)
|
||||
if (picUrl.startsWith('data:')) {
|
||||
const base64Match = picUrl.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
coverImageBuffer = Buffer.from(base64Match[1], 'base64');
|
||||
console.log('从 base64 Data URL 提取封面');
|
||||
}
|
||||
} else {
|
||||
// 如果图片不大于2MB,直接使用原图
|
||||
coverImageBuffer = originalCoverBuffer;
|
||||
const coverResponse = await axios({
|
||||
url: picUrl.replace('http://', 'https://'),
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const originalCoverBuffer = Buffer.from(coverResponse.data);
|
||||
const TWO_MB = 2 * 1024 * 1024;
|
||||
// 检查图片大小是否超过2MB
|
||||
if (originalCoverBuffer.length > TWO_MB) {
|
||||
const originalSizeMB = (originalCoverBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图大于2MB (${originalSizeMB} MB),开始压缩...`);
|
||||
try {
|
||||
// 使用 Electron nativeImage 进行压缩
|
||||
const image = nativeImage.createFromBuffer(originalCoverBuffer);
|
||||
const size = image.getSize();
|
||||
|
||||
// 计算新尺寸,保持宽高比,最大1600px
|
||||
const maxSize = 1600;
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
|
||||
if (size.width > maxSize || size.height > maxSize) {
|
||||
const ratio = Math.min(maxSize / size.width, maxSize / size.height);
|
||||
newWidth = Math.round(size.width * ratio);
|
||||
newHeight = Math.round(size.height * ratio);
|
||||
}
|
||||
|
||||
// 调整大小并转换为 JPEG 格式(质量 80)
|
||||
const resizedImage = image.resize({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
quality: 'good'
|
||||
});
|
||||
coverImageBuffer = resizedImage.toJPEG(80);
|
||||
|
||||
const compressedSizeMB = (coverImageBuffer.length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`封面图压缩完成,新大小: ${compressedSizeMB} MB`);
|
||||
} catch (compressionError) {
|
||||
console.error('封面图压缩失败,将使用原图:', compressionError);
|
||||
coverImageBuffer = originalCoverBuffer; // 如果压缩失败,则回退使用原始图片
|
||||
}
|
||||
} else {
|
||||
// 如果图片不大于2MB,直接使用原图
|
||||
coverImageBuffer = originalCoverBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('封面已准备好,将写入元数据');
|
||||
|
||||
245
src/main/modules/localMusicScanner.ts
Normal file
245
src/main/modules/localMusicScanner.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// 本地音乐扫描模块
|
||||
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as mm from 'music-metadata';
|
||||
import * as path from 'path';
|
||||
|
||||
/** 支持的音频文件格式 */
|
||||
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
|
||||
|
||||
/**
|
||||
* 主进程返回的原始音乐元数据
|
||||
* 与渲染进程 LocalMusicMeta 类型保持一致
|
||||
*/
|
||||
type LocalMusicMeta = {
|
||||
/** 文件绝对路径 */
|
||||
filePath: string;
|
||||
/** 歌曲标题 */
|
||||
title: string;
|
||||
/** 艺术家名称 */
|
||||
artist: string;
|
||||
/** 专辑名称 */
|
||||
album: string;
|
||||
/** 时长(毫秒) */
|
||||
duration: number;
|
||||
/** base64 Data URL 格式的封面图片,无封面时为 null */
|
||||
cover: string | null;
|
||||
/** LRC 格式歌词文本,无歌词时为 null */
|
||||
lyrics: string | null;
|
||||
/** 文件大小(字节) */
|
||||
fileSize: number;
|
||||
/** 文件修改时间戳 */
|
||||
modifiedTime: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断文件扩展名是否为支持的音频格式
|
||||
* @param ext 文件扩展名(含点号,如 .mp3)
|
||||
* @returns 是否为支持的格式
|
||||
*/
|
||||
function isSupportedFormat(ext: string): boolean {
|
||||
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取歌曲标题(去除目录和扩展名)
|
||||
* @param filePath 文件路径
|
||||
* @returns 歌曲标题
|
||||
*/
|
||||
function extractTitleFromFilename(filePath: string): string {
|
||||
const basename = path.basename(filePath);
|
||||
const dotIndex = basename.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
return basename.slice(0, dotIndex);
|
||||
}
|
||||
return basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将封面图片数据转换为 base64 Data URL
|
||||
* @param picture music-metadata 解析出的封面图片对象
|
||||
* @returns base64 Data URL 字符串,转换失败返回 null
|
||||
*/
|
||||
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
|
||||
if (!picture) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const mime = picture.format ?? 'image/jpeg';
|
||||
const base64 = Buffer.from(picture.data).toString('base64');
|
||||
return `data:${mime};base64,${base64}`;
|
||||
} catch (error) {
|
||||
console.error('封面提取失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 music-metadata 解析结果中提取歌词文本
|
||||
* @param lyrics music-metadata 解析出的歌词数组
|
||||
* @returns 歌词文本,提取失败返回 null
|
||||
*/
|
||||
function extractLyrics(lyrics: mm.ILyricsTag[] | undefined): string | null {
|
||||
if (!lyrics || lyrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 优先取第一条歌词的文本内容
|
||||
const firstLyric = lyrics[0];
|
||||
return firstLyric?.text ?? null;
|
||||
} catch (error) {
|
||||
console.error('歌词提取失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归扫描指定文件夹,返回所有支持格式的音乐文件路径
|
||||
* @param folderPath 要扫描的文件夹路径
|
||||
* @returns 音乐文件绝对路径列表
|
||||
*/
|
||||
async function scanMusicFiles(folderPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
|
||||
// 检查文件夹是否存在
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
throw new Error(`文件夹不存在: ${folderPath}`);
|
||||
}
|
||||
|
||||
// 检查是否为目录
|
||||
const stat = await fs.promises.stat(folderPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`路径不是文件夹: ${folderPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历目录
|
||||
* @param dirPath 当前目录路径
|
||||
*/
|
||||
async function walkDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// 递归扫描子目录
|
||||
await walkDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// 检查文件扩展名是否为支持的音频格式
|
||||
const ext = path.extname(entry.name);
|
||||
if (isSupportedFormat(ext)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 单个目录读取失败不中断整体扫描,记录错误后继续
|
||||
console.error(`扫描目录失败: ${dirPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await walkDirectory(folderPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个音乐文件的元数据
|
||||
* 解析失败时使用 fallback 默认值(文件名作标题),不抛出异常
|
||||
* @param filePath 音乐文件绝对路径
|
||||
* @returns 音乐元数据对象
|
||||
*/
|
||||
async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
|
||||
// 获取文件信息(大小和修改时间)
|
||||
let fileSize = 0;
|
||||
let modifiedTime = 0;
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
fileSize = stat.size;
|
||||
modifiedTime = stat.mtimeMs;
|
||||
} catch (error) {
|
||||
console.error(`获取文件信息失败: ${filePath}`, error);
|
||||
}
|
||||
|
||||
// 构建 fallback 默认值
|
||||
const fallback: LocalMusicMeta = {
|
||||
filePath,
|
||||
title: extractTitleFromFilename(filePath),
|
||||
artist: '未知艺术家',
|
||||
album: '未知专辑',
|
||||
duration: 0,
|
||||
cover: null,
|
||||
lyrics: null,
|
||||
fileSize,
|
||||
modifiedTime
|
||||
};
|
||||
|
||||
try {
|
||||
const metadata = await mm.parseFile(filePath);
|
||||
const { common, format } = metadata;
|
||||
|
||||
return {
|
||||
filePath,
|
||||
title: common.title || fallback.title,
|
||||
artist: common.artist || fallback.artist,
|
||||
album: common.album || fallback.album,
|
||||
duration: format.duration ? Math.round(format.duration * 1000) : 0,
|
||||
cover: extractCoverAsDataUrl(common.picture?.[0]),
|
||||
lyrics: extractLyrics(common.lyrics),
|
||||
fileSize,
|
||||
modifiedTime
|
||||
};
|
||||
} catch (error) {
|
||||
// 解析失败使用 fallback,不中断流程
|
||||
console.error(`元数据解析失败,使用 fallback: ${filePath}`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析音乐文件元数据
|
||||
* 内部逐个调用 parseMetadata,单文件失败不影响其他文件
|
||||
* @param filePaths 音乐文件路径列表
|
||||
* @returns 元数据对象列表
|
||||
*/
|
||||
async function batchParseMetadata(filePaths: string[]): Promise<LocalMusicMeta[]> {
|
||||
const results: LocalMusicMeta[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const meta = await parseMetadata(filePath);
|
||||
results.push(meta);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化本地音乐扫描模块
|
||||
* 注册 IPC handler,供渲染进程调用
|
||||
*/
|
||||
export function initializeLocalMusicScanner(): void {
|
||||
// 扫描指定文件夹中的音乐文件
|
||||
ipcMain.handle('scan-local-music', async (_, folderPath: string) => {
|
||||
try {
|
||||
const files = await scanMusicFiles(folderPath);
|
||||
return { files, count: files.length };
|
||||
} catch (error: any) {
|
||||
console.error('扫描本地音乐失败:', error);
|
||||
return { error: error.message || '扫描失败' };
|
||||
}
|
||||
});
|
||||
|
||||
// 批量解析音乐文件元数据
|
||||
ipcMain.handle('parse-local-music-metadata', async (_, filePaths: string[]) => {
|
||||
try {
|
||||
const metadataList = await batchParseMetadata(filePaths);
|
||||
return metadataList;
|
||||
} catch (error: any) {
|
||||
console.error('解析本地音乐元数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
6
src/preload/index.d.ts
vendored
6
src/preload/index.d.ts
vendored
@@ -28,6 +28,12 @@ interface API {
|
||||
getSearchSuggestions: (keyword: string) => Promise<any>;
|
||||
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) => Promise<any>;
|
||||
lxMusicHttpCancel: (requestId: string) => Promise<void>;
|
||||
/** 扫描指定文件夹中的本地音乐文件 */
|
||||
scanLocalMusic: (folderPath: string) => Promise<{ files: string[]; count: number }>;
|
||||
/** 批量解析本地音乐文件元数据 */
|
||||
parseLocalMusicMetadata: (
|
||||
filePaths: string[]
|
||||
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
|
||||
}
|
||||
|
||||
// 自定义IPC渲染进程通信接口
|
||||
|
||||
@@ -51,7 +51,9 @@ const api = {
|
||||
'get-system-fonts',
|
||||
'get-cached-lyric',
|
||||
'cache-lyric',
|
||||
'clear-lyric-cache'
|
||||
'clear-lyric-cache',
|
||||
'scan-local-music',
|
||||
'parse-local-music-metadata'
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
@@ -65,7 +67,12 @@ const api = {
|
||||
lxMusicHttpRequest: (request: { url: string; options: any; requestId: string }) =>
|
||||
ipcRenderer.invoke('lx-music-http-request', request),
|
||||
|
||||
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId)
|
||||
lxMusicHttpCancel: (requestId: string) => ipcRenderer.invoke('lx-music-http-cancel', requestId),
|
||||
|
||||
// 本地音乐扫描相关
|
||||
scanLocalMusic: (folderPath: string) => ipcRenderer.invoke('scan-local-music', folderPath),
|
||||
parseLocalMusicMetadata: (filePaths: string[]) =>
|
||||
ipcRenderer.invoke('parse-local-music-metadata', filePaths)
|
||||
};
|
||||
|
||||
// 创建带类型的ipcRenderer对象,暴露给渲染进程
|
||||
|
||||
@@ -89,6 +89,17 @@ const layoutRouter = [
|
||||
isMobile: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/local-music',
|
||||
name: 'localMusic',
|
||||
meta: {
|
||||
title: 'comp.localMusic',
|
||||
icon: 'ri-folder-music-fill',
|
||||
keepAlive: true,
|
||||
isMobile: false
|
||||
},
|
||||
component: () => import('@/views/local-music/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user',
|
||||
|
||||
@@ -16,6 +16,7 @@ pinia.use(({ store }) => {
|
||||
|
||||
// 导出所有 store
|
||||
export * from './modules/intelligenceMode';
|
||||
export * from './modules/localMusic';
|
||||
export * from './modules/lyric';
|
||||
export * from './modules/menu';
|
||||
export * from './modules/music';
|
||||
|
||||
288
src/renderer/store/modules/localMusic.ts
Normal file
288
src/renderer/store/modules/localMusic.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// 本地音乐 Pinia Store
|
||||
// 管理本地音乐列表、扫描状态和文件夹配置
|
||||
// 使用 IndexedDB 缓存音乐元数据,localStorage 持久化文件夹路径
|
||||
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import useIndexedDB from '@/hooks/IndexDBHook';
|
||||
import type { LocalMusicEntry } from '@/types/localMusic';
|
||||
import { getChangedFiles, removeStaleEntries } from '@/utils/localMusicUtils';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
/** IndexedDB store 名称 */
|
||||
const LOCAL_MUSIC_STORE = 'local_music' as const;
|
||||
|
||||
/** IndexedDB 数据类型映射 */
|
||||
type LocalMusicDBStores = {
|
||||
local_music: LocalMusicEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 filePath 生成唯一 ID
|
||||
* 采用简单的字符串 hash 算法,确保同一路径始终生成相同 ID
|
||||
* @param filePath 文件绝对路径
|
||||
* @returns hash 字符串作为唯一 ID
|
||||
*/
|
||||
function generateId(filePath: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < filePath.length; i++) {
|
||||
const char = filePath.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + char) | 0;
|
||||
}
|
||||
// 转为正数的十六进制字符串
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 IndexedDB 实例
|
||||
* 使用 localMusicDB 数据库,包含 local_music 表
|
||||
*/
|
||||
async function initLocalMusicDB() {
|
||||
return await useIndexedDB<typeof LOCAL_MUSIC_STORE, LocalMusicDBStores>(
|
||||
'localMusicDB',
|
||||
[{ name: LOCAL_MUSIC_STORE, keyPath: 'id' }],
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地音乐管理 Store
|
||||
* 负责:文件夹管理、音乐扫描、IndexedDB 缓存、增量更新
|
||||
*/
|
||||
export const useLocalMusicStore = defineStore(
|
||||
'localMusic',
|
||||
() => {
|
||||
// ==================== 状态 ====================
|
||||
/** 已配置的文件夹路径列表 */
|
||||
const folderPaths = ref<string[]>([]);
|
||||
/** 本地音乐列表(从 IndexedDB 加载) */
|
||||
const musicList = ref<LocalMusicEntry[]>([]);
|
||||
/** 是否正在扫描 */
|
||||
const scanning = ref(false);
|
||||
/** 已扫描文件数(用于显示进度) */
|
||||
const scanProgress = ref(0);
|
||||
|
||||
/** IndexedDB 实例(延迟初始化) */
|
||||
let db: Awaited<ReturnType<typeof initLocalMusicDB>> | null = null;
|
||||
|
||||
/**
|
||||
* 获取 IndexedDB 实例,首次调用时初始化
|
||||
*/
|
||||
async function getDB() {
|
||||
if (!db) {
|
||||
db = await initLocalMusicDB();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// ==================== 动作 ====================
|
||||
|
||||
/**
|
||||
* 添加文件夹路径
|
||||
* 如果路径已存在则忽略
|
||||
* @param path 文件夹路径
|
||||
*/
|
||||
function addFolder(path: string): void {
|
||||
if (!path || folderPaths.value.includes(path)) {
|
||||
return;
|
||||
}
|
||||
folderPaths.value.push(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除文件夹路径
|
||||
* @param path 要移除的文件夹路径
|
||||
*/
|
||||
function removeFolder(path: string): void {
|
||||
const index = folderPaths.value.indexOf(path);
|
||||
if (index !== -1) {
|
||||
folderPaths.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有已配置的文件夹
|
||||
* 流程:IPC 扫描文件 → 增量对比 → 解析变更文件元数据 → 存入 IndexedDB → 更新列表
|
||||
*/
|
||||
async function scanFolders(): Promise<void> {
|
||||
if (scanning.value || folderPaths.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scanning.value = true;
|
||||
scanProgress.value = 0;
|
||||
|
||||
try {
|
||||
const localDB = await getDB();
|
||||
|
||||
// 加载当前缓存数据用于增量对比
|
||||
const cachedEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
|
||||
|
||||
// 遍历每个文件夹进行扫描
|
||||
for (const folderPath of folderPaths.value) {
|
||||
try {
|
||||
// 1. 调用 IPC 扫描文件夹,获取文件路径列表
|
||||
const result = await window.api.scanLocalMusic(folderPath);
|
||||
|
||||
// 检查是否返回错误
|
||||
if ((result as any).error) {
|
||||
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
|
||||
message.error(`扫描失败: ${(result as any).error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { files } = result;
|
||||
scanProgress.value += files.length;
|
||||
|
||||
// 2. 增量扫描:对比缓存,找出需要重新解析的文件
|
||||
const cachedMap = new Map<string, LocalMusicEntry>();
|
||||
for (const entry of cachedEntries) {
|
||||
cachedMap.set(entry.filePath, entry);
|
||||
}
|
||||
|
||||
// 缓存中不存在的新文件,一定需要解析
|
||||
const newFiles = files.filter((f) => !cachedMap.has(f));
|
||||
// 缓存中已存在的文件,需要检查修改时间是否变更
|
||||
const existingFiles = files.filter((f) => cachedMap.has(f));
|
||||
|
||||
// 3. 解析新文件的元数据并存入 IndexedDB
|
||||
if (newFiles.length > 0) {
|
||||
const newMetas = await window.api.parseLocalMusicMetadata(newFiles);
|
||||
for (const meta of newMetas) {
|
||||
const entry: LocalMusicEntry = {
|
||||
...meta,
|
||||
id: generateId(meta.filePath)
|
||||
};
|
||||
await localDB.saveData(LOCAL_MUSIC_STORE, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 对已有文件进行增量对比,仅重新解析修改时间变更的文件
|
||||
if (existingFiles.length > 0) {
|
||||
// 解析已有文件的元数据以获取最新修改时间
|
||||
const existingMetas = await window.api.parseLocalMusicMetadata(existingFiles);
|
||||
const existingWithTime = existingMetas.map((meta) => ({
|
||||
path: meta.filePath,
|
||||
modifiedTime: meta.modifiedTime
|
||||
}));
|
||||
|
||||
// 使用 getChangedFiles 对比修改时间,找出变更文件
|
||||
const changedFilePaths = getChangedFiles(existingWithTime, cachedEntries);
|
||||
const changedSet = new Set(changedFilePaths);
|
||||
|
||||
// 对于修改时间变更的文件,直接使用已解析的元数据更新缓存(避免重复解析)
|
||||
for (const meta of existingMetas) {
|
||||
if (changedSet.has(meta.filePath)) {
|
||||
const entry: LocalMusicEntry = {
|
||||
...meta,
|
||||
id: generateId(meta.filePath)
|
||||
};
|
||||
await localDB.saveData(LOCAL_MUSIC_STORE, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`扫描文件夹出错: ${folderPath}`, error);
|
||||
message.error(`扫描文件夹出错: ${folderPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 从 IndexedDB 重新加载完整列表
|
||||
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
|
||||
} catch (error) {
|
||||
console.error('扫描本地音乐失败:', error);
|
||||
message.error('扫描本地音乐失败');
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 IndexedDB 缓存加载音乐列表
|
||||
* 应用启动时或进入本地音乐页面时调用
|
||||
*/
|
||||
async function loadFromCache(): Promise<void> {
|
||||
try {
|
||||
const localDB = await getDB();
|
||||
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
|
||||
} catch (error) {
|
||||
console.error('从缓存加载本地音乐失败:', error);
|
||||
// 降级:缓存加载失败时保持空列表,用户可手动触发扫描
|
||||
musicList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存:检查文件存在性,移除已不存在的文件条目
|
||||
*/
|
||||
async function clearCache(): Promise<void> {
|
||||
try {
|
||||
const localDB = await getDB();
|
||||
const allEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
|
||||
|
||||
if (allEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建文件存在性映射
|
||||
const existsMap: Record<string, boolean> = {};
|
||||
for (const entry of allEntries) {
|
||||
try {
|
||||
// 使用已有的 IPC 通道检查文件是否存在
|
||||
const exists = await window.electron.ipcRenderer.invoke(
|
||||
'check-file-exists',
|
||||
entry.filePath
|
||||
);
|
||||
existsMap[entry.filePath] = exists !== false;
|
||||
} catch {
|
||||
// 检查失败时假设文件存在,避免误删
|
||||
existsMap[entry.filePath] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用工具函数过滤出仍然存在的条目
|
||||
const validEntries = removeStaleEntries(allEntries, existsMap);
|
||||
const removedEntries = allEntries.filter(
|
||||
(entry) => !validEntries.some((v) => v.id === entry.id)
|
||||
);
|
||||
|
||||
// 从 IndexedDB 中删除不存在的条目
|
||||
for (const entry of removedEntries) {
|
||||
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
|
||||
}
|
||||
|
||||
// 更新内存中的列表
|
||||
musicList.value = validEntries;
|
||||
} catch (error) {
|
||||
console.error('清理缓存失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
folderPaths,
|
||||
musicList,
|
||||
scanning,
|
||||
scanProgress,
|
||||
|
||||
// 动作
|
||||
addFolder,
|
||||
removeFolder,
|
||||
scanFolders,
|
||||
loadFromCache,
|
||||
clearCache
|
||||
};
|
||||
},
|
||||
{
|
||||
// 持久化配置:仅持久化文件夹路径到 localStorage
|
||||
// 音乐列表存储在 IndexedDB 中,不需要 localStorage 持久化
|
||||
persist: {
|
||||
key: 'local-music-store',
|
||||
storage: localStorage,
|
||||
pick: ['folderPaths']
|
||||
}
|
||||
}
|
||||
);
|
||||
6
src/renderer/types/electron.d.ts
vendored
6
src/renderer/types/electron.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import type { LocalMusicMeta } from './localMusic';
|
||||
|
||||
export interface IElectronAPI {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
@@ -16,6 +18,10 @@ export interface IElectronAPI {
|
||||
set: (_key: string, _value: any) => Promise<boolean>;
|
||||
delete: (_key: string) => Promise<boolean>;
|
||||
};
|
||||
/** 扫描指定文件夹中的本地音乐文件 */
|
||||
scanLocalMusic: (_folderPath: string) => Promise<{ files: string[]; count: number }>;
|
||||
/** 批量解析本地音乐文件元数据 */
|
||||
parseLocalMusicMetadata: (_filePaths: string[]) => Promise<LocalMusicMeta[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
44
src/renderer/types/localMusic.ts
Normal file
44
src/renderer/types/localMusic.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 本地音乐相关类型定义
|
||||
|
||||
/**
|
||||
* 支持的音频文件格式
|
||||
* 包含 mp3、flac、wav、ogg、m4a、aac 六种常见格式
|
||||
*/
|
||||
export const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
|
||||
|
||||
/** 支持的音频格式类型 */
|
||||
export type SupportedAudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* 主进程返回的原始音乐元数据
|
||||
* 由主进程扫描模块解析音乐文件后生成
|
||||
*/
|
||||
export type LocalMusicMeta = {
|
||||
/** 文件绝对路径 */
|
||||
filePath: string;
|
||||
/** 歌曲标题 */
|
||||
title: string;
|
||||
/** 艺术家名称 */
|
||||
artist: string;
|
||||
/** 专辑名称 */
|
||||
album: string;
|
||||
/** 时长(毫秒) */
|
||||
duration: number;
|
||||
/** base64 Data URL 格式的封面图片,无封面时为 null */
|
||||
cover: string | null;
|
||||
/** LRC 格式歌词文本,无歌词时为 null */
|
||||
lyrics: string | null;
|
||||
/** 文件大小(字节) */
|
||||
fileSize: number;
|
||||
/** 文件修改时间戳 */
|
||||
modifiedTime: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* IndexedDB 中存储的本地音乐条目
|
||||
* 在 LocalMusicMeta 基础上增加唯一标识 id
|
||||
*/
|
||||
export type LocalMusicEntry = LocalMusicMeta & {
|
||||
/** 使用 filePath 的 hash 作为唯一 ID */
|
||||
id: string;
|
||||
};
|
||||
@@ -76,6 +76,9 @@ export const formatNumber = (num: string | number) => {
|
||||
export const getImgUrl = (url: string | undefined, size: string = '') => {
|
||||
if (!url) return '';
|
||||
|
||||
// base64 Data URL 和本地文件路径不需要添加尺寸参数
|
||||
if (url.startsWith('data:') || url.startsWith('local://')) return url;
|
||||
|
||||
if (url.includes('thumbnail')) {
|
||||
// 只替换最后一个 thumbnail 参数的尺寸
|
||||
return url.replace(/thumbnail=\d+y\d+(?!.*thumbnail)/, `thumbnail=${size}`);
|
||||
|
||||
261
src/renderer/utils/localMusicUtils.ts
Normal file
261
src/renderer/utils/localMusicUtils.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// 本地音乐工具函数
|
||||
// 提供格式过滤、元数据 fallback、类型转换、搜索过滤、增量扫描等功能
|
||||
|
||||
import type { LocalMusicEntry, LocalMusicMeta } from '@/types/localMusic';
|
||||
import { SUPPORTED_AUDIO_FORMATS } from '@/types/localMusic';
|
||||
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
||||
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
||||
|
||||
/**
|
||||
* 判断文件路径是否为支持的音频格式
|
||||
* 通过提取文件扩展名(不区分大小写)与支持格式列表比对
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否为支持的音频格式
|
||||
*/
|
||||
export function isSupportedAudioFormat(filePath: string): boolean {
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||
return (SUPPORTED_AUDIO_FORMATS as readonly string[]).includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取歌曲标题(去除目录和扩展名)
|
||||
* @param filePath 文件路径
|
||||
* @returns 歌曲标题
|
||||
*/
|
||||
export function extractTitleFromFilename(filePath: string): string {
|
||||
// 兼容 Windows 和 Unix 路径分隔符
|
||||
const separator = filePath.includes('\\') ? '\\' : '/';
|
||||
const filename = filePath.split(separator).pop() || filePath;
|
||||
// 去除扩展名
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
return filename.slice(0, dotIndex);
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建缺失元数据时的 fallback 元数据对象
|
||||
* 使用文件名作为标题,"未知艺术家"和"未知专辑"作为默认值
|
||||
* @param filePath 文件路径
|
||||
* @returns 默认的 LocalMusicMeta 对象
|
||||
*/
|
||||
export function buildFallbackMeta(filePath: string): LocalMusicMeta {
|
||||
return {
|
||||
filePath,
|
||||
title: extractTitleFromFilename(filePath),
|
||||
artist: '未知艺术家',
|
||||
album: '未知专辑',
|
||||
duration: 0,
|
||||
cover: null,
|
||||
lyrics: null,
|
||||
fileSize: 0,
|
||||
modifiedTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LRC 格式歌词字符串解析为 ILyric 对象
|
||||
* 复用 yrcParser 解析能力,兼容标准 LRC 和 YRC 格式
|
||||
* @param lrcString LRC 格式歌词文本
|
||||
* @returns ILyric 对象,解析失败返回 null
|
||||
*/
|
||||
export function parseLrcToILyric(lrcString: string | null): ILyric | null {
|
||||
if (!lrcString || typeof lrcString !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parseResult = parseYrcLyrics(lrcString);
|
||||
if (!parseResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lyrics: parsedLyrics } = parseResult.data;
|
||||
const lrcArray: ILyricText[] = [];
|
||||
const lrcTimeArray: number[] = [];
|
||||
let hasWordByWord = false;
|
||||
|
||||
for (const line of parsedLyrics) {
|
||||
const hasWords = line.words && line.words.length > 0;
|
||||
if (hasWords) hasWordByWord = true;
|
||||
|
||||
lrcArray.push({
|
||||
text: line.fullText,
|
||||
trText: '',
|
||||
words: hasWords ? (line.words as IWordData[]) : undefined,
|
||||
hasWordByWord: hasWords,
|
||||
startTime: line.startTime,
|
||||
duration: line.duration
|
||||
});
|
||||
|
||||
lrcTimeArray.push(line.startTime / 1000);
|
||||
}
|
||||
|
||||
if (lrcArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { lrcTimeArray, lrcArray, hasWordByWord };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LocalMusicEntry 转换为 SongResult,以复用现有播放系统
|
||||
* @param entry 本地音乐条目
|
||||
* @returns 兼容播放系统的 SongResult 对象
|
||||
*/
|
||||
export function toSongResult(entry: LocalMusicEntry): SongResult {
|
||||
// 解析内嵌歌词为 ILyric 对象
|
||||
const lyric = parseLrcToILyric(entry.lyrics);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.title,
|
||||
picUrl: entry.cover || '/images/default_cover.png',
|
||||
ar: [
|
||||
{
|
||||
name: entry.artist,
|
||||
id: 0,
|
||||
picId: 0,
|
||||
img1v1Id: 0,
|
||||
briefDesc: '',
|
||||
picUrl: '',
|
||||
img1v1Url: '',
|
||||
albumSize: 0,
|
||||
alias: [],
|
||||
trans: '',
|
||||
musicSize: 0,
|
||||
topicPerson: 0
|
||||
}
|
||||
],
|
||||
al: {
|
||||
name: entry.album,
|
||||
id: 0,
|
||||
type: '',
|
||||
size: 0,
|
||||
picId: 0,
|
||||
blurPicUrl: '',
|
||||
companyId: 0,
|
||||
pic: 0,
|
||||
picUrl: entry.cover || '',
|
||||
publishTime: 0,
|
||||
description: '',
|
||||
tags: '',
|
||||
company: '',
|
||||
briefDesc: '',
|
||||
artist: {
|
||||
name: entry.artist,
|
||||
id: 0,
|
||||
picId: 0,
|
||||
img1v1Id: 0,
|
||||
briefDesc: '',
|
||||
picUrl: '',
|
||||
img1v1Url: '',
|
||||
albumSize: 0,
|
||||
alias: [],
|
||||
trans: '',
|
||||
musicSize: 0,
|
||||
topicPerson: 0
|
||||
},
|
||||
songs: [],
|
||||
alias: [],
|
||||
status: 0,
|
||||
copyrightId: 0,
|
||||
commentThreadId: '',
|
||||
artists: [],
|
||||
subType: '',
|
||||
transName: null,
|
||||
onSale: false,
|
||||
mark: 0,
|
||||
picId_str: ''
|
||||
},
|
||||
song: {
|
||||
artists: [{ name: entry.artist }],
|
||||
album: { name: entry.album }
|
||||
},
|
||||
playMusicUrl: `local:///${entry.filePath}`,
|
||||
duration: entry.duration,
|
||||
dt: entry.duration,
|
||||
source: 'netease' as const,
|
||||
count: 0,
|
||||
// 内嵌歌词(如果有)
|
||||
lyric: lyric ?? undefined,
|
||||
// 本地音乐 URL 不会过期,设置一个极大的过期时间
|
||||
createdAt: Date.now(),
|
||||
expiredAt: Date.now() + 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将封面图片 Buffer 转换为 base64 Data URL
|
||||
* @param buffer 图片二进制数据
|
||||
* @param mime MIME 类型(如 image/jpeg、image/png)
|
||||
* @returns base64 Data URL 字符串
|
||||
*/
|
||||
export function coverToDataUrl(buffer: Buffer, mime: string): string {
|
||||
const base64 = buffer.toString('base64');
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索过滤本地音乐列表
|
||||
* 不区分大小写,匹配歌曲标题或艺术家名称
|
||||
* 空关键词返回完整列表
|
||||
* @param list 本地音乐列表
|
||||
* @param keyword 搜索关键词
|
||||
* @returns 过滤后的音乐列表
|
||||
*/
|
||||
export function filterByKeyword(list: LocalMusicEntry[], keyword: string): LocalMusicEntry[] {
|
||||
if (!keyword || keyword.trim() === '') {
|
||||
return list;
|
||||
}
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return list.filter((entry) => {
|
||||
return (
|
||||
entry.title.toLowerCase().includes(lowerKeyword) ||
|
||||
entry.artist.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量扫描对比:找出新增或修改时间变更的文件
|
||||
* 对比扫描到的文件列表与缓存条目,返回需要重新解析的文件路径
|
||||
* @param files 扫描到的文件列表(包含路径和修改时间)
|
||||
* @param cached 已缓存的本地音乐条目
|
||||
* @returns 需要重新解析的文件路径列表
|
||||
*/
|
||||
export function getChangedFiles(
|
||||
files: { path: string; modifiedTime: number }[],
|
||||
cached: LocalMusicEntry[]
|
||||
): string[] {
|
||||
// 构建缓存映射:filePath -> modifiedTime
|
||||
const cachedMap = new Map<string, number>();
|
||||
for (const entry of cached) {
|
||||
cachedMap.set(entry.filePath, entry.modifiedTime);
|
||||
}
|
||||
|
||||
return files
|
||||
.filter((file) => {
|
||||
const cachedTime = cachedMap.get(file.path);
|
||||
// 缓存中不存在(新文件)或修改时间不匹配(已变更)
|
||||
return cachedTime === undefined || cachedTime !== file.modifiedTime;
|
||||
})
|
||||
.map((file) => file.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存清理:移除文件已不存在的条目
|
||||
* @param entries 缓存的本地音乐条目列表
|
||||
* @param existsMap 文件存在性映射(filePath -> 是否存在)
|
||||
* @returns 清理后的条目列表(仅保留文件仍存在的条目)
|
||||
*/
|
||||
export function removeStaleEntries(
|
||||
entries: LocalMusicEntry[],
|
||||
existsMap: Record<string, boolean>
|
||||
): LocalMusicEntry[] {
|
||||
return entries.filter((entry) => existsMap[entry.filePath] === true);
|
||||
}
|
||||
358
src/renderer/views/local-music/index.vue
Normal file
358
src/renderer/views/local-music/index.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="local-music-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
||||
<n-scrollbar class="h-full">
|
||||
<div class="local-music-content pb-32">
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
||||
<!-- 背景模糊效果 -->
|
||||
<div class="hero-bg absolute inset-0 -top-20">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10 blur-3xl opacity-50 dark:opacity-30"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hero 内容 -->
|
||||
<div class="hero-content relative z-10 px-4 md:px-8 pt-10 pb-8">
|
||||
<div class="flex flex-col md:flex-row gap-8 items-center md:items-end">
|
||||
<div class="cover-wrapper relative group">
|
||||
<div
|
||||
class="cover-container relative w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-primary/10 flex items-center justify-center shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
|
||||
>
|
||||
<i class="ri-folder-music-fill text-6xl text-primary opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-content text-center md:text-left">
|
||||
<div class="badge mb-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
{{ t('localMusic.title') }}
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
|
||||
>
|
||||
{{ t('localMusic.title') }}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm md:text-base text-neutral-500 dark:text-neutral-400">
|
||||
{{ t('localMusic.songCount', { count: localMusicStore.musicList.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Action Bar (Sticky) -->
|
||||
<section
|
||||
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- 左侧:搜索框 -->
|
||||
<div class="flex-1 max-w-xs">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="t('localMusic.search')"
|
||||
clearable
|
||||
size="small"
|
||||
round
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="ri-search-line text-neutral-400" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 播放全部按钮 -->
|
||||
<button
|
||||
v-if="filteredList.length > 0"
|
||||
class="action-btn-pill flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm transition-all bg-primary text-white hover:bg-primary/90"
|
||||
@click="handlePlayAll"
|
||||
>
|
||||
<i class="ri-play-fill text-lg" />
|
||||
<span class="hidden md:inline">{{ t('localMusic.playAll') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 扫描按钮 -->
|
||||
<button
|
||||
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
||||
:disabled="localMusicStore.scanning"
|
||||
@click="handleScan"
|
||||
>
|
||||
<i
|
||||
class="ri-refresh-line text-lg"
|
||||
:class="{ 'animate-spin': localMusicStore.scanning }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 添加文件夹按钮 -->
|
||||
<button
|
||||
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
||||
@click="handleAddFolder"
|
||||
>
|
||||
<i class="ri-folder-add-line text-lg" />
|
||||
</button>
|
||||
|
||||
<!-- 文件夹管理按钮 -->
|
||||
<button
|
||||
v-if="localMusicStore.folderPaths.length > 0"
|
||||
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
||||
@click="showFolderManager = true"
|
||||
>
|
||||
<i class="ri-folder-settings-line text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 扫描进度提示 -->
|
||||
<section v-if="localMusicStore.scanning" class="px-4 md:px-8 mt-6">
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 rounded-2xl bg-primary/5 dark:bg-primary/10 border border-primary/20"
|
||||
>
|
||||
<n-spin size="small" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
{{ t('localMusic.scanning') }}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{{ t('localMusic.songCount', { count: localMusicStore.scanProgress }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<section class="list-section px-4 md:px-8 mt-6">
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="!localMusicStore.scanning && filteredList.length === 0"
|
||||
class="empty-state py-20 text-center"
|
||||
>
|
||||
<i class="ri-folder-music-fill text-5xl mb-4 text-neutral-200 dark:text-neutral-800" />
|
||||
<p class="text-neutral-400">{{ t('localMusic.emptyState') }}</p>
|
||||
<button
|
||||
class="mt-6 px-6 py-2 rounded-full bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-all"
|
||||
@click="handleAddFolder"
|
||||
>
|
||||
<i class="ri-folder-add-line mr-2" />
|
||||
{{ t('localMusic.scanFolder') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟列表 -->
|
||||
<div v-else-if="filteredList.length > 0" class="song-list-container">
|
||||
<n-virtual-list
|
||||
class="song-virtual-list"
|
||||
style="max-height: calc(100vh - 280px)"
|
||||
:items="filteredSongResults"
|
||||
:item-size="70"
|
||||
item-resizable
|
||||
key-field="id"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div>
|
||||
<song-item :index="index" :item="item" @play="handlePlaySong" />
|
||||
<!-- 列表末尾留白 -->
|
||||
<div v-if="index === filteredSongResults.length - 1" class="h-36"></div>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- 文件夹管理抽屉 -->
|
||||
<n-drawer v-model:show="showFolderManager" :width="400" placement="right">
|
||||
<n-drawer-content :title="t('localMusic.removeFolder')" closable>
|
||||
<div class="space-y-3 py-4">
|
||||
<div
|
||||
v-for="folder in localMusicStore.folderPaths"
|
||||
:key="folder"
|
||||
class="flex items-center justify-between p-3 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-800"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<i class="ri-folder-line text-lg text-primary flex-shrink-0" />
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300 truncate">{{
|
||||
folder
|
||||
}}</span>
|
||||
</div>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all flex-shrink-0 ml-2"
|
||||
@click="handleRemoveFolder(folder)"
|
||||
>
|
||||
<i class="ri-delete-bin-line" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空文件夹列表 -->
|
||||
<div v-if="localMusicStore.folderPaths.length === 0" class="text-center py-8">
|
||||
<i class="ri-folder-line text-4xl text-neutral-200 dark:text-neutral-800" />
|
||||
<p class="text-sm text-neutral-400 mt-2">{{ t('localMusic.emptyState') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<n-button type="primary" block @click="handleAddFolder">
|
||||
<template #icon>
|
||||
<i class="ri-folder-add-line" />
|
||||
</template>
|
||||
{{ t('localMusic.scanFolder') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { useLocalMusicStore } from '@/store/modules/localMusic';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { filterByKeyword, toSongResult } from '@/utils/localMusicUtils';
|
||||
|
||||
// ==================== Stores ====================
|
||||
const { t } = useI18n();
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
const localMusicStore = useLocalMusicStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// ==================== State ====================
|
||||
/** 搜索关键词 */
|
||||
const searchKeyword = ref('');
|
||||
/** 文件夹管理抽屉是否显示 */
|
||||
const showFolderManager = ref(false);
|
||||
|
||||
// ==================== Computed ====================
|
||||
/** 根据搜索关键词过滤后的本地音乐列表 */
|
||||
const filteredList = computed(() => {
|
||||
return filterByKeyword(localMusicStore.musicList, searchKeyword.value);
|
||||
});
|
||||
|
||||
/** 将过滤后的列表转换为 SongResult[] 供 SongItem 使用 */
|
||||
const filteredSongResults = computed(() => {
|
||||
return filteredList.value.map(toSongResult);
|
||||
});
|
||||
|
||||
// ==================== Methods ====================
|
||||
|
||||
/**
|
||||
* 选择并添加文件夹
|
||||
* 调用系统文件夹选择对话框
|
||||
* dialog.showOpenDialog 返回 { canceled: boolean, filePaths: string[] }
|
||||
*/
|
||||
async function handleAddFolder(): Promise<void> {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('select-directory');
|
||||
if (result && !result.canceled && result.filePaths?.length > 0) {
|
||||
localMusicStore.addFolder(result.filePaths[0]);
|
||||
// 添加文件夹后自动触发扫描
|
||||
await localMusicStore.scanFolders();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择文件夹失败:', error);
|
||||
message.error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除文件夹
|
||||
* @param folder 要移除的文件夹路径
|
||||
*/
|
||||
function handleRemoveFolder(folder: string): void {
|
||||
localMusicStore.removeFolder(folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发扫描
|
||||
*/
|
||||
async function handleScan(): Promise<void> {
|
||||
if (localMusicStore.folderPaths.length === 0) {
|
||||
// 没有配置文件夹时,引导用户先添加文件夹
|
||||
await handleAddFolder();
|
||||
return;
|
||||
}
|
||||
await localMusicStore.scanFolders();
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放单曲
|
||||
* SongItem 内部已通过 playMusicEvent 调用 playerStore.setPlay 触发播放
|
||||
* 此处只需设置播放列表上下文,确保上下一首切换正常
|
||||
* @param song SongItem 组件 emit 的 SongResult 对象
|
||||
*/
|
||||
async function handlePlaySong(_song: SongResult): Promise<void> {
|
||||
try {
|
||||
// 设置播放列表上下文,确保上下一首切换正常
|
||||
playerStore.setPlayList(filteredSongResults.value);
|
||||
} catch (error) {
|
||||
console.error('播放本地音乐失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放全部
|
||||
* 将完整列表转换为 SongResult[] 后设置为播放列表并从第一首开始播放
|
||||
*/
|
||||
async function handlePlayAll(): Promise<void> {
|
||||
if (filteredSongResults.value.length === 0) return;
|
||||
|
||||
try {
|
||||
const firstSong = filteredSongResults.value[0];
|
||||
const entry = filteredList.value[0];
|
||||
|
||||
// 检查第一首歌文件是否存在
|
||||
const exists = await window.electron.ipcRenderer.invoke('check-file-exists', entry.filePath);
|
||||
if (!exists) {
|
||||
message.error(t('localMusic.fileNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置播放列表并播放第一首
|
||||
playerStore.setPlayList(filteredSongResults.value);
|
||||
await playerStore.setPlay(firstSong);
|
||||
} catch (error) {
|
||||
console.error('播放全部失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
onMounted(async () => {
|
||||
// 进入页面时从 IndexedDB 缓存加载音乐列表
|
||||
await localMusicStore.loadFromCache();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 虚拟列表样式 */
|
||||
.song-virtual-list {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.song-virtual-list :deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 dark:bg-neutral-700 rounded-full;
|
||||
}
|
||||
|
||||
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user