diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index 4129d7e37..f40cfc6c9 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -280,9 +280,16 @@ export function ScreenshotPage() { useEffect(() => { if (!inspectKey) return; setInspectKey(null); - void claimInspectFrame(inspectKey).then((frame) => { - if (frame) void analyze(frame); - }); + claimInspectFrame(inspectKey).then( + (frame) => { + if (frame) void analyze(frame); + else + setError( + "Inspected frame did not arrive — go back to the other tab and press Inspect again", + ); + }, + (e) => setError(String(e)), + ); }, [inspectKey, setInspectKey, analyze]); const event = active?.events[0] as DetectedEvent | undefined; @@ -291,6 +298,7 @@ export function ScreenshotPage() { const isDeath = activeDetector === "death"; const isMapStart = activeDetector === "map-start"; const isOwn = activeDetector === "scoreboard-own"; + const isMinimap = activeDetector === "minimap"; const winnerSide = String(event?.debug?.winnerSide ?? "left"); const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows(); @@ -535,7 +543,52 @@ export function ScreenshotPage() {

)} - {frame && event && !isDeath && !isMapStart && !isOwn && ( + {frame && event && isMinimap && ( +

+ {(() => { + const data = event.data as unknown as MinimapData; + return ( + <> + stage {stageLabel(data.stage) ?? "?"} + {" · "} + {data.spectator ? "spectator map" : "POV overlay"} + {" · "}team{" "} + + {data.teammates + .map( + (p) => + `${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`, + ) + .join(", ") || "—"} + + {data.enemies.length > 0 && ( + <> + {" · "}enemies{" "} + + {data.enemies + .map( + (p) => + `${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`, + ) + .join(", ")} + + + )} + {!data.spectator && ( + <> +
+ {minimap.CARD_LAYOUTS.map((card) => ( + + ))} + + )} + + ); + })()} +

+ )} + + {frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap && ( diff --git a/app/features/scanner/store/db.ts b/app/features/scanner/store/db.ts index e3a459cef..1896eb489 100644 --- a/app/features/scanner/store/db.ts +++ b/app/features/scanner/store/db.ts @@ -39,67 +39,118 @@ function extractFrames(source: IDBObjectStore, frames: IDBObjectStore): void { }; } +const ALL_STORES = [ + EVENTS_STORE, + FRAMES_STORE, + VODS_STORE, + VOD_EVENTS_STORE, + VOD_FRAMES_STORE, + INSPECT_FRAMES_STORE, +]; + // xxx: get rid of migrate before we go live with this /** - * Versioned migrations: each `oldVersion < N` block upgrades a database from - * below version N and runs exactly once per database. Any schema change — - * including one to an EXISTING store (new index, moved field) — must be a new - * block plus a DB_VERSION bump, never an edit to an old block: databases that - * already ran the old block would silently skip the change otherwise. + * Brings any database — fresh, older-versioned or drifted (a dev database + * whose version was bumped past DB_VERSION without these stores) — to the + * current schema. Store creation is existence-guarded rather than + * version-gated so a reopen at version+1 (see openDb) can heal drift; only + * data moves stay keyed on the version they shipped in. */ function migrate( database: IDBDatabase, transaction: IDBTransaction, oldVersion: number, ): void { - if (oldVersion < 2) { - // v1/v2 era stores; contains() guards absorb the pre-versioned scheme, - // where every creation was unconditionally contains()-gated - if (!database.objectStoreNames.contains(EVENTS_STORE)) { - const store = database.createObjectStore(EVENTS_STORE, { - keyPath: "id", - autoIncrement: true, - }); - store.createIndex("t", "t"); - store.createIndex("detectedAt", "detectedAt"); - } - if (!database.objectStoreNames.contains(VODS_STORE)) { - database.createObjectStore(VODS_STORE, { keyPath: "name" }); - } - if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) { - const store = database.createObjectStore(VOD_EVENTS_STORE, { - keyPath: "id", - autoIncrement: true, - }); - store.createIndex("vod", "vod"); - } + if (!database.objectStoreNames.contains(EVENTS_STORE)) { + const store = database.createObjectStore(EVENTS_STORE, { + keyPath: "id", + autoIncrement: true, + }); + store.createIndex("t", "t"); + store.createIndex("detectedAt", "detectedAt"); + } + if (!database.objectStoreNames.contains(VODS_STORE)) { + database.createObjectStore(VODS_STORE, { keyPath: "name" }); + } + if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) { + const store = database.createObjectStore(VOD_EVENTS_STORE, { + keyPath: "id", + autoIncrement: true, + }); + store.createIndex("vod", "vod"); + } + if (!database.objectStoreNames.contains(FRAMES_STORE)) { + database.createObjectStore(FRAMES_STORE); + } + if (!database.objectStoreNames.contains(VOD_FRAMES_STORE)) { + database.createObjectStore(VOD_FRAMES_STORE); } if (oldVersion < 3) { - // frame blobs move out of the event records into keyed frame stores - const frames = database.createObjectStore(FRAMES_STORE); - const vodFrames = database.createObjectStore(VOD_FRAMES_STORE); - extractFrames(transaction.objectStore(EVENTS_STORE), frames); - extractFrames(transaction.objectStore(VOD_EVENTS_STORE), vodFrames); + // v1/v2 era kept the frame blobs embedded in the event records + extractFrames( + transaction.objectStore(EVENTS_STORE), + transaction.objectStore(FRAMES_STORE), + ); + extractFrames( + transaction.objectStore(VOD_EVENTS_STORE), + transaction.objectStore(VOD_FRAMES_STORE), + ); } - if (oldVersion < 4) { + if (!database.objectStoreNames.contains(INSPECT_FRAMES_STORE)) { database.createObjectStore(INSPECT_FRAMES_STORE); } } -function openDb(): Promise { +function openAt(version?: number): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); + const req = + version === undefined + ? indexedDB.open(DB_NAME) + : indexedDB.open(DB_NAME, version); req.onupgradeneeded = (event) => { migrate(req.result, req.transaction!, event.oldVersion); }; + req.onblocked = () => { + // biome-ignore lint/suspicious/noConsole: the only diagnostic channel for a hang caused by other tabs + console.warn( + "scanner database upgrade is blocked — close or reload other sendou.ink tabs", + ); + }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } +/** + * Opens at whatever version exists, then upgrades when the schema is behind — + * either an honest old version or a drifted database sitting at/above + * DB_VERSION without all stores, which a plain open(DB_VERSION) would + * silently accept (or reject with VersionError). + */ +async function openDb(): Promise { + let database = await openAt(); + const missingStore = ALL_STORES.some( + (name) => !database.objectStoreNames.contains(name), + ); + if (database.version < DB_VERSION || missingStore) { + const nextVersion = Math.max(DB_VERSION, database.version + 1); + database.close(); + database = await openAt(nextVersion); + } + return database; +} + let dbPromise: Promise | null = null; export function db(): Promise { - dbPromise ??= openDb(); + dbPromise ??= openDb().then((database) => { + // when another tab needs to upgrade, release the connection instead of + // blocking that tab forever; the next call here reconnects fresh + database.onversionchange = () => { + database.close(); + dbPromise = null; + }; + return database; + }); return dbPromise; }