mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat: parallel downloads, redesigned download overlay (#100)
This commit is contained in:
parent
92bd9c6f2a
commit
a94e4ec758
|
|
@ -1,7 +1,8 @@
|
|||
use std::fs;
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use crate::util;
|
||||
use super::download::DownloadProgress;
|
||||
#[derive(Serialize)]
|
||||
pub struct GitEntry {
|
||||
pub name: String,
|
||||
|
|
@ -180,7 +181,8 @@ pub async fn download_dlc_files(
|
|||
|
||||
fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
for file_path in &files_to_download {
|
||||
let total = files_to_download.len();
|
||||
for (i, file_path) in files_to_download.iter().enumerate() {
|
||||
let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github);
|
||||
let response = client.get(&raw_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
|
|
@ -199,6 +201,10 @@ pub async fn download_dlc_files(
|
|||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::write(&dest_path, &bytes).map_err(|e| format!("Failed to write {}: {}", file_path, e))?;
|
||||
let _ = app.emit("download-progress", DownloadProgress {
|
||||
instance_id: format!("dlc:{}", dlc_folder),
|
||||
percent: ((i + 1) as f64 / total as f64) * 100.0,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -2,30 +2,35 @@ use std::fs;
|
|||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::state::DownloadState;
|
||||
use crate::util;
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadProgress {
|
||||
pub instance_id: String,
|
||||
pub percent: f64,
|
||||
}
|
||||
|
||||
async fn stream_download(
|
||||
app: &AppHandle,
|
||||
state: &DownloadState,
|
||||
url: &str,
|
||||
dest: &std::path::PathBuf,
|
||||
progress_event: &str,
|
||||
instance_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let token = CancellationToken::new();
|
||||
let child_token = token.clone();
|
||||
{
|
||||
let mut lock = state.token.lock().await;
|
||||
if let Some(old_token) = lock.take() {
|
||||
old_token.cancel();
|
||||
}
|
||||
*lock = Some(token);
|
||||
let mut lock = state.tokens.lock().await;
|
||||
lock.insert(instance_id.to_string(), token);
|
||||
}
|
||||
|
||||
let response = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
{ *state.token.lock().await = None; }
|
||||
{ state.tokens.lock().await.remove(instance_id); }
|
||||
return Err(format!("Download failed: {}", response.status()));
|
||||
}
|
||||
|
||||
|
|
@ -41,19 +46,22 @@ async fn stream_download(
|
|||
if child_token.is_cancelled() {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(dest);
|
||||
{ *state.token.lock().await = None; }
|
||||
{ state.tokens.lock().await.remove(instance_id); }
|
||||
return Err("CANCELLED".into());
|
||||
}
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
downloaded += chunk.len() as f64;
|
||||
if total_size > 0.0 {
|
||||
let _ = app.emit(progress_event, downloaded / total_size * 100.0);
|
||||
let _ = app.emit("download-progress", DownloadProgress {
|
||||
instance_id: instance_id.to_string(),
|
||||
percent: downloaded / total_size * 100.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drop(file);
|
||||
{ *state.token.lock().await = None; }
|
||||
{ state.tokens.lock().await.remove(instance_id); }
|
||||
Ok(last_modified)
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +70,7 @@ async fn download_with_retry(
|
|||
state: &DownloadState,
|
||||
url: &str,
|
||||
dest: &std::path::PathBuf,
|
||||
progress_event: &str,
|
||||
instance_id: &str,
|
||||
max_retries: u32,
|
||||
) -> Result<String, String> {
|
||||
let mut last_error = String::new();
|
||||
|
|
@ -71,8 +79,8 @@ async fn download_with_retry(
|
|||
let backoff = Duration::from_secs(2u64.pow(attempt - 2));
|
||||
let cancel = CancellationToken::new();
|
||||
{
|
||||
let mut lock = state.token.lock().await;
|
||||
*lock = Some(cancel.clone());
|
||||
let mut lock = state.tokens.lock().await;
|
||||
lock.insert(instance_id.to_string(), cancel.clone());
|
||||
}
|
||||
let _ = app.emit("download-retry", attempt);
|
||||
tokio::select! {
|
||||
|
|
@ -83,7 +91,7 @@ async fn download_with_retry(
|
|||
}
|
||||
}
|
||||
}
|
||||
match stream_download(app, state, url, dest, progress_event).await {
|
||||
match stream_download(app, state, url, dest, instance_id).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if e == "CANCELLED" => return Err(e),
|
||||
Err(e) => {
|
||||
|
|
@ -107,7 +115,7 @@ pub async fn download_and_install(
|
|||
let instance_dir = util::get_instance_working_dir(&app, &instance_id);
|
||||
let root = util::get_app_dir(&app);
|
||||
let zip_path = root.join(format!("temp_{}.zip", instance_id));
|
||||
let last_modified = download_with_retry(&app, &state, &url, &zip_path, "download-progress", 3).await?;
|
||||
let last_modified = download_with_retry(&app, &state, &url, &zip_path, &instance_id, 3).await?;
|
||||
let keep_list: std::collections::HashSet<&str> = [
|
||||
"Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat",
|
||||
"servers.dat", "servers.txt", "server.properties", "options.txt", "servers.db",
|
||||
|
|
@ -226,8 +234,11 @@ pub async fn download_and_install(
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_download(state: State<'_, DownloadState>) -> Result<(), String> {
|
||||
if let Some(token) = state.token.lock().await.take() { token.cancel(); }
|
||||
pub async fn cancel_download(state: State<'_, DownloadState>, instance_id: String) -> Result<(), String> {
|
||||
let token = state.tokens.lock().await.remove(&instance_id);
|
||||
if let Some(t) = token {
|
||||
t.cancel();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -245,10 +256,9 @@ pub async fn download_runner(
|
|||
let _ = fs::remove_dir_all(&runner_dir);
|
||||
}
|
||||
fs::create_dir_all(&runner_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let tarball_path = runners_dir.join(format!("{}.tar.gz", name));
|
||||
download_with_retry(&app, &state, &url, &tarball_path, "runner-download-progress", 3).await?;
|
||||
|
||||
let runner_id = format!("runner_{}", name);
|
||||
download_with_retry(&app, &state, &url, &tarball_path, &runner_id, 3).await?;
|
||||
let status = std::process::Command::new("tar")
|
||||
.args(["-zxf", tarball_path.to_str().unwrap(), "-C", runner_dir.to_str().unwrap(), "--strip-components=1"])
|
||||
.status()
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(DownloadState {
|
||||
token: Arc::new(Mutex::new(None)),
|
||||
tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
.manage(GameState {
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
pub struct DownloadState {
|
||||
pub token: Arc<Mutex<Option<CancellationToken>>>,
|
||||
pub tokens: Arc<Mutex<HashMap<String, CancellationToken>>>,
|
||||
}
|
||||
|
||||
pub struct GameState {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const game = useMemo(() => gameRaw, [
|
||||
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
|
||||
gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
|
||||
gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading,
|
||||
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
|
||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
|
||||
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
|
||||
|
|
@ -94,7 +94,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
activeView,
|
||||
isGameRunning: game.isGameRunning,
|
||||
downloadProgress: game.downloadProgress,
|
||||
downloadingId: game.downloadingId,
|
||||
downloadingIds: game.downloadingIds,
|
||||
editions: game.editions,
|
||||
isWindowVisible,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ interface DiscordRPCProps {
|
|||
activeView: string;
|
||||
isGameRunning: boolean;
|
||||
isWindowVisible: boolean;
|
||||
downloadProgress: number | null;
|
||||
downloadingId: string | null;
|
||||
downloadProgress: Record<string, number>;
|
||||
downloadingIds: string[];
|
||||
editions: Edition[];
|
||||
}
|
||||
|
||||
|
|
@ -23,15 +23,18 @@ export function useDiscordRPC({
|
|||
isGameRunning,
|
||||
isWindowVisible,
|
||||
downloadProgress,
|
||||
downloadingId,
|
||||
downloadingIds,
|
||||
editions,
|
||||
}: DiscordRPCProps) {
|
||||
useEffect(() => {
|
||||
const updateRPC = async () => {
|
||||
if (!rpcEnabled || showIntro || !username) return;
|
||||
if (!isWindowVisible && !isGameRunning && downloadProgress === null)
|
||||
if (
|
||||
!isWindowVisible &&
|
||||
!isGameRunning &&
|
||||
Object.keys(downloadProgress).length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
const version = editions.find((e) => e.id === profile);
|
||||
const versionName = version ? version.name : "Unknown Version";
|
||||
let details = "In Menus";
|
||||
|
|
@ -41,10 +44,16 @@ export function useDiscordRPC({
|
|||
|
||||
if (isGameRunning) {
|
||||
details = `Playing ${versionName}`;
|
||||
} else if (downloadProgress !== null) {
|
||||
} else if (downloadingIds.length > 0) {
|
||||
const firstId = downloadingIds[0];
|
||||
const pct = downloadProgress[firstId];
|
||||
const downloadingName =
|
||||
editions.find((e) => e.id === downloadingId)?.name || "Game Files";
|
||||
details = `Downloading ${downloadingName} (${downloadProgress.toFixed(0)}%)`;
|
||||
editions.find((e) => e.id === firstId)?.name || "Game Files";
|
||||
const extra =
|
||||
downloadingIds.length > 1
|
||||
? ` +${downloadingIds.length - 1} more`
|
||||
: "";
|
||||
details = `Downloading ${downloadingName}${extra} (${(pct ?? 0).toFixed(0)}%)`;
|
||||
} else {
|
||||
const tabNames: Record<string, string> = {
|
||||
main: "Main Menu",
|
||||
|
|
@ -78,8 +87,8 @@ export function useDiscordRPC({
|
|||
activeView,
|
||||
isGameRunning,
|
||||
isWindowVisible,
|
||||
Math.floor(downloadProgress || 0),
|
||||
downloadingId,
|
||||
downloadProgress,
|
||||
downloadingIds,
|
||||
editions,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ export function useGameManager({
|
|||
}: GameManagerProps) {
|
||||
const [installs, setInstalls] = useState<string[]>([]);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<Record<string, number>>({});
|
||||
const [downloadingIds, setDownloadingIds] = useState<string[]>([]);
|
||||
const [isRunnerDownloading, setIsRunnerDownloading] = useState(false);
|
||||
const [runnerDownloadProgress, setRunnerDownloadProgress] = useState<
|
||||
number | null
|
||||
|
|
@ -359,8 +359,8 @@ export function useGameManager({
|
|||
|
||||
useEffect(() => {
|
||||
checkInstalls();
|
||||
const unlistenDownload = TauriService.onDownloadProgress((p) =>
|
||||
setDownloadProgress(p),
|
||||
const unlistenDownload = TauriService.onDownloadProgress((data) =>
|
||||
setDownloadProgress((prev) => ({ ...prev, [data.instanceId]: data.percent })),
|
||||
);
|
||||
const unlistenRunner = TauriService.onRunnerDownloadProgress((p) =>
|
||||
setRunnerDownloadProgress(p),
|
||||
|
|
@ -406,19 +406,23 @@ export function useGameManager({
|
|||
|
||||
const toggleInstall = useCallback(
|
||||
async (id: string) => {
|
||||
if (downloadingId) return;
|
||||
if (downloadingIds.includes(id)) return;
|
||||
const edition = editions.find((e) => e.instanceId === id);
|
||||
if (!edition) return;
|
||||
setError(null);
|
||||
try {
|
||||
setDownloadingId(id);
|
||||
setDownloadProgress(0);
|
||||
setDownloadingIds((prev) => [...prev, id]);
|
||||
setDownloadProgress((prev) => ({ ...prev, [id]: 0 }));
|
||||
await TauriService.downloadAndInstall(edition.url, id);
|
||||
await TauriService.syncDlc(id);
|
||||
await checkInstalls();
|
||||
setProfile(id);
|
||||
setDownloadProgress(null);
|
||||
setDownloadingId(null);
|
||||
setDownloadProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setDownloadingIds((prev) => prev.filter((did) => did !== id));
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
setError(
|
||||
|
|
@ -428,11 +432,15 @@ export function useGameManager({
|
|||
? e
|
||||
: "Failed to install version",
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
setDownloadingId(null);
|
||||
setDownloadProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setDownloadingIds((prev) => prev.filter((did) => did !== id));
|
||||
}
|
||||
},
|
||||
[downloadingId, editions, checkInstalls, setProfile],
|
||||
[downloadingIds, editions, checkInstalls, setProfile],
|
||||
);
|
||||
|
||||
const handleUninstall = useCallback(
|
||||
|
|
@ -443,18 +451,21 @@ export function useGameManager({
|
|||
[checkInstalls],
|
||||
);
|
||||
|
||||
const handleCancelDownload = useCallback(async () => {
|
||||
if (!downloadingId) return;
|
||||
const handleCancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await TauriService.cancelDownload();
|
||||
await TauriService.deleteInstance(downloadingId);
|
||||
setDownloadingId(null);
|
||||
setDownloadProgress(null);
|
||||
await TauriService.cancelDownload(id);
|
||||
await TauriService.deleteInstance(id);
|
||||
setDownloadProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setDownloadingIds((prev) => prev.filter((did) => did !== id));
|
||||
await checkInstalls();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [downloadingId, checkInstalls]);
|
||||
}, [checkInstalls]);
|
||||
|
||||
const handleLaunch = useCallback(async () => {
|
||||
if (isGameRunning) return;
|
||||
|
|
@ -589,7 +600,7 @@ export function useGameManager({
|
|||
installs,
|
||||
isGameRunning,
|
||||
downloadProgress,
|
||||
downloadingId,
|
||||
downloadingIds,
|
||||
isRunnerDownloading,
|
||||
runnerDownloadProgress,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export default function App() {
|
|||
{
|
||||
isGameRunning: game.isGameRunning,
|
||||
downloadProgress: game.downloadProgress,
|
||||
downloadingId: game.downloadingId,
|
||||
downloadingIds: game.downloadingIds,
|
||||
},
|
||||
game.installs,
|
||||
);
|
||||
|
|
@ -124,7 +124,7 @@ export default function App() {
|
|||
config,
|
||||
game.isGameRunning,
|
||||
game.downloadProgress,
|
||||
game.downloadingId,
|
||||
game.downloadingIds,
|
||||
game.installs,
|
||||
config.isLoaded,
|
||||
]);
|
||||
|
|
@ -367,7 +367,7 @@ export default function App() {
|
|||
|
||||
<DownloadOverlay
|
||||
downloadProgress={game.downloadProgress}
|
||||
downloadingId={game.downloadingId}
|
||||
downloadingIds={game.downloadingIds}
|
||||
editions={game.editions}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ export class TauriService {
|
|||
return invoke("delete_instance", { instanceId });
|
||||
}
|
||||
|
||||
static async cancelDownload(): Promise<void> {
|
||||
return invoke("cancel_download");
|
||||
static async cancelDownload(instanceId: string): Promise<void> {
|
||||
return invoke("cancel_download", { instanceId });
|
||||
}
|
||||
|
||||
static async setupMacosRuntime(): Promise<void> {
|
||||
|
|
@ -188,8 +188,8 @@ export class TauriService {
|
|||
return invoke("workshop_list_installed");
|
||||
}
|
||||
|
||||
static onDownloadProgress(callback: (percent: number) => void) {
|
||||
return listen<number>("download-progress", (event) =>
|
||||
static onDownloadProgress(callback: (data: { instanceId: string; percent: number }) => void) {
|
||||
return listen<{ instanceId: string; percent: number }>("download-progress", (event) =>
|
||||
callback(event.payload),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue