This commit is contained in:
Kalle
2026-08-06 13:32:43 +03:00
parent 5d81444dc9
commit 95f5f260b3
5 changed files with 206 additions and 69 deletions

View File

@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
listVideoInputs,
openVirtualCamera,
@@ -22,7 +22,6 @@ import {
invalidObjectiveEvents,
isIngestableMatch,
} from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import {
clearEvents,
@@ -40,7 +39,8 @@ import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { MatchCard, SetDivider } from "./MatchCard";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { ObjectiveTimeline } from "./ObjectiveTimeline";
import {
aggregateSendStatus,
@@ -254,8 +254,6 @@ export function LivePage({
}, [deviceId, refreshFeed, send]);
const builtMatches = buildScannerMatches(feed);
const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e));
@@ -366,33 +364,30 @@ export function LivePage({
{feed.length === 0 ? (
<p className="score">No detections yet.</p>
) : null}
{[...builtMatches].reverse().map((built, reverseIndex) => {
const index = builtMatches.length - 1 - reverseIndex;
const id = built.sources[0]!.id!;
const ingestable = isIngestableMatch(built.match);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
return (
<Fragment key={id}>
{showSetDividers &&
setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => built.sources[0]!.id!}
renderMatch={(built, justFormed) => {
const id = built.sources[0]!.id!;
const ingestable = isIngestableMatch(built.match);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
const newest = built === builtMatches.at(-1);
return (
<MatchCard
match={built.match}
live={
running && reverseIndex === 0 && built.match.winner === null
}
inProgress={reverseIndex === 0 && built.match.winner === null}
live={running && newest && built.match.winner === null}
inProgress={newest && built.match.winner === null}
ingestable={ingestable}
justFormed={justFormed}
send={aggregateSendStatus(built.sources)}
onSend={
SENDOU_UPLOAD_ENABLED && sendouUser && ingestable
@@ -420,9 +415,9 @@ export function LivePage({
/>
))}
</MatchCard>
</Fragment>
);
})}
);
}}
/>
{ungroupedFeed.length > 0 ? (
<EventsSummary
events={ungroupedFeed}

View File

@@ -31,6 +31,7 @@ export function MatchCard({
live = false,
inProgress = false,
ingestable = true,
justFormed = false,
children,
}: {
match: ScannerMatch;
@@ -47,10 +48,15 @@ export function MatchCard({
inProgress?: boolean;
/** false = isIngestableMatch rejected it (not a private battle) */
ingestable?: boolean;
/** the scan just formed this match — play the enter animation */
justFormed?: boolean;
/** expandable detail content, typically the source event cards */
children?: React.ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
// fixed at mount: re-rendering must not cut the animation short, and a card
// remounting for another reason (switching lobby tabs) must not replay it
const [enter] = useState(justFormed);
// one-shot flash animations only on a state *change*, so already-sent
// matches don't replay the glow on every mount
@@ -122,6 +128,7 @@ export function MatchCard({
);
const className = clsx("match-card", send?.state, {
enter,
live,
"flash-sent": flash === "sent",
"flash-failed": flash === "failed",

View File

@@ -0,0 +1,137 @@
/**
* Lobby tabs over the match feed: private battles (the games /ingest cares
* about) split from X Battles and everything else, so a scan that picked up
* ranked play between tournament sets stays readable. Sets are a private
* battle concept — consecutive games between the same two teams — so only
* that tab gets set dividers, and set numbers are assigned within the tab.
*/
import { Fragment, useEffect, useRef } from "react";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
type LobbyGroup = "private" | "x" | "other";
const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"];
const LOBBY_GROUP_LABELS: Record<LobbyGroup, string> = {
private: "Private Battle",
x: "X Battle",
other: "Other",
};
const NO_KEYS: ReadonlySet<React.Key> = new Set();
export function MatchLobbyTabs<E extends DetectedEvent>({
matches,
keyOf,
renderMatch,
}: {
/** built matches in chronological order (oldest first) */
matches: readonly BuiltMatch<E>[];
/** stable render key for one match, typically its first source event's id */
keyOf: (built: BuiltMatch<E>) => React.Key;
/** `justFormed`: the match appeared while the page was open (enter animation) */
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const justFormedKeys = useJustFormedKeys(matches.map(keyOf));
const groups = LOBBY_GROUPS.map((group) => ({
group,
matches: matches.filter((built) => lobbyGroup(built.match.lobby) === group),
})).filter(({ matches: groupMatches }) => groupMatches.length > 0);
if (groups.length === 0) return null;
return (
<SendouTabs>
<SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTab key={group} id={group} number={groupMatches.length}>
{LOBBY_GROUP_LABELS[group]}
</SendouTab>
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className="match-list">
<MatchList
matches={groupMatches}
sets={group === "private"}
keyOf={keyOf}
justFormedKeys={justFormedKeys}
renderMatch={renderMatch}
/>
</SendouTabPanel>
))}
</SendouTabs>
);
}
function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup {
if (lobby === "PRIVATE") return "private";
if (lobby === "X") return "x";
return "other";
}
/**
* Keys of the matches that showed up since the previous render — the ones a
* scan just formed. A list arriving whole (the live feed loaded from storage,
* a saved VoD opened) is not "just formed": every card would animate in at
* once for something the user did not watch happen.
*/
function useJustFormedKeys(keys: React.Key[]): ReadonlySet<React.Key> {
const seenRef = useRef<Set<React.Key> | null>(null);
const seen = seenRef.current;
// after commit, not during render: under StrictMode the render runs twice
// and the second pass would find every key already seen
useEffect(() => {
seenRef.current = new Set(keys);
});
if (seen === null) return NO_KEYS;
const justFormed = keys.filter((key) => !seen.has(key));
return justFormed.length === keys.length && keys.length > 1
? NO_KEYS
: new Set(justFormed);
}
function MatchList<E extends DetectedEvent>({
matches,
sets,
keyOf,
justFormedKeys,
renderMatch,
}: {
matches: readonly BuiltMatch<E>[];
sets: boolean;
keyOf: (built: BuiltMatch<E>) => React.Key;
justFormedKeys: ReadonlySet<React.Key>;
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const setNumbers = sets ? assignMatchSets(matches.map((b) => b.match)) : [];
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
// newest match on top; the builder keeps ascending time order
return [...matches].reverse().map((built, reverseIndex) => {
const index = matches.length - 1 - reverseIndex;
const key = keyOf(built);
return (
<Fragment key={key}>
{showSetDividers && setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
{renderMatch(built, justFormedKeys.has(key))}
</Fragment>
);
});
}

View File

@@ -12,14 +12,7 @@
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { openVodScan } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
@@ -33,7 +26,6 @@ import {
invalidObjectiveEvents,
isIngestableMatch,
} from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import type { SendStatus } from "../store/events";
import {
@@ -52,7 +44,8 @@ import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { formatTime } from "./format";
import { MatchCard, SetDivider } from "./MatchCard";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { ObjectiveTimeline } from "./ObjectiveTimeline";
import {
countIngestableMatches,
@@ -148,8 +141,6 @@ export function VodPage({
);
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
@@ -600,34 +591,31 @@ export function VodPage({
: "No matches found in this VoD."}
</p>
) : null}
{/* newest match on top; the builder keeps ascending video-time order */}
{[...builtMatches].reverse().map((built, reverseIndex) => {
const index = builtMatches.length - 1 - reverseIndex;
const ingestable = isIngestableMatch(built.match);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
return (
<Fragment key={vodMatchByEvent.get(built.sources[0]!)!.key}>
{showSetDividers &&
setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key}
renderMatch={(built, justFormed) => {
const ingestable = isIngestableMatch(built.match);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
return (
<MatchCard
match={built.match}
inProgress={
status === "scanning" &&
reverseIndex === 0 &&
built === builtMatches.at(-1) &&
built.match.winner === null
}
ingestable={ingestable}
justFormed={justFormed}
send={ingestable ? bulkSend : undefined}
>
{objectiveEvents.length > 0 ? (
@@ -649,9 +637,9 @@ export function VodPage({
);
})}
</MatchCard>
</Fragment>
);
})}
);
}}
/>
{ungroupedMatches.length > 0 ? (
<EventsSummary
events={ungroupedMatches.map((m) => m.event)}

View File

@@ -163,6 +163,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
gap: 12px;
}
.scanner-app .match-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.scanner-app .card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
@@ -653,7 +659,11 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
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;
/* only cards the scan just formed animate in — see MatchCard's `enter` */
&.enter {
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 {