More UI work

This commit is contained in:
Kalle
2026-08-05 15:26:59 +03:00
parent 866369f6cd
commit 20d4a3d58f
15 changed files with 396 additions and 185 deletions

View File

@@ -2,7 +2,7 @@ import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { DeathData } from "../core/detectors/death/index";
import { AbilityGrid } from "./AbilityGrid";
import { saveFixtureFromEvent } from "./fixture-export";
import { FrameThumb } from "./FrameThumb";
import { formatTime } from "./format";
import { weaponLabel } from "./labels";
@@ -30,28 +30,14 @@ export function DeathCard(props: {
</span>
<span>confidence {(confidence * 100).toFixed(0)}%</span>
{detectedAt && <span>{new Date(detectedAt).toLocaleTimeString()}</span>}
{onInspect && (
<button type="button" onClick={onInspect}>
Inspect
</button>
)}
{getFrame && (
<button
type="button"
onClick={() =>
void getFrame().then(
(f) => f && saveFixtureFromEvent(f, data, "Death"),
)
}
>
Save fixture
</button>
)}
{thumbnail && (
<img className="thumb" src={thumbnail} alt="analyzed frame" />
)}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Death" }}
/>
</div>
<div className="teams">
<div className="teams solo">
<div className="team">
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage

View File

@@ -2,11 +2,12 @@
* Single dispatch point from a detected event to its card component, shared
* by the live feed and the VoD feed. Frames are loaded lazily through
* `getFrame` (IndexedDB keeps them out of the listed records); the Inspect
* action (open the frame in the screenshot page) is derived from it here so
* pages don't duplicate the wiring.
* action (open the frame in the screenshot page in a new browser tab, so
* the running scan is left undisturbed) is derived from it here so pages
* don't duplicate the wiring.
*/
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import {
DEATH_EVENT_TYPE,
@@ -27,13 +28,13 @@ import {
} from "../core/detectors/scoreboard-own/index";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import type { FixtureData } from "./fixture-export";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
import { setScreenshotFrame } from "./screenshot-handoff";
export type GetFrame = () => Promise<Blob | null | undefined>;
@@ -54,14 +55,22 @@ export function EventCard(props: {
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
const [, setTab] = useSearchParam(scannerSearchParams, "tab");
// window.open must run synchronously in the click gesture (popup blockers);
// the frame write catches up and the new tab polls for it
const onInspect = getFrame
? () =>
? () => {
const key = newInspectKey();
window.open(
scannerSearchParams.href(SCANNER_PAGE, {
tab: "screenshot",
inspect: key,
}),
"_blank",
);
void getFrame().then((frame) => {
if (!frame) return;
setScreenshotFrame(frame);
setTab("screenshot");
})
if (frame) void putInspectFrame(key, frame);
});
}
: undefined;
const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect };

View File

@@ -0,0 +1,108 @@
/**
* Analyzed-frame preview in an event card's meta row. Clicking it opens the
* frame big in a dialog — the exact lossless frame once its lazy loader
* resolves, the thumbnail as a stand-in until then. The frame actions
* (Inspect, Save fixture) live under the dialog image.
*/
import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
thumbnail,
getFrame,
onInspect,
fixture,
}: {
thumbnail?: string;
getFrame?: () => Promise<Blob | null | undefined>;
/** opens the frame in the screenshot page in a new browser tab */
onInspect?: () => void;
/** enables Save fixture: the event's payload and fixture type label */
fixture?: { data: FixtureData; type: string };
}) {
const [open, setOpen] = useState(false);
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const frameUrlRef = useRef<string | null>(null);
useEffect(
() => () => {
if (frameUrlRef.current) URL.revokeObjectURL(frameUrlRef.current);
},
[],
);
if (!thumbnail) return null;
const show = () => {
setOpen(true);
if (!getFrame || frameUrlRef.current) return;
void getFrame().then((frame) => {
if (!frame || frameUrlRef.current) return;
frameUrlRef.current = URL.createObjectURL(frame);
setFrameUrl(frameUrlRef.current);
});
};
const onSaveFixture =
getFrame && fixture
? () =>
void getFrame().then(
(frame) =>
frame && saveFixtureFromEvent(frame, fixture.data, fixture.type),
)
: undefined;
return (
<>
<button
type="button"
className="thumb-button"
title="View frame"
onClick={show}
>
<img className="thumb" src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className="scanner-frame-dialog"
onClose={() => setOpen(false)}
>
<img
className="frame-full"
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className="frame-actions">
{onInspect ? (
<SendouButton
size="small"
icon={<ExternalLink />}
onPress={onInspect}
>
Inspect
</SendouButton>
) : null}
{onSaveFixture ? (
<SendouButton
size="small"
variant="outlined"
icon={<FlaskConical />}
onPress={onSaveFixture}
>
Save fixture
</SendouButton>
) : null}
</div>
) : null}
</SendouDialog>
) : null}
</>
);
}

View File

@@ -1,5 +1,5 @@
import type { MapStartData } from "../core/detectors/map-start/index";
import { saveFixtureFromEvent } from "./fixture-export";
import { FrameThumb } from "./FrameThumb";
import { formatTime } from "./format";
import { modeLabel, stageLabel } from "./labels";
@@ -25,26 +25,12 @@ export function MapStartCard(props: {
</span>
<span>confidence {(confidence * 100).toFixed(0)}%</span>
{detectedAt && <span>{new Date(detectedAt).toLocaleTimeString()}</span>}
{onInspect && (
<button type="button" onClick={onInspect}>
Inspect
</button>
)}
{getFrame && (
<button
type="button"
onClick={() =>
void getFrame().then(
(f) => f && saveFixtureFromEvent(f, data, "MapStart"),
)
}
>
Save fixture
</button>
)}
{thumbnail && (
<img className="thumb" src={thumbnail} alt="analyzed frame" />
)}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "MapStart" }}
/>
</div>
</div>
);

View File

@@ -1,8 +1,8 @@
/**
* Glanceable card for one ScannerMatch in the live feed: stage banner
* background, mode + stage, score, team weapons, and the match's /ingest
* status. The source event cards render inside the expandable detail
* section, so the raw per-event view stays one click away.
* status. Expanding the card reveals the source event cards below it,
* so the raw per-event view stays one click away.
*/
import clsx from "clsx";
@@ -115,9 +115,6 @@ export function MatchCard({
{send?.state === "failed" && send.error ? (
<div className="match-error">{send.error}</div>
) : null}
{expanded && children ? (
<div className="match-card-detail">{children}</div>
) : null}
</>
);
@@ -127,12 +124,21 @@ export function MatchCard({
"flash-failed": flash === "failed",
});
return match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
const card =
match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
);
if (!children) return card;
return (
<div className="match-card-group">
{card}
{expanded ? <div className="match-events">{children}</div> : null}
</div>
);
}

View File

