nsfwapp/frontend/src/components/ui/ModelPreview.tsx
2026-03-14 18:00:28 +01:00

424 lines
14 KiB
TypeScript

// frontend/src/components/ui/ModelPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveVideo from './LiveVideo'
import {
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline'
type Props = {
jobId: string
thumbTick?: number
autoTickMs?: number
blur?: boolean
className?: string
fit?: 'cover' | 'contain'
roomStatus?: string
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
thumbsWebpUrl?: string | null
thumbsCandidates?: Array<string | null | undefined>
}
export default function ModelPreview({
jobId,
thumbTick,
autoTickMs = 10_000,
blur = false,
className,
roomStatus,
alignStartAt,
alignEndAt = null,
alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
thumbsWebpUrl,
thumbsCandidates,
}: Props) {
const blurCls = blur ? 'blur-md' : ''
const CONTROLBAR_H = 0
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const rootRef = useRef<HTMLDivElement | null>(null)
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
const pageVisibleRef = useRef(true)
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
const [inView, setInView] = useState(false)
const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false)
const [apiImgError, setApiImgError] = useState(false)
const retryT = useRef<number | null>(null)
const fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
const ms = Date.parse(String(v ?? ''))
return Number.isFinite(ms) ? ms : NaN
}
const normalizeUrl = (u?: string | null): string => {
const s = String(u ?? '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
if (s.startsWith('/')) return s
return `/${s}`
}
const thumbsCandidatesKey = useMemo(() => {
const list = [
thumbsWebpUrl,
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
]
.map(normalizeUrl)
.filter(Boolean)
// Reihenfolge behalten, nur dedupe
return Array.from(new Set(list)).join('|')
}, [thumbsWebpUrl, thumbsCandidates])
// ✅ visibilitychange -> nur REF updaten
useEffect(() => {
const onVis = () => {
const vis = !document.hidden
pageVisibleRef.current = vis
setPageVisible(vis) // ✅ sorgt dafür, dass Tick-Effect neu aufgebaut wird
}
const vis = !document.hidden
pageVisibleRef.current = vis
setPageVisible(vis)
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0))
if (next === inViewRef.current) return
inViewRef.current = next
setInView(next)
},
{
root: null,
threshold: 0,
rootMargin: '300px 0px',
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView) return
if (!pageVisibleRef.current) return
if (enteredViewOnce.current) return
enteredViewOnce.current = true
setLocalTick((x) => x + 1)
}, [inView, thumbTick])
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView) return
if (!pageVisibleRef.current) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// aligned schedule
if (Number.isFinite(startMs)) {
let t: number | undefined
const schedule = () => {
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
if (!pageVisibleRef.current) return
const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return
const elapsed = Math.max(0, now - startMs)
const rem = elapsed % period
const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => {
// ✅ nochmal checken, falls inzwischen offscreen/hidden
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
schedule()
}, wait)
}
schedule()
return () => {
if (t) window.clearTimeout(t)
}
}
// fallback interval
const id = window.setInterval(() => {
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
// ✅ tick Quelle
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
// Wir "freezen" den Tick, solange inView=false oder tab hidden
const frozenTickRef = useRef(0)
const [frozenTick, setFrozenTick] = useState(0)
useEffect(() => {
if (!inView) return
if (!pageVisibleRef.current) return
frozenTickRef.current = rawTick
setFrozenTick(rawTick)
}, [rawTick, inView])
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => {
setDirectImgError(false)
setApiImgError(false)
}, [frozenTick])
// bei Job-Wechsel reset
useEffect(() => {
hadSuccess.current = false
fastTries.current = 0
enteredViewOnce.current = false
setDirectImgError(false)
setApiImgError(false)
if (inViewRef.current && pageVisibleRef.current) {
setLocalTick((x) => x + 1)
}
}, [jobId, thumbsCandidatesKey])
const thumb = useMemo(
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, frozenTick]
)
const hq = useMemo(
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
[jobId]
)
const directThumbCandidates = useMemo(() => {
if (!thumbsCandidatesKey) return []
return thumbsCandidatesKey.split('|')
}, [thumbsCandidatesKey])
const directThumb = directThumbCandidates[0] || ''
const useDirectThumb = Boolean(directThumb) && !directImgError
const currentImgSrc = useMemo(() => {
if (useDirectThumb) {
const sep = directThumb.includes('?') ? '&' : '?'
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}`
}
return thumb
}, [useDirectThumb, directThumb, frozenTick, thumb])
return (
<HoverPopover
content={(open, { close }) =>
open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div
className="relative rounded-lg overflow-hidden bg-black"
style={{
// 16:9 Videofläche + feste 30px Controlbar
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
}}
>
<div className="absolute inset-0">
<LiveVideo
src={hq}
muted={popupMuted}
volume={popupVolume}
roomStatus={roomStatus}
onVolumeChange={(nextVolume, nextMuted) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom relative z-0"
/>
{showLiveBadge ? (
<div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
) : null}
<div className="absolute right-2 bottom-2 z-[60]">
<button
type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
title={popupMuted ? 'Ton an' : 'Stumm'}
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (popupMuted) {
setPopupMuted(false)
if (popupVolume <= 0) setPopupVolume(1)
} else {
setPopupMuted(true)
}
}}
>
<span className="text-[13px] leading-none">
{popupMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</span>
</button>
</div>
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
aria-label="Live-Vorschau schließen"
title="Vorschau schließen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
close()
}}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
>
<div
ref={rootRef}
className={[
'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden',
className || 'w-full h-full',
].join(' ')}
onClick={(e) => {
e.stopPropagation()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
onTouchStart={(e) => {
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
>
{!apiImgError ? (
<img
src={currentImgSrc}
loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'}
decoding="async"
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
hadSuccess.current = true
fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current)
// nur den aktuell genutzten Pfad als "ok" markieren
if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false)
}}
onError={() => {
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten
if (useDirectThumb) {
setDirectImgError(true)
return
}
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
setApiImgError(true)
if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return
const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setApiImgError(false) // API erneut probieren
setLocalTick((x) => x + 1)
}, fastRetryMs)
}}
/>
) : (
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
keine Vorschau
</div>
)}
</div>
</HoverPopover>
)
}