// MatchReadyOverlay.tsx 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { sound } from '@/lib/soundManager' import { useSSEStore } from '@/lib/useSSEStore' import { useSession } from 'next-auth/react' import LoadingSpinner from './LoadingSpinner' import { MAP_OPTIONS } from '@/lib/mapOptions' type Props = { open: boolean matchId: string mapLabel: string mapBg: string onAccept: () => void | Promise deadlineAt?: number onTimeout?: () => void forceGif?: boolean connectHref?: string } type Presence = 'online' | 'away' | 'offline' function fmt(ms: number) { const sec = Math.max(0, Math.ceil(ms / 1000)) const m = Math.floor(sec / 60) const s = sec % 60 return `${m}:${String(s).padStart(2, '0')}` } export default function MatchReadyOverlay({ open, matchId, mapLabel, mapBg, onAccept, deadlineAt, onTimeout, forceGif, connectHref }: Props) { const { data: session } = useSession() const mySteamId = session?.user?.steamId ? String(session.user.steamId) : null const { lastEvent } = useSSEStore() // --- Team-Guard: nur Team A/B const [allowedIds, setAllowedIds] = useState(null) const iAmAllowed = useMemo(() => { if (!mySteamId) return false if (allowedIds === null) return true // bis die Liste da ist, nicht blocken return allowedIds.includes(mySteamId) }, [allowedIds, mySteamId]) // Verbindungslink const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000' const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF // Timer const [now, setNow] = useState(() => Date.now()) const [startedAt] = useState(() => Date.now()) const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt]) const effectiveDeadline = deadlineAt ?? fallbackDeadline const rest = Math.max(0, effectiveDeadline - now) // UI-States const [accepted, setAccepted] = useState(false) const [finished, setFinished] = useState(false) const [showWaitHint, setShowWaitHint] = useState(false) const [connecting, setConnecting] = useState(false) const [showConnectHelp, setShowConnectHelp] = useState(false) const [showBackdrop, setShowBackdrop] = useState(false) const [showContent, setShowContent] = useState(false) // Sichtbarkeit (ohne early return!) const isVisibleBase = open || accepted || showWaitHint const shouldRender = Boolean(isVisibleBase && iAmAllowed) // Ready-Status type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null } const [participants, setParticipants] = useState([]) const [readyMap, setReadyMap] = useState>({}) const [total, setTotal] = useState(0) const [countReady, setCountReady] = useState(0) // Presence (SSE) const [statusMap, setStatusMap] = useState>({}) // ----- AUDIO ----- const beepsRef = useRef | null>(null) const audioStartedRef = useRef(false) const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } } const ensureAudioUnlocked = async () => { try { if (typeof (sound as any).ensureUnlocked === 'function') { await (sound as any).ensureUnlocked(); return true } if (typeof (sound as any).unlock === 'function') { await (sound as any).unlock(); return true } const ctx = (sound as any).ctx || (sound as any).audioContext if (ctx && typeof ctx.resume === 'function' && ctx.state !== 'running') await ctx.resume() return true } catch { return false } } const startBeeps = () => { try { sound.play('ready') } catch {} stopBeeps() beepsRef.current = setInterval(() => { try { sound.play('beep') } catch {} }, 1000) } const playMenuAccept = () => { try { (sound as any).play?.('menu_accept') } catch {} try { new Audio('/assets/sounds/menu_accept.wav').play() } catch {} } // --- sofort verbinden helper --- const startConnectingNow = useCallback(() => { if (finished) return stopBeeps() setFinished(true) setConnecting(true) try { sound.play('loading') } catch {} const doConnect = () => { try { window.location.href = effectiveConnectHref } catch { try { const a = document.createElement('a') a.href = effectiveConnectHref document.body.appendChild(a) a.click() a.remove() } catch {} } try { onTimeout?.() } catch {} } setTimeout(doConnect, 200) }, [finished, effectiveConnectHref, onTimeout]) // Ready-API nur nach Accept const loadReady = useCallback(async () => { try { const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' }) if (!r.ok) return const j = await r.json() const parts: Participant[] = j.participants ?? [] setParticipants(parts) setReadyMap(j.ready ?? {}) setTotal(j.total ?? 0) setCountReady(j.countReady ?? 0) // Team-Guard füttern const ids = parts.map(p => String(p.steamId)).filter(Boolean) if (ids.length) setAllowedIds(ids) } catch {} }, [matchId]) // Accept const postingRef = useRef(false) const onAcceptClick = async () => { if (postingRef.current) return postingRef.current = true try { stopBeeps() playMenuAccept() const res = await fetch(`/api/matches/${matchId}/ready`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' }, cache: 'no-store', body: JSON.stringify({ intent: 'accept' }), }) if (!res.ok) return setAccepted(true) try { await onAccept() } catch {} await loadReady() } finally { postingRef.current = false } } // „Es lädt nicht?“ nach 30s useEffect(() => { let id: number | null = null if (connecting) { setShowConnectHelp(false) id = window.setTimeout(() => setShowConnectHelp(true), 30_000) } return () => { if (id) window.clearTimeout(id) } }, [connecting]) // Backdrop → Content useEffect(() => { if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return } setShowBackdrop(true) const id = setTimeout(() => setShowContent(true), 300) return () => clearTimeout(id) }, [shouldRender]) // Nach Accept kurzer Refresh useEffect(() => { if (!accepted) return const id = setTimeout(loadReady, 250) return () => clearTimeout(id) }, [accepted, loadReady]) // SSE useEffect(() => { if (!lastEvent) return const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent // participants aus Event übernehmen (falls geschickt) const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants) ? payload.participants.map((sid: any) => String(sid)).filter(Boolean) : undefined if (payloadParticipants && payloadParticipants.length) { setAllowedIds(payloadParticipants) } if (type === 'ready-updated' && payload?.matchId === matchId) { if (accepted) { const otherSteamId = payload?.steamId as string | undefined if (otherSteamId && otherSteamId !== mySteamId) playMenuAccept() } loadReady() return } if (type === 'user-status-updated') { const steamId: string | undefined = payload?.steamId ?? payload?.user?.steamId const status = payload?.status as Presence | undefined if (steamId && (status === 'online' || status === 'away' || status === 'offline')) { setStatusMap(prev => (prev[steamId] === status ? prev : { ...prev, [steamId]: status })) } } }, [accepted, lastEvent, matchId, mySteamId, loadReady]) // Mount-Animation const [fadeIn, setFadeIn] = useState(false) useEffect(() => { if (!shouldRender) { setFadeIn(false); return } const id = requestAnimationFrame(() => setFadeIn(true)) return () => cancelAnimationFrame(id) }, [shouldRender]) // Motion-Layer const prefersReducedMotion = useMemo( () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, [] ) const videoRef = useRef(null) const [useGif, setUseGif] = useState(() => !!forceGif || !!prefersReducedMotion) useEffect(() => { if (!shouldRender) return if (forceGif || prefersReducedMotion) { setUseGif(true); return } const tryPlay = async () => { const v = videoRef.current if (!v) return try { await v.play(); setUseGif(false) } catch { setUseGif(true) const once = async () => { try { await v.play(); setUseGif(false) } catch {} window.removeEventListener('pointerdown', once) window.removeEventListener('keydown', once) } window.addEventListener('pointerdown', once, { once: true }) window.addEventListener('keydown', once, { once: true }) } } const id = setTimeout(tryPlay, 0) return () => clearTimeout(id) }, [shouldRender, forceGif, prefersReducedMotion]) useEffect(() => { if (shouldRender) return const v = videoRef.current if (v) { try { v.pause() } catch {} v.removeAttribute('src') while (v.firstChild) v.removeChild(v.firstChild) } }, [shouldRender]) // AUDIO: Beeps starten/stoppen useEffect(() => { if (!showContent) { stopBeeps() audioStartedRef.current = false return } if (audioStartedRef.current) return let cleanup = () => {} ;(async () => { const ok = await ensureAudioUnlocked() if (ok) { audioStartedRef.current = true; startBeeps(); return } const onGesture = async () => { const ok2 = await ensureAudioUnlocked() if (ok2) { audioStartedRef.current = true; startBeeps() } window.removeEventListener('pointerdown', onGesture) window.removeEventListener('keydown', onGesture) } window.addEventListener('pointerdown', onGesture, { once: true }) window.addEventListener('keydown', onGesture, { once: true }) cleanup = () => { window.removeEventListener('pointerdown', onGesture) window.removeEventListener('keydown', onGesture) } })() return () => { cleanup(); stopBeeps() } }, [showContent]) // Auto-Connect wenn alle bereit useEffect(() => { if (!shouldRender) return if (total > 0 && countReady >= total && !finished) { startConnectingNow() } }, [shouldRender, total, countReady, finished, startConnectingNow]) // Countdown const rafRef = useRef(null) useEffect(() => { if (!shouldRender) return const step = () => { const t = Date.now() setNow(t) if (effectiveDeadline - t <= 0 && !finished) { if (accepted) { startConnectingNow() } else { stopBeeps() setFinished(true) setShowWaitHint(true) } return } rafRef.current = requestAnimationFrame(step) } rafRef.current = requestAnimationFrame(step) return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow]) // Map-Icon const mapIconUrl = useMemo(() => { const norm = (s?: string | null) => (s ?? '').trim().toLowerCase() const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1] const byKey = keyFromBg ? MAP_OPTIONS.find(o => o.key === keyFromBg) : undefined const byLabel = mapLabel ? MAP_OPTIONS.find(o => norm(o.label) === norm(mapLabel)) : undefined const byImage = mapBg ? MAP_OPTIONS.find(o => o.images.includes(mapBg)) : undefined const opt = byKey ?? byLabel ?? byImage return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg' }, [mapBg, mapLabel]) // ---------- RENDER ---------- if (!shouldRender) return null const ReadyRow = () => (
{Array.from({ length: Math.max(total, 10) }, (_, i) => { const p = participants[i] const isReady = p ? !!readyMap[p.steamId] : false const presence: Presence = (p && statusMap[p.steamId]) || 'offline' const borderCls = presence === 'online' ? 'border-[#2ecc71]' : presence === 'away' ? 'border-yellow-400' : 'border-white/20' return (
{p ? ( <> {p.name} {!isReady &&
} ) : (
)}
) })}
{countReady} / {total} Spieler bereit
) return (
{/* Backdrop */}
{/* Content */} {showContent && (
{/* Map */} {mapLabel} {/* Motion-Layer */} {useGif ? (
) : ( )} {/* Gradient */}
{/* Inhalt */}
DEIN SPIEL IST BEREIT!
{/* Icon + Label */}
{`${mapLabel} {mapLabel}
{/* Accept / ReadyRow / WaitHint */} {accepted ? (
) : showWaitHint ? (
Dein Team wartet auf dich!
Verbinden
) : ( )} {/* Countdown / Verbinde-Status */} {!showWaitHint && (
{connecting ? ( showConnectHelp ? ( Es lädt nicht? Verbinden ) : ( Verbinde… ) ) : ( {fmt(rest)} )}
)}
)}
) }