import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { api } from '../lib/api.js'; import { FORMAT_LABEL } from '../lib/labels.js'; import { getSocket } from '../lib/useMatch.js'; import MapImage from '../components/MapImage.jsx'; import SideIcon from '../components/SideIcon.jsx'; import { ScaledStage, useClock } from './StreamPage.jsx'; import '../stream.css'; import '../stream-overview.css'; const PER_PAGE = 6; const PAGE_SECONDS = 12; /** * Stream-Übersicht aller laufenden Vetos und Matches: /stream * Mit STREAM_KEY: /stream?key=. Optional ?bg=transparent. * Ein einzelnes Match: /stream/match/, daraus führen die Karten der Übersicht. */ export default function StreamOverviewPage() { const params = new URLSearchParams(window.location.search); const key = params.get('key') ?? ''; const transparent = params.get('bg') === 'transparent'; const { matchId } = useParams(); const { data, error } = useStreamOverview(key); const now = useClock(); const [page, setPage] = useState(0); useEffect(() => { document.documentElement.classList.toggle('stream-transparent', transparent); document.documentElement.classList.add('stream-mode'); return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent'); }, [transparent]); const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId); // Mehr Matches als auf eine Seite passen: automatisch weiterblättern const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE)); useEffect(() => { if (pages <= 1) { setPage(0); return undefined; } const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000); return () => clearInterval(t); }, [pages]); if (error) { return (
Maxlan e.V.

Stream-Übersicht nicht verfügbar

{error}

); } const active = activeMatches; const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE); const liveCount = active.filter((m) => m.status === 'live').length; const vetoCount = active.filter((m) => m.status === 'veto').length; const density = shown.length <= 1 ? 1 : shown.length <= 4 ? 2 : 3; return (
Maxlan e.V.
Counter-Strike 2 {liveCount > 0 && {liveCount} live} {vetoCount > 0 && {vetoCount} im Veto} {pages > 1 && Seite {page + 1} / {pages}}
{!data &&
Lade Matches …
} {data && shown.length === 0 && (
{matchId ? ( <>

Dieses Match läuft gerade nicht

Es erscheint hier, sobald das Veto oder das Match beginnt.

) : ( <>

Gerade läuft kein Match

{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}

)}
)} {shown.map((m) => ( ))}
); } /** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */ function matchHref(id, key) { return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`; } function useStreamOverview(key) { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { const socket = getSocket(); const subscribe = () => socket.emit('stream:subscribe', { key }); const onData = (d) => { setData(d); setError(null); }; const onError = (e) => setError(e.error); socket.on('stream:overview', onData); socket.on('stream:error', onError); socket.on('connect', subscribe); if (socket.connected) subscribe(); api(`/stream/overview?key=${encodeURIComponent(key)}`).then(onData).catch((e) => setError(e.message)); return () => { socket.off('stream:overview', onData); socket.off('stream:error', onError); socket.off('connect', subscribe); }; }, [key]); return { data, error }; } /** Bei BO1 zählt der Map-Stand, sonst der Serienstand. */ function resultScore(m) { if (m.format === 'bo1' && m.series[0]) return `${m.series[0].score.A}:${m.series[0].score.B}`; return `${m.seriesScore.A}:${m.seriesScore.B}`; } function winner(m) { const [a, b] = resultScore(m).split(':').map(Number); return a === b ? null : a > b ? 'A' : 'B'; } function MatchCard({ m }) { const current = m.series.find((s) => s.number === m.currentMapNumber); const isBo1 = m.format === 'bo1'; const playing = m.status === 'live' && current?.live?.status === 'live' && current.status !== 'done'; const badge = m.status === 'veto' ? 'Veto' : m.status === 'server_setup' ? 'Server' : playing ? 'Live' : current?.status === 'done' ? 'Map beendet' : 'Aufwärmen'; return (
{m.status !== 'veto' && current && }
{m.modeLabel}, {FORMAT_LABEL[m.format]} {badge}
{m.teams.A} {!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}:{m.seriesScore.B} : vs} {m.teams.B}
{m.status === 'veto' && } {m.status === 'server_setup' && current && (
Server wird eingerichtet: Map {current.number}, {current.name}
)} {m.status === 'live' && current && }
); } function VetoBody({ m }) { const { veto, teams } = m; const c = veto.current; const action = c && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Seite' })[c.type]; return (
{c ? <>{teams[c.team]} {action} : 'Veto abgeschlossen'} Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}
{veto.maps.map((x) => (
{x.name}
))}
); } function LiveBody({ m, current }) { const sides = current.live?.sides; const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null); const finished = current.status === 'done'; const rounds = current.live?.roundsPlayed ?? 0; return (
{current.name} {finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}
{sideOf('A') && } {current.score.A} : {current.score.B} {sideOf('B') && }
{m.series.length > 1 && (
    {m.series.map((s) => (
  1. {s.name} {(s.status === 'done' || s.number === current.number) && {s.score.A}:{s.score.B}}
  2. ))}
)}
); }