import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { api } from '../lib/api.js'; import { FORMAT_LABEL } from '../lib/labels.js'; import { useMatch } from '../lib/useMatch.js'; import MapImage from '../components/MapImage.jsx'; import SideIcon from '../components/SideIcon.jsx'; import RoundStrip from '../components/RoundStrip.jsx'; import '../stream.css'; const STAGE_W = 1920; const STAGE_H = 1080; /** * Vollbild-Übersicht für den Stream: /stream/ * Optional ?bg=transparent für eine transparente OBS-Browserquelle. */ export default function StreamPage() { const { code } = useParams(); const [matchId, setMatchId] = useState(null); const [error, setError] = useState(null); const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent'; useEffect(() => { api('/access', { method: 'POST', body: { code } }) .then((info) => { if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.'); setMatchId(info.matchId); }) .catch((e) => setError(e.message)); }, [code]); useEffect(() => { document.documentElement.classList.toggle('stream-transparent', transparent); document.documentElement.classList.add('stream-mode'); return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent'); }, [transparent]); return ( {error ? : matchId ? : } ); } /** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */ export function ScaledStage({ children }) { const ref = useRef(null); const [scale, setScale] = useState(1); useLayoutEffect(() => { const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H)); fit(); window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); }, []); return (
{children}
); } function StreamMessage({ title, text }) { return (
Maxlan e.V.

{title}

{text &&

{text}

}
); } export function useClock() { const [now, setNow] = useState(() => new Date()); useEffect(() => { const t = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(t); }, []); return now; } function StreamContent({ matchId, code }) { const { state, connected } = useMatch(matchId, code); const now = useClock(); if (!state) return ; const { teams } = state; const current = state.series.find((s) => s.number === state.currentMapNumber); const playing = current?.live?.status === 'live' && current.status !== 'done'; const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match'; const showSeries = state.series.length > 1; // Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type)); return (
0 ? 'has-maps' : 'no-maps'}`}>
Maxlan e.V.
Counter-Strike 2 {state.modeLabel} {FORMAT_LABEL[state.format]}
{playing && Live} {!connected && connected !== null && Verbindung …}

{teams.A.name}

{teams.B.name}

{phase === 'lobby' && } {phase === 'veto' && } {phase === 'match' && }
{phase !== 'lobby' && state.series.length > 0 && (
{state.series.map((s) => )}
)}
{phaseLabel(state)} {tickerEvent?.message ?? 'Warten auf die Teams'}
); } function phaseLabel(state) { if (state.status === 'lobby') return 'Lobby'; if (state.status === 'veto') return 'Map-Veto'; if (state.status === 'finished') return 'Endstand'; if (state.status === 'cancelled') return 'Abgebrochen'; return 'Match'; } function LobbyScene({ state }) { return (
{['A', 'B'].map((key) => { const team = state.teams[key]; return (

{team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}

    {team.players.map((p) => (
  • {p.nickname} {p.isCaptain && Captain}
  • ))}
); })}
Das Map-Veto beginnt gleich
); } function VetoScene({ state }) { const { veto, teams } = state; const current = veto?.current; const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type]; return (
{current && (
{teams[current.team].name} {action} {current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'}
)}
{veto.maps.map((m) => (
{mapsById[m.id]?.name} {m.status === 'banned' && `Bann ${teams[m.by].name}`} {m.status === 'picked' && `Pick ${teams[m.by].name}`} {m.status === 'decider' && 'Decider'}
))}
); } function MatchScene({ state, current }) { const { teams } = state; if (!current) return
Server wird vorbereitet
; const map = state.maps.find((m) => m.id === current.map); const live = current.live; const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null); const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null); const finished = current.status === 'done'; const waiting = !live || live.status === 'warmup'; return (
Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''} {current.mapName} {finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`}
{sideOf('A') && } {current.score.A}
:
{current.score.B} {sideOf('B') && }
{finished && current.winner &&
{teams[current.winner].name} gewinnt {current.mapName}
}
); } function SeriesCard({ s, state }) { const map = state.maps.find((m) => m.id === s.map); const isCurrent = s.number === state.currentMapNumber && state.status === 'live'; const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done'; return (
Map {s.number} {s.mapName} {s.status === 'done' || playing ? ( {s.score.A} : {s.score.B} ) : ( {s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'} )}
); }