mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
More UI work
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
108
app/features/scanner/components/FrameThumb.tsx
Normal file
108
app/features/scanner/components/FrameThumb.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("scannerSearchParams", () => {
|
||||
it("round-trips", () => {
|
||||
assertRoundTrips(scannerSearchParams, {
|
||||
tab: ["live", "screenshot", "vod"],
|
||||
inspect: ["1723456789012-abc123", null],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
48
app/features/scanner/store/inspect.ts
Normal file
48
app/features/scanner/store/inspect.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user