feat: parallel downloads, redesigned download overlay (#100)

This commit is contained in:
/home/neo
2026-06-28 18:12:40 +03:00
committed by GitHub
parent 92bd9c6f2a
commit a94e4ec758
13 changed files with 239 additions and 133 deletions
+56 -38
View File
@@ -3,52 +3,70 @@ import { memo } from "react";
import type { Edition } from "../../types/edition";
interface DownloadOverlayProps {
downloadProgress: number | null;
downloadingId: string | null;
downloadProgress: Record<string, number>;
downloadingIds: string[];
editions: Edition[];
}
export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingId, editions }: DownloadOverlayProps) {
if (downloadProgress === null) return null;
export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) {
if (downloadingIds.length === 0) return null;
return (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
className="absolute top-14 right-8 z-100 w-64 p-4 shadow-2xl flex flex-col gap-2"
style={{
backgroundImage: "url('/images/Download_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute top-14 right-8 z-100 w-72 bg-[#1a1a1a] border-2 border-[#555] shadow-2xl"
style={{ imageRendering: "pixelated" }}
>
<div className="flex flex-col gap-1 w-full">
<span className="text-[15px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest text-center w-full">
Downloading
<div className="px-3 pt-2.5 pb-2 border-b border-white/10">
<span className="text-[13px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest">
Downloads
</span>
<div className="text-[10px] text-gray-300 mc-text-shadow truncate uppercase opacity-80 pb-1 text-center w-full">
{editions.find((e) => e.id === downloadingId)?.name || "Game Files"}
</div>
<div className="flex items-center gap-2 w-full">
<span className="text-[10px] text-white mc-text-shadow w-6 text-right shrink-0 flex items-center justify-end h-[14px] leading-none">
{Math.floor(downloadProgress)}%
</span>
<div className="flex-1 h-3.5 border-2 border-white bg-black/40 relative">
<div
className="h-full bg-white transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<div className="w-6 flex items-center justify-start shrink-0">
<img
src="/images/loading.gif"
alt="Loading"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-1.5 px-3 py-2 max-h-[260px] overflow-y-auto custom-scrollbar">
{downloadingIds.map((id) => {
const pct = downloadProgress[id] ?? 0;
const edition = editions.find((e) => e.instanceId === id || e.id === id);
const name = edition?.name || "Game Files";
return (
<div key={id} className="flex items-center gap-2.5">
{edition?.logo ? (
<img
src={edition.logo}
alt=""
className="w-6 h-6 object-contain shrink-0"
style={{ imageRendering: "pixelated" }}
/>
) : (
<div className="w-6 h-6 flex items-center justify-center border border-[#555] bg-black/40 shrink-0">
<svg className="w-3 h-3 text-[#FFFF55]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<span className="text-[11px] text-white mc-text-shadow truncate leading-tight">
{name}
</span>
<div className="flex items-center gap-1.5">
<div className="flex-1 h-2 border border-white/30 bg-black/60">
<div
className="h-full bg-[#FFFF55]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[10px] text-gray-400 mc-text-shadow w-7 text-right shrink-0 leading-none">
{Math.floor(pct)}%
</span>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
);
+70 -16
View File
@@ -28,6 +28,7 @@ export default function DownloadDlcModal({
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<string[]>([]);
const [dlcProgress, setDlcProgress] = useState<Record<string, number>>({});
const [focusIndex, setFocusIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const [branch, repoUrl] = officialDLC.includes(":")
@@ -82,6 +83,7 @@ export default function DownloadDlcModal({
setError(null);
setDownloading(false);
setDownloaded([]);
setDlcProgress({});
setFocusIndex(0);
fetchDlcs();
}
@@ -148,23 +150,46 @@ export default function DownloadDlcModal({
playPressSound();
setDownloading(true);
setDownloaded([]);
for (const dlc of selected) {
try {
await TauriService.downloadDlcFiles(
instanceId,
repoUrl,
branch,
dlc.name,
);
setDownloaded((prev) => [...prev, dlc.name]);
} catch (e) {
setDlcProgress({});
const initialProgress: Record<string, number> = {};
selected.forEach((dlc) => {
initialProgress[dlc.name] = 0;
});
setDlcProgress(initialProgress);
const unlisten = await TauriService.onDownloadProgress((data) => {
if (data.instanceId.startsWith("dlc:")) {
const name = data.instanceId.slice(4);
setDlcProgress((prev) => {
if (prev[name] === undefined) return prev;
return { ...prev, [name]: data.percent };
});
if (data.percent >= 100) {
setDownloaded((prev) =>
prev.includes(name) ? prev : [...prev, name],
);
}
}
});
const results = await Promise.allSettled(
selected.map((dlc) =>
TauriService.downloadDlcFiles(instanceId, repoUrl, branch, dlc.name),
),
);
unlisten();
const succeeded: string[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (r.status === "fulfilled") {
succeeded.push(selected[i].name);
} else {
setError(
`Failed to download ${dlc.name}: ${e instanceof Error ? e.message : String(e)}`,
`Failed to download ${selected[i].name}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
);
setDownloading(false);
return;
}
}
setDownloaded(succeeded);
setDownloading(false);
};
@@ -204,13 +229,13 @@ export default function DownloadDlcModal({
</div>
)}
{!loading && !error && dlcList.length === 0 && (
{!loading && !error && dlcList.length === 0 && !downloading && (
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
No DLC folders found in the repository.
</div>
)}
{!loading && dlcList.length > 0 && (
{!loading && dlcList.length > 0 && !downloading && (
<>
<div className="flex items-center justify-between w-full mb-2 gap-2">
<span className="text-white text-[10px] mc-text-shadow uppercase tracking-widest">
@@ -270,10 +295,39 @@ export default function DownloadDlcModal({
</>
)}
{downloading && (
<div className="w-full max-h-[40vh] overflow-y-auto custom-scrollbar border border-[#373737] bg-black/20 flex flex-col gap-2 p-3">
{dlcList
.filter((d) => d.selected)
.map((dlc) => {
const pct = dlcProgress[dlc.name] ?? 0;
const isDone = downloaded.includes(dlc.name);
return (
<div key={dlc.name} className="flex items-center gap-2">
<span className="text-[11px] text-white mc-text-shadow w-7 text-right shrink-0">
{isDone ? "100" : Math.floor(pct)}%
</span>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<span className="text-[10px] text-gray-300 mc-text-shadow truncate">
{dlc.name}
</span>
<div className="h-2 border border-white/30 bg-black/60">
<div
className="h-full bg-[#FFFF55]"
style={{ width: `${isDone ? 100 : pct}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-center gap-4 mt-4">
{downloading && (
<div className="text-[#FFFF55] text-sm mc-text-shadow">
Downloading... ({downloaded.length}/{selectedCount})
Downloaded {downloaded.length}/{selectedCount}
</div>
)}
</div>
+2 -2
View File
@@ -19,7 +19,7 @@ const HomeView = memo(function HomeView() {
editions,
installs,
toggleInstall,
downloadingId,
downloadingIds,
isGameRunning,
stopGame,
updatesAvailable,
@@ -30,7 +30,7 @@ const HomeView = memo(function HomeView() {
const selectedEdition = editions.find((e: Edition) => e.id === profile);
const selectedVersionName = selectedEdition?.name || "Game";
const isInstalled = installs.includes(profile);
const isDownloading = downloadingId === profile;
const isDownloading = downloadingIds.includes(profile);
const [menuFocus, setMenuFocus] = useState<number | null>(null);
const hasAnyInstall = installs.length > 0;
+12 -14
View File
@@ -74,7 +74,7 @@ const VersionsView = memo(function VersionsView() {
deleteCustomEdition: onDeleteEdition,
addCustomEdition: onAddEdition,
updateCustomEdition: onUpdateEdition,
downloadingId,
downloadingIds,
downloadProgress,
updatesAvailable,
addToSteam,
@@ -150,17 +150,17 @@ const VersionsView = memo(function VersionsView() {
if (focusIndex < editions.length) {
const edition = editions[focusIndex];
const isInstalled = installedVersions.includes(edition.instanceId);
const isDownloading = downloadingId === edition.instanceId;
const isDownloading = downloadingIds.includes(edition.instanceId);
if (focusBtn === 0) {
if (isInstalled) {
playPressSound();
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
} else {
if (!isDownloading && !downloadingId) {
if (!isDownloading) {
playPressSound();
toggleInstall(edition.instanceId);
} else if (isDownloading) {
handleCancelDownload();
} else {
handleCancelDownload(edition.instanceId);
}
}
} else if (focusBtn === 1 && !isInstalled) {
@@ -190,7 +190,7 @@ const VersionsView = memo(function VersionsView() {
focusBtn,
editions,
installedVersions,
downloadingId,
downloadingIds,
ITEM_COUNT,
playPressSound,
playBackSound,
@@ -285,7 +285,7 @@ const VersionsView = memo(function VersionsView() {
hasAnyInstall && selectedProfile === edition.instanceId;
const isFocused = focusIndex === i;
const isCustom = edition.id.startsWith("custom_");
const isDownloading = downloadingId === edition.instanceId;
const isDownloading = downloadingIds.includes(edition.instanceId);
const isComingSoon = edition.comingSoon;
return (
@@ -302,7 +302,7 @@ const VersionsView = memo(function VersionsView() {
<div className="w-6 flex items-center justify-center flex-shrink-0">
{isDownloading ? (
<span className="text-xs text-gray-400 font-bold">
{Math.floor(downloadProgress || 0)}%
{Math.floor(downloadProgress[edition.instanceId] || 0)}%
</span>
) : edition.logo ? (
edition.logo.startsWith("http") ||
@@ -404,10 +404,10 @@ const VersionsView = memo(function VersionsView() {
<button
onClick={(e) => {
e.stopPropagation();
if (!isDownloading && !downloadingId) {
if (!isDownloading) {
toggleInstall(edition.instanceId);
} else if (isDownloading) {
handleCancelDownload();
} else {
handleCancelDownload(edition.instanceId);
}
}}
onMouseEnter={() =>
@@ -415,9 +415,7 @@ const VersionsView = memo(function VersionsView() {
}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-9 h-9 flex items-center justify-center ${
isDownloading || (!!downloadingId && !isInstalled)
? "opacity-50"
: ""
isDownloading ? "opacity-50" : ""
}`}
style={{
backgroundImage: