mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-14 14:22:38 -05:00
UI work
This commit is contained in:
13
app/components/StageBannerBox.module.css
Normal file
13
app/components/StageBannerBox.module.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.banner {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--stage-banner-fade, var(--color-bg-high)) 35%,
|
||||
transparent 80%
|
||||
),
|
||||
var(--stage-banner);
|
||||
background-origin: border-box;
|
||||
background-position: right center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
33
app/components/StageBannerBox.tsx
Normal file
33
app/components/StageBannerBox.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { stageBannerImageUrl } from "~/utils/urls";
|
||||
import styles from "./StageBannerBox.module.css";
|
||||
|
||||
/**
|
||||
* Box with a stage banner image fading in from the right. The fade color
|
||||
* defaults to `--color-bg-high`; override per use with the
|
||||
* `--stage-banner-fade` CSS variable.
|
||||
*/
|
||||
export function StageBannerBox({
|
||||
stageId,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
stageId: StageId;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.banner, className)}
|
||||
style={
|
||||
{
|
||||
"--stage-banner": `url(${stageBannerImageUrl(stageId)})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,13 +55,7 @@
|
||||
}
|
||||
|
||||
.bestStageRow {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--graphic-row-bg) 35%, transparent 80%),
|
||||
var(--best-stage-banner);
|
||||
background-origin: border-box;
|
||||
background-position: right center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
--stage-banner-fade: var(--graphic-row-bg);
|
||||
}
|
||||
|
||||
.bestStageName {
|
||||
|
||||
@@ -12,11 +12,12 @@ import { Avatar } from "~/components/Avatar";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { TierImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
import { StageBannerBox } from "~/components/StageBannerBox";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import { stageBannerImageUrl, userSeasonsPage } from "~/utils/urls";
|
||||
import { userSeasonsPage } from "~/utils/urls";
|
||||
import {
|
||||
GRAPHIC_DATE_FORMAT_OPTIONS,
|
||||
GraphicContainer,
|
||||
@@ -264,13 +265,9 @@ export function SeasonSummaryGraphic({
|
||||
</div>
|
||||
) : null}
|
||||
{bestStage ? (
|
||||
<div
|
||||
<StageBannerBox
|
||||
stageId={bestStage.stageId}
|
||||
className={clsx(graphicStyles.box, styles.bestStageRow)}
|
||||
style={
|
||||
{
|
||||
"--best-stage-banner": `url(${stageBannerImageUrl(bestStage.stageId)})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.bestStage")}
|
||||
@@ -281,7 +278,7 @@ export function SeasonSummaryGraphic({
|
||||
{Math.round(bestStage.winratePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</StageBannerBox>
|
||||
) : null}
|
||||
<div className={styles.middleGrid}>
|
||||
<div className={clsx(graphicStyles.box, styles.activityBox)}>
|
||||
|
||||
51
app/features/scanner/components/EventsSummary.tsx
Normal file
51
app/features/scanner/components/EventsSummary.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* One light line summarizing a scan's raw detections as per-type counts,
|
||||
* with a toggle for the full event card feed — the matches are the main
|
||||
* view, the events stay one click away.
|
||||
*/
|
||||
|
||||
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
|
||||
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
|
||||
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
|
||||
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
|
||||
import { SCOREBOARD_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-replay/index";
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
[MAP_START_EVENT_TYPE]: "map start",
|
||||
[DEATH_EVENT_TYPE]: "death",
|
||||
[MINIMAP_EVENT_TYPE]: "minimap",
|
||||
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
|
||||
[SCOREBOARD_REPLAY_EVENT_TYPE]: "replay scoreboard",
|
||||
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
|
||||
};
|
||||
|
||||
export function EventsSummary({
|
||||
events,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
events: ReadonlyArray<{ type: string }>;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const event of events) {
|
||||
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
|
||||
}
|
||||
const parts = [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => {
|
||||
const label = EVENT_TYPE_LABELS[type] ?? type;
|
||||
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="events-summary">
|
||||
<span>{parts.join(" · ")}</span>
|
||||
<button type="button" className="events-toggle" onClick={onToggle}>
|
||||
{open ? "hide events" : "show events"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
|
||||
import type { DetectedEvent, GateResult } from "../core/detectors/types";
|
||||
import type { BuiltMatch } from "../core/match-builder";
|
||||
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
|
||||
import { TimelineBuilder } from "../core/timeline/index";
|
||||
import {
|
||||
clearEvents,
|
||||
@@ -22,10 +23,13 @@ import {
|
||||
} from "../store/events";
|
||||
import { AnalyzerClient } from "../worker/client";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { EventsSummary } from "./EventsSummary";
|
||||
import { downloadEventsCsv } from "./events-csv";
|
||||
import { type FixtureData, saveFixture } from "./fixture-export";
|
||||
import { SENDOU_UPLOAD_ENABLED } from "./flags";
|
||||
import { MatchCard } from "./MatchCard";
|
||||
import {
|
||||
aggregateSendStatus,
|
||||
matchContaining,
|
||||
type SendouUser,
|
||||
sendMatches,
|
||||
@@ -69,6 +73,7 @@ export function LivePage({
|
||||
const [feed, setFeed] = useState<StoredEvent[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [sendouError, setSendouError] = useState<string | null>(null);
|
||||
const [eventsOpen, setEventsOpen] = useState(false);
|
||||
const [liveSend, setLiveSend] = useState(false);
|
||||
const liveSendRef = useRef(false);
|
||||
const sendingRef = useRef(false);
|
||||
@@ -300,32 +305,83 @@ export function LivePage({
|
||||
<div className="live-layout">
|
||||
<video ref={videoRef} className="preview" muted playsInline />
|
||||
<div className="feed">
|
||||
{feed.length === 0 && <p className="score">No detections yet.</p>}
|
||||
{feed.map((e) => (
|
||||
<EventCard
|
||||
key={e.id}
|
||||
type={e.type}
|
||||
t={e.t}
|
||||
confidence={e.confidence}
|
||||
data={e.data as FixtureData}
|
||||
thumbnail={e.thumbnail}
|
||||
detectedAt={e.detectedAt}
|
||||
getFrame={
|
||||
e.hasFrame && e.id !== undefined
|
||||
? () => loadEventFrame(e.id!)
|
||||
: undefined
|
||||
}
|
||||
send={e.send}
|
||||
onSend={
|
||||
SENDOU_UPLOAD_ENABLED &&
|
||||
sendouUser &&
|
||||
e.id !== undefined &&
|
||||
INGESTABLE_TYPES.includes(e.type)
|
||||
? () => void send(matchContaining(e.id!), { manual: true })
|
||||
: undefined
|
||||
}
|
||||
{feed.length === 0 ? (
|
||||
<p className="score">No detections yet.</p>
|
||||
) : null}
|
||||
{[...buildScannerMatches(feed)]
|
||||
.reverse()
|
||||
.map((built, reverseIndex) => {
|
||||
const id = built.sources[0]!.id!;
|
||||
const ingestable = isIngestableMatch(built.match);
|
||||
return (
|
||||
<MatchCard
|
||||
key={id}
|
||||
match={built.match}
|
||||
live={
|
||||
running && reverseIndex === 0 && built.match.winner === null
|
||||
}
|
||||
ingestable={ingestable}
|
||||
send={aggregateSendStatus(built.sources)}
|
||||
onSend={
|
||||
SENDOU_UPLOAD_ENABLED && sendouUser && ingestable
|
||||
? () => void send(matchContaining(id), { manual: true })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{built.sources.map((e) => (
|
||||
<EventCard
|
||||
key={e.id}
|
||||
type={e.type}
|
||||
t={e.t}
|
||||
confidence={e.confidence}
|
||||
data={e.data as FixtureData}
|
||||
thumbnail={e.thumbnail}
|
||||
detectedAt={e.detectedAt}
|
||||
getFrame={
|
||||
e.hasFrame && e.id !== undefined
|
||||
? () => loadEventFrame(e.id!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</MatchCard>
|
||||
);
|
||||
})}
|
||||
{feed.length > 0 ? (
|
||||
<EventsSummary
|
||||
events={feed}
|
||||
open={eventsOpen}
|
||||
onToggle={() => setEventsOpen(!eventsOpen)}
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
{eventsOpen
|
||||
? feed.map((e) => (
|
||||
<EventCard
|
||||
key={e.id}
|
||||
type={e.type}
|
||||
t={e.t}
|
||||
confidence={e.confidence}
|
||||
data={e.data as FixtureData}
|
||||
thumbnail={e.thumbnail}
|
||||
detectedAt={e.detectedAt}
|
||||
getFrame={
|
||||
e.hasFrame && e.id !== undefined
|
||||
? () => loadEventFrame(e.id!)
|
||||
: undefined
|
||||
}
|
||||
send={e.send}
|
||||
onSend={
|
||||
SENDOU_UPLOAD_ENABLED &&
|
||||
sendouUser &&
|
||||
e.id !== undefined &&
|
||||
INGESTABLE_TYPES.includes(e.type)
|
||||
? () =>
|
||||
void send(matchContaining(e.id!), { manual: true })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
230
app/features/scanner/components/MatchCard.tsx
Normal file
230
app/features/scanner/components/MatchCard.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { StageBannerBox } from "~/components/StageBannerBox";
|
||||
import type { ScannerMatch } from "../core/scanner-match";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import { formatTime } from "./format";
|
||||
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
|
||||
|
||||
const SEND_CHIP_LABELS: Record<SendStatus["state"], string> = {
|
||||
queued: "queued",
|
||||
sending: "sending…",
|
||||
sent: "ingested",
|
||||
failed: "failed",
|
||||
};
|
||||
|
||||
export function MatchCard({
|
||||
match,
|
||||
send,
|
||||
onSend,
|
||||
live = false,
|
||||
ingestable = true,
|
||||
children,
|
||||
}: {
|
||||
match: ScannerMatch;
|
||||
/** the match's /ingest status, aggregated from its source events */
|
||||
send?: SendStatus;
|
||||
/** when set, shows a Send/Retry button for this match */
|
||||
onSend?: () => void;
|
||||
/** still being played: no closing scoreboard yet and the scan is running */
|
||||
live?: boolean;
|
||||
/** false = isIngestableMatch rejected it (not a private battle) */
|
||||
ingestable?: boolean;
|
||||
/** expandable detail content, typically the source event cards */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// one-shot flash animations only on a state *change*, so already-sent
|
||||
// matches don't replay the glow on every mount
|
||||
const [prevSendState, setPrevSendState] = useState(send?.state);
|
||||
const [flash, setFlash] = useState<"sent" | "failed" | null>(null);
|
||||
if (prevSendState !== send?.state) {
|
||||
setPrevSendState(send?.state);
|
||||
setFlash(
|
||||
send?.state === "sent" || send?.state === "failed" ? send.state : null,
|
||||
);
|
||||
}
|
||||
|
||||
const meta = [
|
||||
modeLabel(match.mode),
|
||||
ingestable ? null : lobbyLabel(match.lobby),
|
||||
match.startsAt !== null ? timeRangeLabel(match) : null,
|
||||
match.matchScores
|
||||
? `set ${match.matchScores[0] ?? "?"}–${match.matchScores[1] ?? "?"}`
|
||||
: null,
|
||||
match.replayCode,
|
||||
match.cast ? "cast" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="match-card-main">
|
||||
{match.mode !== null ? (
|
||||
<ModeImage mode={match.mode} size={30} className="match-mode" />
|
||||
) : null}
|
||||
<div className="match-headline">
|
||||
<div className="match-stage">
|
||||
{stageLabel(match.stage) ?? "Unknown stage"}
|
||||
</div>
|
||||
{meta ? <div className="match-meta">{meta}</div> : null}
|
||||
<TeamWeapons match={match} />
|
||||
</div>
|
||||
<div className="match-side">
|
||||
{live ? (
|
||||
<span className="match-chip live">
|
||||
<span className="dot" />
|
||||
live
|
||||
</span>
|
||||
) : (
|
||||
<Score match={match} />
|
||||
)}
|
||||
<StatusChip send={send} ingestable={ingestable} live={live} />
|
||||
{onSend && send?.state !== "sent" && send?.state !== "sending" ? (
|
||||
<button type="button" onClick={onSend}>
|
||||
{send?.state === "failed" ? "Retry" : "Send"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<ChevronDown />}
|
||||
className={clsx("match-expand", { expanded })}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Hide events" : "Show events"}
|
||||
onPress={() => setExpanded(!expanded)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{send?.state === "failed" && send.error ? (
|
||||
<div className="match-error">{send.error}</div>
|
||||
) : null}
|
||||
{expanded && children ? (
|
||||
<div className="match-card-detail">{children}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = clsx("match-card", send?.state, {
|
||||
live,
|
||||
"flash-sent": flash === "sent",
|
||||
"flash-failed": flash === "failed",
|
||||
});
|
||||
|
||||
return match.stage !== null ? (
|
||||
<StageBannerBox stageId={match.stage} className={className}>
|
||||
{inner}
|
||||
</StageBannerBox>
|
||||
) : (
|
||||
<div className={className}>{inner}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeRangeLabel(match: ScannerMatch): string {
|
||||
const start = formatTime(match.startsAt!);
|
||||
return match.endsAt !== null && match.endsAt !== match.startsAt
|
||||
? `${start}–${formatTime(match.endsAt)}`
|
||||
: start;
|
||||
}
|
||||
|
||||
function Score({ match }: { match: ScannerMatch }) {
|
||||
const [alpha, bravo] = match.teams;
|
||||
if (alpha.score === null && bravo.score === null) return null;
|
||||
|
||||
// scoreboard-sourced matches list the winners first
|
||||
const winnerKnown = match.winner !== null;
|
||||
return (
|
||||
<div className="match-score">
|
||||
<span className={winnerKnown ? "win" : undefined}>
|
||||
{alpha.score ?? "?"}
|
||||
</span>
|
||||
<span className="sep"> – </span>
|
||||
<span className={winnerKnown ? "lose" : undefined}>
|
||||
{bravo.score ?? "?"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamWeapons({ match }: { match: ScannerMatch }) {
|
||||
const weaponsOf = (team: 0 | 1) =>
|
||||
match.teams[team].players
|
||||
.map((player, index) => ({
|
||||
weaponId: player.weaponId,
|
||||
pov: match.pov?.team === team && match.pov.index === index,
|
||||
}))
|
||||
.filter((weapon) => weapon.weaponId !== null);
|
||||
const alpha = weaponsOf(0);
|
||||
const bravo = weaponsOf(1);
|
||||
if (alpha.length + bravo.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="match-weapons">
|
||||
{alpha.map((weapon, i) => (
|
||||
<WeaponImage
|
||||
key={i}
|
||||
weaponSplId={weapon.weaponId!}
|
||||
variant="build"
|
||||
size={22}
|
||||
className={clsx("weapon-icon", { pov: weapon.pov })}
|
||||
/>
|
||||
))}
|
||||
{alpha.length > 0 && bravo.length > 0 ? (
|
||||
<span className="vs">vs</span>
|
||||
) : null}
|
||||
{bravo.map((weapon, i) => (
|
||||
<WeaponImage
|
||||
key={i}
|
||||
weaponSplId={weapon.weaponId!}
|
||||
variant="build"
|
||||
size={22}
|
||||
className={clsx("weapon-icon", { pov: weapon.pov })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({
|
||||
send,
|
||||
ingestable,
|
||||
live,
|
||||
}: {
|
||||
send?: SendStatus;
|
||||
ingestable: boolean;
|
||||
live: boolean;
|
||||
}) {
|
||||
if (!ingestable) return <span className="match-chip">not ingested</span>;
|
||||
if (send) {
|
||||
return (
|
||||
<span className={clsx("match-chip", send.state)} title={send.error}>
|
||||
{send.state === "queued" || send.state === "sending" ? (
|
||||
<span className="dot" />
|
||||
) : null}
|
||||
{send.state === "sent" ? "✓ " : null}
|
||||
{SEND_CHIP_LABELS[send.state]}
|
||||
{send.state === "sent"
|
||||
? ` ${new Date(send.at).toLocaleTimeString()}`
|
||||
: null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (live) return null;
|
||||
return <span className="match-chip">not sent</span>;
|
||||
}
|
||||
@@ -17,7 +17,9 @@ import { Link } from "react-router";
|
||||
import { openVodScan } from "../capture/vod-frames";
|
||||
import { connectAbilities } from "../core/ability-harvest";
|
||||
import type { DetectedEvent } from "../core/detectors/types";
|
||||
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
|
||||
import { TimelineBuilder } from "../core/timeline/index";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import {
|
||||
deleteVod,
|
||||
listVods,
|
||||
@@ -28,10 +30,12 @@ import {
|
||||
} from "../store/vods";
|
||||
import { AnalyzerPool, defaultPoolSize } from "../worker/pool";
|
||||
import { EventCard, type GetFrame } from "./EventCard";
|
||||
import { EventsSummary } from "./EventsSummary";
|
||||
import { downloadEventsCsv } from "./events-csv";
|
||||
import type { FixtureData } from "./fixture-export";
|
||||
import { SENDOU_UPLOAD_ENABLED } from "./flags";
|
||||
import { formatTime } from "./format";
|
||||
import { MatchCard } from "./MatchCard";
|
||||
import {
|
||||
countIngestableMatches,
|
||||
type SendouUser,
|
||||
@@ -72,7 +76,13 @@ interface Progress {
|
||||
/** "Upload as results" progress/outcome shown next to the button. */
|
||||
type ResultsSend =
|
||||
| { state: "sending"; sent: number; total: number }
|
||||
| { state: "done"; sent: number; total: number; error: string | null };
|
||||
| {
|
||||
state: "done";
|
||||
sent: number;
|
||||
total: number;
|
||||
error: string | null;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export function VodPage({
|
||||
sendouUser,
|
||||
@@ -105,6 +115,7 @@ export function VodPage({
|
||||
const [vods, setVods] = useState<VodSummary[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(false);
|
||||
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
|
||||
|
||||
const abilityMap = useMemo(
|
||||
@@ -112,6 +123,21 @@ export function VodPage({
|
||||
[matches],
|
||||
);
|
||||
|
||||
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
|
||||
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
|
||||
|
||||
// "Upload as results" sends the whole scan in one go, so its outcome maps
|
||||
// onto every ingestable card; a partial failure (some chunks sent, some
|
||||
// not) can't be attributed per match — the bulk status text covers it
|
||||
const bulkSend: SendStatus | undefined =
|
||||
resultsSend?.state === "sending"
|
||||
? { state: "sending", at: 0 }
|
||||
: resultsSend?.state === "done" && resultsSend.error === null
|
||||
? { state: "sent", at: resultsSend.at }
|
||||
: resultsSend?.state === "done" && resultsSend.sent === 0
|
||||
? { state: "failed", at: resultsSend.at }
|
||||
: undefined;
|
||||
|
||||
// only offered once the whole VoD has been processed (a stored VoD is a
|
||||
// completed scan by construction)
|
||||
const upload = useMemo(
|
||||
@@ -145,6 +171,7 @@ export function VodPage({
|
||||
sent: report.sentMatches,
|
||||
total: report.totalMatches,
|
||||
error: report.error,
|
||||
at: Date.now(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -174,6 +201,7 @@ export function VodPage({
|
||||
setError(null);
|
||||
setMatches([]);
|
||||
setResultsSend(null);
|
||||
setEventsOpen(false);
|
||||
setProgress(null);
|
||||
setGateScore(null);
|
||||
setMethod(null);
|
||||
@@ -324,6 +352,7 @@ export function VodPage({
|
||||
matchesRef.current = loaded;
|
||||
setMatches(loaded);
|
||||
setResultsSend(null);
|
||||
setEventsOpen(false);
|
||||
setFileName(name);
|
||||
setSource("stored");
|
||||
setStatus("done");
|
||||
@@ -350,6 +379,7 @@ export function VodPage({
|
||||
matchesRef.current = [];
|
||||
setMatches([]);
|
||||
setResultsSend(null);
|
||||
setEventsOpen(false);
|
||||
setFileName(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
@@ -418,9 +448,10 @@ export function VodPage({
|
||||
` · ${progress.rate.toFixed(0)}× realtime`}
|
||||
</span>
|
||||
)}
|
||||
{matches.length > 0 && (
|
||||
{builtMatches.length > 0 && (
|
||||
<span className="score">
|
||||
{matches.length} match{matches.length === 1 ? "" : "es"}
|
||||
{builtMatches.length} match
|
||||
{builtMatches.length === 1 ? "" : "es"}
|
||||
</span>
|
||||
)}
|
||||
{matches.length > 0 && (
|
||||
@@ -522,39 +553,79 @@ export function VodPage({
|
||||
/>
|
||||
</div>
|
||||
<div className="feed">
|
||||
{matches.length === 0 && (
|
||||
{matches.length === 0 ? (
|
||||
<p className="score">
|
||||
{status === "scanning"
|
||||
? "Scanning — matches appear here as scoreboards are detected."
|
||||
: "No matches found in this VoD."}
|
||||
</p>
|
||||
)}
|
||||
{/* newest detection on top; storage keeps ascending video-time order */}
|
||||
{[...matches].reverse().map((m, i) => {
|
||||
const getFrame: GetFrame | undefined = m.frame
|
||||
? () => Promise.resolve(m.frame)
|
||||
: m.frameId !== undefined
|
||||
? () => loadVodEventFrame(m.frameId!)
|
||||
: undefined;
|
||||
) : null}
|
||||
{/* newest match on top; the builder keeps ascending video-time order */}
|
||||
{[...builtMatches].reverse().map((built) => {
|
||||
const ingestable = isIngestableMatch(built.match);
|
||||
return (
|
||||
<EventCard
|
||||
key={matches.length - 1 - i}
|
||||
type={m.event.type}
|
||||
t={m.event.t}
|
||||
confidence={m.event.confidence}
|
||||
data={m.event.data}
|
||||
abilities={abilityMap.get(m.event)}
|
||||
thumbnail={m.thumbnail}
|
||||
getFrame={getFrame}
|
||||
/>
|
||||
<MatchCard
|
||||
key={built.sources[0]!.t}
|
||||
match={built.match}
|
||||
ingestable={ingestable}
|
||||
send={ingestable ? bulkSend : undefined}
|
||||
>
|
||||
{built.sources.map((e, i) => {
|
||||
const vodMatch = vodMatchByEvent.get(e);
|
||||
return (
|
||||
<EventCard
|
||||
key={i}
|
||||
type={e.type}
|
||||
t={e.t}
|
||||
confidence={e.confidence}
|
||||
data={e.data}
|
||||
abilities={abilityMap.get(e)}
|
||||
thumbnail={vodMatch?.thumbnail}
|
||||
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MatchCard>
|
||||
);
|
||||
})}
|
||||
{matches.length > 0 ? (
|
||||
<EventsSummary
|
||||
events={matches.map((m) => m.event)}
|
||||
open={eventsOpen}
|
||||
onToggle={() => setEventsOpen(!eventsOpen)}
|
||||
/>
|
||||
) : null}
|
||||
{/* newest detection on top; storage keeps ascending video-time order */}
|
||||
{eventsOpen
|
||||
? [...matches]
|
||||
.reverse()
|
||||
.map((m, i) => (
|
||||
<EventCard
|
||||
key={matches.length - 1 - i}
|
||||
type={m.event.type}
|
||||
t={m.event.t}
|
||||
confidence={m.event.confidence}
|
||||
data={m.event.data}
|
||||
abilities={abilityMap.get(m.event)}
|
||||
thumbnail={m.thumbnail}
|
||||
getFrame={frameLoader(m)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function frameLoader(m: VodMatch): GetFrame | undefined {
|
||||
return m.frame
|
||||
? () => Promise.resolve(m.frame)
|
||||
: m.frameId !== undefined
|
||||
? () => loadVodEventFrame(m.frameId!)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function drawPreview(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
frame: ImageBitmap | VideoFrame,
|
||||
|
||||
@@ -15,7 +15,11 @@ import type { DetectedEvent } from "../core/detectors/types";
|
||||
import type { BuiltMatch } from "../core/match-builder";
|
||||
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
|
||||
import type { ScannerMatch } from "../core/scanner-match";
|
||||
import { type StoredEvent, updateEventsSend } from "../store/events";
|
||||
import {
|
||||
type SendStatus,
|
||||
type StoredEvent,
|
||||
updateEventsSend,
|
||||
} from "../store/events";
|
||||
|
||||
const INGEST_URL = "/ingest";
|
||||
|
||||
@@ -126,6 +130,26 @@ export function matchContaining(
|
||||
return (built) => built.sources.some((e) => e.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single send status a match displays, folded from its source events:
|
||||
* an in-flight send wins, then a failure, then success, then queued. Within
|
||||
* a state the most recent change is shown.
|
||||
*/
|
||||
export function aggregateSendStatus(
|
||||
sources: readonly StoredEvent[],
|
||||
): SendStatus | undefined {
|
||||
const statuses = sources
|
||||
.map((e) => e.send)
|
||||
.filter((status) => status !== undefined);
|
||||
for (const state of ["sending", "failed", "sent", "queued"] as const) {
|
||||
const ofState = statuses.filter((status) => status.state === state);
|
||||
if (ofState.length > 0) {
|
||||
return ofState.reduce((a, b) => (a.at >= b.at ? a : b));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Match selector: matches not yet sent (nor currently sending). */
|
||||
export function unsentMatches(built: BuiltMatch<StoredEvent>): boolean {
|
||||
return !built.sources.some(
|
||||
|
||||
@@ -435,3 +435,341 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
.scanner-app .link-button:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* ── ingested matches feed ─────────────────────────────────────────── */
|
||||
|
||||
.scanner-app .match-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
|
||||
/* one overlay element all send-state effects draw on */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.sending {
|
||||
box-shadow: inset 0 0 0 1px var(--color-info-low);
|
||||
}
|
||||
|
||||
&.sending::after {
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
transparent 40%,
|
||||
rgb(255 255 255 / 0.09) 50%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
animation: scanner-sheen 1.4s linear infinite;
|
||||
}
|
||||
|
||||
&.flash-sent::after {
|
||||
animation: scanner-sent-pop 0.8s ease-out both;
|
||||
}
|
||||
|
||||
&.live {
|
||||
box-shadow: inset 0 0 0 1px var(--color-info-low);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-sheen {
|
||||
from {
|
||||
background-position: 130% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -130% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-sent-pop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--color-success),
|
||||
inset 0 0 32px var(--color-success-low);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--color-success),
|
||||
inset 0 0 32px var(--color-success-low);
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .match-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.scanner-app .match-card.flash-failed .match-card-main {
|
||||
animation: scanner-shake 0.35s ease;
|
||||
}
|
||||
|
||||
@keyframes scanner-shake {
|
||||
20% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .match-mode {
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
|
||||
}
|
||||
|
||||
.scanner-app .match-headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* claims row space so the score/chips can never crush the stage name
|
||||
out of view in a narrow feed column; wraps instead */
|
||||
flex: 1 1 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.scanner-app .match-stage {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.scanner-app .match-meta {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.scanner-app .match-weapons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
& .vs {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
& img.weapon-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
&.pov {
|
||||
outline: 2px solid var(--color-text-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .match-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
margin-inline-start: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* frosted-glass plate: keeps the score readable over vivid banner art
|
||||
without dimming the image itself */
|
||||
.scanner-app .match-score {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in oklab, var(--color-bg) 72%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
& .win {
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
& .lose {
|
||||
color: var(--color-info-high);
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .match-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
|
||||
& .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
&.live {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error-low);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
& .dot {
|
||||
animation: scanner-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.queued,
|
||||
&.sending {
|
||||
color: var(--color-info-high);
|
||||
border-color: var(--color-info-low);
|
||||
background-color: var(--color-info-low);
|
||||
|
||||
& .dot {
|
||||
animation: scanner-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.sent {
|
||||
color: var(--color-success-high);
|
||||
border-color: var(--color-success-low);
|
||||
background-color: var(--color-success-low);
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: var(--color-error-high);
|
||||
border-color: var(--color-error-low);
|
||||
background-color: var(--color-error-low);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* undo the scanner-wide solid button look for the banner icon button */
|
||||
.scanner-app button.match-expand {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in oklab, var(--color-bg) 55%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--color-text-high);
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
& svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background: color-mix(in oklab, var(--color-bg) 78%, transparent);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .match-error {
|
||||
padding: 0 16px 10px;
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.scanner-app .match-card-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
animation: scanner-detail-in 0.25s ease both;
|
||||
|
||||
& .card {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .events-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 2px 4px;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.scanner-app button.events-toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-accent);
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.scanner-app .status.watching::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
margin-right: 6px;
|
||||
animation: scanner-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@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-chip.live .dot,
|
||||
.scanner-app .match-chip.queued .dot,
|
||||
.scanner-app .match-chip.sending .dot,
|
||||
.scanner-app .status.watching::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user