ironie-nextjs/src/app/[locale]/components/MatchReadyOverlay.tsx
2025-09-23 15:27:42 +02:00

547 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<void>
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<string[] | null>(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<Participant[]>([])
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
const [total, setTotal] = useState(0)
const [countReady, setCountReady] = useState(0)
// Presence (SSE)
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
// ----- AUDIO -----
const beepsRef = useRef<ReturnType<typeof setInterval> | 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<HTMLVideoElement | null>(null)
const [useGif, setUseGif] = useState<boolean>(() => !!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<number | null>(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 = () => (
<div className="absolute left-0 right-0 bottom-0 px-4 py-3">
<div className="flex items-center gap-2 mb-1 justify-center">
<div className="flex gap-2">
{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 (
<div
key={i}
className={[
'relative w-9 h-9 rounded-sm border overflow-hidden',
borderCls,
isReady ? 'bg-[#2ecc71]/85' : 'bg-white/10'
].join(' ')}
title={p ? `${p.name} ${isReady ? 'bereit' : 'nicht bereit'}` : ''}
>
{p ? (
<>
<img
src={p.avatar}
alt={p.name}
className={[
'w-full h-full object-cover rounded-[2px] transition-opacity',
isReady ? '' : 'opacity-40 filter grayscale'
].join(' ')}
/>
{!isReady && <div className="absolute inset-0 bg-black/30 pointer-events-none" />}
</>
) : (
<div className="w-full h-full grid place-items-center">
<svg viewBox="0 0 24 24" className="w-6 h-6 opacity-60" fill="currentColor">
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5A1.5 1.5 0 0 0 4.5 21h15A1.5 1.5 0 0 0 21 19.5C21 16.5 17 14 12 14Z" />
</svg>
</div>
)}
</div>
)
})}
</div>
</div>
<div className="text-center text-[14px] font-medium text-[#63d45d]">
{countReady} / {total} Spieler bereit
</div>
</div>
)
return (
<div className="fixed inset-0 z-[1000]">
{/* Backdrop */}
<div
className={[
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out',
showBackdrop ? 'opacity-100' : 'opacity-0'
].join(' ')}
/>
{/* Content */}
{showContent && (
<div
className={[
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'w-[720px] h-[352px] max-w-[95vw]',
'border-[6px] border-[#30c237] rounded-md overflow-hidden shadow-2xl relative',
'transition-all duration-300 ease-out',
fadeIn ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.98]'
].join(' ')}
>
{/* Map */}
<img
src={mapBg}
alt={mapLabel}
className="absolute inset-0 w-full h-full object-cover brightness-90"
/>
{/* Motion-Layer */}
{useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none">
<img
src="/assets/vids/overlay_cs2_accept.webp"
alt=""
className="absolute inset-0 w-full h-full object-cover"
decoding="async"
loading="eager"
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
</div>
) : (
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover opacity-50 pointer-events-none"
autoPlay
loop
muted
playsInline
preload="auto"
>
<source src="/assets/vids/overlay_cs2_accept.webm" type="video/webm" />
</video>
)}
{/* Gradient */}
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
{/* Inhalt */}
<div className="relative z-10 h-full w-full flex flex-col items-center">
<div className="mt-[28px] text-[30px] font-semibold text-[#6ae364]">
DEIN SPIEL IST BEREIT!
<span aria-hidden className="block h-px w-full bg-[#6ae364] shadow-[1px_1px_1px_#6ae3642c] rounded-sm" />
</div>
{/* Icon + Label */}
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" />
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
</div>
{/* Accept / ReadyRow / WaitHint */}
{accepted ? (
<div className="mt-[18px] mb-[6px] h-[100px] w-full relative">
<ReadyRow />
</div>
) : showWaitHint ? (
<div className="mt-[18px] mb-[6px] min-h-[100px] w-full flex flex-col items-center justify-center gap-2">
<div className="px-3 py-1 rounded bg-yellow-100/90 text-yellow-900 text-[14px] font-semibold">
Dein Team wartet auf dich!
</div>
<a
href={effectiveConnectHref}
className="px-4 py-2 rounded-md bg-[#61d365] hover:bg-[#4dc250] text-[#174d10] font-semibold text-lg shadow"
>
Verbinden
</a>
</div>
) : (
<button
type="button"
onClick={onAcceptClick}
onMouseDown={(e) => e.preventDefault()}
className="relative mt-[28px] w-[227px] h-[90px] bg-[#61d365] hover:bg-[#4dc250] text-[#277018] text-[40px] font-bold leading-[90px] rounded-md shadow-md active:scale-[0.99] transition-transform select-none"
>
Akzeptieren
</button>
)}
{/* Countdown / Verbinde-Status */}
{!showWaitHint && (
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? (
showConnectHelp ? (
<span className="inline-flex items-center gap-3 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
<span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
<a
href={effectiveConnectHref}
className="px-3 py-1 rounded bg-[#61d365] hover:bg-[#4dc250] text-[#174d10] font-semibold text-[16px] shadow"
>
Verbinden
</a>
</span>
) : (
<span
className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10"
role="status"
aria-live="polite"
>
<LoadingSpinner />
<span>Verbinde</span>
</span>
)
) : (
<span>{fmt(rest)}</span>
)}
</div>
)}
</div>
</div>
)}
</div>
)
}