Spaces:
Running
Running
| <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> | |