feat: redesign match play and add broadcast tooling

This commit is contained in:
2026-09-29 02:52:56 +00:00
parent f417a831f7
commit 9b9aafefc1
348 changed files with 31588 additions and 11094 deletions

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { MapView, MatchView } from '$lib/match-view';
import { MatchLive } from './live.svelte';
import Scoreboard from './Scoreboard.svelte';
import MapPool from './MapPool.svelte';
import MapHistory from './MapHistory.svelte';
import MatchFeed from './MatchFeed.svelte';
import ActionDialog from './ActionDialog.svelte';
import RefereePanel from './RefereePanel.svelte';
import SimPanel from './SimPanel.svelte';
import StageLobby from './StageLobby.svelte';
import StageRoll from './StageRoll.svelte';
import StageTurn from './StageTurn.svelte';
import NowPlaying from './NowPlaying.svelte';
import StageFinal from './StageFinal.svelte';
import MapReveal from './MapReveal.svelte';
import PredictionCard from '$lib/components/PredictionCard.svelte';
import type { PredictionResult, PredictionState } from '$lib/predictions';
import { blinkTitle, chime, notify } from './attention';
import { Button } from '$lib/components/ui/button/index.js';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Bell from '@lucide/svelte/icons/bell';
import BellOff from '@lucide/svelte/icons/bell-off';
import Eye from '@lucide/svelte/icons/eye';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Pause from '@lucide/svelte/icons/pause';
import Wifi from '@lucide/svelte/icons/wifi';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import CirclePlay from '@lucide/svelte/icons/circle-play';
import WifiOff from '@lucide/svelte/icons/wifi-off';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import List from '@lucide/svelte/icons/list';
import Layers from '@lucide/svelte/icons/layers';
import Trophy from '@lucide/svelte/icons/trophy';
import Sparkles from '@lucide/svelte/icons/sparkles';
import { SIDE_COLORS, modColor } from './theme';
const {
view,
prediction = null,
videos = { live: [], recorded: [] },
userId,
isReferee,
devTools
}: {
view: MatchView;
prediction?: PredictionState | null;
videos?: {
live: { channelId: string; name: string }[];
recorded: { channelId: string; sessionId: string; name: string; t: number }[];
};
userId: string | null;
isReferee: boolean;
devTools: boolean;
} = $props();
// svelte-ignore state_referenced_locally
const live = new MatchLive(view, userId);
$effect(() => live.connect());
const v = $derived(live.view);
const decided = $derived(
v.phase === 'closing' || v.phase === 'finished' || v.phase === 'cancelled'
);
const turnPhase = $derived(['choosing', 'banning', 'picking', 'tb'].includes(v.phase));
// svelte-ignore state_referenced_locally
const celebrate = !(view.phase === 'closing' || view.phase === 'finished');
// ── Your turn: open the dialog once per prompt, and get attention ──
let dialogOpen = $state(false);
let sound = $state(true);
let lastPrompt = '';
$effect(() => {
try {
sound = localStorage.getItem('vash:sound') !== 'off';
} catch {
/* storage blocked */
}
});
$effect(() => {
const key = live.myTurn ? `${v.prompt.kind}:${v.prompt.side}:${v.log.length}` : '';
if (!key || key === lastPrompt) {
if (!live.myTurn) dialogOpen = false;
return;
}
lastPrompt = key;
if (v.prompt.kind !== 'roll') dialogOpen = true;
const what =
v.prompt.kind === 'roll' ? 'roll' : v.prompt.kind === 'choose' ? 'choice' : v.prompt.kind;
if (sound) chime();
notify(`Your ${what}!`, `${v.sides[0].name} vs ${v.sides[1].name}`);
if (document.hidden) return blinkTitle(`🔔 Your ${what}!`);
});
function toggleSound() {
sound = !sound;
try {
localStorage.setItem('vash:sound', sound ? 'on' : 'off');
} catch {
/* storage blocked */
}
if (sound) {
chime();
if (typeof Notification !== 'undefined' && Notification.permission === 'default')
void Notification.requestPermission();
}
}
// ── A short result reveal after each map ──
let reveal = $state<MapView | null>(null);
// svelte-ignore state_referenced_locally
let seenMaps = view.maps.length;
let revealTimer: ReturnType<typeof setTimeout> | undefined;
// Snapshots arrive every second during a map, so the hide timer lives
// outside the effect; an effect cleanup would cancel it on every update.
$effect(() => {
const count = v.maps.length;
if (count > seenMaps) {
reveal = v.maps.at(-1) ?? null;
clearTimeout(revealTimer);
revealTimer = setTimeout(() => (reveal = null), 6500);
}
seenMaps = count;
});
$effect(() => () => clearTimeout(revealTimer));
const title = $derived(`${v.sides[0].name} vs ${v.sides[1].name}`);
// Predictions close when the first map is picked, and settle when a winner is known.
const predictionsOpen = $derived(
!decided &&
!v.live &&
v.maps.length === 0 &&
['created', 'waiting', 'rolling', 'choosing', 'banning', 'picking'].includes(v.phase)
);
const predictionResult = $derived.by((): PredictionResult | null => {
const winner = v.sides.find((side) => side.id === v.winner);
const loser = v.sides.find((side) => side.id !== v.winner);
return winner && loser && decided
? { winnerTeamId: winner.teamId, score: [winner.points, loser.points] }
: null;
});
let panel = $state<'activity' | 'pool' | 'results' | 'predictions'>('activity');
const activeStep = $derived(
decided ? 3 : v.phase === 'playing' || turnPhase ? 2 : v.phase === 'rolling' ? 1 : 0
);
const steps = ['Lobby', 'Roll for order', 'Pick & play', 'Results'];
const guidance = $derived.by(() => {
if (v.paused) return { label: 'Match paused', detail: 'A referee will resume play.' };
if (decided)
return { label: 'That’s a wrap', detail: 'Open results for every map and player score.' };
if (v.phase === 'created')
return {
label: 'Lobby is being prepared',
detail: 'The invite will arrive in osu! chat when it opens.'
};
if (v.phase === 'waiting')
return live.mySide
? {
label: 'Join the osu! lobby',
detail: 'Accept your invite in osu! chat. Your team’s arrivals update here live.'
}
: { label: 'Waiting for players', detail: 'Both teams are joining the osu! lobby.' };
if (v.phase === 'rolling')
return live.myTurn
? { label: 'Your turn to roll', detail: 'Roll here or type !roll in the osu! lobby.' }
: { label: 'Rolling for order', detail: 'The higher roll decides who goes first.' };
if (turnPhase)
return live.myTurn
? { label: 'Your move', detail: 'Choose below before the timer runs out.' }
: {
label: 'Waiting for a choice',
detail: `${live.side(v.prompt.side)?.name ?? 'A team'} is making the next move.`
};
if (v.phase === 'playing')
return v.live?.startedAt
? { label: 'Map in progress', detail: 'Scores and progress update as players finish.' }
: { label: 'Ready up in osu!', detail: 'The selected map is loading. Get ready to play.' };
return { label: 'Match in progress', detail: 'Updates appear here as they happen.' };
});
</script>
<svelte:head>
<title>{v.sides[0].points}–{v.sides[1].points} · {title} · Vash Esports</title>
<meta name="description" content="{title}, best of {v.rules.bestOf} on Vash Esports." />
<meta property="og:title" content="{title} · Vash Esports" />
<meta
property="og:description"
content="{v.sides[0].points}–{v.sides[1].points} · best of {v.rules.bestOf}"
/>
<meta
property="og:image"
content={v.slots.find((slot) => slot.coverUrl)?.coverUrl ?? '/logo.png'}
/>
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<div class="mx-auto flex max-w-[1600px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]">
<header class="flex min-h-10 flex-wrap items-center gap-2">
<a
href="/matches"
class="grid size-9 place-items-center rounded-lg border border-white/10 text-zinc-400 transition hover:bg-white/5 hover:text-white"
aria-label="All matches"><ArrowLeft class="size-4" /></a
>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-bold tracking-tight sm:text-lg">{v.name ?? title}</h1>
<div class="flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
{#if v.tournament}
<a
href="/tournaments/{v.tournament.id}"
class="font-medium hover:underline"
style="color:{v.tournament.accentColor ?? '#e4e4e7'}"
>{v.tournament.name}{v.tournament.round ? ` · ${v.tournament.round}` : ''}</a
>
{:else}
<span class="capitalize">{v.kind} match</span>
{/if}
<span>·</span><span>BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}</span>
</div>
</div>
<span
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium sm:flex {decided ||
live.connected
? 'border-emerald-400/20 bg-emerald-400/5 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'}"
title={decided
? 'Final score'
: live.connected
? 'Live updates connected'
: live.fallbackConnected
? 'Updating every five seconds'
: 'Reconnecting to live updates'}
>
{#if decided}<Trophy class="size-3.5" />Final{:else if live.connected}<Wifi
class="size-3.5"
/>Live · <Eye class="size-3.5" />{live.viewers}{:else if live.fallbackConnected}<Wifi
class="size-3.5"
/>Updating{:else}<WifiOff class="size-3.5" />Reconnecting{/if}
</span>
{#each videos.live as stream (stream.channelId)}
<a
href="/watch/{stream.channelId}"
class="flex items-center gap-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-400"
><CirclePlay class="size-4" />Watch on {stream.name}</a
>
{/each}
{#each videos.recorded.slice(0, 1) as recording (recording.sessionId)}
<a
href="/watch/{recording.channelId}?session={recording.sessionId}&t={recording.t}"
class="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-sm hover:bg-white/5"
><CirclePlay class="size-4" />Watch the broadcast</a
>
{/each}
<Button
variant="ghost"
size="icon-sm"
onclick={toggleSound}
title={sound ? 'Turn alerts off' : 'Turn alerts on'}
>
{#if sound}<Bell class="size-4" />{:else}<BellOff class="size-4" />{/if}
</Button>
<details class="relative z-20">
<summary
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-xs text-zinc-300 hover:bg-white/5"
>Details <ChevronDown class="size-3" /></summary
>
<div
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-xs shadow-2xl"
>
<p class="mb-2 font-semibold text-white">Match details</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-zinc-400">
<dt>Format</dt>
<dd class="text-right text-zinc-200">
BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}
</dd>
<dt>Win by</dt>
<dd class="text-right text-zinc-200 capitalize">
{v.rules.winCondition.replace('_', ' ')}
</dd>
<dt>Viewers</dt>
<dd class="text-right text-zinc-200">{live.viewers}</dd>
</dl>
{#if v.lobby.id && v.lobby.provider === 'irc'}
<a
href="https://osu.ppy.sh/mp/{v.lobby.id}"
target="_blank"
rel="noopener"
class="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-zinc-200 hover:text-white"
>Open osu! lobby <ExternalLink class="size-3" /></a
>
{:else if v.lobby.provider === 'simulator' && v.lobby.id}
<p class="mt-3 flex items-center gap-1.5 border-t border-white/10 pt-3 text-fuchsia-300">
<FlaskConical class="size-3" />Simulated lobby
</p>
{/if}
</div>
</details>
{#if devTools && v.sim}<SimPanel {live} />{/if}
{#if isReferee}<RefereePanel {live} />{/if}
</header>
<Scoreboard {live} />
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_23rem]">
<section
class="relative flex min-h-[25rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-b from-zinc-900/90 to-zinc-950 lg:min-h-0"
>
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-white/8 px-4 py-3 sm:px-5"
>
<div class="flex items-center gap-2 text-xs font-semibold text-zinc-300">
<span
class="size-2 rounded-full {live.myTurn
? 'animate-pulse bg-amber-300'
: 'bg-emerald-400'}"
></span>{guidance.label}
</div>
<nav aria-label="Match progress" class="flex items-center gap-1.5">
{#each steps as step, index}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold {index === activeStep
? 'bg-white/10 text-white'
: index < activeStep
? 'text-emerald-300'
: 'text-zinc-600'}">{index < activeStep ? '✓ ' : ''}{step}</span
>
{/each}
</nav>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
<div class="my-auto w-full">
<p class="mb-4 text-center text-xs text-zinc-500">{guidance.detail}</p>
{#if v.paused && !decided}
<div
transition:fade
class="mb-4 flex items-center gap-2 rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-amber-200"
>
<Pause class="size-4 shrink-0" />
<span class="flex-1">{v.paused.reason}</span>
</div>
{/if}
{#if reveal}
{#key reveal.index}
<div class="mb-4" out:fade={{ duration: 300 }}><MapReveal {live} map={reveal} /></div>
{/key}
{/if}
{#key v.phase === 'playing' ? `playing:${v.live?.slotId}` : v.phase}
<div in:fade={{ duration: 250 }}>
{#if decided}
<StageFinal {live} {celebrate} />
{:else if v.phase === 'created' || v.phase === 'waiting'}
<StageLobby {live} {isReferee} />
{:else if v.phase === 'rolling'}
<StageRoll {live} />
{:else if turnPhase}
<StageTurn {live} onact={() => (dialogOpen = true)} />
{:else if v.phase === 'playing' && v.live}
<NowPlaying {live} />
{/if}
</div>
{/key}
</div>
</div>
<div class="border-t border-white/8 bg-black/20 px-3 py-2.5 sm:px-4">
<div class="mb-2 flex items-center justify-between text-[11px] font-semibold text-zinc-400">
<span>MAP JOURNEY</span><span>{v.maps.length} played · first to {v.rules.winsNeeded}</span
>
</div>
<div class="flex gap-1.5 overflow-x-auto pb-1">
{#each Array.from({ length: v.rules.bestOf }) as _, index}
{@const map = v.maps[index]}
{@const isLive = !map && !!v.live && index === v.maps.length}
{@const slot = map ? live.slot(map.slotId) : isLive ? live.slot(v.live?.slotId) : null}
{@const winner = map ? live.side(map.winner) : null}
<div
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-xs {isLive
? 'border-emerald-400/40 bg-emerald-400/10'
: map
? 'border-white/12 bg-white/5'
: 'border-white/5 bg-white/[0.02] text-zinc-600'}"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-[10px] font-bold"
style={slot ? `color:${modColor(slot.category)}` : ''}
>{slot?.label ?? index + 1}</span
>
<span class="min-w-0 truncate"
>{isLive
? 'Now playing'
: winner
? winner.name
: map
? 'Draw'
: `Map ${index + 1}`}</span
>
{#if winner}<span
class="ml-auto size-2 shrink-0 rounded-full"
style="background:{SIDE_COLORS[winner.color].solid}"
></span>{/if}
</div>
{/each}
</div>
</div>
</section>
<aside
class="flex min-h-[26rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/70 lg:min-h-0"
aria-label="Match information"
>
<div
class="flex shrink-0 items-center gap-1 border-b border-white/8 p-2"
role="tablist"
aria-label="Match information"
>
<button
type="button"
role="tab"
aria-selected={panel === 'activity'}
onclick={() => (panel = 'activity')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'activity'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><List class="size-3.5" />Activity</button
>
<button
type="button"
role="tab"
aria-selected={panel === 'pool'}
onclick={() => (panel = 'pool')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'pool'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Layers class="size-3.5" />Maps</button
>
<button
type="button"
role="tab"
aria-selected={panel === 'results'}
onclick={() => (panel = 'results')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'results'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Trophy class="size-3.5" />Results</button
>
{#if prediction}<button
type="button"
role="tab"
aria-selected={panel === 'predictions'}
onclick={() => (panel = 'predictions')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'predictions'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Sparkles class="size-3.5" />Picks</button
>{/if}
</div>
<div class="min-h-0 flex-1" role="tabpanel">
{#if panel === 'activity'}
<MatchFeed {live} embedded />
{:else if panel === 'pool'}
<div class="h-full overflow-y-auto p-2"><MapPool {live} compact /></div>
{:else if panel === 'results'}
<div class="h-full overflow-y-auto p-2">
{#if v.maps.length}<MapHistory {live} {isReferee} embedded />{:else}<p
class="px-3 py-12 text-center text-sm text-zinc-500"
>
Map results will appear here after the first play.
</p>{/if}
</div>
{:else if prediction}
<div class="h-full overflow-y-auto p-2">
<PredictionCard
state={prediction}
crowd={live.crowd}
open={predictionsOpen}
result={predictionResult}
/>
</div>
{/if}
</div>
</aside>
</div>
</div>
<ActionDialog {live} bind:open={dialogOpen} />