// MatchReadyOverlay.tsx 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { sound } from '@/app/lib/soundManager' import { useSSEStore } from '@/app/lib/useSSEStore' import { useSession } from 'next-auth/react' import LoadingSpinner from './LoadingSpinner' import { MAP_OPTIONS } from '../lib/mapOptions' import type { MapOption } 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 const { lastEvent } = useSSEStore() 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 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) // ⬅️ nutzt du unten zum Ausblenden des Countdowns const [connecting, setConnecting] = useState(false) const isVisible = open || accepted || showWaitHint const [showConnectHelp, setShowConnectHelp] = useState(false) const [showBackdrop, setShowBackdrop] = useState(false) const [showContent, setShowContent] = useState(false) // Ready-Listen-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-Map (SSE) const [statusMap, setStatusMap] = useState>({}) const prevCountReadyRef = useRef(0) const ignoreNextIncreaseRef = useRef(false) // ----- 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 {} } // mini delay für UI Feedback setTimeout(doConnect, 200) }, [finished, effectiveConnectHref, onTimeout]) // NUR nach Acceptance laden/aktualisieren 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() setParticipants(j.participants ?? []) setReadyMap(j.ready ?? {}) setTotal(j.total ?? 0) setCountReady(j.countReady ?? 0) } catch {} }, [matchId]) // ---- Accept-Handling ---- 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 } } // ⬇️ NEU: nach 30s „Es lädt nicht?“ anzeigen 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 zuerst faden, dann Content useEffect(() => { if (!isVisible) { setShowBackdrop(false); setShowContent(false); return } setShowBackdrop(true) const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000 return () => clearTimeout(id) }, [isVisible]) if (!isVisible) return null // Nach Accept ein kurzer Refresh useEffect(() => { if (!accepted) return const id = setTimeout(loadReady, 250) return () => clearTimeout(id) }, [accepted, loadReady]) // SSE const { lastEvent: le } = useSSEStore() 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 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]) // ----- simple mount animation flags ----- const [fadeIn, setFadeIn] = useState(false) useEffect(() => { if (!isVisible) { setFadeIn(false); return } const id = requestAnimationFrame(() => setFadeIn(true)) return () => cancelAnimationFrame(id) }, [isVisible]) // ----- motion layer (video/gif) ----- 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 (!isVisible) 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) }, [isVisible, forceGif, prefersReducedMotion]) useEffect(() => { if (isVisible) return const v = videoRef.current if (v) { try { v.pause() } catch {} v.removeAttribute('src') while (v.firstChild) v.removeChild(v.firstChild) } }, [isVisible]) // ----- AUDIO: Beeps starten/stoppen ----- // Beeps erst starten, wenn der Content sichtbar ist useEffect(() => { if (!showContent) { // vorher: if (!isVisible) 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]) // vorher: [isVisible] // ⏩ Sofort verbinden, wenn alle bereit sind useEffect(() => { if (!isVisible) return if (total > 0 && countReady >= total && !finished) { startConnectingNow() } }, [isVisible, total, countReady, finished, startConnectingNow]) // ----- countdown / timeout ----- const rafRef = useRef(null) useEffect(() => { if (!isVisible) 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) } }, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout]) // 🔎 Map-Icon aus MAP_OPTIONS ermitteln 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]) // --- UI Helpers --- 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: 2s-Fade */}
{/* Content erst nach Backdrop-Delay */} {showContent && (
{/* Map */} {mapLabel} {/* Deko-Layer (Gif/Video) */} {useGif ? (
) : ( )} {/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
{/* Inhalt */}
DEIN SPIEL IST BEREIT!
{/* Icon + Label */}
{`${mapLabel} {mapLabel}
{/* Accept / ReadyRow / WaitHint */} {accepted ? (
) : showWaitHint ? (
Dein Team wartet auf dich!
Verbinden
) : ( )} {/* Countdown oder Verbinde-Status */} {/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */} {!showWaitHint && (
{connecting ? ( showConnectHelp ? ( // ⬇️ NEU: nach 30s Es lädt nicht? Verbinden ) : ( // bisheriges „Verbinde…“ Verbinde… ) ) : ( {fmt(rest)} )}
)}
)}
) }