@@ -6,7 +6,7 @@ import type {
MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { ScannerAbility } from "../scanner-types";
import { saveFixtureFromEvent } from "./fixture-export";
import { FrameThumb } from "./FrameThumb";
import { formatTime } from "./format";
import { stageLabel } from "./labels";
@@ -34,25 +34,23 @@ function PlayerRow({
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<tr>
<td>{label}</td>
<td>{player.name ?? ""}</td>
<td>
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={28}
className="weapon-icon"
/>
) : (
"?"
)}
</td>
<td>
<div className="minimap-player">
<span className="slot">{label}</span>
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className="weapon-icon"
/>
) : (
<span className="weapon-missing">?</span>
)}
<span className="name">{player.name ?? ""}</span>
<span className="abilities">
<AbilityRow abilities={player.abilities} />
</td>
</tr>
</span>
</div>
);
}
@@ -76,40 +74,28 @@ export function MinimapCard(props: {
{data.stage !== null && <span>{stageLabel(data.stage)}</span>}
<span>confidence {(confidence * 100).toFixed(0)}%</span>
{detectedAt && <span>{new Date(detectedAt).toLocaleTimeString()}</span>}
{onInspect && (
<button type="button" onClick={onInspect}>
Inspect
</button>
)}
{getFrame && (
<button
type="button"
onClick={() =>
void getFrame().then(
(f) => f && saveFixtureFromEvent(f, data, "Minimap"),
)
}
>
Save fixture
</button>
)}
{thumbnail && (
<img className="thumb" src={thumbnail} alt="analyzed frame" />
)}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className="teams">
<div className="team">
<table className="players">
<tbody>
{data.teammates.map((p) => (
<PlayerRow key={p.slot} label={p.slot} player={p} />
))}
{data.enemies.map((p, i) => (
<PlayerRow key={`e${i}`} label={`enemy ${i + 1}`} player={p} />
))}
</tbody>
</table>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow key={p.slot} label={p.slot} player={p} />
))}
</div>
{data.enemies.length > 0 ? (
<div className="team">
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow key={i} label={`${i + 1}`} player={p} />
))}
</div>
) : null}
</div>
</div>
);

View File

