Files
Cs2-Mapveto/client/src/pages/BeamerPage.jsx
T
mregosar dc601a32fe 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.
2026-11-01 19:00:59 +01:00

255 lines
9.7 KiB
React

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>
);
}