diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 866719e..89b3683 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -2,6 +2,7 @@ import React from "react"; import StickyActionBar from "@/components/Hack/StickyActionBar"; +import PatchProgressBar from "@/components/Hack/PatchProgressBar"; import { useBaseRoms } from "@/contexts/BaseRomContext"; import { baseRoms } from "@/data/baseRoms"; import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js"; @@ -9,6 +10,19 @@ import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js" import type { DownloadEventDetail } from "@/types/util"; import { getSignedPatchUrl, updatePatchDownloadCount } from "@/app/hack/[slug]/actions"; +const PROGRESS_MESSAGES = { + PREPARING_DOWNLOAD: "Preparing download...", + DOWNLOADING_PATCH: "Downloading patch file...", + DOWNLOAD_COMPLETE: "Download complete!", + PREPARING_PATCH: "Preparing to patch...", + LOADING_BASE_ROM: "Loading base ROM...", + READING_ROM_FILES: "Reading ROM files...", + BUILDING_PATCH_FILES: "Building patch files...", + APPLYING_PATCH: "Applying patch...", + PREPARING_FINAL_DOWNLOAD: "Preparing download...", + COMPLETE: "Complete!", +} as const; + interface HackActionsProps { title: string; version: string; @@ -20,7 +34,7 @@ interface HackActionsProps { hackSlug: string; } -const HackActions: React.FC = ({ +export default function HackActions({ title, version, author, @@ -29,7 +43,7 @@ const HackActions: React.FC = ({ patchFilename, patchId, hackSlug, -}) => { +}: HackActionsProps) { const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms(); const [file, setFile] = React.useState(null); const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle"); @@ -37,12 +51,20 @@ const HackActions: React.FC = ({ const [patchBlob, setPatchBlob] = React.useState(null); const [patchUrl, setPatchUrl] = React.useState(null); const [termsAgreed, setTermsAgreed] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [progressLabel, setProgressLabel] = React.useState(""); const baseRomName = React.useMemo(() => baseRoms.find(r => r.id === baseRomId)?.name || null, [baseRomId]); + const progressBarHeight = React.useMemo(() => { + const isVisible = status === "downloading" || status === "patching" || progress > 0; + if (!isVisible) return 0; + return progressLabel ? 64 : 44; + }, [status, progress, progressLabel]); + // Basic client-side bot detection React.useEffect(() => { - if (typeof window === 'undefined') return; - if (typeof localStorage === 'undefined') { + if (typeof window === "undefined") return; + if (typeof localStorage === "undefined") { setError("Browser features not available"); return; } @@ -74,7 +96,7 @@ const HackActions: React.FC = ({ } return () => { if (timeoutId) clearTimeout(timeoutId); - } + }; }, [error]); // When patch URL is fetched and terms are agreed, automatically proceed with patching if ROM is ready @@ -118,23 +140,63 @@ const HackActions: React.FC = ({ try { setError(null); setStatus("downloading"); + setProgress(10); + setProgressLabel(PROGRESS_MESSAGES.PREPARING_DOWNLOAD); // Fetch signed URL from server const result = await getSignedPatchUrl(hackSlug); if (!result.ok) { setError(result.error); setStatus("idle"); + setProgress(0); + setProgressLabel(""); return; } setPatchUrl(result.url); setTermsAgreed(true); + setProgress(30); + setProgressLabel(PROGRESS_MESSAGES.DOWNLOADING_PATCH); // Download patch blob const res = await fetch(result.url); if (!res.ok) throw new Error("Failed to fetch patch"); - const blob = await res.blob(); - setPatchBlob(blob); + + const contentLength = res.headers.get("content-length"); + const total = contentLength ? parseInt(contentLength, 10) : 0; + + if (total > 0 && res.body) { + const reader = res.body.getReader(); + let receivedLength = 0; + const chunks: Uint8Array[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + receivedLength += value.length; + + const downloadProgress = 30 + (receivedLength / total) * 60; + setProgress(downloadProgress); + } + + setProgress(90); + const blob = new Blob(chunks); + setPatchBlob(blob); + } else { + const blob = await res.blob(); + setPatchBlob(blob); + setProgress(90); + } + + setProgress(100); + setProgressLabel(PROGRESS_MESSAGES.DOWNLOAD_COMPLETE); + + setTimeout(() => { + setProgress(0); + setProgressLabel(""); + }, 500); // Update status based on ROM readiness const romReady = !!file || (isLinked(baseRomId) && (hasPermission(baseRomId) || hasCached(baseRomId))); @@ -146,6 +208,8 @@ const HackActions: React.FC = ({ } catch (e: any) { setError(e?.message || "Failed to fetch patch URL"); setStatus("idle"); + setProgress(0); + setProgressLabel(""); } } @@ -164,20 +228,55 @@ const HackActions: React.FC = ({ return; } + setProgress(5); + setProgressLabel(PROGRESS_MESSAGES.PREPARING_PATCH); + setStatus("patching"); + + // this is to make sure it slides down properly + await new Promise(resolve => { + let count = 0; + const waitFrames = () => { + if (++count < 18) { + requestAnimationFrame(waitFrames); + } else { + resolve(undefined); + } + }; + requestAnimationFrame(waitFrames); + }); + let baseFile = file; if (!baseFile) { if (!isLinked(baseRomId) && !hasCached(baseRomId)) return; if (!hasCached(baseRomId)) { const perm = await ensurePermission(baseRomId, true); - if (perm !== "granted") return; + if (perm !== "granted") { + setProgress(0); + setProgressLabel(""); + setStatus("idle"); + return; + } } + setProgress(15); + setProgressLabel(PROGRESS_MESSAGES.LOADING_BASE_ROM); + await new Promise(resolve => requestAnimationFrame(resolve)); + const linkedFile = await getFileBlob(baseRomId); - if (!linkedFile) return; + if (!linkedFile) { + setProgress(0); + setProgressLabel(""); + setStatus("idle"); + return; + } baseFile = linkedFile; } if (!patchUrl) return; + setProgress(30); + setProgressLabel(PROGRESS_MESSAGES.READING_ROM_FILES); + await new Promise(resolve => requestAnimationFrame(resolve)); + setStatus("patching"); // Read inputs @@ -195,21 +294,41 @@ const HackActions: React.FC = ({ })(), ]); + setProgress(50); + setProgressLabel(PROGRESS_MESSAGES.BUILDING_PATCH_FILES); + await new Promise(resolve => requestAnimationFrame(resolve)); + // Build BinFiles const romBin = new BinFile(romBuf); romBin.fileName = baseFile.name + (platform ? `.${platform.toLowerCase()}` : ""); const patchBin = new BinFile(patchBuf); + setProgress(65); + setProgressLabel(PROGRESS_MESSAGES.APPLYING_PATCH); + await new Promise(resolve => requestAnimationFrame(resolve)); + // Parse and apply BPS const patch = BPS.fromFile(patchBin); const patchedRom = patch.apply(romBin); + setProgress(85); + setProgressLabel(PROGRESS_MESSAGES.PREPARING_FINAL_DOWNLOAD); + await new Promise(resolve => requestAnimationFrame(resolve)); + // Name output and download - const outExt = platform ? platform.toLowerCase() : 'bin'; + const outExt = platform ? platform.toLowerCase() : "bin"; const outputName = `${title} (${version}).${outExt}`; patchedRom.fileName = outputName; patchedRom.save(); + setProgress(100); + setProgressLabel(PROGRESS_MESSAGES.COMPLETE); + + setTimeout(() => { + setProgress(0); + setProgressLabel(""); + }, 1000); + setStatus("done"); // Best-effort log applied event for counting and animate badge @@ -221,9 +340,10 @@ const HackActions: React.FC = ({ deviceId = crypto.randomUUID(); localStorage.setItem(key, deviceId); } + const finalDeviceId = deviceId; // Defer count update to avoid Safari cancelling the request setTimeout(async () => { - const deviceIdObscured = deviceId.split("-"); + const deviceIdObscured = finalDeviceId.split("-"); const result = await updatePatchDownloadCount(patchId, deviceIdObscured); if (!result.ok) { console.error(result.error); @@ -238,31 +358,38 @@ const HackActions: React.FC = ({ } catch (e: any) { setError(e?.message || "Failed to patch ROM"); setStatus("idle"); + setProgress(0); + setProgressLabel(""); console.error(e); } } return ( - (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))} - supported={supported} - onUploadChange={onSelectFile} - termsAgreed={termsAgreed} - /> + <> + 0} + label={progressLabel} + /> + (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))} + supported={supported} + onUploadChange={onSelectFile} + termsAgreed={termsAgreed} + progressBarHeight={progressBarHeight} + /> + ); -}; - -export default HackActions; - +} diff --git a/src/components/Hack/PatchProgressBar.tsx b/src/components/Hack/PatchProgressBar.tsx new file mode 100644 index 0000000..c40f314 --- /dev/null +++ b/src/components/Hack/PatchProgressBar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; + +interface PatchProgressBarProps { + progress: number; + visible: boolean; + label?: string; +} + +export default function PatchProgressBar({ progress, visible, label }: PatchProgressBarProps) { + const barHeight = label ? 64 : 44; + + return ( + <> +
+ +
+
+ {label && ( +

+ {label} +

+ )} +
+
+
+
+
+ + ); +} + + diff --git a/src/components/Hack/StickyActionBar.tsx b/src/components/Hack/StickyActionBar.tsx index 927007a..02982d9 100644 --- a/src/components/Hack/StickyActionBar.tsx +++ b/src/components/Hack/StickyActionBar.tsx @@ -21,6 +21,7 @@ interface StickyActionBarProps { supported: boolean; onUploadChange: (e: React.ChangeEvent) => void; termsAgreed: boolean; + progressBarHeight?: number; } export default function StickyActionBar({ @@ -39,6 +40,7 @@ export default function StickyActionBar({ supported, onUploadChange, termsAgreed, + progressBarHeight = 0, }: StickyActionBarProps) { const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); @@ -77,7 +79,12 @@ export default function StickyActionBar({ }, [status]); return ( -
+
@@ -142,7 +149,7 @@ export default function StickyActionBar({ onClick={onPatch} data-ready={romReady} disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady} - className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`} + className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === "downloading" ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== "downloading" && status !== "ready" && termsAgreed ? "mt-6 md:mt-0" : ""}`} > { status === "patching" ? "Patching…" :