// 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 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 fadeTimerRef = useRef(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 [liveUnavailable, setLiveUnavailable] = useState(false) const [popupMuted, setPopupMuted] = useState(true) const [popupVolume, setPopupVolume] = useState(1) const handleLiveLoadingStart = useCallback(() => { setLiveReady(false) setLiveUnavailable(false) }, []) const handleLiveReady = useCallback(() => { setLiveReady(true) setLiveUnavailable(false) }, []) const handleLiveUnavailable = useCallback(() => { setLiveUnavailable(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) setLiveUnavailable(false) return } setLivePreparing(true) setLiveReady(false) setLiveUnavailable(false) const cookieHeader = buildChaturbateCookieHeader() try { let prepared = false for (let attempt = 0; attempt < 2; attempt += 1) { const prepareUrl = apiUrl( `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1&attempt=${attempt + 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 const prepareData = await prepareRes.json().catch(() => null) if (ac.signal.aborted) return if (prepareRes.ok && prepareData?.ok !== false) { prepared = true break } if (attempt === 0) { await new Promise((resolve) => window.setTimeout(resolve, 500)) if (ac.signal.aborted) return } } if (!prepared) { throw new Error('Live preview preparation failed') } 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) setLiveUnavailable(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 ( { const nextOpen = enableHoverLive && open setHoverOpen(nextOpen) if (!nextOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) setLiveUnavailable(false) } }} content={(open, { close }) => enableHoverLive && open ? (
{!liveUnavailable && (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 ? (
{displaySrc ? ( ) : null} {incomingSrc ? ( ) : null}
) : (
) ) : (
)}
) }