// 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' 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 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(null) const [inView, setInView] = useState(false) const [imgError, setImgError] = useState(false) const [imgSrc, setImgSrc] = useState('') 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(() => { 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 prepareRes = await fetch( `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`, { 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(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) } catch { if (ac.signal.aborted) return setLiveSrc(`/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(() => { setImgError(false) setUsingFallback(false) setImgSrc(previewSrc) setLiveSrc('') setLivePreparing(false) setLiveReady(false) }, [jobId, previewSrc]) useEffect(() => { if (!usingFallback) return if (!fallbackImgSrc) return setImgError(false) setImgSrc(fallbackImgSrc) }, [usingFallback, fallbackImgSrc]) return ( { const nextOpen = enableHoverLive && open setHoverOpen(nextOpen) if (!nextOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) } }} content={(open, { close }) => enableHoverLive && open ? (
{livePreparing || !liveReady ? (
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
) : null} {showLiveBadge ? (
Live
) : null}
) : null } >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > {inView ? ( !imgError ? ( { setImgError(false) const current = String(imgSrc || '').trim() const fallback = String(fallbackImgSrc || '').trim() setUsingFallback(Boolean(fallback && current === fallback)) }} onError={() => { const current = String(imgSrc || '').trim() const fallback = String(fallbackImgSrc || '').trim() if (fallback && current !== fallback) { setUsingFallback(true) setImgError(false) setImgSrc(fallback) return } setImgError(true) }} /> ) : (
) ) : (
)}
) }