Files
vpinball/components/pinball/PlayView.tsx
T

120 lines
4.1 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useRef, useState } from "react";
import PinballCanvas from "./PinballCanvas";
import { takePendingTable } from "@/lib/pinball/tableSelectionStore";
import { getLibraryEntry, saveToLibrary } from "@/lib/pinball/tableLibrary";
import { romGameNameFromFileName } from "@/lib/pinball/rom";
import type { RomSelection } from "@/lib/pinball/usePinballInstance";
import type { TableInfo } from "@/lib/pinball/types";
interface ResolvedTable {
tableData?: ArrayBuffer;
rom?: RomSelection;
tableInfo: TableInfo;
}
async function toRomSelection(romFile: File): Promise<RomSelection> {
return { gameName: romGameNameFromFileName(romFile.name), romData: await romFile.arrayBuffer() };
}
export default function PlayView() {
const [resolved, setResolved] = useState<ResolvedTable | null>(null);
// takePendingTable() pops the store on read, so it must run at most once
// per real mount — React's dev-mode double-invoke of effects would
// otherwise have the second call see an already-emptied store (the first
// call already popped the selection) and silently fall back to the demo
// table. No unmount-cancellation guard here: this ref makes the
// double-invoke's second pass a full no-op, so the async work below only
// ever runs once for a given component instance — a cleanup-driven cancel
// flag would incorrectly cancel that one real run's own result, since the
// phantom unmount's cleanup fires for whichever pass actually started it.
const hasResolvedRef = useRef(false);
useEffect(() => {
if (hasResolvedRef.current) return;
hasResolvedRef.current = true;
(async () => {
const selection = takePendingTable();
if (selection?.kind === "upload") {
const { vpxFile, romFile, saveToLibrary: shouldSave } = selection;
const [tableData, rom] = await Promise.all([
vpxFile.arrayBuffer(),
romFile ? toRomSelection(romFile) : Promise.resolve(undefined),
]);
if (shouldSave) {
saveToLibrary({
id: crypto.randomUUID(),
name: vpxFile.name,
sizeBytes: vpxFile.size,
addedAt: Date.now(),
vpxBlob: vpxFile,
romFileName: romFile?.name,
romBlob: romFile,
}).catch((err) => console.error("Failed to save table to library:", err));
}
setResolved({
tableData,
rom,
tableInfo: { name: vpxFile.name, sizeBytes: vpxFile.size, isDemo: false, romFileName: romFile?.name },
});
return;
}
if (selection?.kind === "library") {
const entry = await getLibraryEntry(selection.id);
if (entry) {
const [tableData, rom] = await Promise.all([
entry.vpxBlob.arrayBuffer(),
entry.romBlob
? entry.romBlob.arrayBuffer().then((romData) => ({
gameName: romGameNameFromFileName(entry.romFileName ?? ""),
romData,
}))
: Promise.resolve(undefined),
]);
setResolved({
tableData,
rom,
tableInfo: {
name: entry.name,
sizeBytes: entry.sizeBytes,
isDemo: false,
romFileName: entry.romFileName,
},
});
return;
}
// Fall through to the demo table if the library entry was removed
// (e.g. deleted in another tab) between selecting it and arriving here.
}
// No pending upload/library selection (fresh visit, or a hard refresh
// of /play) — fall back to the bundled demo table rather than
// dead-ending the route.
setResolved({
tableInfo: { name: "Demo table", sizeBytes: 0, isDemo: true },
});
})();
}, []);
if (!resolved) {
return (
<div className="flex h-full w-full items-center justify-center bg-ink font-mono text-chrome">
Preparing table
</div>
);
}
return (
<PinballCanvas
tableData={resolved.tableData}
rom={resolved.rom}
tableInfo={resolved.tableInfo}
key={resolved.tableInfo.name}
/>
);
}