diff --git a/README.md b/README.md index 96a54ee..de135a6 100644 --- a/README.md +++ b/README.md @@ -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/`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen. - **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=` 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=` aufrufen, wie bei der Stream-Übersicht. + ### Stream-Übersicht eines Matches Unter `/stream/` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen. diff --git a/client/src/App.jsx b/client/src/App.jsx index e5ea837..036564f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> } /> } /> + {/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */} + } /> } /> ); @@ -44,6 +47,7 @@ function MainLayout() { diff --git a/client/src/beamer.css b/client/src/beamer.css new file mode 100644 index 0000000..cb7ff46 --- /dev/null +++ b/client/src/beamer.css @@ -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); } } diff --git a/client/src/pages/BeamerPage.jsx b/client/src/pages/BeamerPage.jsx new file mode 100644 index 0000000..b6e9b8f --- /dev/null +++ b/client/src/pages/BeamerPage.jsx @@ -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=. + * 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 ( + +
+ Maxlan e.V. +

Diashow nicht verfügbar

+

{error}

+
+
+ ); + } + + const live = (data?.active ?? []).filter((m) => m.status === 'live').length; + + return ( + +
+ {current?.type === 'match' && } +
+ Maxlan e.V. +
+ Counter-Strike 2 + {live > 0 && {live} live} + {count > 1 && {(index % count) + 1} / {count}} + {paused && angehalten} +
+
+
+ +
+ {!data &&
Lade Matches …
} + {current?.type === 'match' && } + {current?.type === 'summary' && } +
+ +
+ {count > 1 && ( +
+ {slides.map((s, i) => )} +
+ )} + {count > 1 && !paused &&
} +
+
+
+ ); +} + +/** 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 ; +} + +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 ( +
+
+ {m.modeLabel}, {FORMAT_LABEL[m.format]} + {badge} +
+
+

{m.teams.A}

+
+ {!isBo1 && m.status !== 'veto' ? ( + <> + {m.seriesScore.A}:{m.seriesScore.B} + Maps + + ) : vs} +
+

{m.teams.B}

+
+ + {m.status === 'veto' && } + {m.status === 'server_setup' && map &&

Server wird eingerichtet: Map {map.number}, {map.name}

} + {m.status === 'live' && map && } +
+ ); +} + +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 ( + <> +
+ {map.name} + {finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'} +
+
+ {sideOf('A') && } + {map.score.A} + : + {map.score.B} + {sideOf('B') && } +
+ {m.series.length > 1 && ( +
    + {m.series.map((s) => ( +
  1. + + {s.name} + {(s.status === 'done' || s.number === map.number) && {s.score.A}:{s.score.B}} +
  2. + ))} +
+ )} + + ); +} + +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 ( + <> +

+ {c ? <>{teams[c.team]} {action} : 'Veto abgeschlossen'} + Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal} +

+
+ {veto.maps.map((x) => ( +
+ + {x.name} +
+ ))} +
+ + ); +} + +function SummarySlide({ data }) { + const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore); + return ( +
+ {data.active.length === 0 &&

Gerade läuft kein Match

} +
+
+

Gleich

+ {data.upcoming.length ? ( +
    + {data.upcoming.map((m) => ( +
  • + {m.teams.A}vs{m.teams.B} + {FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''} +
  • + ))} +
+ ) :

Keine offenen Lobbys

} +
+
+

Ergebnisse

+ {data.results.length ? ( +
    + {data.results.map((m) => { + const s = score(m); + return ( +
  • + s.B ? 'won' : ''}`}>{m.teams.A} + {s.A}:{s.B} + s.A ? 'won' : ''}`}>{m.teams.B} +
  • + ); + })} +
+ ) :

Noch keine Ergebnisse

} +
+
+
+ ); +} diff --git a/client/src/pages/StreamOverviewPage.jsx b/client/src/pages/StreamOverviewPage.jsx index b48d3c8..d7663f6 100644 --- a/client/src/pages/StreamOverviewPage.jsx +++ b/client/src/pages/StreamOverviewPage.jsx @@ -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(() => {