Stream-Übersicht aller laufenden Vetos und Matches
/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.
This commit is contained in:
1 parent
ea2207899f
commit
85041b2bff
11 files changed
+495
-3
No files matched your search
@@ -66,3 +66,5 @@ LOG_RECEIVER_URL=
|
|||||||
LOG_DEBUG=false
|
LOG_DEBUG=false
|
||||||
# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
||||||
SERIES_AUTO_ADVANCE_SECONDS=30
|
SERIES_AUTO_ADVANCE_SECONDS=30
|
||||||
|
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
|
||||||
|
STREAM_KEY=
|
||||||
@@ -293,7 +293,19 @@ Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
|||||||
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||||
- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.
|
- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.
|
||||||
|
|
||||||
### Stream-Übersicht
|
### Stream-Übersicht aller Matches
|
||||||
|
|
||||||
|
Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle laufenden Vetos und Matches gleichzeitig, live aktualisiert:
|
||||||
|
|
||||||
|
- **Karten:** eine Karte pro Match.
|
||||||
|
- Im Veto: wer am Zug ist, Fortschritt, Map-Board.
|
||||||
|
- Im Match: aktuelle Map mit Live-Stand, CT/T-Seiten und Serienstand.
|
||||||
|
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
||||||
|
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
||||||
|
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
||||||
|
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
||||||
|
|
||||||
|
### Stream-Übersicht eines Matches
|
||||||
|
|
||||||
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||||
|
|
||||||
@@ -326,6 +338,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
|
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
|
||||||
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
|
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
|
||||||
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
|
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||||||
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||||||
|
|
||||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Overview from './pages/Overview.jsx';
|
|||||||
import CreateMatch from './pages/CreateMatch.jsx';
|
import CreateMatch from './pages/CreateMatch.jsx';
|
||||||
import MatchPage from './pages/MatchPage.jsx';
|
import MatchPage from './pages/MatchPage.jsx';
|
||||||
import StreamPage from './pages/StreamPage.jsx';
|
import StreamPage from './pages/StreamPage.jsx';
|
||||||
|
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -12,6 +13,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||||
<Route path="/stream/:code" element={<StreamPage />} />
|
<Route path="/stream/:code" element={<StreamPage />} />
|
||||||
|
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||||
<Route path="*" element={<MainLayout />} />
|
<Route path="*" element={<MainLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -39,6 +41,7 @@ function MainLayout() {
|
|||||||
)}
|
)}
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/" className="nav-link">Übersicht</Link>
|
<Link to="/" className="nav-link">Übersicht</Link>
|
||||||
|
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export default function StreamPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
|
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
|
||||||
function ScaledStage({ children }) {
|
export function ScaledStage({ children }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -74,7 +74,7 @@ function StreamMessage({ title, text }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useClock() {
|
export function useClock() {
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => setNow(new Date()), 1000);
|
const t = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* ---------- Stream-Übersicht aller Matches (1920×1080) ---------- */
|
||||||
|
.so-root {
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
display: grid; grid-template-rows: 104px 1fr 190px; padding: 0 56px;
|
||||||
|
color: var(--text); font-family: var(--body);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 0% 20%, rgba(52, 74, 153, .25), transparent 70%),
|
||||||
|
radial-gradient(900px 600px at 100% 20%, rgba(167, 42, 28, .22), transparent 70%),
|
||||||
|
#000;
|
||||||
|
}
|
||||||
|
.stream-transparent .so-root { background: transparent; }
|
||||||
|
|
||||||
|
.so-title { display: flex; align-items: center; gap: 18px; font: 600 30px var(--display); }
|
||||||
|
.so-count { padding: 4px 14px; border-radius: 4px; background: var(--surface-2); color: var(--muted); font-size: 24px; }
|
||||||
|
.so-count-live { background: var(--brand-red); color: #fff; }
|
||||||
|
|
||||||
|
/* Raster je nach Anzahl */
|
||||||
|
.so-grid { display: grid; gap: 24px; padding: 24px 0; min-height: 0; }
|
||||||
|
.so-grid.count-1 { grid-template-columns: 1fr; }
|
||||||
|
.so-grid.count-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.so-grid.count-3, .so-grid.count-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
|
||||||
|
.so-grid.count-5, .so-grid.count-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr 1fr; }
|
||||||
|
.so-empty { grid-column: 1 / -1; display: grid; place-content: center; text-align: center; color: var(--muted); }
|
||||||
|
.so-empty h2 { font: 700 64px var(--display); color: var(--text); margin-bottom: 12px; }
|
||||||
|
.so-empty p { font-size: 28px; }
|
||||||
|
|
||||||
|
/* Karte */
|
||||||
|
.so-card {
|
||||||
|
position: relative; overflow: hidden; min-height: 0; border-radius: 10px;
|
||||||
|
display: grid; grid-template-rows: auto auto 1fr; gap: 10px; padding: 22px 28px;
|
||||||
|
background: rgba(17, 20, 30, .82); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
|
.so-card.is-playing { box-shadow: inset 0 0 0 3px var(--brand-red), 0 0 40px rgba(167, 42, 28, .35); }
|
||||||
|
.so-card-art { opacity: .3; }
|
||||||
|
.so-card-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .4), rgba(0, 0, 0, .9)); }
|
||||||
|
.so-card > :not(.so-card-art) { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.so-card-head { display: flex; justify-content: space-between; align-items: center; font: 600 22px var(--display); color: var(--muted); }
|
||||||
|
.so-badge { padding: 3px 12px; border-radius: 4px; background: var(--surface-2); color: var(--text); font-size: 20px; }
|
||||||
|
.so-badge.badge-live { background: var(--brand-red); color: #fff; }
|
||||||
|
|
||||||
|
.so-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
|
||||||
|
.so-team { font: 700 52px/1 var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.so-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.so-team.b { color: var(--team-b); }
|
||||||
|
.so-series { font: 700 56px var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-series i { font-style: normal; color: var(--muted); margin: 0 .15em; }
|
||||||
|
.so-series .so-vs { font-size: 32px; color: var(--muted); margin: 0; }
|
||||||
|
|
||||||
|
/* Veto */
|
||||||
|
.so-veto { display: grid; grid-template-rows: auto 1fr; gap: 14px; min-height: 0; }
|
||||||
|
.so-turn { display: flex; align-items: baseline; justify-content: center; gap: 14px; font: 600 32px var(--display); }
|
||||||
|
.so-turn .a { color: var(--team-a); }
|
||||||
|
.so-turn .b { color: var(--team-b); }
|
||||||
|
.so-turn small { font: 500 20px var(--body); color: var(--muted); }
|
||||||
|
.so-vmaps { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 10px; min-height: 0; }
|
||||||
|
.so-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; align-items: flex-end; padding: 10px; min-height: 0; }
|
||||||
|
.so-vmap.by-A { --by: var(--team-a); }
|
||||||
|
.so-vmap.by-B { --by: var(--team-b); }
|
||||||
|
.so-vmap-name { position: relative; z-index: 2; font: 700 24px/1 var(--display); text-shadow: 0 2px 8px #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.so-vmap .map-art::after { background: linear-gradient(180deg, transparent 40%, rgba(0, 0, 0, .85)); }
|
||||||
|
.so-vmap.status-banned { filter: grayscale(.95) brightness(.45); }
|
||||||
|
.so-vmap.status-banned::after {
|
||||||
|
content: ''; position: absolute; inset: 0; z-index: 3;
|
||||||
|
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
|
||||||
|
}
|
||||||
|
.so-vmap.status-picked::before, .so-vmap.status-decider::before { content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 4px var(--by); }
|
||||||
|
.so-vmap.status-decider { --by: #fff; }
|
||||||
|
|
||||||
|
/* Live */
|
||||||
|
.so-live { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; align-items: center; min-height: 0; }
|
||||||
|
.so-map { display: flex; align-items: baseline; gap: 16px; }
|
||||||
|
.so-map strong { font: 700 40px var(--display); }
|
||||||
|
.so-map span { font: 600 24px var(--display); color: var(--pending); }
|
||||||
|
.so-score { display: flex; align-items: center; gap: 22px; }
|
||||||
|
.so-score b { font: 700 140px/0.9 var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-score b.a { color: var(--team-a); }
|
||||||
|
.so-score b.b { color: var(--team-b); }
|
||||||
|
.so-score b.won { text-shadow: 0 0 30px currentColor; }
|
||||||
|
.so-score i { font: 700 70px var(--display); font-style: normal; color: var(--muted); }
|
||||||
|
.so-maps { list-style: none; display: flex; gap: 10px; padding: 0; margin: 0; }
|
||||||
|
.so-maps li { display: flex; gap: 10px; align-items: baseline; padding: 6px 14px; border-radius: 4px; background: rgba(0, 0, 0, .55); font: 600 22px var(--display); color: var(--muted); }
|
||||||
|
.so-maps li.is-current { color: var(--text); box-shadow: inset 0 -3px 0 var(--brand-red); }
|
||||||
|
.so-maps li b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-setup { align-self: center; justify-self: center; font: 600 32px var(--display); color: var(--muted); }
|
||||||
|
|
||||||
|
/* Dichte: weniger Matches = größere Schrift */
|
||||||
|
.density-1 .so-team { font-size: 96px; }
|
||||||
|
.density-1 .so-series { font-size: 110px; }
|
||||||
|
.density-1 .so-score b { font-size: 260px; }
|
||||||
|
.density-1 .so-map strong { font-size: 60px; }
|
||||||
|
.density-1 .so-vmap-name { font-size: 40px; }
|
||||||
|
.density-1 .so-turn { font-size: 44px; }
|
||||||
|
.density-3 .so-team { font-size: 38px; }
|
||||||
|
.density-3 .so-series { font-size: 42px; }
|
||||||
|
.density-3 .so-score b { font-size: 96px; }
|
||||||
|
.density-3 .so-score .side-icon { width: 32px; height: 32px; }
|
||||||
|
.density-3 .so-map strong { font-size: 30px; }
|
||||||
|
.density-3 .so-vmap-name { font-size: 18px; }
|
||||||
|
.density-3 .so-turn { font-size: 26px; }
|
||||||
|
.density-3 .so-maps li { font-size: 18px; padding: 4px 10px; }
|
||||||
|
|
||||||
|
/* Unten: Lobbys und Ergebnisse */
|
||||||
|
.so-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding-bottom: 28px; min-height: 0; }
|
||||||
|
.so-strip { background: rgba(17, 20, 30, .82); border-radius: 10px; padding: 16px 24px; overflow: hidden; border-top: 4px solid var(--line-strong); }
|
||||||
|
.so-strip:last-child { border-top-color: var(--brand-red); }
|
||||||
|
.so-strip h3 { font: 700 26px var(--display); color: var(--muted); margin: 0 0 10px; }
|
||||||
|
.so-strip ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 28px; }
|
||||||
|
.so-strip li { display: flex; align-items: baseline; gap: 10px; font: 600 26px var(--display); white-space: nowrap; overflow: hidden; }
|
||||||
|
.so-strip li .a { color: var(--team-a); }
|
||||||
|
.so-strip li .b { color: var(--team-b); }
|
||||||
|
.so-strip li .vs { color: var(--muted); font-size: 20px; }
|
||||||
|
.so-strip li small { font: 500 18px var(--body); color: var(--muted); }
|
||||||
|
.so-strip li b { font-variant-numeric: tabular-nums; }
|
||||||
|
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
||||||
|
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
||||||
@@ -39,6 +39,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
|
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
|
||||||
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
|
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
|
||||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||||
|
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||||
|
streamKey: str('STREAM_KEY'),
|
||||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||||
trustProxy: bool('TRUST_PROXY', false),
|
trustProxy: bool('TRUST_PROXY', false),
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
|||||||
|
|
||||||
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
||||||
|
|
||||||
|
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||||
|
router.get('/stream/overview', wrap((req) => {
|
||||||
|
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||||
|
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||||
|
}
|
||||||
|
return service.streamOverview();
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/matches', limit('create'), wrap((req) => {
|
router.post('/matches', limit('create'), wrap((req) => {
|
||||||
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
|
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
|
||||||
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
|
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
|
||||||
|
|||||||
@@ -561,6 +561,76 @@ export class MatchService extends EventEmitter {
|
|||||||
this.emit('changed', matchId);
|
this.emit('changed', matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
|
||||||
|
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
||||||
|
* keine Codes, keine Verbindungsdaten.
|
||||||
|
*/
|
||||||
|
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
|
const now = this.now();
|
||||||
|
const active = [];
|
||||||
|
const upcoming = [];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const m of rows) {
|
||||||
|
const pool = this.pools[m.mode];
|
||||||
|
const maps = JSON.parse(m.maps);
|
||||||
|
const mapInfo = (id) => {
|
||||||
|
const map = maps.find((x) => x.id === id);
|
||||||
|
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
|
||||||
|
};
|
||||||
|
const base = {
|
||||||
|
id: m.id,
|
||||||
|
teams: { A: m.team_a_name, B: m.team_b_name },
|
||||||
|
mode: m.mode,
|
||||||
|
modeLabel: pool?.label ?? m.mode,
|
||||||
|
format: m.format,
|
||||||
|
status: m.status,
|
||||||
|
statusLabel: MATCH_STATUS[m.status],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (m.status === 'lobby') {
|
||||||
|
const readiness = this.teamReadiness(m);
|
||||||
|
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seriesRows = this.seriesMaps(m.id);
|
||||||
|
const series = seriesRows.map((s) => {
|
||||||
|
const live = s.live ? JSON.parse(s.live) : null;
|
||||||
|
return {
|
||||||
|
number: s.number,
|
||||||
|
...mapInfo(s.map),
|
||||||
|
pickedBy: s.picked_by,
|
||||||
|
status: s.status,
|
||||||
|
score: { A: s.score_a, B: s.score_b },
|
||||||
|
winner: s.winner,
|
||||||
|
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
|
||||||
|
|
||||||
|
if (CLOSED_STATUSES.includes(m.status)) {
|
||||||
|
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.status === 'veto') {
|
||||||
|
const veto = this.vetoState(m);
|
||||||
|
item.veto = {
|
||||||
|
current: veto.current,
|
||||||
|
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
|
||||||
|
stepsDone: veto.steps.filter((x) => x.done).length,
|
||||||
|
stepsTotal: veto.steps.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
active.push(item);
|
||||||
|
}
|
||||||
|
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
|
||||||
|
const order = { live: 0, veto: 1, server_setup: 2 };
|
||||||
|
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
|
||||||
|
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
listMatches() {
|
listMatches() {
|
||||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
|
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
|
import { safeEqual } from './security.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
||||||
@@ -50,9 +51,16 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
}, 50));
|
}, 50));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let streamTimer = null;
|
||||||
|
const broadcastStream = () => {
|
||||||
|
clearTimeout(streamTimer);
|
||||||
|
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
|
||||||
|
};
|
||||||
|
|
||||||
service.on('changed', (matchId) => {
|
service.on('changed', (matchId) => {
|
||||||
broadcastMatch(matchId);
|
broadcastMatch(matchId);
|
||||||
broadcastOverview();
|
broadcastOverview();
|
||||||
|
broadcastStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -61,6 +69,15 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
socket.emit('overview', { matches: service.listMatches() });
|
socket.emit('overview', { matches: service.listMatches() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('stream:subscribe', ({ key } = {}) => {
|
||||||
|
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
||||||
|
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.join('stream-overview');
|
||||||
|
socket.emit('stream:overview', service.streamOverview());
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
||||||
if (typeof matchId !== 'string') return;
|
if (typeof matchId !== 'string') return;
|
||||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||||
|
|||||||
@@ -348,6 +348,35 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
|
||||||
|
const { app, call, close } = await setup({ streamKey: 'geheim123' });
|
||||||
|
try {
|
||||||
|
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
|
||||||
|
|
||||||
|
assert.equal((await call('GET', '/stream/overview')).status, 403);
|
||||||
|
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
|
||||||
|
const res = await call('GET', '/stream/overview?key=geheim123');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { active, upcoming } = res.body;
|
||||||
|
assert.equal(active.length, 1);
|
||||||
|
assert.equal(active[0].status, 'veto');
|
||||||
|
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
|
||||||
|
assert.equal(active[0].veto.current.team, 'B');
|
||||||
|
assert.ok(upcoming.some((m) => m.id === lobby.id));
|
||||||
|
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
const row = app.service.getMatch(id);
|
||||||
|
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
|
||||||
|
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||||
const { call, close } = await setup();
|
const { call, close } = await setup();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user