mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-19 03:57:28 +08:00
refactor(download): 重构下载系统,支持暂停/恢复/取消,修复歌词加载
- 新建 DownloadManager 类(主进程),每个任务独立 AbortController 控制 - 新建 Pinia useDownloadStore 作为渲染进程单一数据源 - 支持暂停/恢复/取消下载,支持断点续传(Range header) - 批量下载全部完成后发送汇总系统通知,单首不重复通知 - 并发数可配置(1-5),队列持久化(重启后恢复) - 修复下载列表不全、封面加载失败、通知重复等 bug - 修复本地/下载歌曲歌词加载:优先从 ID3/FLAC 元数据提取,API 作为 fallback - 删除 useDownloadStatus.ts,统一状态管理 - DownloadDrawer/DownloadPage 全面重写,移除 @apply 违规 - 新增 5 语言 i18n 键值(暂停/恢复/取消/排队中等)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { isElectron } from '@/utils';
|
||||
|
||||
import {
|
||||
createDefaultDownloadSettings,
|
||||
DOWNLOAD_TASK_STATE,
|
||||
type DownloadSettings,
|
||||
type DownloadTask
|
||||
} from '../../../shared/download';
|
||||
|
||||
const DEFAULT_COVER = '/images/default_cover.png';
|
||||
|
||||
function validatePicUrl(url?: string): string {
|
||||
if (!url || url === '' || url.startsWith('/')) return DEFAULT_COVER;
|
||||
return url.replace(/^http:\/\//, 'https://');
|
||||
}
|
||||
|
||||
export const useDownloadStore = defineStore(
|
||||
'download',
|
||||
() => {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
const tasks = ref(new Map<string, DownloadTask>());
|
||||
const completedList = ref<any[]>([]);
|
||||
const settings = ref<DownloadSettings>(createDefaultDownloadSettings());
|
||||
const isLoadingCompleted = ref(false);
|
||||
|
||||
// Track whether IPC listeners have been registered
|
||||
let listenersInitialised = false;
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────────
|
||||
const downloadingList = computed(() => {
|
||||
const active = [
|
||||
DOWNLOAD_TASK_STATE.queued,
|
||||
DOWNLOAD_TASK_STATE.downloading,
|
||||
DOWNLOAD_TASK_STATE.paused
|
||||
] as string[];
|
||||
return [...tasks.value.values()]
|
||||
.filter((t) => active.includes(t.state))
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
});
|
||||
|
||||
const downloadingCount = computed(() => downloadingList.value.length);
|
||||
|
||||
const totalProgress = computed(() => {
|
||||
const list = downloadingList.value;
|
||||
if (list.length === 0) return 0;
|
||||
const sum = list.reduce((acc, t) => acc + t.progress, 0);
|
||||
return sum / list.length;
|
||||
});
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────
|
||||
const addDownload = async (songInfo: DownloadTask['songInfo'], url: string, type: string) => {
|
||||
if (!isElectron) return;
|
||||
const validatedInfo = {
|
||||
...songInfo,
|
||||
picUrl: validatePicUrl(songInfo.picUrl)
|
||||
};
|
||||
const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? '';
|
||||
const filename = `${validatedInfo.name} - ${artistNames}`;
|
||||
await window.api.downloadAdd({ url, filename, songInfo: validatedInfo, type });
|
||||
};
|
||||
|
||||
const batchDownload = async (
|
||||
items: Array<{ songInfo: DownloadTask['songInfo']; url: string; type: string }>
|
||||
) => {
|
||||
if (!isElectron) return;
|
||||
const validatedItems = items.map((item) => {
|
||||
const validatedInfo = {
|
||||
...item.songInfo,
|
||||
picUrl: validatePicUrl(item.songInfo.picUrl)
|
||||
};
|
||||
const artistNames = validatedInfo.ar?.map((a) => a.name).join(',') ?? '';
|
||||
const filename = `${validatedInfo.name} - ${artistNames}`;
|
||||
return { url: item.url, filename, songInfo: validatedInfo, type: item.type };
|
||||
});
|
||||
await window.api.downloadAddBatch({ items: validatedItems });
|
||||
};
|
||||
|
||||
const pauseTask = async (taskId: string) => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadPause(taskId);
|
||||
};
|
||||
|
||||
const resumeTask = async (taskId: string) => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadResume(taskId);
|
||||
};
|
||||
|
||||
const cancelTask = async (taskId: string) => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadCancel(taskId);
|
||||
tasks.value.delete(taskId);
|
||||
};
|
||||
|
||||
const cancelAll = async () => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadCancelAll();
|
||||
tasks.value.clear();
|
||||
};
|
||||
|
||||
const updateConcurrency = async (n: number) => {
|
||||
if (!isElectron) return;
|
||||
const clamped = Math.min(5, Math.max(1, n));
|
||||
settings.value = { ...settings.value, maxConcurrent: clamped };
|
||||
await window.api.downloadSetConcurrency(clamped);
|
||||
};
|
||||
|
||||
const refreshCompleted = async () => {
|
||||
if (!isElectron) return;
|
||||
isLoadingCompleted.value = true;
|
||||
try {
|
||||
const list = await window.api.downloadGetCompleted();
|
||||
completedList.value = list;
|
||||
} finally {
|
||||
isLoadingCompleted.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCompleted = async (filePath: string) => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadDeleteCompleted(filePath);
|
||||
completedList.value = completedList.value.filter((item) => item.filePath !== filePath);
|
||||
};
|
||||
|
||||
const clearCompleted = async () => {
|
||||
if (!isElectron) return;
|
||||
await window.api.downloadClearCompleted();
|
||||
completedList.value = [];
|
||||
};
|
||||
|
||||
const loadPersistedQueue = async () => {
|
||||
if (!isElectron) return;
|
||||
const queue = await window.api.downloadGetQueue();
|
||||
tasks.value.clear();
|
||||
for (const task of queue) {
|
||||
tasks.value.set(task.taskId, task);
|
||||
}
|
||||
};
|
||||
|
||||
const initListeners = () => {
|
||||
if (!isElectron || listenersInitialised) return;
|
||||
listenersInitialised = true;
|
||||
|
||||
window.api.onDownloadProgress((event) => {
|
||||
const task = tasks.value.get(event.taskId);
|
||||
if (task) {
|
||||
tasks.value.set(event.taskId, {
|
||||
...task,
|
||||
progress: event.progress,
|
||||
loaded: event.loaded,
|
||||
total: event.total
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onDownloadStateChange((event) => {
|
||||
const { taskId, state, task } = event;
|
||||
if (state === DOWNLOAD_TASK_STATE.completed || state === DOWNLOAD_TASK_STATE.cancelled) {
|
||||
tasks.value.delete(taskId);
|
||||
if (state === DOWNLOAD_TASK_STATE.completed) {
|
||||
setTimeout(() => {
|
||||
refreshCompleted();
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
tasks.value.set(taskId, task);
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onDownloadBatchComplete((_event) => {
|
||||
// no-op: main process handles the desktop notification
|
||||
});
|
||||
|
||||
window.api.onDownloadRequestUrl(async (event) => {
|
||||
try {
|
||||
const { getSongUrl } = await import('@/store/modules/player');
|
||||
const result = (await getSongUrl(event.songInfo.id, event.songInfo as any, true)) as any;
|
||||
const url = typeof result === 'string' ? result : (result?.url ?? '');
|
||||
await window.api.downloadProvideUrl(event.taskId, url);
|
||||
} catch (err) {
|
||||
console.error('[downloadStore] onDownloadRequestUrl failed:', err);
|
||||
await window.api.downloadProvideUrl(event.taskId, '');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (!isElectron) return;
|
||||
window.api.removeDownloadListeners();
|
||||
listenersInitialised = false;
|
||||
};
|
||||
|
||||
return {
|
||||
// state
|
||||
tasks,
|
||||
completedList,
|
||||
settings,
|
||||
isLoadingCompleted,
|
||||
// computed
|
||||
downloadingList,
|
||||
downloadingCount,
|
||||
totalProgress,
|
||||
// actions
|
||||
addDownload,
|
||||
batchDownload,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
cancelTask,
|
||||
cancelAll,
|
||||
updateConcurrency,
|
||||
refreshCompleted,
|
||||
deleteCompleted,
|
||||
clearCompleted,
|
||||
loadPersistedQueue,
|
||||
initListeners,
|
||||
cleanup
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'download-settings',
|
||||
// WARNING: Do NOT add 'tasks' — Map doesn't serialize with JSON.stringify
|
||||
pick: ['settings']
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user