nsfwapp/frontend/src/components/ui/ModelPreview.tsx
2026-04-27 14:40:16 +02:00

482 lines
15 KiB
TypeScript

// frontend/src/components/ui/ModelPreview.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveVideo from './LiveVideo'
import {
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} from '@heroicons/react/24/outline'
import LoadingSpinner from './LoadingSpinner'
import { apiUrl, apiFetch } from '../../lib/api'
type Props = {
jobId: string
enableHoverLive?: boolean
refreshKey?: number
blur?: boolean
className?: string
fit?: 'cover' | 'contain'
roomStatus?: string
fallbackSrc?: string
initialSrc?: string
}
function buildChaturbateCookieHeader(): string {
try {
const raw = window.localStorage.getItem('record_cookies')
if (!raw) return ''
const obj = JSON.parse(raw) as Record<string, string>
return Object.entries(obj ?? {})
.map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const)
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
} catch {
return ''
}
}
export default function ModelPreview({
jobId,
enableHoverLive = false,
refreshKey,
blur = false,
className,
fit = 'cover',
roomStatus,
fallbackSrc,
initialSrc,
}: Props) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [imgError, setImgError] = useState(false)
const fadeTimerRef = useRef<number | null>(null)
const [displaySrc, setDisplaySrc] = useState('')
const [pendingDisplaySrc, setPendingDisplaySrc] = useState('')
const [incomingSrc, setIncomingSrc] = useState('')
const [showIncoming, setShowIncoming] = useState(false)
const [liveSrc, setLiveSrc] = useState('')
const [hoverOpen, setHoverOpen] = useState(false)
const [localRefreshNonce, setLocalRefreshNonce] = useState(0)
const [usingFallback, setUsingFallback] = useState(false)
const [livePreparing, setLivePreparing] = useState(false)
const [liveReady, setLiveReady] = useState(false)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const handleLiveLoadingStart = useCallback(() => {
setLiveReady(false)
}, [])
const handleLiveReady = useCallback(() => {
setLiveReady(true)
}, [])
const handleLiveVolumeChange = useCallback((nextVolume: number, nextMuted: boolean) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}, [])
const blurCls = blur ? 'blur-md' : ''
const objectFitCls = fit === 'contain' ? 'object-contain' : 'object-cover'
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const previewSrc = useMemo(() => {
const explicit = String(initialSrc ?? '').trim()
const base = explicit || `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
const version =
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
? `${refreshKey}-${localRefreshNonce}`
: localRefreshNonce > 0
? String(localRefreshNonce)
: ''
if (!version) return base
return base.includes('?')
? `${base}&v=${encodeURIComponent(version)}`
: `${base}?v=${encodeURIComponent(version)}`
}, [initialSrc, jobId, refreshKey, localRefreshNonce])
const fallbackImgSrc = useMemo(() => {
const s = String(fallbackSrc ?? '').trim()
const base = s || `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
const version =
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
? `${refreshKey}-${localRefreshNonce}`
: localRefreshNonce > 0
? String(localRefreshNonce)
: ''
if (!version) return base
return base.includes('?')
? `${base}&v=${encodeURIComponent(version)}`
: `${base}?v=${encodeURIComponent(version)}`
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
useEffect(() => {
return () => {
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
}
}, [])
useEffect(() => {
if (!inView) return
const id = window.setInterval(() => {
setLocalRefreshNonce((n) => n + 1)
}, 20000)
return () => window.clearInterval(id)
}, [inView, jobId])
useEffect(() => {
const ac = new AbortController()
const loadLive = async () => {
if (!enableHoverLive || !hoverOpen) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
return
}
setLivePreparing(true)
setLiveReady(false)
const cookieHeader = buildChaturbateCookieHeader()
try {
const prepareUrl = apiUrl(
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`
)
const prepareRes = await apiFetch(prepareUrl, {
method: 'GET',
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (ac.signal.aborted) return
await prepareRes.json().catch(() => null)
if (ac.signal.aborted) return
setLiveSrc(
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
)
} catch {
if (ac.signal.aborted) return
setLiveSrc(
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
)
} finally {
if (!ac.signal.aborted) setLivePreparing(false)
}
}
void loadLive()
return () => ac.abort()
}, [enableHoverLive, hoverOpen, jobId])
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
},
{
root: null,
threshold: 0.01,
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
setImgError(false)
setUsingFallback(false)
setPendingDisplaySrc('')
setDisplaySrc('')
setIncomingSrc('')
setShowIncoming(false)
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}, [jobId])
useEffect(() => {
if (!inView) return
if (!previewSrc) return
if (!displaySrc) {
setDisplaySrc(previewSrc)
return
}
if (previewSrc === displaySrc || previewSrc === incomingSrc) return
setIncomingSrc(previewSrc)
setShowIncoming(false)
}, [inView, previewSrc, displaySrc, incomingSrc])
useEffect(() => {
if (!inView) return
if (!usingFallback) return
if (!fallbackImgSrc) return
if (!displaySrc) return
if (fallbackImgSrc === displaySrc || fallbackImgSrc === incomingSrc) return
setIncomingSrc(fallbackImgSrc)
setShowIncoming(false)
}, [inView, usingFallback, fallbackImgSrc, displaySrc, incomingSrc])
const handleDisplayLoad = useCallback(() => {
setImgError(false)
const current = String(displaySrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
setUsingFallback(Boolean(fallback && current === fallback))
if (pendingDisplaySrc && current === pendingDisplaySrc) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIncomingSrc('')
setShowIncoming(false)
setPendingDisplaySrc('')
})
})
}
}, [displaySrc, fallbackImgSrc, pendingDisplaySrc])
const handleDisplayError = useCallback(() => {
const current = String(displaySrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
if (fallback && current !== fallback) {
setUsingFallback(true)
setImgError(false)
setDisplaySrc(fallback)
return
}
setImgError(true)
}, [displaySrc, fallbackImgSrc])
const handleIncomingLoad = useCallback(() => {
const next = String(incomingSrc || '').trim()
if (!next) return
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
requestAnimationFrame(() => {
setShowIncoming(true)
})
fadeTimerRef.current = window.setTimeout(() => {
setPendingDisplaySrc(next)
setDisplaySrc(next)
}, 320)
}, [incomingSrc])
const handleIncomingError = useCallback(() => {
const next = String(incomingSrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
if (fallback && next !== fallback) {
setIncomingSrc(fallback)
setShowIncoming(false)
return
}
setIncomingSrc('')
setShowIncoming(false)
if (!displaySrc) {
setImgError(true)
}
}, [incomingSrc, fallbackImgSrc, displaySrc])
return (
<HoverPopover
onOpenChange={(open) => {
const nextOpen = enableHoverLive && open
setHoverOpen(nextOpen)
if (!nextOpen) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}
}}
content={(open, { close }) =>
enableHoverLive && open ? (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div
className="relative rounded-lg overflow-hidden bg-black"
style={{ paddingBottom: '56.25%' }}
>
<div className="absolute inset-0">
<LiveVideo
src={liveSrc}
muted={popupMuted}
volume={popupVolume}
roomStatus={roomStatus}
onLoadingStart={handleLiveLoadingStart}
onReady={handleLiveReady}
onVolumeChange={handleLiveVolumeChange}
className="w-full h-full object-contain object-center relative z-0"
/>
{livePreparing || !liveReady ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner />
<div className="text-sm font-medium">
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
</div>
</div>
) : null}
{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-white/90 px-2.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-black/10 hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500/70 dark:bg-black/55 dark:text-white dark:ring-white/10 dark:hover:bg-black/70 dark: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)
}
}}
>
{popupMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</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>
) : null
}
>
<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()}
>
{inView ? (
!imgError ? (
<div className="relative w-full h-full">
{displaySrc ? (
<img
src={displaySrc}
loading="lazy"
decoding="async"
alt=""
referrerPolicy="no-referrer"
className={[
'block w-full h-full',
objectFitCls,
'object-center',
blurCls,
]
.filter(Boolean)
.join(' ')}
onLoad={handleDisplayLoad}
onError={handleDisplayError}
/>
) : null}
{incomingSrc ? (
<img
src={incomingSrc}
loading="lazy"
decoding="async"
alt=""
referrerPolicy="no-referrer"
className={[
'absolute inset-0 block w-full h-full transition-opacity duration-300 ease-in-out',
objectFitCls,
'object-center',
blurCls,
showIncoming ? 'opacity-100' : 'opacity-0',
]
.filter(Boolean)
.join(' ')}
onLoad={handleIncomingLoad}
onError={handleIncomingError}
/>
) : null}
</div>
) : (
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
)
) : (
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
)}
</div>
</HoverPopover>
)
}