/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.
233 lines
9.3 KiB
React
233 lines
9.3 KiB
React
import { useEffect, useState } from 'react';
|
|
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=<schlüssel>. Optional ?bg=transparent.
|
|
*/
|
|
export default function StreamOverviewPage() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const key = params.get('key') ?? '';
|
|
const transparent = params.get('bg') === 'transparent';
|
|
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]);
|
|
|
|
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
|
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
|
|
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 (
|
|
<ScaledStage>
|
|
<div className="st-message">
|
|
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
|
<h1>Stream-Übersicht nicht verfügbar</h1>
|
|
<p>{error}</p>
|
|
</div>
|
|
</ScaledStage>
|
|
);
|
|
}
|
|
|
|
const active = data?.active ?? [];
|
|
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 (
|
|
<ScaledStage>
|
|
<div className="so-root">
|
|
<header className="st-top">
|
|
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
|
<div className="so-title">
|
|
<strong>Counter-Strike 2</strong>
|
|
{liveCount > 0 && <span className="so-count so-count-live">{liveCount} live</span>}
|
|
{vetoCount > 0 && <span className="so-count">{vetoCount} im Veto</span>}
|
|
{pages > 1 && <span className="so-count">Seite {page + 1} / {pages}</span>}
|
|
</div>
|
|
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
|
|
</header>
|
|
|
|
<main className={`so-grid density-${density} count-${shown.length}`}>
|
|
{!data && <div className="so-empty">Lade Matches …</div>}
|
|
{data && shown.length === 0 && (
|
|
<div className="so-empty">
|
|
<h2>Gerade läuft kein Match</h2>
|
|
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
|
</div>
|
|
)}
|
|
{shown.map((m) => <MatchCard key={m.id} m={m} />)}
|
|
</main>
|
|
|
|
<footer className="so-bottom">
|
|
<section className="so-strip">
|
|
<h3>Gleich</h3>
|
|
{data?.upcoming.length ? (
|
|
<ul>
|
|
{data.upcoming.map((m) => (
|
|
<li key={m.id}>
|
|
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
|
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : <p className="so-none">Keine offenen Lobbys</p>}
|
|
</section>
|
|
<section className="so-strip">
|
|
<h3>Ergebnisse</h3>
|
|
{data?.results.length ? (
|
|
<ul>
|
|
{data.results.map((m) => (
|
|
<li key={m.id}>
|
|
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
|
<b>{resultScore(m)}</b>
|
|
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : <p className="so-none">Noch keine Ergebnisse</p>}
|
|
</section>
|
|
</footer>
|
|
</div>
|
|
</ScaledStage>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<article className={`so-card status-${m.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': current?.color ?? '#556' }}>
|
|
{m.status !== 'veto' && current && <MapImage map={current} className="so-card-art" />}
|
|
<div className="so-card-head">
|
|
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
|
<span className={`so-badge badge-${badge === 'Live' ? 'live' : 'other'}`}>{badge}</span>
|
|
</div>
|
|
|
|
<div className="so-teams">
|
|
<span className="so-team a">{m.teams.A}</span>
|
|
<span className="so-series">
|
|
{!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</> : <i className="so-vs">vs</i>}
|
|
</span>
|
|
<span className="so-team b">{m.teams.B}</span>
|
|
</div>
|
|
|
|
{m.status === 'veto' && <VetoBody m={m} />}
|
|
{m.status === 'server_setup' && current && (
|
|
<div className="so-setup">Server wird eingerichtet: Map {current.number}, {current.name}</div>
|
|
)}
|
|
{m.status === 'live' && current && <LiveBody m={m} current={current} />}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="so-veto">
|
|
<div className="so-turn">
|
|
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
|
|
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
|
|
</div>
|
|
<div className="so-vmaps" style={{ '--count': veto.maps.length }}>
|
|
{veto.maps.map((x) => (
|
|
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
|
|
<MapImage map={x} />
|
|
<span className="so-vmap-name">{x.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="so-live">
|
|
<div className="so-map">
|
|
<strong>{current.name}</strong>
|
|
<span>{finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
|
|
</div>
|
|
<div className="so-score">
|
|
{sideOf('A') && <SideIcon side={sideOf('A')} size={44} />}
|
|
<b className={`a ${current.winner === 'A' ? 'won' : ''}`}>{current.score.A}</b>
|
|
<i>:</i>
|
|
<b className={`b ${current.winner === 'B' ? 'won' : ''}`}>{current.score.B}</b>
|
|
{sideOf('B') && <SideIcon side={sideOf('B')} size={44} />}
|
|
</div>
|
|
{m.series.length > 1 && (
|
|
<ol className="so-maps">
|
|
{m.series.map((s) => (
|
|
<li key={s.number} className={`is-${s.status} ${s.number === current.number ? 'is-current' : ''}`}>
|
|
<span>{s.name}</span>
|
|
{(s.status === 'done' || s.number === current.number) && <b>{s.score.A}:{s.score.B}</b>}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|