feat: 添加下载管理页面, 引入文件类型检测库以支持多种音频格式

This commit is contained in:
alger
2025-06-03 22:35:04 +08:00
parent bfaa06b0d5
commit 3ac3159058
7 changed files with 1217 additions and 651 deletions

View File

@@ -30,8 +30,10 @@
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.18.2",
"file-type": "^21.0.0",
"font-list": "^1.5.1",
"husky": "^9.1.7",
"music-metadata": "^11.2.3",
"netease-cloud-music-api-alger": "^4.26.1",
"node-id3": "^0.2.9",
"node-machine-id": "^1.1.12",

View File

@@ -45,5 +45,11 @@ export default {
downloadComplete: '{filename} download completed',
downloadFailed: '{filename} download failed: {error}'
},
loading: 'Loading...'
loading: 'Loading...',
playStarted: 'Play started: {name}',
playFailed: 'Play failed: {name}',
path: {
copied: 'Path copied to clipboard',
copyFailed: 'Failed to copy path'
}
};

View File

@@ -44,5 +44,11 @@ export default {
downloadComplete: '{filename} 下载完成',
downloadFailed: '{filename} 下载失败: {error}'
},
loading: '加载中...'
loading: '加载中...',
playStarted: '开始播放: {name}',
playFailed: '播放失败: {name}',
path: {
copied: '路径已复制到剪贴板',
copyFailed: '复制路径失败'
}
};

View File

