opencs2-dataset-viewer / src /lib /components /grid-tile.svelte
blanchon's picture
Video: drive virtualTime emission off rAF for smooth minimap
dd360a4
<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>