mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-24 17:11:00 +00:00
feat: parallel downloads, redesigned download overlay (#100)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user