Beamer-Diashow der laufenden Matches unter /beamer

Ein Match pro Folie, groß und aus der Ferne lesbar: Teams, Serienstand, aktuelle Map mit Rundenstand und Seiten, Maps der Serie, im Veto wer dran ist. Zum Schluss eine Folie mit Lobbys und Ergebnissen. Schaltet automatisch weiter (?sekunden=), Vollbild per Klick oder F, Pfeiltasten und Leertaste. In der Kopfzeile verlinkt.
This commit is contained in:
mregosar committed 2026-11-01 19:00:59 +01:00
1 parent 6a922f80de
commit dc601a32fe
5 files changed
+346 -1

No files matched your search

+8
View File
@@ -395,6 +395,14 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
### Beamer-Diashow
Unter `/beamer` (in der Kopfzeile verlinkt) läuft eine Diashow für den Beamer: ein laufendes Match pro Folie, groß und aus der Ferne lesbar, mit Teams, Serienstand, aktueller Map samt Rundenstand und Seiten sowie den Maps der Serie. Matches im Veto zeigen, wer gerade dran ist. Nach den Matches folgt eine Folie mit offenen Lobbys und Ergebnissen.
- **Weiterschalten:** alle 15 Sekunden, einstellbar mit `?sekunden=20` (5 bis 120). Ein roter Balken unten zeigt die Zeit bis zur nächsten Folie.
- **Bedienung:** Klick schaltet Vollbild um, `F` ebenso, `←`/`→` blättern, Leertaste hält an.
- **Schlüssel:** Mit `STREAM_KEY` als `/beamer?key=<schlüssel>` aufrufen, wie bei der Stream-Übersicht.
### Stream-Übersicht eines Matches
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
+4
View File
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
import MatchPage from './pages/MatchPage.jsx';
import StreamPage from './pages/StreamPage.jsx';
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
import BeamerPage from './pages/BeamerPage.jsx';
import AdminPage from './pages/AdminPage.jsx';
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
@@ -16,6 +17,8 @@ export default function App() {
<Route path="/stream/:code" element={<StreamPage />} />
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
<Route path="/stream" element={<StreamOverviewPage />} />
{/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */}
<Route path="/beamer" element={<BeamerPage />} />
<Route path="*" element={<MainLayout />} />
</Routes>
);
@@ -44,6 +47,7 @@ function MainLayout() {
<nav>
<Link to="/" className="nav-link">Übersicht</Link>
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
<a href="/beamer" target="_blank" rel="noreferrer" className="nav-link">Beamer</a>
<Link to="/admin" className="nav-link">Admin</Link>
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
</nav>
+79
View File
@@ -0,0 +1,79 @@
/* ---------- Beamer-Diashow (1920×1080, skaliert): ein Match pro Folie, groß und aus der Ferne lesbar ---------- */
.bm-root {
position: relative; width: 100%; height: 100%; overflow: hidden; cursor: pointer;
display: grid; grid-template-rows: 104px 1fr 56px; padding: 0 72px;
color: var(--text); font-family: var(--body);
background:
radial-gradient(900px 600px at 0% 40%, rgba(52, 74, 153, .3), transparent 70%),
radial-gradient(900px 600px at 100% 40%, rgba(167, 42, 28, .28), transparent 70%),
#000;
}
.bm-root > :not(.bm-art) { position: relative; z-index: 1; }
.bm-art { position: absolute; inset: 0; opacity: .32; }
.bm-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .92) 70%); }
.bm-main { display: grid; min-height: 0; animation: bm-in .6s ease both; }
@keyframes bm-in { from { opacity: 0; transform: translateY(18px); } }
.bm-slide { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; min-height: 0; padding: 20px 0; }
.bm-meta { display: flex; align-items: center; gap: 22px; font: 600 32px var(--display); color: var(--muted); }
.bm-meta .so-badge { font-size: 30px; padding: 6px 18px; }
.bm-hero { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 56px; width: 100%; }
.bm-team { margin: 0; font: 700 128px/1 var(--display); letter-spacing: -.01em; overflow-wrap: anywhere; }
.bm-team.a { color: var(--team-a); text-align: right; }
.bm-team.b { color: var(--team-b); }
.bm-series { display: flex; flex-direction: column; align-items: center; font: 700 130px/1 var(--display); font-variant-numeric: tabular-nums; }
.bm-series span { display: flex; align-items: center; }
.bm-series i { font-style: normal; color: var(--muted); padding: 0 14px; }
.bm-series small { font: 600 26px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .12em; }
.bm-vs { font-size: 90px; }
.bm-map { display: flex; align-items: baseline; gap: 26px; }
.bm-map strong { font: 700 64px var(--display); }
.bm-map span { font: 600 38px var(--display); color: var(--muted); }
.bm-score { display: flex; align-items: center; gap: 34px; font: 700 200px/1 var(--display); font-variant-numeric: tabular-nums; }
.bm-score i { font-style: normal; color: var(--muted); }
.bm-score b.a { color: var(--team-a); }
.bm-score b.b { color: var(--team-b); }
.bm-score b.won { text-shadow: 0 0 40px currentColor; }
.bm-maps { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; justify-content: center; }
.bm-maps li {
position: relative; overflow: hidden; width: 250px; height: 104px; border-radius: 8px; padding: 12px 16px;
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; border: 2px solid var(--line-strong);
}
.bm-maps li .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .85)); }
.bm-maps li > span:not(.map-art), .bm-maps li b { position: relative; z-index: 1; font: 700 28px var(--display); text-shadow: 0 2px 8px #000; }
.bm-maps li.is-pending { filter: grayscale(.8) brightness(.6); }
.bm-maps li.is-current { border-color: var(--brand-red); box-shadow: 0 0 0 3px var(--brand-red); }
.bm-note { font: 600 44px var(--display); color: var(--muted); margin: 0; }
.bm-turn { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 0; font: 600 52px var(--display); }
.bm-turn .a { color: var(--team-a); }
.bm-turn .b { color: var(--team-b); }
.bm-turn small { font-size: 28px; color: var(--muted); }
.bm-vmaps { width: 100%; height: 330px; }
.bm-vmaps .so-vmap-name { font-size: 34px; }
.bm-summary { display: flex; flex-direction: column; justify-content: center; gap: 40px; min-height: 0; }
.bm-idle { margin: 0; text-align: center; font: 700 80px var(--display); }
.bm-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 72px; }
.bm-columns h3 { margin: 0 0 18px; font: 700 40px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .1em; border-bottom: 3px solid var(--brand-red); padding-bottom: 10px; }
.bm-columns ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.bm-columns li { display: flex; align-items: baseline; gap: 18px; font: 700 44px var(--display); }
.bm-columns li .a { color: var(--team-a); }
.bm-columns li .b { color: var(--team-b); }
.bm-columns li i { font-style: normal; color: var(--muted); font-size: 32px; }
.bm-columns li b { font-variant-numeric: tabular-nums; }
.bm-columns li .won { text-decoration: underline; text-underline-offset: 8px; }
.bm-columns li small { margin-left: auto; font: 600 28px var(--display); color: var(--muted); }
.bm-columns .so-none { font-size: 32px; }
.bm-bottom { position: relative; display: flex; align-items: center; justify-content: center; }
.bm-dots { display: flex; gap: 12px; }
.bm-dots span { width: 14px; height: 14px; border-radius: 50%; background: var(--line-strong); }
.bm-dots span.on { background: var(--brand-red); }
.bm-progress { position: absolute; left: 0; bottom: 0; height: 6px; width: 100%; background: var(--brand-red); transform-origin: left; animation: bm-progress linear both; }
@keyframes bm-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+254
View File
@@ -0,0 +1,254 @@
import { useCallback, useEffect, useState } from 'react';
import { FORMAT_LABEL } from '../lib/labels.js';
import MapImage from '../components/MapImage.jsx';
import SideIcon from '../components/SideIcon.jsx';
import { ScaledStage, useClock } from './StreamPage.jsx';
import { useStreamOverview } from './StreamOverviewPage.jsx';
import '../stream.css';
import '../stream-overview.css';
import '../beamer.css';
const DEFAULT_SECONDS = 15;
/**
* Diashow der laufenden Matches für den Beamer: /beamer
* Ein Match pro Folie, schaltet automatisch weiter. Am Ende eine Folie mit Lobbys und Ergebnissen.
* Optional ?sekunden=20 (5 bis 120) und ?key=<STREAM_KEY>.
* Tasten: ← → blättern, Leertaste anhalten, F Vollbild. Klick schaltet Vollbild um.
*/
export default function BeamerPage() {
const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? '';
const seconds = Math.max(5, Math.min(120, Number.parseInt(params.get('sekunden'), 10) || DEFAULT_SECONDS));
const { data, error } = useStreamOverview(key);
const now = useClock();
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
document.documentElement.classList.add('stream-mode');
return () => document.documentElement.classList.remove('stream-mode');
}, []);
const slides = buildSlides(data);
const count = slides.length;
const current = slides[count ? index % count : 0];
// Folie über die ID merken, damit sich bei neuen Daten nicht die angezeigte Folie verschiebt
const currentId = current?.id;
const go = useCallback((step) => setIndex((i) => (count ? (i + step + count) % count : 0)), [count]);
useEffect(() => {
if (paused || count <= 1) return undefined;
const t = setTimeout(() => go(1), seconds * 1000);
return () => clearTimeout(t);
}, [index, paused, count, seconds, go, currentId]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowRight') go(1);
else if (e.key === 'ArrowLeft') go(-1);
else if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p); } else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go]);
if (error) {
return (
<ScaledStage>
<div className="st-message">
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<h1>Diashow nicht verfügbar</h1>
<p>{error}</p>
</div>
</ScaledStage>
);
}
const live = (data?.active ?? []).filter((m) => m.status === 'live').length;
return (
<ScaledStage>
<div className="bm-root" onClick={toggleFullscreen}>
{current?.type === 'match' && <Backdrop m={current.match} />}
<header className="st-top bm-top">
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<div className="so-title">
<strong>Counter-Strike 2</strong>
{live > 0 && <span className="so-count so-count-live">{live} live</span>}
{count > 1 && <span className="so-count">{(index % count) + 1} / {count}</span>}
{paused && <span className="so-count">angehalten</span>}
</div>
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
</header>
<main className="bm-main" key={currentId ?? 'leer'}>
{!data && <div className="so-empty">Lade Matches …</div>}
{current?.type === 'match' && <MatchSlide m={current.match} />}
{current?.type === 'summary' && <SummarySlide data={data} />}
</main>
<footer className="bm-bottom">
{count > 1 && (
<div className="bm-dots">
{slides.map((s, i) => <span key={s.id} className={i === index % count ? 'on' : ''} />)}
</div>
)}
{count > 1 && !paused && <div className="bm-progress" key={`${currentId}-${index}`} style={{ animationDuration: `${seconds}s` }} />}
</footer>
</div>
</ScaledStage>
);
}
/** Ein Match pro Folie, danach eine Übersicht mit Lobbys und Ergebnissen (wenn es welche gibt). */
function buildSlides(data) {
if (!data) return [];
const slides = data.active.map((m) => ({ id: m.id, type: 'match', match: m }));
if (data.upcoming.length || data.results.length || slides.length === 0) slides.push({ id: 'summary', type: 'summary' });
return slides;
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
else document.documentElement.requestFullscreen?.().catch(() => {});
}
function currentMap(m) {
return m.series.find((s) => s.number === m.currentMapNumber);
}
function Backdrop({ m }) {
const map = currentMap(m);
if (m.status === 'veto' || !map) return null;
return <MapImage map={map} className="bm-art" />;
}
function MatchSlide({ m }) {
const map = currentMap(m);
const isBo1 = m.format === 'bo1';
const playing = m.status === 'live' && map?.live?.status === 'live' && map.status !== 'done';
const badge = m.status === 'veto' ? 'Map-Veto' : m.status === 'server_setup' ? 'Server startet' : playing ? 'Live' : map?.status === 'done' ? 'Map beendet' : 'Aufwärmphase';
return (
<section className={`bm-slide status-${m.status}`}>
<div className="bm-meta">
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
<span className={`so-badge ${playing ? 'badge-live' : ''}`}>{badge}</span>
</div>
<div className="bm-hero">
<h1 className="bm-team a">{m.teams.A}</h1>
<div className="bm-series">
{!isBo1 && m.status !== 'veto' ? (
<>
<span>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</span>
<small>Maps</small>
</>
) : <i className="bm-vs">vs</i>}
</div>
<h1 className="bm-team b">{m.teams.B}</h1>
</div>
{m.status === 'veto' && <VetoPart m={m} />}
{m.status === 'server_setup' && map && <p className="bm-note">Server wird eingerichtet: Map {map.number}, {map.name}</p>}
{m.status === 'live' && map && <LivePart m={m} map={map} />}
</section>
);
}
function LivePart({ m, map }) {
const sides = map.live?.sides;
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
const finished = map.status === 'done';
const rounds = map.live?.roundsPlayed ?? 0;
return (
<>
<div className="bm-map">
<strong>{map.name}</strong>
<span>{finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
</div>
<div className="bm-score">
{sideOf('A') && <SideIcon side={sideOf('A')} size={72} />}
<b className={`a ${map.winner === 'A' ? 'won' : ''}`}>{map.score.A}</b>
<i>:</i>
<b className={`b ${map.winner === 'B' ? 'won' : ''}`}>{map.score.B}</b>
{sideOf('B') && <SideIcon side={sideOf('B')} size={72} />}
</div>
{m.series.length > 1 && (
<ol className="bm-maps">
{m.series.map((s) => (
<li key={s.number} className={`is-${s.status} ${s.number === map.number ? 'is-current' : ''}`} style={{ '--map': s.color ?? '#556' }}>
<MapImage map={s} />
<span>{s.name}</span>
{(s.status === 'done' || s.number === map.number) && <b>{s.score.A}:{s.score.B}</b>}
</li>
))}
</ol>
)}
</>
);
}
function VetoPart({ m }) {
const { veto, teams } = m;
const c = veto.current;
const action = c && ({ ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Seite' })[c.type];
return (
<>
<p className="bm-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>
</p>
<div className="so-vmaps bm-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>
</>
);
}
function SummarySlide({ data }) {
const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore);
return (
<section className="bm-summary">
{data.active.length === 0 && <h2 className="bm-idle">Gerade läuft kein Match</h2>}
<div className="bm-columns">
<div>
<h3>Gleich</h3>
{data.upcoming.length ? (
<ul>
{data.upcoming.map((m) => (
<li key={m.id}>
<span className="a">{m.teams.A}</span><i>vs</i><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>}
</div>
<div>
<h3>Ergebnisse</h3>
{data.results.length ? (
<ul>
{data.results.map((m) => {
const s = score(m);
return (
<li key={m.id}>
<span className={`a ${s.A > s.B ? 'won' : ''}`}>{m.teams.A}</span>
<b>{s.A}:{s.B}</b>
<span className={`b ${s.B > s.A ? 'won' : ''}`}>{m.teams.B}</span>
</li>
);
})}
</ul>
) : <p className="so-none">Noch keine Ergebnisse</p>}
</div>
</div>
</section>
);
}
+1 -1
View File
@@ -141,7 +141,7 @@ function matchHref(id, key) {
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
}
function useStreamOverview(key) {
export function useStreamOverview(key) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {