472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
// 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>
|
||
)
|
||
}
|