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.
255 lines
9.7 KiB
React
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>
|
|
);
|
|
}
|