replay wip
This commit is contained in:
@@ -564,6 +564,15 @@ export function AdminDashboard({
|
||||
System logs
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches || pageAccess.analysis ? (
|
||||
<Link
|
||||
href="/replays"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Replays
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/settings"
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { parseReplayJsonText } from "@/lib/replay-parse";
|
||||
import {
|
||||
discontinuityTimes,
|
||||
eventCursorAfter,
|
||||
formatReplayTime,
|
||||
resolvePoses,
|
||||
scoreAtTime,
|
||||
} from "@/lib/replay-playback";
|
||||
import type { ReplayEvent, ReplayFile } from "@/types/replay";
|
||||
import {
|
||||
REPLAY_BOTTOM_GOAL,
|
||||
REPLAY_FIELD_BOUNDS,
|
||||
REPLAY_PITCH_BOUNDS,
|
||||
REPLAY_TOP_GOAL,
|
||||
type ReplayGoalBox,
|
||||
} from "@/types/replay";
|
||||
|
||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
||||
|
||||
/** Padding around the field rect inside the canvas (CSS px). */
|
||||
const VIEW_PAD = 12;
|
||||
|
||||
type ViewRect = {
|
||||
ox: number;
|
||||
oy: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
/** Letterbox the fixed field into the canvas, preserving aspect. */
|
||||
function fieldViewRect(canvasW: number, canvasH: number): ViewRect {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
const availW = Math.max(1, canvasW - VIEW_PAD * 2);
|
||||
const availH = Math.max(1, canvasH - VIEW_PAD * 2);
|
||||
const scale = Math.min(availW / worldW, availH / worldH);
|
||||
const w = worldW * scale;
|
||||
const h = worldH * scale;
|
||||
return {
|
||||
ox: (canvasW - w) / 2,
|
||||
oy: (canvasH - h) / 2,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
function worldToCanvas(
|
||||
x: number,
|
||||
y: number,
|
||||
view: ViewRect,
|
||||
): { cx: number; cy: number } {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
return {
|
||||
cx: view.ox + ((x - minX) / worldW) * view.w,
|
||||
cy: view.oy + ((maxY - y) / worldH) * view.h, // +Y up → canvas Y down
|
||||
};
|
||||
}
|
||||
|
||||
function fillWorldRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
view: ViewRect,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
fill: string,
|
||||
) {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fillRect(
|
||||
Math.min(a.cx, b.cx),
|
||||
Math.min(a.cy, b.cy),
|
||||
Math.abs(b.cx - a.cx),
|
||||
Math.abs(b.cy - a.cy),
|
||||
);
|
||||
}
|
||||
|
||||
function strokeWorldRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
view: ViewRect,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
stroke: string,
|
||||
lineWidth = 2,
|
||||
) {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeRect(
|
||||
Math.min(a.cx, b.cx),
|
||||
Math.min(a.cy, b.cy),
|
||||
Math.abs(b.cx - a.cx),
|
||||
Math.abs(b.cy - a.cy),
|
||||
);
|
||||
}
|
||||
|
||||
function goalExtents(g: ReplayGoalBox) {
|
||||
const halfW = g.width / 2;
|
||||
const halfH = g.height / 2;
|
||||
return {
|
||||
left: g.x - halfW,
|
||||
right: g.x + halfW,
|
||||
bottom: g.y - halfH,
|
||||
top: g.y + halfH,
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Optional preloaded replay (e.g. later: server file by match id). */
|
||||
initialReplay?: ReplayFile | null;
|
||||
initialFileName?: string | null;
|
||||
};
|
||||
|
||||
function entityFill(type: string, team: string): string {
|
||||
if (type === "ball") return "#f4f0e6";
|
||||
if (team === "Red") return "#e5484d";
|
||||
if (team === "Blue") return "#3b82f6";
|
||||
return "#a1a1aa";
|
||||
}
|
||||
|
||||
function entityStroke(type: string, team: string): string {
|
||||
if (type === "ball") return "#c4b89a";
|
||||
if (team === "Red") return "#9f1239";
|
||||
if (team === "Blue") return "#1d4ed8";
|
||||
return "#52525b";
|
||||
}
|
||||
|
||||
function drawPitch(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
view: ViewRect,
|
||||
) {
|
||||
const pitch = REPLAY_PITCH_BOUNDS;
|
||||
const field = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = field.maxX - field.minX;
|
||||
const worldH = field.maxY - field.minY;
|
||||
|
||||
ctx.fillStyle = "#0d1117";
|
||||
ctx.fillRect(0, 0, canvasW, canvasH);
|
||||
|
||||
// Playable pitch + goal boxes (view letterboxes to include goals)
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
pitch.minX,
|
||||
pitch.minY,
|
||||
pitch.maxX,
|
||||
pitch.maxY,
|
||||
"#1a3d2b",
|
||||
);
|
||||
|
||||
const topG = goalExtents(REPLAY_TOP_GOAL);
|
||||
const botG = goalExtents(REPLAY_BOTTOM_GOAL);
|
||||
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
pitch.maxY,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"#1a3d2b",
|
||||
);
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
pitch.minY,
|
||||
"#1a3d2b",
|
||||
);
|
||||
|
||||
// Subtle horizontal stripes on the playable pitch
|
||||
const stripes = 10;
|
||||
for (let i = 0; i < stripes; i++) {
|
||||
if (i % 2 === 0) continue;
|
||||
const y0 =
|
||||
pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * (i + 1);
|
||||
const y1 = pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * i;
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
pitch.minX,
|
||||
y0,
|
||||
pitch.maxX,
|
||||
y1,
|
||||
"rgba(255,255,255,0.03)",
|
||||
);
|
||||
}
|
||||
|
||||
const strokeLine = (
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
width = 2,
|
||||
) => {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.cx, a.cy);
|
||||
ctx.lineTo(b.cx, b.cy);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.4)";
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// Sidelines
|
||||
strokeLine(pitch.minX, pitch.minY, pitch.minX, pitch.maxY, 2.5);
|
||||
strokeLine(pitch.maxX, pitch.minY, pitch.maxX, pitch.maxY, 2.5);
|
||||
|
||||
// Top end: end-line with goal mouth notch
|
||||
strokeLine(pitch.minX, pitch.maxY, topG.left, pitch.maxY, 2.5);
|
||||
strokeLine(topG.right, pitch.maxY, pitch.maxX, pitch.maxY, 2.5);
|
||||
strokeLine(topG.left, pitch.maxY, topG.left, topG.top, 2.5);
|
||||
strokeLine(topG.right, pitch.maxY, topG.right, topG.top, 2.5);
|
||||
strokeLine(topG.left, topG.top, topG.right, topG.top, 2.5);
|
||||
|
||||
// Bottom end: end-line with goal mouth notch
|
||||
strokeLine(pitch.minX, pitch.minY, botG.left, pitch.minY, 2.5);
|
||||
strokeLine(botG.right, pitch.minY, pitch.maxX, pitch.minY, 2.5);
|
||||
strokeLine(botG.left, pitch.minY, botG.left, botG.bottom, 2.5);
|
||||
strokeLine(botG.right, pitch.minY, botG.right, botG.bottom, 2.5);
|
||||
strokeLine(botG.left, botG.bottom, botG.right, botG.bottom, 2.5);
|
||||
|
||||
// Center line + circle
|
||||
strokeLine(pitch.minX, 0, pitch.maxX, 0, 2);
|
||||
const r = Math.min(worldW, worldH) * 0.14;
|
||||
const c = worldToCanvas(0, 0, view);
|
||||
const rx = (r / worldW) * view.w;
|
||||
const ry = (r / worldH) * view.h;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(c.cx, c.cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.35)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Goal fills (exact size/position)
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
topG.bottom,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"rgba(229, 72, 77, 0.45)",
|
||||
);
|
||||
strokeWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
topG.bottom,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"rgba(229, 72, 77, 0.85)",
|
||||
1.5,
|
||||
);
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
botG.top,
|
||||
"rgba(59, 130, 246, 0.45)",
|
||||
);
|
||||
strokeWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
botG.top,
|
||||
"rgba(59, 130, 246, 0.85)",
|
||||
1.5,
|
||||
);
|
||||
}
|
||||
|
||||
function drawEntities(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
replay: ReplayFile,
|
||||
poses: { x: number; y: number }[],
|
||||
view: ViewRect,
|
||||
) {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
const unit = Math.min(view.w / worldW, view.h / worldH);
|
||||
const ballR = Math.max(4, unit * 0.12);
|
||||
const puckR = Math.max(6, unit * 0.18);
|
||||
|
||||
for (let i = 0; i < replay.entities.length; i++) {
|
||||
const ent = replay.entities[i]!;
|
||||
const pose = poses[i];
|
||||
if (!pose) continue;
|
||||
const { cx, cy } = worldToCanvas(pose.x, pose.y, view);
|
||||
const r = ent.type === "ball" ? ballR : puckR;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = entityFill(ent.type, ent.team);
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = entityStroke(ent.type, ent.team);
|
||||
ctx.stroke();
|
||||
|
||||
if (ent.type === "ball") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.12)";
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function eventMarkerColor(e: ReplayEvent): string {
|
||||
if (e.type === "goal") {
|
||||
return e.team === "Red" ? "#3b82f6" : "#e5484d";
|
||||
}
|
||||
if (e.type === "reset") return "#a1a1aa";
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
export function ReplayViewer({
|
||||
initialReplay = null,
|
||||
initialFileName = null,
|
||||
}: Props) {
|
||||
const [replay, setReplay] = useState<ReplayFile | null>(initialReplay);
|
||||
const [fileName, setFileName] = useState<string | null>(initialFileName);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [time, setTime] = useState(0);
|
||||
const [score, setScore] = useState({ red: 0, blue: 0 });
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const cutsRef = useRef<number[]>([]);
|
||||
const timeRef = useRef(0);
|
||||
const playingRef = useRef(false);
|
||||
const speedRef = useRef(1);
|
||||
const lastTsRef = useRef<number | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const replayRef = useRef<ReplayFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
timeRef.current = time;
|
||||
}, [time]);
|
||||
useEffect(() => {
|
||||
playingRef.current = playing;
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
speedRef.current = speed;
|
||||
}, [speed]);
|
||||
useEffect(() => {
|
||||
replayRef.current = replay;
|
||||
}, [replay]);
|
||||
|
||||
const loadReplay = useCallback((file: ReplayFile, name: string) => {
|
||||
setReplay(file);
|
||||
setFileName(name);
|
||||
setError(null);
|
||||
setTime(0);
|
||||
timeRef.current = 0;
|
||||
setPlaying(false);
|
||||
setScore({ red: 0, blue: 0 });
|
||||
cutsRef.current = discontinuityTimes(file.events);
|
||||
eventCursorRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const onFileText = useCallback(
|
||||
(text: string, name: string) => {
|
||||
try {
|
||||
const parsed = parseReplayJsonText(text);
|
||||
loadReplay(parsed, name);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to parse replay");
|
||||
setReplay(null);
|
||||
setFileName(null);
|
||||
}
|
||||
},
|
||||
[loadReplay],
|
||||
);
|
||||
|
||||
const onPickFile = useCallback(
|
||||
(file: File | null) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = typeof reader.result === "string" ? reader.result : "";
|
||||
onFileText(text, file.name);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read file");
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[onFileText],
|
||||
);
|
||||
|
||||
const seekTo = useCallback((t: number) => {
|
||||
const r = replayRef.current;
|
||||
if (!r) return;
|
||||
const clamped = Math.min(Math.max(0, t), r.duration);
|
||||
timeRef.current = clamped;
|
||||
setTime(clamped);
|
||||
setScore(scoreAtTime(r.events, clamped));
|
||||
eventCursorRef.current = eventCursorAfter(r.events, clamped);
|
||||
}, []);
|
||||
|
||||
const paint = useEffectEvent(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const r = replayRef.current;
|
||||
if (!canvas || !r) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.clientWidth;
|
||||
const cssH = canvas.clientHeight;
|
||||
if (cssW < 2 || cssH < 2) return;
|
||||
|
||||
const needW = Math.floor(cssW * dpr);
|
||||
const needH = Math.floor(cssH * dpr);
|
||||
if (canvas.width !== needW || canvas.height !== needH) {
|
||||
canvas.width = needW;
|
||||
canvas.height = needH;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const view = fieldViewRect(cssW, cssH);
|
||||
const poses = resolvePoses(
|
||||
r.frames,
|
||||
r.entities.length,
|
||||
timeRef.current,
|
||||
cutsRef.current,
|
||||
);
|
||||
drawPitch(ctx, cssW, cssH, view);
|
||||
drawEntities(ctx, r, poses, view);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = (ts: number) => {
|
||||
const r = replayRef.current;
|
||||
if (r && playingRef.current) {
|
||||
if (lastTsRef.current == null) lastTsRef.current = ts;
|
||||
const dt = (ts - lastTsRef.current) / 1000;
|
||||
lastTsRef.current = ts;
|
||||
let next = timeRef.current + dt * speedRef.current;
|
||||
if (next >= r.duration) {
|
||||
next = r.duration;
|
||||
playingRef.current = false;
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
// Fire events crossed this frame (SFX later; update score for goals)
|
||||
const events = r.events;
|
||||
let cursor = eventCursorRef.current;
|
||||
while (cursor < events.length && events[cursor]!.t <= next) {
|
||||
const e = events[cursor]!;
|
||||
if (e.type === "goal") {
|
||||
setScore({ red: e.redScore, blue: e.blueScore });
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
eventCursorRef.current = cursor;
|
||||
|
||||
timeRef.current = next;
|
||||
setTime(next);
|
||||
} else {
|
||||
lastTsRef.current = null;
|
||||
}
|
||||
paint();
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [paint]);
|
||||
|
||||
const goalEvents =
|
||||
replay?.events.filter((e) => e.type === "goal") ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{!replay ? (
|
||||
<label
|
||||
className={[
|
||||
"flex flex-1 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-16 text-center transition",
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-500/10"
|
||||
: "border-zinc-600 bg-zinc-900/40 hover:border-zinc-400 hover:bg-zinc-900/70",
|
||||
].join(" ")}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPickFile(f);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<p className="text-base font-medium text-zinc-100">
|
||||
Drop a replay JSON file here
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-sm text-zinc-400">
|
||||
From the game server:{" "}
|
||||
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
|
||||
Logs/{matchId}_replay.json
|
||||
</code>
|
||||
</p>
|
||||
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
|
||||
Choose file
|
||||
</span>
|
||||
{error ? (
|
||||
<p className="mt-4 max-w-lg text-sm text-red-400">{error}</p>
|
||||
) : null}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm text-zinc-300">
|
||||
{fileName ?? `match_${replay.matchId}_replay.json`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Match {replay.matchId}
|
||||
{replay.isPractice ? " · practice" : ""}
|
||||
{" · "}
|
||||
{replay.entities.length} entities
|
||||
{" · "}
|
||||
{replay.frames.length.toLocaleString("en-US")} samples
|
||||
{" · "}
|
||||
v{replay.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-sm tabular-nums">
|
||||
<span className="text-[#e5484d]">{score.red}</span>
|
||||
<span className="text-zinc-500">–</span>
|
||||
<span className="text-[#3b82f6]">{score.blue}</span>
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700">
|
||||
Load other…
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="relative min-h-[280px] flex-1 overflow-hidden rounded-xl border border-zinc-700 bg-[#0d1117] shadow-inner">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-label="Replay field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-zinc-700 bg-[#161b22] p-3">
|
||||
<div className="relative px-1 pt-3">
|
||||
{/* Event markers */}
|
||||
<div className="pointer-events-none absolute inset-x-1 top-0 h-3">
|
||||
{goalEvents.map((e, i) => (
|
||||
<span
|
||||
key={`${e.t}-${i}`}
|
||||
title={`Goal @ ${formatReplayTime(e.t)}`}
|
||||
className="absolute top-0 h-2.5 w-1 -translate-x-1/2 rounded-sm"
|
||||
style={{
|
||||
left: `${replay.duration > 0 ? (e.t / replay.duration) * 100 : 0}%`,
|
||||
backgroundColor: eventMarkerColor(e),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={replay.duration || 1}
|
||||
step={0.01}
|
||||
value={Math.min(time, replay.duration)}
|
||||
onChange={(e) => {
|
||||
setPlaying(false);
|
||||
seekTo(Number(e.target.value));
|
||||
}}
|
||||
className="w-full accent-emerald-500"
|
||||
aria-label="Replay timeline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (timeRef.current >= (replay.duration || 0)) {
|
||||
seekTo(0);
|
||||
}
|
||||
setPlaying((p) => !p);
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
|
||||
>
|
||||
{playing ? "Pause" : "Play"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false);
|
||||
seekTo(0);
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<span className="ml-1 font-mono text-xs tabular-nums text-zinc-400">
|
||||
{formatReplayTime(time)} / {formatReplayTime(replay.duration)}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1">
|
||||
<span className="mr-1 text-xs text-zinc-500">Speed</span>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
className={[
|
||||
"rounded px-2 py-1 font-mono text-xs",
|
||||
speed === s
|
||||
? "bg-emerald-600/30 text-emerald-300"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goalEvents.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-zinc-800 pt-2">
|
||||
<span className="mr-1 self-center text-[11px] uppercase tracking-wide text-zinc-500">
|
||||
Goals
|
||||
</span>
|
||||
{goalEvents.map((e, i) => {
|
||||
const scorer =
|
||||
e.team === "Red"
|
||||
? "Blue"
|
||||
: e.team === "Blue"
|
||||
? "Red"
|
||||
: "?";
|
||||
return (
|
||||
<button
|
||||
key={`${e.t}-jump-${i}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false);
|
||||
seekTo(Math.max(0, e.t - 0.05));
|
||||
}}
|
||||
className="rounded border border-zinc-700 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-300 hover:border-zinc-500"
|
||||
>
|
||||
{formatReplayTime(e.t)} · {scorer} ({e.redScore}–
|
||||
{e.blueScore})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user