diff --git a/app/features/scanner/components/DeathCard.tsx b/app/features/scanner/components/DeathCard.tsx index a0068ba09..1ea286e43 100644 --- a/app/features/scanner/components/DeathCard.tsx +++ b/app/features/scanner/components/DeathCard.tsx @@ -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: { confidence {(confidence * 100).toFixed(0)}% {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && ( - - )} - {getFrame && ( - - )} - {thumbnail && ( - analyzed frame - )} + -
+
{data.weaponId !== null && data.weaponType === "MAIN" ? ( Promise; @@ -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 }; diff --git a/app/features/scanner/components/FrameThumb.tsx b/app/features/scanner/components/FrameThumb.tsx new file mode 100644 index 000000000..5b8364c5a --- /dev/null +++ b/app/features/scanner/components/FrameThumb.tsx @@ -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; + /** 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(null); + const frameUrlRef = useRef(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 ( + <> + + {open ? ( + setOpen(false)} + > + analyzed frame + {onInspect || onSaveFixture ? ( +
+ {onInspect ? ( + } + onPress={onInspect} + > + Inspect + + ) : null} + {onSaveFixture ? ( + } + onPress={onSaveFixture} + > + Save fixture + + ) : null} +
+ ) : null} +
+ ) : null} + + ); +} diff --git a/app/features/scanner/components/MapStartCard.tsx b/app/features/scanner/components/MapStartCard.tsx index 227d7fe50..b29e4f933 100644 --- a/app/features/scanner/components/MapStartCard.tsx +++ b/app/features/scanner/components/MapStartCard.tsx @@ -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: { confidence {(confidence * 100).toFixed(0)}% {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && ( - - )} - {getFrame && ( - - )} - {thumbnail && ( - analyzed frame - )} +
); diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 0835bdd96..145044772 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -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 ? (
{send.error}
) : null} - {expanded && children ? ( -
{children}
- ) : null} ); @@ -127,12 +124,21 @@ export function MatchCard({ "flash-failed": flash === "failed", }); - return match.stage !== null ? ( - - {inner} - - ) : ( -
{inner}
+ const card = + match.stage !== null ? ( + + {inner} + + ) : ( +
{inner}
+ ); + + if (!children) return card; + return ( +
+ {card} + {expanded ?
{children}
: null} +
); } diff --git a/app/features/scanner/components/MinimapCard.tsx b/app/features/scanner/components/MinimapCard.tsx index 264d52a05..5593e2ab0 100644 --- a/app/features/scanner/components/MinimapCard.tsx +++ b/app/features/scanner/components/MinimapCard.tsx @@ -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 ( - - {label} - {player.name ?? ""} - - {player.weaponId !== null ? ( - - ) : ( - "?" - )} - - +
+ {label} + {player.weaponId !== null ? ( + + ) : ( + ? + )} + {player.name ?? ""} + - - + +
); } @@ -76,40 +74,28 @@ export function MinimapCard(props: { {data.stage !== null && {stageLabel(data.stage)}} confidence {(confidence * 100).toFixed(0)}% {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && ( - - )} - {getFrame && ( - - )} - {thumbnail && ( - analyzed frame - )} +
- - - {data.teammates.map((p) => ( - - ))} - {data.enemies.map((p, i) => ( - - ))} - -
+

Team

+ {data.teammates.map((p) => ( + + ))}
+ {data.enemies.length > 0 ? ( +
+

Enemies

+ {data.enemies.map((p, i) => ( + + ))} +
+ ) : null}
); diff --git a/app/features/scanner/components/ScoreboardCard.tsx b/app/features/scanner/components/ScoreboardCard.tsx index da168523d..43de3a1e2 100644 --- a/app/features/scanner/components/ScoreboardCard.tsx +++ b/app/features/scanner/components/ScoreboardCard.tsx @@ -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 && {data.replayCode}} confidence {(confidence * 100).toFixed(0)}% {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && ( - - )} - {getFrame && ( - - )} - {thumbnail && ( - analyzed frame - )} +
diff --git a/app/features/scanner/components/ScoreboardOwnCard.tsx b/app/features/scanner/components/ScoreboardOwnCard.tsx index e380e2866..46fd15432 100644 --- a/app/features/scanner/components/ScoreboardOwnCard.tsx +++ b/app/features/scanner/components/ScoreboardOwnCard.tsx @@ -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: { confidence {(confidence * 100).toFixed(0)}% {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && ( - - )} - {getFrame && ( - - )} - {thumbnail && ( - analyzed frame - )} +
-
+
{data.weaponId !== null ? ( ; @@ -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 | undefined; const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[]; diff --git a/app/features/scanner/components/screenshot-handoff.ts b/app/features/scanner/components/screenshot-handoff.ts deleted file mode 100644 index 5bb9ba79c..000000000 --- a/app/features/scanner/components/screenshot-handoff.ts +++ /dev/null @@ -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; -} diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css index e480c8e96..1a11ac96d 100644 --- a/app/features/scanner/components/styles.css +++ b/app/features/scanner/components/styles.css @@ -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, diff --git a/app/features/scanner/scanner-search-params.test.ts b/app/features/scanner/scanner-search-params.test.ts index b3f137171..56af67ec4 100644 --- a/app/features/scanner/scanner-search-params.test.ts +++ b/app/features/scanner/scanner-search-params.test.ts @@ -9,6 +9,7 @@ describe("scannerSearchParams", () => { it("round-trips", () => { assertRoundTrips(scannerSearchParams, { tab: ["live", "screenshot", "vod"], + inspect: ["1723456789012-abc123", null], }); }); diff --git a/app/features/scanner/scanner-search-params.ts b/app/features/scanner/scanner-search-params.ts index ccc8f909a..fcb4a8b0e 100644 --- a/app/features/scanner/scanner-search-params.ts +++ b/app/features/scanner/scanner-search-params.ts @@ -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 }), }); diff --git a/app/features/scanner/store/db.ts b/app/features/scanner/store/db.ts index 75342c081..e3a459cef 100644 --- a/app/features/scanner/store/db.ts +++ b/app/features/scanner/store/db.ts @@ -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 { diff --git a/app/features/scanner/store/inspect.ts b/app/features/scanner/store/inspect.ts new file mode 100644 index 000000000..cda8d7c9b --- /dev/null +++ b/app/features/scanner/store/inspect.ts @@ -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=. 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 { + 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 { + for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt++) { + if (attempt > 0) await delay(CLAIM_RETRY_MS); + const frame = await tx( + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +}