sendou.ink/app/features/scanner/components/EventCard.tsx
2026-08-07 20:47:19 +03:00

159 lines
4.7 KiB
TypeScript

/**
* 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 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 clsx from "clsx";
import { SCANNER_PAGE } from "~/utils/urls";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} 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 { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
export type GetFrame = () => Promise<Blob | null | undefined>;
export function EventCard(props: {
type: string;
t: number;
confidence: number;
data: FixtureData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame; enables Inspect + fixture export */
getFrame?: GetFrame;
/** Scoreboard only: abilities harvested from the match's death events */
abilities?: PlayerAbilityMap;
/** sendou.ink /ingest status of this event; absent = never attempted */
send?: SendStatus;
/** when set, shows a Send/Retry button that sends this event's match batch */
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
// 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) void putInspectFrame(key, frame);
});
}
: undefined;
const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect };
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className="send-wrap">
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
);
}
const SEND_LABELS: Record<SendStatus["state"], string> = {
queued: "queued",
sending: "sending…",
sent: "sent",
unlinked: "waiting for report",
failed: "failed",
};
function SendStrip({
send,
onSend,
}: {
send?: SendStatus;
onSend?: () => void;
}) {
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx("send-strip", state ?? "unsent")}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? <span className="error">{send.error}</span> : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}
</button>
) : null}
</div>
);
}
function renderCard(
type: string,
data: FixtureData,
shared: {
t: number;
confidence: number;
thumbnail?: string;
detectedAt?: number;
getFrame?: GetFrame;
onInspect?: () => void;
},
abilities?: PlayerAbilityMap,
) {
return type === DEATH_EVENT_TYPE ? (
<DeathCard {...shared} data={data as DeathData} />
) : type === MAP_START_EVENT_TYPE ? (
<MapStartCard {...shared} data={data as MapStartData} />
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (
<ScoreboardOwnCard {...shared} data={data as ScoreboardOwnData} />
) : type === MINIMAP_EVENT_TYPE ? (
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : (
<ScoreboardCard
{...shared}
eventType={type}
data={data as ScoreboardData}
abilities={abilities}
/>
);
}