@@ -6,7 +6,8 @@ import type {
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-replay/index";
import { AbilityPopover } from "./AbilityGrid";
import { type CardData, saveFixtureFromEvent } from "./fixture-export";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { formatTime } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
@@ -97,26 +98,12 @@ export function ScoreboardCard(props: {
{data.replayCode && <span className="score">{data.replayCode}</span>}
<span>confidence {(confidence * 100).toFixed(0)}%</span>
{detectedAt && <span>{new Date(detectedAt).toLocaleTimeString()}</span>}
{onInspect && (
<button type="button" onClick={onInspect}>
Inspect
</button>
)}
{getFrame && (
<button
type="button"
onClick={() =>
void getFrame().then(
(f) => f && saveFixtureFromEvent(f, data, eventType),
)
}
>
Save fixture
</button>
)}
{thumbnail && (
<img className="thumb" src={thumbnail} alt="analyzed frame" />
)}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: eventType }}
/>
</div>
<div className="teams">
<div className="team win">

View File

@@ -1,7 +1,7 @@
import { WeaponImage } from "~/components/Image";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import { saveFixtureFromEvent } from "./fixture-export";
import { FrameThumb } from "./FrameThumb";
import { formatTime } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
@@ -36,28 +36,14 @@ export function ScoreboardOwnCard(props: {
</span>
<span>confidence {(confidence * 100).toFixed(0)}%</span>
{detectedAt && <span>{new Date(detectedAt).toLocaleTimeString()}</span>}
{onInspect && (
<button type="button" onClick={onInspect}>
Inspect
</button>
)}
{getFrame && (
<button
type="button"
onClick={() =>
void getFrame().then(
(f) => f && saveFixtureFromEvent(f, data, "ScoreboardOwn"),
)
}
>
Save fixture
</button>
)}
{thumbnail && (
<img className="thumb" src={thumbnail} alt="analyzed frame" />
)}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className="teams">
<div className="teams solo">
<div className="team">
{data.weaponId !== null ? (
<WeaponImage

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
@@ -14,6 +15,8 @@ import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import * as replay from "../core/detectors/scoreboard-replay/rois";
import type { DetectedEvent } from "../core/detectors/types";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
import type { WorkerResponse } from "../worker/protocol";
import { downloadEventsCsv } from "./events-csv";
@@ -25,7 +28,6 @@ import {
stageLabel,
weaponLabel,
} from "./labels";
import { takeScreenshotFrame } from "./screenshot-handoff";
type Result = Extract<WorkerResponse, { kind: "result" }>;
@@ -269,11 +271,19 @@ export function ScreenshotPage() {
}
}, []);
// frame handed off from another page (e.g. a VoD match's Inspect button)
// frame handed off from an Inspect click in another browser tab; the
// handoff write races this tab's load, so the claim polls briefly
const [inspectKey, setInspectKey] = useSearchParam(
scannerSearchParams,
"inspect",
);
useEffect(() => {
const frame = takeScreenshotFrame();
if (frame) void analyze(frame);
}, [analyze]);
if (!inspectKey) return;
setInspectKey(null);
void claimInspectFrame(inspectKey).then((frame) => {
if (frame) void analyze(frame);
});
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];

View File

@@ -1,16 +0,0 @@
/**
* One-shot frame handoff into the screenshot page: a page stashes the exact
* analyzed frame here and switches to the screenshot tab, which picks it up
* on mount.
*/
let pending: Blob | null = null;
export function setScreenshotFrame(frame: Blob): void {
pending = frame;
}
export function takeScreenshotFrame(): Blob | null {
const frame = pending;
pending = null;
return frame;
}

View File

@@ -168,23 +168,32 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
.scanner-app .card .meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: 8px;
align-items: center;
}
.scanner-app .card img.thumb {
width: 160px;
display: block;
height: 40px;
width: auto;
border-radius: var(--radius-field);
}
.scanner-app .teams {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 8px;
/* single hug-content box (death / own-results cards) */
&.solo {
grid-template-columns: max-content;
}
}
/* sendou.ink send status strip under a feed card */
@@ -240,6 +249,48 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-radius: var(--radius-field);
padding: 8px;
background: var(--color-bg);
min-width: 0;
overflow-x: auto;
}
.scanner-app .minimap-player {
display: flex;
align-items: center;
gap: 8px;
padding-block: 3px;
font-size: var(--font-xs);
& .slot {
flex-shrink: 0;
min-width: 3.25rem;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-high);
}
& .weapon-missing {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-high);
}
& .name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& .abilities {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
}
.scanner-app .team h3 {
@@ -274,6 +325,10 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
font-variant-numeric: tabular-nums;
}
.scanner-app .teams.solo table.players {
width: auto;
}
.scanner-app img.weapon-icon {
width: 28px;
height: 28px;
@@ -282,6 +337,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-radius: 4px;
}
.scanner-app .minimap-player img.weapon-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
}
.scanner-app .weapon-cell {
display: inline-flex;
align-items: center;
@@ -704,21 +765,45 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
}
}
/* the frame preview: an unstyled button so the image itself is the target */
.scanner-app .card .meta button.thumb-button {
height: auto;
padding: 0;
border: none;
background: none;
margin-inline-start: auto;
border-radius: var(--radius-field);
&:hover {
filter: brightness(1.2);
}
}
.scanner-app .match-error {
padding: 0 16px 10px;
font-size: var(--font-2xs);
color: var(--color-error);
}
.scanner-app .match-card-detail {
.scanner-app .match-card-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 12px 12px;
}
/* source event cards, nested under their match card via an indent rail */
.scanner-app .match-events {
display: flex;
flex-direction: column;
gap: 8px;
margin-inline-start: 10px;
padding-inline-start: 12px;
border-inline-start: 2px solid var(--color-border);
animation: scanner-detail-in 0.25s ease both;
& .card {
background-color: var(--color-bg);
padding: 10px 14px;
min-width: 0;
}
}
@@ -760,12 +845,32 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
animation: scanner-pulse 1.6s ease-in-out infinite;
}
/* the frame dialog portals outside .scanner-app, so these stay unscoped */
.scanner-frame-dialog {
width: max-content;
max-width: min(96vw, 1400px);
}
.scanner-frame-dialog img.frame-full {
display: block;
max-width: 100%;
max-height: calc(80dvh - 9rem);
border-radius: var(--radius-field);
}
.scanner-frame-dialog .frame-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-block-start: 12px;
}
@media (prefers-reduced-motion: reduce) {
.scanner-app .match-card,
.scanner-app .match-card.sending::after,
.scanner-app .match-card.flash-sent::after,
.scanner-app .match-card.flash-failed .match-card-main,
.scanner-app .match-card-detail,
.scanner-app .match-events,
.scanner-app .match-chip.live .dot,
.scanner-app .match-chip.queued .dot,
.scanner-app .match-chip.sending .dot,

View File

@@ -9,6 +9,7 @@ describe("scannerSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(scannerSearchParams, {
tab: ["live", "screenshot", "vod"],
inspect: ["1723456789012-abc123", null],
});
});

