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 {