Spaces:
Running
Running
| <script lang="ts"> | |
| import type { BufferedRange } from '$lib/types'; | |
| import { onDestroy } from 'svelte'; | |
| import { ChunkedMediaSource, type Chunk } from '$lib/components/video-player'; | |
| import { cn } from '$lib/utils'; | |
| import SkullIcon from 'phosphor-svelte/lib/SkullIcon'; | |
| import SpeakerHighIcon from 'phosphor-svelte/lib/SpeakerHighIcon'; | |
| import { playerColor } from '$lib/utils/player-colors'; | |
| interface Props { | |
| chunks: Chunk[]; | |
| masterTime: number; | |
| masterPaused: boolean; | |
| isLeader: boolean; | |
| isCurrent: boolean; | |
| isMuted: boolean; | |
| playbackRate?: number; | |
| available: boolean; | |
| duration: number; | |
| player: number; | |
| side: 'CT' | 'T' | string; | |
| onSelect: () => void; | |
| onLeaderTime?: (t: number) => void; | |
| onLeaderEnded?: () => void; | |
| onBufferedChange?: (player: number, ranges: BufferedRange[]) => void; | |
| onReady?: (player: number) => void; | |
| } | |
| let { | |
| chunks, | |
| masterTime, | |
| masterPaused, | |
| isLeader, | |
| isCurrent, | |
| isMuted, | |
| playbackRate = 1, | |
| available, | |
| duration, | |
| player, | |
| side, | |
| onSelect, | |
| onLeaderTime, | |
| onLeaderEnded, | |
| onBufferedChange, | |
| onReady | |
| }: Props = $props(); | |
| let videoEl = $state<HTMLVideoElement | undefined>(); | |
| let mse: ChunkedMediaSource | null = null; | |
| let videoSrc = $state<string | undefined>(undefined); | |
| function chunksSig(cs: Chunk[]): string { | |
| return cs.length ? `${cs.length}|${cs[0].src}|${cs[cs.length - 1].src}` : ''; | |
| } | |
| let prevSig = ''; | |
| $effect(() => { | |
| const sig = chunksSig(chunks); | |
| if (sig === prevSig) return; | |
| prevSig = sig; | |
| if (mse) { | |
| mse.destroy(); | |
| mse = null; | |
| } | |
| if (videoSrc) { | |
| URL.revokeObjectURL(videoSrc); | |
| videoSrc = undefined; | |
| } | |
| if (!chunks.length) return; | |
| const m = new ChunkedMediaSource(chunks, { | |
| codecOverride: ChunkedMediaSource.getCachedCodec() ?? undefined, | |
| priorityTime: masterTime | |
| }); | |
| mse = m; | |
| videoSrc = m.url; | |
| m.start().catch(() => {}); | |
| }); | |
| // Drift correction: pull our currentTime back toward the master if we | |
| // fall out of step (network jitter, decoder hiccups, etc.). | |
| $effect(() => { | |
| if (!videoEl) return; | |
| const t = masterTime; | |
| if (!Number.isFinite(t)) return; | |
| if (Math.abs(videoEl.currentTime - t) > 0.35) { | |
| try { | |
| videoEl.currentTime = t; | |
| } catch { | |
| /* not seekable yet */ | |
| } | |
| } | |
| }); | |
| // Mirror the master play/pause state. | |
| $effect(() => { | |
| if (!videoEl) return; | |
| if (masterPaused) videoEl.pause(); | |
| else videoEl.play()?.catch(() => {}); | |
| }); | |
| $effect(() => { | |
| if (videoEl) videoEl.muted = isMuted; | |
| }); | |
| // Re-apply on src swap (chunks change) — browsers reset playbackRate on load. | |
| $effect(() => { | |
| if (!videoEl) return; | |
| void videoSrc; | |
| videoEl.playbackRate = playbackRate; | |
| }); | |
| // Drive leader-time emission off rAF instead of the <video>'s `timeupdate` | |
| // event. `timeupdate` fires at ~4 Hz best-case and drops to ~1 Hz under | |
| // load, which is what was making the minimap stutter. rAF is bound to the | |
| // display refresh, free when paused (t doesn't change → no emit), and | |
| // auto-throttled when the tab is backgrounded. | |
| $effect(() => { | |
| if (!isLeader || !videoEl) return; | |
| const el = videoEl; | |
| let cancelled = false; | |
| let raf = 0; | |
| let last = -1; | |
| const tick = () => { | |
| if (cancelled) return; | |
| const t = el.currentTime; | |
| if (t !== last) { | |
| last = t; | |
| onLeaderTime?.(t); | |
| } | |
| raf = requestAnimationFrame(tick); | |
| }; | |
| raf = requestAnimationFrame(tick); | |
| return () => { | |
| cancelled = true; | |
| cancelAnimationFrame(raf); | |
| }; | |
| }); | |
| function onEnded() { | |
| if (isLeader) onLeaderEnded?.(); | |
| } | |
| function onProgress() { | |
| if (!videoEl || !onBufferedChange) return; | |
| const ranges = videoEl.buffered; | |
| const out: BufferedRange[] = []; | |
| for (let i = 0; i < ranges.length; i++) { | |
| out.push({ start: ranges.start(i), end: ranges.end(i) }); | |
| } | |
| onBufferedChange(player, out); | |
| } | |
| function onLoadedData() { | |
| // First decoded frame available — the tile is now safe to play. | |
| onReady?.(player); | |
| } | |
| function onKeyDown(e: KeyboardEvent) { | |
| if (!available) return; | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| onSelect(); | |
| } | |
| } | |
| onDestroy(() => { | |
| mse?.destroy(); | |
| if (videoSrc) URL.revokeObjectURL(videoSrc); | |
| }); | |
| const ct = $derived(side === 'CT'); | |
| const color = $derived(playerColor(player)); | |
| </script> | |
| <!-- svelte-ignore a11y_click_events_have_key_events --> | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <div | |
| data-current={isCurrent} | |
| data-side={side} | |
| aria-disabled={!available} | |
| class={cn( | |
| 'group relative aspect-video cursor-pointer overflow-hidden rounded-md border bg-black transition', | |
| 'aria-disabled:opacity-60 data-[current=false]:border-border data-[current=false]:hover:border-foreground/40', | |
| 'data-[current=true]:ring-2 data-[side=CT]:data-[current=true]:border-sky-500 data-[side=CT]:data-[current=true]:ring-sky-500/40', | |
| 'data-[side=T]:data-[current=true]:border-amber-500 data-[side=T]:data-[current=true]:ring-amber-500/40' | |
| )} | |
| onclick={() => available && onSelect()} | |
| onkeydown={onKeyDown} | |
| role="button" | |
| tabindex={available ? 0 : -1} | |
| aria-label="Switch to player {player}" | |
| > | |
| <!-- svelte-ignore a11y_media_has_caption --> | |
| <video | |
| bind:this={videoEl} | |
| src={videoSrc} | |
| muted={isMuted} | |
| playsinline | |
| autoplay={!masterPaused} | |
| preload="auto" | |
| disablepictureinpicture | |
| disableremoteplayback | |
| controlslist="nodownload nofullscreen noplaybackrate noremoteplayback" | |
| oncontextmenu={(e) => e.preventDefault()} | |
| onended={onEnded} | |
| onprogress={onProgress} | |
| onloadeddata={onLoadedData} | |
| class="size-full object-cover" | |
| ></video> | |
| <!-- top-left: player number, colored per slot --> | |
| <div | |
| class="absolute top-1.5 left-1.5 flex size-6 items-center justify-center rounded-sm font-mono text-[11px] font-bold text-white/95 shadow-sm ring-1 ring-black/20" | |
| style="background-color: {color};" | |
| > | |
| {player} | |
| </div> | |
| {#if isLeader} | |
| <div | |
| class="absolute top-1.5 right-1.5 grid size-5 place-items-center rounded-sm bg-emerald-500/85 text-white shadow-sm ring-1 ring-black/20" | |
| aria-label="Audio source" | |
| title="Audio source" | |
| > | |
| <SpeakerHighIcon size={11} weight="fill" /> | |
| </div> | |
| {/if} | |
| {#if !available} | |
| <div | |
| class="absolute right-1.5 bottom-1.5 grid size-5 place-items-center rounded-sm bg-rose-500/85 text-white shadow-sm ring-1 ring-black/20" | |
| aria-label="Player is dead" | |
| title="Player is dead" | |
| > | |
| <SkullIcon size={11} weight="duotone" /> | |
| </div> | |
| {/if} | |
| </div> | |