ironie-nextjs/src/app/components/MatchReadyOverlay.tsx
2025-09-08 15:30:46 +02:00

472 lines
17 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 '@/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<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 = 'steam://connect/cs2.ironieopen.de:27015/ironie',
}: Props) {
const { data: session } = useSession()
const mySteamId = session?.user?.steamId
const { lastEvent } = useSSEStore()
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 isVisible = open || accepted || showWaitHint
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<Participant[]>([])
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
const [total, setTotal] = useState(0)
const [countReady, setCountReady] = useState(0)
// Presence-Map (SSE)
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
const prevCountReadyRef = useRef<number>(0)
const ignoreNextIncreaseRef = useRef(false)
// ----- 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 {}
}
// 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
}
}
// Backdrop zuerst faden, dann Content
useEffect(() => {
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true)
const id = setTimeout(() => setShowContent(true), 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
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<HTMLVideoElement | null>(null)
const [useGif, setUseGif] = useState<boolean>(() => !!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 -----
useEffect(() => {
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() }
}, [isVisible])
// ----- countdown / timeout -----
const rafRef = useRef<number | null>(null)
useEffect(() => {
if (!isVisible) return
const step = () => {
const t = Date.now()
setNow(t)
if (effectiveDeadline - t <= 0 && !finished) {
stopBeeps()
setFinished(true)
if (accepted) {
setConnecting(true)
try { sound.play('loading') } catch {}
const doConnect = () => {
try { window.location.href = connectHref }
catch {
try {
const a = document.createElement('a')
a.href = connectHref
document.body.appendChild(a)
a.click()
a.remove()
} catch {}
}
try { onTimeout?.() } catch {}
}
setTimeout(doConnect, 2000)
} else {
setShowWaitHint(true)
}
return
}
rafRef.current = requestAnimationFrame(step)
}
rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
// ---- Präsenz → Rahmenfarbe ----
const borderByPresence = (s: Presence | undefined): string => {
switch (s) {
case 'online': return 'border-[#2ecc71]'
case 'away': return 'border-yellow-400'
case 'offline':
default: return 'border-white/20'
}
}
// 🔎 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 = () => (
<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 = borderByPresence(presence)
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: 2s-Fade */}
<div
className={[
'absolute inset-0 bg-black/60 transition-opacity duration-[2000ms] ease-out',
showBackdrop ? 'opacity-100' : 'opacity-0'
].join(' ')}
/>
{/* Content erst nach Backdrop-Delay */}
{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" />
{/* Deko-Layer */}
{useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none">
<img src="/assets/vids/overlay_cs2_accept.gif" 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>
)}
{/* 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 aus MAP_OPTIONS + 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={connectHref}
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 oder Verbinde-Status */}
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? (
<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>
)
}