View File

@@ -8,4 +8,6 @@ export type ScannerTab = (typeof SCANNER_TABS)[number];
export const scannerSearchParams = SearchParams.define({
tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }),
/** Inspect handoff key: the screenshot tab claims this frame on load */
inspect: SP.param(z.string().max(100).nullable(), { loader: false }),
});

View File

@@ -6,15 +6,18 @@
* - `vods`: one summary record per fully scanned VoD, keyed by file name
* - `vod-events`: the detections of each saved VoD, indexed by VoD name
* - `vod-frames`: the vod-events' PNGs, keyed by vod-event id
* - `inspect-frames`: one-shot Inspect handoffs into a new screenshot tab,
* keyed by handoff key (see inspect.ts)
*/
const DB_NAME = "vod-parser";
const DB_VERSION = 3;
const DB_VERSION = 4;
export const EVENTS_STORE = "events";
export const FRAMES_STORE = "frames";
export const VODS_STORE = "vods";
export const VOD_EVENTS_STORE = "vod-events";
export const VOD_FRAMES_STORE = "vod-frames";
export const INSPECT_FRAMES_STORE = "inspect-frames";
/**
* Move a store's embedded `frame` blobs into a keyed frame store (v3
@@ -36,6 +39,7 @@ function extractFrames(source: IDBObjectStore, frames: IDBObjectStore): void {
};
}
// 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 —
@@ -77,6 +81,9 @@ function migrate(
extractFrames(transaction.objectStore(EVENTS_STORE), frames);
extractFrames(transaction.objectStore(VOD_EVENTS_STORE), vodFrames);
}
if (oldVersion < 4) {
database.createObjectStore(INSPECT_FRAMES_STORE);
}
}
function openDb(): Promise<IDBDatabase> {

View File

@@ -0,0 +1,48 @@
/**
* Cross-tab frame handoff for the Inspect action: the source tab stashes the
* frame under a fresh key and opens the screenshot page in a new browser tab
* with ?inspect=<key>. The write races the new tab's load, so claiming polls
* briefly before giving up. Claimed records are deleted; unclaimed leftovers
* (blocked popup, tab closed mid-load) are swept by key age on the next
* handoff.
*/
import { INSPECT_FRAMES_STORE, tx } from "./db";
const CLAIM_ATTEMPTS = 20;
const CLAIM_RETRY_MS = 150;
const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
/** Fresh handoff key; the fixed-width timestamp prefix keys the stale sweep. */
export function newInspectKey(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Stash a frame for the tab that was opened with this key. */
export async function putInspectFrame(key: string, frame: Blob): Promise<void> {
await tx(INSPECT_FRAMES_STORE, "readwrite", (store) =>
store.delete(IDBKeyRange.upperBound(String(Date.now() - STALE_AFTER_MS))),
);
await tx(INSPECT_FRAMES_STORE, "readwrite", (store) => store.put(frame, key));
}
/** Take (and delete) the frame stashed under this key, polling the write race. */
export async function claimInspectFrame(key: string): Promise<Blob | null> {
for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt++) {
if (attempt > 0) await delay(CLAIM_RETRY_MS);
const frame = await tx<Blob | undefined>(
INSPECT_FRAMES_STORE,
"readonly",
(store) => store.get(key),
);
if (frame) {
await tx(INSPECT_FRAMES_STORE, "readwrite", (store) => store.delete(key));
return frame;
}
}
return null;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}