diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx
index 4604eef0e..3e42206ad 100644
--- a/app/features/scanner/components/LivePage.tsx
+++ b/app/features/scanner/components/LivePage.tsx
@@ -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 ? (
No detections yet.
) : 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 (
-
- {showSetDividers &&
- setNumbers[index + 1] !== setNumbers[index] ? (
-
- ) : null}
+ 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 (
))}
-
- );
- })}
+ );
+ }}
+ />
{ungroupedFeed.length > 0 ? (
= {
+ private: "Private Battle",
+ x: "X Battle",
+ other: "Other",
+};
+
+const NO_KEYS: ReadonlySet = new Set();
+
+export function MatchLobbyTabs({
+ matches,
+ keyOf,
+ renderMatch,
+}: {
+ /** built matches in chronological order (oldest first) */
+ matches: readonly BuiltMatch[];
+ /** stable render key for one match, typically its first source event's id */
+ keyOf: (built: BuiltMatch) => React.Key;
+ /** `justFormed`: the match appeared while the page was open (enter animation) */
+ renderMatch: (built: BuiltMatch, 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 (
+
+
+ {groups.map(({ group, matches: groupMatches }) => (
+
+ {LOBBY_GROUP_LABELS[group]}
+
+ ))}
+
+ {groups.map(({ group, matches: groupMatches }) => (
+
+
+
+ ))}
+
+ );
+}
+
+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 {
+ const seenRef = useRef | 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({
+ matches,
+ sets,
+ keyOf,
+ justFormedKeys,
+ renderMatch,
+}: {
+ matches: readonly BuiltMatch[];
+ sets: boolean;
+ keyOf: (built: BuiltMatch) => React.Key;
+ justFormedKeys: ReadonlySet;
+ renderMatch: (built: BuiltMatch, 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 (
+
+ {showSetDividers && setNumbers[index + 1] !== setNumbers[index] ? (
+
+ ) : null}
+ {renderMatch(built, justFormedKeys.has(key))}
+
+ );
+ });
+}
diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx
index 8be0e47c0..08450b14b 100644
--- a/app/features/scanner/components/VodPage.tsx
+++ b/app/features/scanner/components/VodPage.tsx
@@ -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."}
) : 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 (
-
- {showSetDividers &&
- setNumbers[index + 1] !== setNumbers[index] ? (
-
- ) : null}
+ 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 (
{objectiveEvents.length > 0 ? (
@@ -649,9 +637,9 @@ export function VodPage({
);
})}
-
- );
- })}
+ );
+ }}
+ />
{ungroupedMatches.length > 0 ? (
m.event)}
diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css
index e56055a40..5aa12c845 100644
--- a/app/features/scanner/components/styles.css
+++ b/app/features/scanner/components/styles.css
@@ -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 {