add demo timeline

This commit is contained in:
Brian Beck 2026-03-17 15:21:14 -07:00
parent 0de43ece22
commit 68f2c184da
67 changed files with 1420 additions and 621 deletions

View file

@ -48,6 +48,9 @@
padding: 16px 12px 10px 12px;
}
.BodyNoPadding {
}
@keyframes slideDown {
from {
height: 0;

View file

@ -11,10 +11,12 @@ export function Accordion({
value,
label,
children,
noPadding = false,
}: {
value: string;
label: ReactNode;
children: ReactNode;
noPadding?: boolean;
}) {
return (
<RadixAccordion.Item value={value}>
@ -22,7 +24,9 @@ export function Accordion({
<IoCaretForward className={styles.TriggerIcon} /> {label}
</RadixAccordion.Trigger>
<RadixAccordion.Content className={styles.Content}>
<div className={styles.Body}>{children}</div>
<div className={noPadding ? styles.BodyNoPadding : styles.Body}>
{children}
</div>
</RadixAccordion.Content>
</RadixAccordion.Item>
);

View file

@ -0,0 +1,179 @@
.Root {
display: flex;
flex-direction: column;
}
.ProgressWrap {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 10px;
}
.ProgressLabel {
font-size: 11px;
opacity: 0.7;
text-align: center;
}
.ProgressBar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
}
.ProgressFill {
height: 100%;
background: rgba(100, 180, 255, 0.7);
transition: width 0.15s;
}
.Filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
padding: 10px;
}
.FilterButton {
padding: 2px 8px;
font-family: inherit;
font-size: 11px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.FilterButton[data-active="true"] {
background: rgba(0, 98, 179, 0.6);
border-color: rgba(100, 180, 255, 0.5);
color: #fff;
}
.EventList {
display: flex;
flex-direction: column;
padding: 2px 0 12px 0;
}
.EventRow {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px 3px 8px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
@media (hover: hover) {
.EventRow:hover {
background: rgba(255, 255, 255, 0.1);
}
.FilterButton:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
.FilterButton[data-active="true"]:hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(100, 180, 255, 0.7);
}
}
@media (pointer: coarse) {
.Filters {
}
.FilterButton {
flex: 1 0 auto;
font-size: 13px;
padding: 4px 8px;
}
.EventList {
gap: 2px;
}
.EventRow {
padding-top: 5px;
padding-bottom: 5px;
font-size: 14px;
}
}
.EventTime {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
opacity: 0.6;
min-width: 3.5em;
font-size: 11px;
text-align: right;
}
.EventIcon {
flex-shrink: 0;
font-size: 13px;
display: flex;
align-items: center;
margin: 0 1px;
}
.EventIcon[data-type="kill"] {
color: #8a8380;
}
.EventIcon[data-type="flag-cap"] {
color: #69db7c;
font-size: 15px;
margin: 0;
}
.EventIcon[data-type="flag-cap"][data-affinity="enemy"] {
color: #ff6b6b;
}
.EventIcon[data-type="flag-cap"][data-affinity="neutral"] {
color: #adb5bd;
}
.EventIcon[data-type="match-start"] {
color: #74c0fc;
font-size: 15px;
margin: 0;
}
.EventIcon[data-type="match-end"] {
color: #74c0fc;
}
.EventDescription {
overflow: hidden;
text-overflow: ellipsis;
}
.Killer {
}
.Victim {
}
.DamageType {
}
.Empty {
font-size: 12px;
opacity: 0.5;
padding: 4px 10px 12px 10px;
text-align: center;
}

View file

@ -0,0 +1,170 @@
import { useState, useCallback } from "react";
import { PiFlagBannerFill } from "react-icons/pi";
import { IoSkullSharp } from "react-icons/io5";
import { useDemoTimeline } from "../state/demoTimelineStore";
import type {
TimelineEvent,
TimelineEventType,
} from "../state/demoTimelineStore";
import { usePlaybackActions } from "./RecordingProvider";
import { BsPlayFill } from "react-icons/bs";
import { AiFillStop } from "react-icons/ai";
import styles from "./DemoTimeline.module.css";
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
kill: <IoSkullSharp />,
"flag-cap": <PiFlagBannerFill />,
"match-start": <BsPlayFill />,
"match-end": <AiFillStop />,
};
const WEAPONS_PAST_TENSE: Record<string, string> = {
chaingun: "chaingunned",
};
function renderEventDescription(event: TimelineEvent): React.ReactNode {
if (event.type === "kill" && event.killer && event.victim) {
return (
<>
<span className={styles.Killer}>{event.killer}</span>{" "}
<span className={styles.DamageType}>
{event.weapon
? (WEAPONS_PAST_TENSE[event.weapon] ??
`${event.weapon}${event.weapon.endsWith("e") ? "d" : "ed"}`)
: "killed"}
</span>{" "}
<span className={styles.Victim}>{event.victim}</span>
</>
);
}
if (event.type === "flag-cap" && event.capturer) {
const flagLabel =
event.teamAffinity === "friendly"
? "the enemy flag"
: event.teamAffinity === "enemy"
? "your flag"
: event.flagTeamName
? `the ${event.flagTeamName} flag`
: "a flag";
return (
<>
{event.capturer} captured {flagLabel}
</>
);
}
if (event.type === "match-start") {
return "Match started";
}
if (event.type === "match-end") {
return "Match ended";
}
return event.description;
}
type Filter = "all" | "kill" | "flag-cap";
export function DemoTimeline() {
const events = useDemoTimeline((s) => s.events);
const scanProgress = useDemoTimeline((s) => s.scanProgress);
const { seek } = usePlaybackActions();
const [filter, setFilter] = useState<Filter>("all");
const filtered =
events?.filter((e) => filter === "all" || e.type === filter) ?? [];
const handleClick = useCallback(
(timeSec: number) => {
seek(Math.max(0, timeSec - 3));
},
[seek],
);
// Scanning in progress.
if (scanProgress != null && events == null) {
return (
<div className={styles.Root}>
<div className={styles.ProgressWrap}>
<span className={styles.ProgressLabel}>
Scanning {Math.round(scanProgress * 100)}%
</span>
<div className={styles.ProgressBar}>
<div
className={styles.ProgressFill}
style={{ width: `${scanProgress * 100}%` }}
/>
</div>
</div>
</div>
);
}
if (!events) return null;
const killCount = events.filter((e) => e.type === "kill").length;
const flagCount = events.filter((e) => e.type === "flag-cap").length;
return (
<div className={styles.Root}>
<div className={styles.Filters}>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "all"}
onClick={() => setFilter("all")}
>
All ({events.length})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "kill"}
onClick={() => setFilter("kill")}
>
Kills ({killCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "flag-cap"}
onClick={() => setFilter("flag-cap")}
>
Flags ({flagCount})
</button>
</div>
{filtered.length === 0 ? (
<div className={styles.Empty}>No events found.</div>
) : (
<div className={styles.EventList}>
{filtered.map((event, i) => (
<button
key={`${event.timeSec}-${event.type}-${i}`}
type="button"
className={styles.EventRow}
onClick={() => handleClick(event.timeSec)}
>
<span className={styles.EventTime}>
{formatTime(event.timeSec)}
</span>
<span
className={styles.EventIcon}
data-type={event.type}
data-affinity={event.teamAffinity}
>
{EVENT_ICON[event.type]}
</span>
<span className={styles.EventDescription}>
{renderEventDescription(event)}
</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -15,12 +15,13 @@ import { JoinServerButton } from "./JoinServerButton";
import { Accordion, AccordionGroup } from "./Accordion";
import styles from "./InspectorControls.module.css";
import { useTouchDevice } from "./useTouchDevice";
import { DemoTimeline } from "./DemoTimeline";
import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
const DEFAULT_PANELS = ["controls", "preferences", "audio"];
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];
export const InspectorControls = memo(function InspectorControls({
missionName,
@ -181,6 +182,11 @@ export const InspectorControls = memo(function InspectorControls({
</div>
<div className={styles.Accordions}>
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
{recording?.source === "demo" && (
<Accordion value="timeline" label="Timeline" noPadding>
<DemoTimeline />
</Accordion>
)}
<Accordion value="controls" label="Controls">
<div className={styles.Field}>
<label htmlFor="speedInput">Fly speed</label>

View file

@ -1,6 +1,7 @@
import { useCallback, useRef } from "react";
import { MdOndemandVideo } from "react-icons/md";
import { createLogger } from "../logger";
import { demoTimelineStore } from "../state/demoTimelineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { usePlaybackActions, useRecording } from "./RecordingProvider";
import styles from "./LoadDemoButton.module.css";
@ -21,6 +22,7 @@ export function LoadDemoButton({
const { setRecording } = usePlaybackActions();
const inputRef = useRef<HTMLInputElement>(null);
const parseTokenRef = useRef(0);
const scanAbortRef = useRef<AbortController | null>(null);
const handleClick = useCallback(() => {
if (choosingMap && isDemoLoaded) {
@ -30,7 +32,10 @@ export function LoadDemoButton({
if (isDemoLoaded) {
// Unload the recording/parser but leave entities frozen in the store.
parseTokenRef.current += 1;
scanAbortRef.current?.abort();
scanAbortRef.current = null;
setRecording(null);
demoTimelineStore.getState().reset();
return;
}
inputRef.current?.click();
@ -57,6 +62,37 @@ export function LoadDemoButton({
liveState.disconnectServer();
// Metadata-first: mission/game-mode sync happens immediately.
setRecording(recording);
// Kick off background timeline scan.
scanAbortRef.current?.abort();
const abortController = new AbortController();
scanAbortRef.current = abortController;
const store = demoTimelineStore.getState();
store.reset();
store.setScanProgress(0);
import("../stream/demoTimelineScanner")
.then(({ scanDemoTimeline }) =>
scanDemoTimeline(
buffer,
recording.recorderName,
(p) => {
if (parseTokenRef.current !== parseToken) return;
demoTimelineStore.getState().setScanProgress(p);
},
abortController.signal,
),
)
.then((events) => {
if (parseTokenRef.current !== parseToken) return;
const s = demoTimelineStore.getState();
s.setEvents(events);
s.setScanProgress(null);
})
.catch((err: unknown) => {
if (parseTokenRef.current !== parseToken) return;
if (err instanceof Error && err.name === "AbortError") return;
log.error("Timeline scan failed: %o", err);
demoTimelineStore.getState().setScanProgress(null);
});
} catch (err) {
log.error("Failed to load demo: %o", err);
}