opencs2-dataset-viewer / src /lib /components /perspective-grid.svelte
blanchon's picture
Eval: 2-round policy + sync-start gate + no auto-advance
7f23fd2
<script lang="ts">
import type { BufferedRange, PreviewChunk } from '$lib/types';
import GridTile from './grid-tile.svelte';
import ShieldIcon from 'phosphor-svelte/lib/ShieldIcon';
import SwordIcon from 'phosphor-svelte/lib/SwordIcon';
import { playerColor } from '$lib/utils/player-colors';
interface Props {
previews: PreviewChunk[];
currentPlayer: number;
availablePlayers: Set<number>;
playerDurations: Map<number, number>;
virtualTime: number;
paused?: boolean;
muted?: boolean;
playbackRate?: number;
onPausedChange?: (paused: boolean) => void;
onSelect: (player: number) => void;
onTimeUpdate?: (t: number) => void;
onLeaderEnded?: () => void;
onBufferedChange?: (ranges: BufferedRange[]) => void;
onReady?: (player: number) => void;
}
let {
previews,
currentPlayer,
availablePlayers,
playerDurations,
virtualTime,
paused = false,
muted = false,
playbackRate = 1,
onSelect,
onTimeUpdate,
onLeaderEnded,
onBufferedChange,
onReady
}: Props = $props();
type PerPlayer = {
player: number;
side: PreviewChunk['player_side'];
weapon: string;
chunks: { src: string; duration: number }[];
duration: number;
};
const perPlayer = $derived.by<PerPlayer[]>(() => {
const previewMap = new Map<number, PreviewChunk[]>();
for (const preview of previews) {
if (!previewMap.has(preview.player)) previewMap.set(preview.player, []);
previewMap.get(preview.player)!.push(preview);
}
const players: PerPlayer[] = [];
for (const [player, chunks] of previewMap.entries()) {
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
const last = chunks[chunks.length - 1];
players.push({
player,
side: last.player_side,
weapon: last.primary_weapon,
chunks: chunks.map((chunk) => ({
src: chunk.preview_video.src,
duration: chunk.duration_s
})),
duration: playerDurations.get(player) ?? 0
});
}
players.sort((a, b) => a.player - b.player);
return players;
});
const ctPlayers = $derived(perPlayer.filter((player) => player.side === 'CT'));
const tPlayers = $derived(perPlayer.filter((player) => player.side === 'T'));
function onLeaderTime(t: number) {
onTimeUpdate?.(t);
}
// Intersect every tile's buffered ranges: the timeline span that's safe
// to play across all perspectives at once.
const tileBuffers = new Map<number, BufferedRange[]>();
function intersectAll(): BufferedRange[] {
const roundEnd = Math.max(0, ...Array.from(playerDurations.values()));
if (roundEnd <= 0) return [];
// For a player who died early (stream shorter than the round), pad the
// buffer list with a synthetic [dur, roundEnd] range. There is no stream
// there for them to buffer, so they shouldn't constrain the unified
// indicator past their own death.
const lists: BufferedRange[][] = [];
for (const [player, ranges] of tileBuffers) {
const dur = playerDurations.get(player);
const padded =
dur != null && Number.isFinite(dur) && dur < roundEnd
? [...ranges, { start: dur, end: roundEnd }]
: ranges;
if (padded.length > 0) lists.push(padded);
}
if (!lists.length) return [];
let acc = lists[0];
for (let i = 1; i < lists.length; i++) {
const next: BufferedRange[] = [];
for (const a of acc) {
for (const b of lists[i]) {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
if (end > start) next.push({ start, end });
}
}
if (!next.length) return [];
acc = next;
}
return acc;
}
function handleTileBuffered(player: number, ranges: BufferedRange[]) {
tileBuffers.set(player, ranges);
onBufferedChange?.(intersectAll());
}
</script>
<div class="max-h-[80vh] overflow-y-auto rounded-lg border bg-card shadow-sm">
<div class="space-y-4 p-3">
{#if ctPlayers.length}
<div>
<div
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-sky-700 uppercase dark:text-sky-400"
>
<ShieldIcon size={12} weight="duotone" /> Counter-Terrorists
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2">
{#each ctPlayers as player (player.player)}
<div class="flex min-w-0 flex-col gap-1">
<GridTile
chunks={player.chunks}
masterTime={virtualTime}
masterPaused={paused}
isLeader={player.player === currentPlayer}
isCurrent={player.player === currentPlayer}
isMuted={muted || player.player !== currentPlayer}
{playbackRate}
available={availablePlayers.has(player.player)}
duration={player.duration}
player={player.player}
side={player.side}
onSelect={() => onSelect(player.player)}
{onLeaderTime}
onLeaderEnded={() => onLeaderEnded?.()}
onBufferedChange={handleTileBuffered}
{onReady}
/>
<div class="flex items-center gap-1.5 px-0.5 text-xs">
<span
class="inline-block size-2 shrink-0 rounded-full"
style={`background: ${playerColor(player.player)};`}
></span>
<span class="truncate font-medium">Player {player.player}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if tPlayers.length}
<div>
<div
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-amber-700 uppercase dark:text-amber-400"
>
<SwordIcon size={12} weight="duotone" /> Terrorists
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2">
{#each tPlayers as player (player.player)}
<div class="flex min-w-0 flex-col gap-1">
<GridTile
chunks={player.chunks}
masterTime={virtualTime}
masterPaused={paused}
isLeader={player.player === currentPlayer}
isCurrent={player.player === currentPlayer}
isMuted={muted || player.player !== currentPlayer}
{playbackRate}
available={availablePlayers.has(player.player)}
duration={player.duration}
player={player.player}
side={player.side}
onSelect={() => onSelect(player.player)}
{onLeaderTime}
onLeaderEnded={() => onLeaderEnded?.()}
onBufferedChange={handleTileBuffered}
{onReady}
/>
<div class="flex items-center gap-1.5 px-0.5 text-xs">
<span
class="inline-block size-2 shrink-0 rounded-full"
style={`background: ${playerColor(player.player)};`}
></span>
<span class="truncate font-medium">Player {player.player}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>