mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 13:46:10 -05:00
Tabs
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
137
app/features/scanner/components/MatchLobbyTabs.tsx
Normal file
137
app/features/scanner/components/MatchLobbyTabs.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user