488 lines
18 KiB
Svelte
488 lines
18 KiB
Svelte
<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} />
|