@@ -6,6 +6,11 @@ import * as http from 'http';
import * as https from 'https';
import * as NodeID3 from 'node-id3';
import * as path from 'path';
import * as os from 'os';
import * as mm from 'music-metadata';
// 导入文件类型库这里使用CommonJS兼容方式导入
// 对于file-type v21.0.0需要这样导入
import { fileTypeFromFile } from 'file-type';
import { getStore } from './config';
@@ -36,9 +41,18 @@ export function initializeFileManager() {
// 注册本地文件协议
protocol.registerFileProtocol('local', (request, callback) => {
try {
const decodedUrl = decodeURIComponent(request.url);
const filePath = decodedUrl.replace('local://', '');
let url = request.url;
// local://C:/Users/xxx.mp3
let filePath = decodeURIComponent(url.replace('local:///', ''));
// 兼容 local:///C:/Users/xxx.mp3 这种情况
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
// 还原为系统路径格式
filePath = path.normalize(filePath);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error('File not found:', filePath);
@@ -53,6 +67,31 @@ export function initializeFileManager() {
}
});
// 检查文件是否存在
ipcMain.handle('check-file-exists', (_, filePath) => {
try {
return fs.existsSync(filePath);
} catch (error) {
console.error('Error checking if file exists:', error);
return false;
}
});
// 获取支持的音频格式列表
ipcMain.handle('get-supported-audio-formats', () => {
return {
formats: [
{ ext: 'mp3', name: 'MP3' },
{ ext: 'm4a', name: 'M4A/AAC' },
{ ext: 'flac', name: 'FLAC' },
{ ext: 'wav', name: 'WAV' },
{ ext: 'ogg', name: 'OGG Vorbis' },
{ ext: 'aac', name: 'AAC' }
],
default: 'mp3'
};
});
// 通用的选择目录处理
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
@@ -311,6 +350,7 @@ async function downloadMusic(
) {
let finalFilePath = '';
let writer: fs.WriteStream | null = null;
let tempFilePath = '';
try {
// 使用配置Store来获取设置
@@ -322,25 +362,21 @@ async function downloadMusic(
// 清理文件名中的非法字符
const sanitizedFilename = sanitizeFilename(filename);
// 从URL中获取文件扩展名如果没有则使用传入的type或默认mp3
const urlExt = type ? `.${type}` : '.mp3';
const filePath = path.join(downloadPath, `${sanitizedFilename}${urlExt}`);
// 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath;
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
// 创建临时文件路径 (在系统临时目录中创建)
const tempDir = path.join(os.tmpdir(), 'AlgerMusicPlayerTemp');
// 确保临时目录存在
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
tempFilePath = path.join(tempDir, `${Date.now()}_${sanitizedFilename}.tmp`);
// 先获取文件大小
const headResponse = await axios.head(url);
const totalSize = parseInt(headResponse.headers['content-length'] || '0', 10);
// 开始下载
// 开始下载到临时文件
const response = await axios({
url,
method: 'GET',
@@ -350,7 +386,7 @@ async function downloadMusic(
httpsAgent: new https.Agent({ keepAlive: true })
});
writer = fs.createWriteStream(finalFilePath);
writer = fs.createWriteStream(tempFilePath);
let downloadedSize = 0;
// 使用 data 事件来跟踪下载进度
@@ -362,7 +398,7 @@ async function downloadMusic(
progress,
loaded: downloadedSize,
total: totalSize,
path: finalFilePath,
path: tempFilePath,
status: progress === 100 ? 'completed' : 'downloading',
songInfo: songInfo || {
name: filename,
@@ -380,11 +416,77 @@ async function downloadMusic(
});
// 验证文件是否完整下载
const stats = fs.statSync(finalFilePath);
const stats = fs.statSync(tempFilePath);
if (stats.size !== totalSize) {
throw new Error('文件下载不完整');
}
// 检测文件类型
let fileExtension = '';
try {
// 首先尝试使用file-type库检测
const fileType = await fileTypeFromFile(tempFilePath);
if (fileType && fileType.ext) {
fileExtension = `.${fileType.ext}`;
console.log(`文件类型检测结果: ${fileType.mime}, 扩展名: ${fileExtension}`);
} else {
// 如果file-type无法识别尝试使用music-metadata
const metadata = await mm.parseFile(tempFilePath);
if (metadata && metadata.format) {
// 根据format.container或codec判断扩展名
const formatInfo = metadata.format;
const container = formatInfo.container || '';
const codec = formatInfo.codec || '';
// 音频格式映射表
const formatMap = {
'mp3': ['MPEG', 'MP3', 'mp3'],
'aac': ['AAC'],
'flac': ['FLAC'],
'ogg': ['Ogg', 'Vorbis'],
'wav': ['WAV', 'PCM'],
'm4a': ['M4A', 'MP4']
};
// 查找匹配的格式
const format = Object.entries(formatMap).find(([_, keywords]) =>
keywords.some(keyword => container.includes(keyword) || codec.includes(keyword))
);
// 设置文件扩展名如果没找到则默认为mp3
fileExtension = format ? `.${format[0]}` : '.mp3';
console.log(`music-metadata检测结果: 容器:${container}, 编码:${codec}, 扩展名: ${fileExtension}`);
} else {
// 两种方法都失败使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
console.log(`无法检测文件类型,使用默认扩展名: ${fileExtension}`);
}
}
} catch (err) {
console.error('检测文件类型失败:', err);
// 检测失败使用传入的type或默认mp3
fileExtension = type ? `.${type}` : '.mp3';
}
// 使用检测到的文件扩展名创建最终文件路径
const filePath = path.join(downloadPath, `${sanitizedFilename}${fileExtension}`);
// 检查文件是否已存在,如果存在则添加序号
finalFilePath = filePath;
let counter = 1;
while (fs.existsSync(finalFilePath)) {
const ext = path.extname(filePath);
const nameWithoutExt = filePath.slice(0, -ext.length);
finalFilePath = `${nameWithoutExt} (${counter})${ext}`;
counter++;
}
// 将临时文件移动到最终位置
fs.copyFileSync(tempFilePath, finalFilePath);
fs.unlinkSync(tempFilePath); // 删除临时文件
// 下载歌词
let lyricData = null;
let lyricsContent = '';
@@ -413,8 +515,7 @@ async function downloadMusic(
}
}
// 不再单独写入歌词文件只保存在ID3标签中
console.log('歌词已准备好将写入ID3标签');
console.log('歌词已准备好,将写入元数据');
}
}
} catch (lyricError) {
@@ -437,9 +538,7 @@ async function downloadMusic(
// 获取封面图片的buffer
coverImageBuffer = Buffer.from(coverResponse.data);
// 不再单独保存封面文件只保存在ID3标签中
console.log('封面已准备好将写入ID3标签');
console.log('封面已准备好,将写入元数据');
}
}
} catch (coverError) {
@@ -447,54 +546,58 @@ async function downloadMusic(
// 继续处理,不影响音乐下载
}
// 在写入ID3标签前先移除可能存在的旧标签
try {
NodeID3.removeTags(finalFilePath);
} catch (err) {
console.error('Error removing existing ID3 tags:', err);
}
// 强化ID3标签的写入格式
const fileFormat = fileExtension.toLowerCase();
const artistNames =
(songInfo?.ar || songInfo?.song?.artists)?.map((a: any) => a.name).join('/ ') || '未知艺术家';
const tags = {
title: filename,
artist: artistNames,
TPE1: artistNames,
TPE2: artistNames,
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
APIC: {
// 专辑封面
imageBuffer: coverImageBuffer,
type: {
id: 3,
name: 'front cover'
},
description: 'Album cover',
mime: 'image/jpeg'
},
USLT: {
// 歌词
language: 'chi',
description: 'Lyrics',
text: lyricsContent || ''
},
trackNumber: songInfo?.no || undefined,
year: songInfo?.publishTime
? new Date(songInfo.publishTime).getFullYear().toString()
: undefined
};
try {
const success = NodeID3.write(tags, finalFilePath);
if (!success) {
console.error('Failed to write ID3 tags');
} else {
console.log('ID3 tags written successfully');
// 根据文件类型处理元数据
if (['.mp3'].includes(fileFormat)) {
// 对MP3文件使用NodeID3处理ID3标签
try {
// 在写入ID3标签前先移除可能存在的旧标签
NodeID3.removeTags(finalFilePath);
const tags = {
title: filename,
artist: artistNames,
TPE1: artistNames,
TPE2: artistNames,
album: songInfo?.al?.name || songInfo?.song?.album?.name || songInfo?.name || filename,
APIC: {
// 专辑封面
imageBuffer: coverImageBuffer,
type: {
id: 3,
name: 'front cover'
},
description: 'Album cover',
mime: 'image/jpeg'
},
USLT: {
// 歌词
language: 'chi',
description: 'Lyrics',
text: lyricsContent || ''
},
trackNumber: songInfo?.no || undefined,
year: songInfo?.publishTime
? new Date(songInfo.publishTime).getFullYear().toString()
: undefined
};
const success = NodeID3.write(tags, finalFilePath);
if (!success) {
console.error('Failed to write ID3 tags');
} else {
console.log('ID3 tags written successfully');
}
} catch (err) {
console.error('Error writing ID3 tags:', err);
}
} catch (err) {
console.error('Error writing ID3 tags:', err);
} else {
// 对于非MP3文件使用music-metadata来写入元数据可能需要专门的库
// 或者根据不同文件类型使用专用工具,暂时只记录但不处理
console.log(`文件类型 ${fileFormat} 不支持使用NodeID3写入标签跳过元数据写入`);
}
// 保存下载信息
@@ -519,7 +622,7 @@ async function downloadMusic(
size: totalSize,
path: finalFilePath,
downloadTime: Date.now(),
type: type || 'mp3',
type: fileExtension.substring(1), // 去掉前面的点号,只保留扩展名
lyric: lyricData
};
@@ -571,6 +674,17 @@ async function downloadMusic(
if (writer) {
writer.end();
}
// 清理临时文件
if (tempFilePath && fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch (e) {
console.error('Failed to delete temporary file:', e);
}
}
// 清理未完成的最终文件
if (finalFilePath && fs.existsSync(finalFilePath)) {
try {
fs.unlinkSync(finalFilePath);

View File

@@ -1,475 +1,34 @@
<template>
<div class="download-drawer-trigger">
<n-badge :value="downloadingCount" :max="99" :show="downloadingCount > 0">
<n-button circle @click="settingsStore.showDownloadDrawer = true">
<n-button circle @click="navigateToDownloads">
<template #icon>
<i class="iconfont ri-download-cloud-2-line"></i>
</template>
</n-button>
</n-badge>
</div>
<n-drawer
v-model:show="showDrawer"
:height="'80%'"
placement="bottom"
@after-leave="handleDrawerClose"
>
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
<div class="drawer-container">
<n-tabs type="line" animated class="h-full" v-model:value="tabName">
<!-- 下载列表 -->
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
<div class="download-list">
<div v-if="downloadList.length === 0" class="empty-tip">
<n-empty :description="t('download.empty.noTasks')" />
</div>
<template v-else>
<div class="total-progress">
<div class="total-progress-text">
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
</div>
<n-progress
type="line"
:percentage="Number(totalProgress.toFixed(1))"
:height="12"
:border-radius="6"
:indicator-placement="'inside'"
/>
</div>
<div class="download-content">
<div class="download-items">
<div v-for="item in downloadList" :key="item.path" class="download-item">
<div class="download-item-content">
<div class="download-item-cover">
<n-image
:src="getImgUrl(item.songInfo?.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="download-item-info">
<div class="download-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="download-item-artist">
{{
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
t('download.artist.unknown')
}}
</div>
<div class="download-item-progress">
<n-progress
type="line"
:percentage="item.progress"
:processing="item.status === 'downloading'"
:status="getProgressStatus(item)"
:height="8"
/>
</div>
<div class="download-item-size">
<span
>{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}</span
>
</div>
</div>
<div class="download-item-status">
<n-tag :type="getStatusType(item)" size="small">
{{ getStatusText(item) }}
</n-tag>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</n-tab-pane>
<!-- 已下载列表 -->
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
<div class="downloaded-list">
<div v-if="isLoadingDownloaded" class="loading-tip">
<n-spin size="medium" />
<span class="loading-text">{{ t('download.loading') }}</span>
</div>
<div v-else-if="downloadedList.length === 0" class="empty-tip">
<n-empty :description="t('download.empty.noDownloaded')" />
</div>
<div v-else class="downloaded-content">
<div class="downloaded-header">
<div class="header-title">
{{ t('download.count', { count: downloadedList.length }) }}
</div>
<n-button secondary size="small" @click="showClearConfirm = true">
<template #icon>
<i class="iconfont ri-delete-bin-line mr-1"></i>
</template>
{{ t('download.clearAll') }}
</n-button>
</div>
<div class="downloaded-items">
<div v-for="item in downList" :key="item.path" class="downloaded-item">
<div class="downloaded-item-content">
<div class="downloaded-item-cover">
<n-image
:src="getImgUrl(item.picUrl, '200y200')"
preview-disabled
:object-fit="'cover'"
class="cover-image"
/>
</div>
<div class="downloaded-item-info">
<div class="downloaded-item-name" :title="item.filename">
{{ item.filename }}
</div>
<div class="downloaded-item-artist">
{{ item.ar?.map((a) => a.name).join(', ') }}
</div>
<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)">
<template #icon>
<i class="iconfont ri-play-circle-line text-xl"></i>
</template>
</n-button> -->
<n-button
text
type="primary"
size="large"
@click="openDirectory(item.path)"
>
<template #icon>
<i class="iconfont ri-folder-open-line text-xl"></i>
</template>
</n-button>
<n-button text type="error" size="large" @click="handleDelete(item)">
<template #icon>
<i class="iconfont ri-delete-bin-line text-xl"></i>
</template>
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</n-drawer-content>
</n-drawer>
<!-- 删除确认对话框 -->
<n-modal
v-model:show="showDeleteConfirm"
preset="dialog"
type="warning"
:title="t('download.delete.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
<span>{{ t('download.delete.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
</div>
<template #action>
<n-button size="small" @click="showDeleteConfirm = false">{{
t('download.delete.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="confirmDelete">{{
t('download.delete.confirm')
}}</n-button>
</template>
</n-modal>
<!-- 清空确认对话框 -->
<n-modal
v-model:show="showClearConfirm"
preset="dialog"
type="warning"
:title="t('download.clear.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-delete-bin-line mr-2 text-xl"></i>
<span>{{ t('download.clear.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
{{ t('download.clear.message') }}
</div>
<template #action>
<n-button size="small" @click="showClearConfirm = false">{{
t('download.clear.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="clearDownloadRecords">{{
t('download.clear.confirm')
}}</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
import type { ProgressStatus } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getMusicDetail } from '@/api/music';
// import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
// import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils';
// import { SongResult } from '@/type/music';
const { t } = useI18n();
interface DownloadItem {
filename: string;
progress: number;
loaded: number;
total: number;
path: string;
status: 'downloading' | 'completed' | 'error';
error?: string;
songInfo?: any;
}
interface DownloadedItem {
filename: string;
path: string;
size: number;
id: number;
picUrl: string;
ar: { name: string }[];
}
const tabName = ref('downloading');
const message = useMessage();
// const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const showDrawer = computed({
get: () => settingsStore.showDownloadDrawer,
set: (val) => {
settingsStore.showDownloadDrawer = val;
}
});
const downloadList = ref<DownloadItem[]>([]);
const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]')
);
const downList = computed(() => downloadedList.value);
const router = useRouter();
const downloadList = ref<any[]>([]);
// 计算下载中的任务数量
const downloadingCount = computed(() => {
return downloadList.value.filter((item) => item.status === 'downloading').length;
});
// 计算总进度
const totalProgress = computed(() => {
if (downloadList.value.length === 0) return 0;
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
return total / downloadList.value.length;
});
watch(totalProgress, (newVal) => {
if (newVal === 100) {
refreshDownloadedList();
}
});
// 获取状态类型
const getStatusType = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return 'info';
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'default';
}
// 导航到下载页面
const navigateToDownloads = () => {
router.push('/downloads');
};
// 获取状态文本
const getStatusText = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return t('download.status.downloading');
case 'completed':
return t('download.status.completed');
case 'error':
return t('download.status.failed');
default:
return t('download.status.unknown');
}
};
// 获取进度条状态
const getProgressStatus = (item: DownloadItem): ProgressStatus => {
switch (item.status) {
case 'completed':
return 'success';
case 'error':
return 'error';
default:
return 'info';
}
};
// 格式化文件大小
const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// 打开目录
const openDirectory = (path: string) => {
window.electron.ipcRenderer.send('open-directory', path);
};
// 删除相关
const showDeleteConfirm = ref(false);
const itemToDelete = ref<DownloadedItem | null>(null);
// 处理删除点击
const handleDelete = (item: DownloadedItem) => {
itemToDelete.value = item;
showDeleteConfirm.value = true;
};
// 确认删除
const confirmDelete = async () => {
const item = itemToDelete.value;
if (!item) return;
try {
const success = await window.electron.ipcRenderer.invoke(
'delete-downloaded-music',
item.path
);
if (success) {
const newList = downloadedList.value.filter(i => i.id !== item.id);
downloadedList.value = newList;
localStorage.setItem('downloadedList', JSON.stringify(newList));
message.success(t('download.delete.success'));
} else {
message.warning(t('download.delete.fileNotFound'));
}
} catch (error) {
console.error('Failed to delete music:', error);
message.warning(t('download.delete.recordRemoved'));
} finally {
showDeleteConfirm.value = false;
itemToDelete.value = null;
}
};
// 清空下载记录相关
const showClearConfirm = ref(false);
// 清空下载记录
const clearDownloadRecords = async () => {
try {
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
message.success(t('download.clear.success'));
} catch (error) {
console.error('Failed to clear download records:', error);
message.error(t('download.clear.failed'));
} finally {
showClearConfirm.value = false;
}
};
// 播放音乐
// const handlePlay = async (musicInfo: SongResult) => {
// await playerStore.setPlay(musicInfo);
// playerStore.setPlayMusic(true);
// playerStore.setIsPlay(true);
// };
// 添加加载状态
const isLoadingDownloaded = ref(false);
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
if (isLoadingDownloaded.value) return; // 防止重复加载
try {
isLoadingDownloaded.value = true;
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
if (!Array.isArray(list) || list.length === 0) {
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
return;
}
const songIds = list.filter(item => item.id).map(item => item.id);
if (songIds.length === 0) {
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
return;
}
try {
const detailRes = await getMusicDetail(songIds);
const songDetails = detailRes.data.songs.reduce((acc, song) => {
acc[song.id] = song;
return acc;
}, {});
const updatedList = list.map(item => ({
...item,
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
}));
downloadedList.value = updatedList;
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
} catch (error) {
console.error('Failed to get music details:', error);
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
}
} catch (error) {
console.error('Failed to get downloaded music list:', error);
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
} finally {
isLoadingDownloaded.value = false;
}
};
// 监听抽屉显示状态
watch(
() => showDrawer.value,
(newVal) => {
if (newVal && !isLoadingDownloaded.value) {
refreshDownloadedList();
}
}
);
// 监听下载进度
onMounted(() => {
refreshDownloadedList();
// 监听下载进度
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
@@ -501,9 +60,6 @@ onMounted(() => {
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
if (data.success) {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
// 延迟刷新已下载列表,避免文件系统未完全写入
setTimeout(() => refreshDownloadedList(), 500);
message.success(t('download.message.downloadComplete', { filename: data.filename }));
} else {
const existingItem = downloadList.value.find(item => item.filename === data.filename);
if (existingItem) {
@@ -516,7 +72,6 @@ onMounted(() => {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
}, 3000);
}
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
}
});
@@ -536,20 +91,6 @@ onMounted(() => {
}
});
});
const handleDrawerClose = () => {
settingsStore.showDownloadDrawer = false;
};
watch(
() => tabName.value,
(newVal) => {
if (newVal) {
refreshDownloadedList();
}
}
);
</script>
<style lang="scss" scoped>
@@ -568,117 +109,4 @@ watch(
}
}
}
.drawer-container {
@apply h-full;
}
.download-list,
.downloaded-list {
@apply flex flex-col h-full;
.empty-tip {
@apply flex-1 flex items-center justify-center;
@apply text-gray-400 dark:text-gray-600;
}
}
.download-content,
.downloaded-content {
@apply flex-1 overflow-hidden pb-40;
}
.downloaded-header {
@apply flex items-center justify-between p-4 bg-light-100 dark:bg-dark-200 sticky top-0 z-10;
@apply border-b border-gray-100 dark:border-gray-800;
.header-title {
@apply text-sm font-medium text-gray-600 dark:text-gray-400;
}
}
.download-items,
.downloaded-items {
@apply space-y-3 p-4;
}
.total-progress {
@apply px-4 py-3 bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border-b border-gray-100 dark:border-gray-800;
@apply sticky top-0 z-10;
&-text {
@apply mb-2 text-sm font-medium text-gray-600 dark:text-gray-400;
}
}
.download-item,
.downloaded-item {
@apply p-3 rounded-lg;
@apply bg-light-100 dark:bg-dark-200 backdrop-blur-sm;
@apply border border-gray-100 dark:border-gray-700;
@apply transition-all duration-300;
@apply hover:bg-light-300 dark:hover:bg-dark-300;
@apply hover:shadow-md;
&-content {
@apply flex items-center gap-3;
}
&-cover {
@apply w-10 h-10 flex-shrink-0 rounded-lg overflow-hidden;
@apply shadow-md;
.cover-image {
@apply w-full h-full object-cover;
}
}
&-info {
@apply flex-1 min-w-0;
}
&-name {
@apply text-sm font-medium truncate;
@apply text-gray-900 dark:text-gray-100;
}
&-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
&-progress {
@apply mt-1;
}
&-size {
@apply text-xs text-gray-500 dark:text-gray-400 mt-1;
}
&-status {
@apply flex-shrink-0;
}
}
.downloaded-item {
&-actions {
@apply flex items-center gap-1;
.n-button {
@apply p-2;
@apply hover:bg-gray-200/80 dark:hover:bg-gray-600/80;
@apply rounded-lg;
@apply transition-colors duration-300;
.iconfont {
@apply text-xl;
}
}
}
}
.delete-confirm-content {
@apply py-6 px-4;
@apply text-base text-gray-600 dark:text-gray-400;
}
</style>

View File

@@ -21,6 +21,18 @@ const otherRouter = [
},
component: () => import('@/views/user/followers.vue')
},
{
path: '/downloads',
name: 'downloads',
meta: {
title: '下载管理',
keepAlive: true,
showInMenu: true,
back: true,
icon: 'ri-download-cloud-2-line'
},
component: () => import('@/views/download/DownloadPage.vue')
},
{
path: '/user/detail/:uid',
name: 'userDetail',

View File

@@ -0,0 +1,998 @@
<template>
<div class="download-page">
<div class="page-header">
<h1 class="page-title">{{ t('download.title') }}</h1>
<div class="segment-control">
<div
class="segment-item"
:class="{ 'active': tabName === 'downloading' }"
@click="tabName = 'downloading'"
>
{{ t('download.tabs.downloading') }}
</div>
<div
class="segment-item"
:class="{ 'active': tabName === 'downloaded' }"
@click="tabName = 'downloaded'"
>
{{ t('download.tabs.downloaded') }}
</div>
</div>
</div>
<div class="page-content">
<!-- 下载列表 -->
<div v-show="tabName === 'downloading'" class="tab-content">
<div class="download-list">
<div v-if="downloadList.length === 0" class="empty-state">
<div class="empty-icon">
<i class="iconfont ri-download-cloud-2-line"></i>
</div>
<h3 class="empty-title">{{ t('download.empty.noTasks') }}</h3>
</div>
<template v-else>
<div class="total-progress">
<div class="progress-header">
<div class="progress-title">
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
</div>
<div class="progress-info">
{{ downloadList.length }} {{ t('download.items') }}
</div>
</div>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${totalProgress}%` }"
></div>
</div>
</div>
</div>
<div class="download-items">
<div v-for="item in downloadList" :key="item.path" class="download-item">
<div class="item-left flex items-center gap-3">
<div class="item-cover">
<img :src="getImgUrl(item.songInfo?.picUrl, '200y200')" alt="Cover" />
</div>
<div class="item-info flex items-center gap-4 w-full">
<div class="item-name min-w-[160px] max-w-[160px] truncate" :title="item.filename">
{{ item.filename }}
</div>
<div class="item-artist min-w-[120px] max-w-[120px] truncate">
{{ item.songInfo?.ar?.map((a) => a.name).join(', ') || t('download.artist.unknown') }}
</div>
<div class="item-progress flex-1 min-w-0">
<div class="progress-bar">
<div
class="progress-fill"
:class="[`status-${item.status}`]"
:style="{ width: `${item.progress}%` }"
></div>
</div>
</div>
<div class="item-details min-w-[120px] max-w-[120px] flex flex-col items-end">
<span class="item-size">
{{ formatSize(item.loaded) }} / {{ formatSize(item.total) }}
</span>
<span class="item-status-badge" :class="[`status-${item.status}`]">
{{ getStatusText(item) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 已下载列表 -->
<div v-show="tabName === 'downloaded'" class="tab-content">
<div class="downloaded-list">
<div v-if="isLoadingDownloaded" class="loading-state">
<div class="spinner"></div>
<span class="loading-text">{{ t('download.loading') }}</span>
</div>
<div v-else-if="downloadedList.length === 0" class="empty-state">
<div class="empty-icon">
<i class="iconfont ri-inbox-archive-line"></i>
</div>
<h3 class="empty-title">{{ t('download.empty.noDownloaded') }}</h3>
<p class="empty-text">{{ t('download.empty.noDownloadedHint') }}</p>
</div>
<template v-else>
<div class="downloaded-header">
<div class="header-info">
<i class="iconfont ri-archive-line"></i>
<span>{{ t('download.count', { count: downloadedList.length }) }}</span>
</div>
<button class="clear-button" @click="showClearConfirm = true">
<i class="iconfont ri-delete-bin-line"></i>
<span>{{ t('download.clearAll') }}</span>
</button>
</div>
<div class="downloaded-items">
<div v-for="item in downList" :key="item.path" class="downloaded-item">
<div class="item-cover">
<img :src="getImgUrl(item.picUrl, '200y200')" alt="Cover" />
</div>
<div class="item-info flex items-center gap-4 w-full">
<div class="item-name min-w-[160px] max-w-[160px] truncate" :title="item.filename">
{{ item.filename }}
</div>
<div class="item-artist min-w-[120px] max-w-[120px] flex items-center gap-1 truncate">
<i class="iconfont ri-user-line"></i>
<span>{{ item.ar?.map((a) => a.name).join(', ') }}</span>
</div>
<div class="item-size min-w-[80px] max-w-[80px] flex items-center gap-1">
<i class="iconfont ri-file-line"></i>
<span>{{ formatSize(item.size) }}</span>
</div>
<div class="item-path min-w-[220px] max-w-[220px] flex items-center gap-1" :title="item.path">
<i class="iconfont ri-folder-path-line"></i>
<span>{{ shortenPath(item.path) }}</span>
<button class="copy-button" @click="copyPath(item.path)">
<i class="iconfont ri-file-copy-line"></i>
</button>
</div>
<div class="item-actions flex gap-1 ml-2">
<button class="action-btn play" @click="handlePlayMusic(item)">
<i class="iconfont ri-play-circle-line"></i>
</button>
<button class="action-btn open" @click="openDirectory(item.path)">
<i class="iconfont ri-folder-open-line"></i>
</button>
<button class="action-btn delete" @click="handleDelete(item)">
<i class="iconfont ri-delete-bin-line"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 删除确认对话框 -->
<div class="modal-overlay" v-if="showDeleteConfirm" @click="showDeleteConfirm = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<i class="iconfont ri-error-warning-line"></i>
<span>{{ t('download.delete.title') }}</span>
</div>
<div class="modal-body">
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
</div>
<div class="modal-footer">
<button class="modal-btn cancel" @click="showDeleteConfirm = false">
{{ t('download.delete.cancel') }}
</button>
<button class="modal-btn confirm" @click="confirmDelete">
{{ t('download.delete.confirm') }}
</button>
</div>
</div>
</div>
<!-- 清空确认对话框 -->
<div class="modal-overlay" v-if="showClearConfirm" @click="showClearConfirm = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<i class="iconfont ri-delete-bin-line"></i>
<span>{{ t('download.clear.title') }}</span>
</div>
<div class="modal-body">
{{ t('download.clear.message') }}
</div>
<div class="modal-footer">
<button class="modal-btn cancel" @click="showClearConfirm = false">
{{ t('download.clear.cancel') }}
</button>
<button class="modal-btn confirm" @click="clearDownloadRecords">
{{ t('download.clear.confirm') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getMusicDetail } from '@/api/music';
import { usePlayerStore } from '@/store/modules/player';
import { getImgUrl } from '@/utils';
import type { SongResult } from '@/type/music';
const { t } = useI18n();
const playerStore = usePlayerStore();
const message = useMessage();
interface DownloadItem {
filename: string;
progress: number;
loaded: number;
total: number;
path: string;
status: 'downloading' | 'completed' | 'error';
error?: string;
songInfo?: any;
}
interface DownloadedItem {
filename: string;
path: string;
size: number;
id: number;
picUrl: string;
ar: { name: string }[];
}
const tabName = ref('downloading');
const downloadList = ref<DownloadItem[]>([]);
const downloadedList = ref<DownloadedItem[]>(
JSON.parse(localStorage.getItem('downloadedList') || '[]')
);
const downList = computed(() => downloadedList.value);
// 计算总进度
const totalProgress = computed(() => {
if (downloadList.value.length === 0) return 0;
const total = downloadList.value.reduce((sum, item) => sum + item.progress, 0);
return total / downloadList.value.length;
});
watch(totalProgress, (newVal) => {
if (newVal === 100) {
refreshDownloadedList();
}
});
// 获取状态文本
const getStatusText = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return t('download.status.downloading');
case 'completed':
return t('download.status.completed');
case 'error':
return t('download.status.failed');
default:
return t('download.status.unknown');
}
};
// 格式化文件大小
const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// 复制文件路径
const copyPath = (path: string) => {
navigator.clipboard.writeText(path)
.then(() => {
message.success(t('download.path.copied'));
})
.catch(err => {
console.error('复制失败:', err);
message.error(t('download.path.copyFailed'));
});
};
// 格式化路径
const shortenPath = (path: string) => {
if (!path) return '';
// 获取文件名和目录
const parts = path.split(/[/\\]/);
const fileName = parts.pop() || '';
// 如果路径很短,直接返回
if (path.length < 30) return path;
// 保留开头的部分目录和结尾的文件名
if (parts.length <= 2) return path;
const start = parts.slice(0, 1).join('/');
const end = parts.slice(-1).join('/');
return `${start}/.../${end}/${fileName}`;
};
// 获取本地文件URL
const getLocalFilePath = (path: string) => {
if (!path) return '';
// 确保URL格式正确
return `local:///${encodeURIComponent(path)}`;
};
// 打开目录
const openDirectory = (path: string) => {
window.electron.ipcRenderer.send('open-directory', path);
};
// 播放音乐
const handlePlayMusic = async (item: DownloadedItem) => {
try {
// 先检查文件是否存在
const fileExists = await window.electron.ipcRenderer.invoke('check-file-exists', item.path);
if (!fileExists) {
message.error(t('download.delete.fileNotFound', { name: item.filename }));
return;
}
// 转换下载项为播放所需的歌曲对象
const song: SongResult = {
id: item.id,
name: item.filename,
ar: item.ar?.map(a => ({
id: 0,
name: a.name,
picId: 0,
img1v1Id: 0,
briefDesc: '',
picUrl: '',
img1v1Url: '',
albumSize: 0,
alias: [],
trans: '',
musicSize: 0,
topicPerson: 0
})) || [],
al: {
name: item.filename,
id: 0,
picUrl: item.picUrl,
pic: 0,
picId: 0
} as any,
picUrl: item.picUrl,
// 使用本地文件协议
playMusicUrl: getLocalFilePath(item.path),
source: 'netease' as 'netease',
count: 0
};
console.log('开始播放本地音乐:', song.name, '路径:', song.playMusicUrl);
// 播放歌曲
await playerStore.setPlay(song);
playerStore.setPlayMusic(true);
playerStore.setIsPlay(true);
message.success(t('download.playStarted', { name: item.filename }));
} catch (error) {
console.error('播放音乐失败:', error);
message.error(t('download.playFailed', { name: item.filename }));
}
};
// 删除相关
const showDeleteConfirm = ref(false);
const itemToDelete = ref<DownloadedItem | null>(null);
// 处理删除点击
const handleDelete = (item: DownloadedItem) => {
itemToDelete.value = item;
showDeleteConfirm.value = true;
};
// 确认删除
const confirmDelete = async () => {
const item = itemToDelete.value;
if (!item) return;
try {
const success = await window.electron.ipcRenderer.invoke(
'delete-downloaded-music',
item.path
);
if (success) {
const newList = downloadedList.value.filter(i => i.id !== item.id);
downloadedList.value = newList;
localStorage.setItem('downloadedList', JSON.stringify(newList));
message.success(t('download.delete.success'));
} else {
message.warning(t('download.delete.fileNotFound'));
}
} catch (error) {
console.error('Failed to delete music:', error);
message.warning(t('download.delete.recordRemoved'));
} finally {
showDeleteConfirm.value = false;
itemToDelete.value = null;
}
};
// 清空下载记录相关
const showClearConfirm = ref(false);
// 清空下载记录
const clearDownloadRecords = async () => {
try {
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
await window.electron.ipcRenderer.invoke('clear-downloaded-music');
message.success(t('download.clear.success'));
} catch (error) {
console.error('Failed to clear download records:', error);
message.error(t('download.clear.failed'));
} finally {
showClearConfirm.value = false;
}
};
// 添加加载状态
const isLoadingDownloaded = ref(false);
// 获取已下载音乐列表
const refreshDownloadedList = async () => {
if (isLoadingDownloaded.value) return; // 防止重复加载
try {
isLoadingDownloaded.value = true;
const list = await window.electron.ipcRenderer.invoke('get-downloaded-music');
if (!Array.isArray(list) || list.length === 0) {
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
return;
}
const songIds = list.filter(item => item.id).map(item => item.id);
if (songIds.length === 0) {
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
return;
}
try {
const detailRes = await getMusicDetail(songIds);
const songDetails = detailRes.data.songs.reduce((acc, song) => {
acc[song.id] = song;
return acc;
}, {});
const updatedList = list.map(item => ({
...item,
picUrl: songDetails[item.id]?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetails[item.id]?.ar || item.ar || [{ name: t('download.localMusic') }]
}));
downloadedList.value = updatedList;
localStorage.setItem('downloadedList', JSON.stringify(updatedList));
} catch (error) {
console.error('Failed to get music details:', error);
downloadedList.value = list;
localStorage.setItem('downloadedList', JSON.stringify(list));
}
} catch (error) {
console.error('Failed to get downloaded music list:', error);
downloadedList.value = [];
localStorage.setItem('downloadedList', '[]');
} finally {
isLoadingDownloaded.value = false;
}
};
watch(
() => tabName.value,
(newVal) => {
if (newVal) {
refreshDownloadedList();
}
}
);
// 监听下载进度
onMounted(() => {
refreshDownloadedList();
// 监听下载进度
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
// 如果进度为100%,将状态设置为已完成
if (data.progress === 100) {
data.status = 'completed';
}
if (existingItem) {
Object.assign(existingItem, {
...data,
songInfo: data.songInfo || existingItem.songInfo
});
// 如果下载完成,从列表中移除
if (data.status === 'completed') {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
}
} else {
downloadList.value.push({
...data,
songInfo: data.songInfo
});
}
});
// 监听下载完成
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
if (data.success) {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
// 延迟刷新已下载列表,避免文件系统未完全写入
setTimeout(() => refreshDownloadedList(), 500);
message.success(t('download.message.downloadComplete', { filename: data.filename }));
} else {
const existingItem = downloadList.value.find(item => item.filename === data.filename);
if (existingItem) {
Object.assign(existingItem, {
status: 'error',
error: data.error,
progress: 0
});
setTimeout(() => {
downloadList.value = downloadList.value.filter(item => item.filename !== data.filename);
}, 3000);
}
message.error(t('download.message.downloadFailed', { filename: data.filename, error: data.error }));
}
});
// 监听下载队列
window.electron.ipcRenderer.on('music-download-queued', (_, data) => {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
if (!existingItem) {
downloadList.value.push({
filename: data.filename,
progress: 0,
loaded: 0,
total: 0,
path: '',
status: 'downloading',
songInfo: data.songInfo
});
}
});
});
</script>
<style lang="scss" scoped>
/* macOS style with Neumorphism */
.download-page {
@apply h-full w-full flex flex-col overflow-hidden;
@apply bg-gray-50 dark:bg-dark-200;
@apply text-gray-900 dark:text-gray-100;
}
.page-header {
@apply px-4 py-3;
@apply bg-white/90 dark:bg-dark-100/90;
@apply border-b border-gray-200/50 dark:border-gray-700/50;
@apply backdrop-blur-xl backdrop-saturate-150;
@apply sticky top-0 z-10;
@apply flex items-center justify-between;
}
.page-title {
@apply text-lg font-medium;
@apply text-gray-800 dark:text-gray-200;
}
.segment-control {
@apply flex rounded-lg overflow-hidden;
@apply bg-gray-100 dark:bg-dark-300;
@apply w-max;
@apply border border-gray-200/60 dark:border-gray-700/60;
}
.segment-item {
@apply px-3 py-1 cursor-pointer text-sm font-medium;
@apply text-gray-500 dark:text-gray-400;
@apply transition-all duration-200;
@apply hover:bg-light-300 dark:hover:bg-dark-300;
&.active {
@apply bg-white dark:bg-dark-200;
@apply text-gray-900 dark:text-gray-100;
@apply shadow-sm;
}
}
.page-content {
@apply flex-1 overflow-hidden;
}
.tab-content {
@apply h-full overflow-auto pb-16;
}
/* Empty & Loading States */
.empty-state,
.loading-state {
@apply flex flex-col items-center justify-center h-full;
@apply py-16;
}
.empty-icon {
@apply text-4xl mb-3 text-gray-300 dark:text-gray-600;
}
.empty-title {
@apply text-base font-medium mb-1;
@apply text-gray-500 dark:text-gray-400;
}
.empty-text {
@apply text-xs text-gray-400 dark:text-gray-500;
}
.spinner {
@apply w-8 h-8 rounded-full border border-t-primary;
@apply animate-spin mb-3;
}
.loading-text {
@apply text-sm text-gray-500 dark:text-gray-400;
}
/* Progress Bar */
.total-progress {
@apply px-4 py-3;
@apply bg-white/90 dark:bg-dark-100/90;
@apply backdrop-blur-lg backdrop-saturate-150;
@apply border-b border-gray-200/50 dark:border-gray-700/50;
@apply sticky top-0 z-10;
}
.progress-header {
@apply flex justify-between items-center mb-2;
}
.progress-title {
@apply text-xs font-medium;
@apply text-gray-700 dark:text-gray-300;
}
.progress-info {
@apply text-xs;
@apply text-gray-500 dark:text-gray-400;
}
.progress-bar-wrapper {
@apply w-full;
}
.progress-bar {
@apply h-1.5 rounded-full w-full;
@apply bg-gray-200 dark:bg-dark-300;
@apply overflow-hidden;
}
.progress-fill {
@apply h-full rounded-full;
@apply bg-primary;
@apply transition-all duration-300;
&.status-downloading {
@apply bg-primary;
}
&.status-completed {
@apply bg-green-500;
}
&.status-error {
@apply bg-red-500;
}
}
/* Download Items */
.download-items {
@apply p-4 space-y-3;
}
.download-item {
@apply rounded-lg p-3;
@apply bg-white dark:bg-dark-100;
@apply border border-gray-200/60 dark:border-gray-700/50;
@apply shadow-sm;
@apply transition-all duration-300;
&:hover {
@apply shadow-md;
@apply transform -translate-y-0.5;
}
.item-left {
@apply flex gap-3;
}
.item-cover {
@apply w-12 h-12 rounded-md overflow-hidden;
@apply flex-shrink-0;
@apply bg-gray-100 dark:bg-dark-300;
@apply shadow-sm;
img {
@apply w-full h-full object-cover;
}
}
.item-info {
@apply flex-1 min-w-0 flex items-center justify-between;
}
.item-name {
@apply text-sm font-medium truncate;
}
.item-artist {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.item-progress {
@apply mb-2;
}
.item-details {
@apply flex justify-between items-center;
}
.item-size {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.item-status-badge {
@apply text-xs px-2 py-0.5 rounded-full;
@apply bg-gray-100 dark:bg-dark-300;
&.status-downloading {
@apply bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400;
}
&.status-completed {
@apply bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400;
}
&.status-error {
@apply bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400;
}
}
}
/* Downloaded List */
.downloaded-header {
@apply px-4 py-3;
@apply bg-white/90 dark:bg-dark-100/90;
@apply backdrop-blur-lg backdrop-saturate-150;
@apply border-b border-gray-200/50 dark:border-gray-700/50;
@apply sticky top-0 z-10;
@apply flex items-center justify-between;
}
.header-info {
@apply flex items-center gap-2;
@apply text-xs font-medium;
@apply text-gray-700 dark:text-gray-300;
i {
@apply text-gray-400 dark:text-gray-500;
}
}
.clear-button {
@apply flex items-center gap-1;
@apply px-2 py-1 rounded-md;
@apply text-xs font-medium;
@apply bg-gray-100 dark:bg-dark-300;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-gray-200 dark:hover:bg-dark-300;
@apply transition-colors duration-200;
}
.downloaded-items {
@apply p-4 space-y-3;
}
.downloaded-item {
@apply p-3 rounded-lg;
@apply bg-white dark:bg-dark-100;
@apply border border-gray-200/60 dark:border-gray-700/50;
@apply shadow-sm;
@apply flex gap-3;
@apply transition-all duration-300;
&:hover {
@apply shadow-md;
@apply transform -translate-y-0.5;
}
.item-cover {
@apply w-14 h-14 rounded-md overflow-hidden;
@apply flex-shrink-0;
@apply bg-gray-100 dark:bg-dark-300;
@apply shadow-sm;
img {
@apply w-full h-full object-cover;
}
}
.item-info {
@apply flex-1 flex justify-between items-center;
@apply min-w-0;
}
.item-primary {
@apply flex-1 min-w-0 flex items-center justify-between;
}
.item-name {
@apply text-sm font-medium truncate;
}
.item-artist,
.item-size {
@apply flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400;
i {
@apply text-gray-400 dark:text-gray-500;
}
}
.item-path {
@apply flex items-center gap-1 text-xs;
@apply text-gray-500 dark:text-gray-400;
@apply bg-gray-50 dark:bg-dark-300;
@apply rounded-md py-1 px-2;
@apply truncate;
i {
@apply text-gray-400 dark:text-gray-500 flex-shrink-0;
}
span {
@apply truncate flex-1;
}
.copy-button {
@apply ml-1 opacity-0;
@apply text-gray-400 hover:text-primary;
@apply transition-all duration-200;
}
&:hover .copy-button {
@apply opacity-100;
}
}
.item-actions {
@apply flex gap-1;
@apply ml-2;
}
.action-btn {
@apply flex items-center gap-1;
@apply px-2 py-1 rounded-md;
@apply text-xs;
@apply transition-colors duration-200;
&.play {
@apply text-primary dark:text-white;
@apply hover:bg-gray-100 dark:hover:bg-dark-300 hover:text-green-500;
}
&.open {
@apply text-gray-600 dark:text-gray-300;
@apply hover:bg-gray-100 dark:hover:bg-dark-300;
}
&.delete {
@apply text-red-500;
@apply hover:bg-red-500/10;
}
}
}
/* Modal */
.modal-overlay {
@apply fixed inset-0 z-50;
@apply bg-black/40 backdrop-blur-sm;
@apply flex items-center justify-center;
}
.modal-content {
@apply bg-white dark:bg-dark-100;
@apply rounded-lg overflow-hidden;
@apply shadow-xl;
@apply w-full max-w-sm;
@apply border border-gray-200/60 dark:border-gray-700/50;
@apply animate-fade-in;
}
.modal-header {
@apply flex items-center gap-2;
@apply px-4 py-3;
@apply border-b border-gray-100 dark:border-gray-800;
@apply text-gray-900 dark:text-gray-100;
@apply font-medium;
i {
@apply text-amber-500 dark:text-amber-400;
}
}
.modal-body {
@apply px-4 py-4;
@apply text-sm text-gray-700 dark:text-gray-300;
}
.modal-footer {
@apply px-4 py-3;
@apply flex justify-end gap-2;
@apply border-t border-gray-100 dark:border-gray-800;
}
.modal-btn {
@apply px-3 py-1.5 rounded-md;
@apply text-sm font-medium;
@apply transition-colors duration-200;
&.cancel {
@apply bg-gray-100 dark:bg-dark-300;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-gray-200 dark:hover:bg-dark-300;
}
&.confirm {
@apply bg-amber-500 dark:bg-amber-600;
@apply text-white;
@apply hover:bg-amber-600 dark:hover:bg-amber-700;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>