ironie-nextjs/src/app/components/MatchReadyOverlay.tsx
2025-09-21 22:33:16 +02:00

539 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 '@/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
}: 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<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 {}
}
// --- 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<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 -----
// 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<number | null>(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 = () => (
<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: 2s-Fade */}
<div
className={[
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] 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 (Gif/Video) */}
{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>
)}
{/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
<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 oder Verbinde-Status */}
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
{!showWaitHint && (
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? (
showConnectHelp ? (
// ⬇️ NEU: nach 30s
<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>
) : (
// bisheriges „Verbinde…“
<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>
)
}