// 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' import LoadingSpinner from './LoadingSpinner' type Props = { jobId: string modelKey?: string live?: boolean thumbTick?: number autoTickMs?: number blur?: boolean className?: string fit?: 'cover' | 'contain' roomStatus?: string fallbackSrc?: 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, live = false, thumbTick, autoTickMs = 10_000, blur = false, className, fit = 'cover', roomStatus, fallbackSrc, }: Props) { const rootRef = useRef(null) const [inView, setInView] = useState(false) const [pageVisible, setPageVisible] = useState(!document.hidden) const [localTick, setLocalTick] = useState(0) const [imgError, setImgError] = useState(false) const [liveSrc, setLiveSrc] = useState('') const [hoverOpen, setHoverOpen] = useState(false) const [livePreparing, setLivePreparing] = useState(false) const [liveReady, setLiveReady] = useState(false) const [popupMuted, setPopupMuted] = useState(true) const [popupVolume, setPopupVolume] = useState(1) 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 tick = live ? (typeof thumbTick === 'number' ? thumbTick : localTick) : 0 const previewSrc = useMemo(() => { // Nur bereits vorhandenes preview.jpg anfordern, niemals on-the-fly generieren return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${tick}` }, [jobId, tick]) const fallbackImgSrc = useMemo(() => { const s = String(fallbackSrc ?? '').trim() if (s) return s return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` }, [fallbackSrc, jobId]) const liveStreamSrc = useMemo( () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`, [jobId] ) useEffect(() => { const onVis = () => setPageVisible(!document.hidden) document.addEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis) }, []) useEffect(() => { const ac = new AbortController() const loadLive = async () => { if (!hoverOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) return } setLivePreparing(true) setLiveReady(false) const cookieHeader = buildChaturbateCookieHeader() try { // ✅ HLS-Refresh jetzt direkt über /api/preview/live const prepareRes = await fetch( `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&prepare=1`, { method: 'GET', cache: 'no-store', signal: ac.signal, headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined, } ) if (ac.signal.aborted) return // Auch wenn prepare fehlschlägt: // best effort -> Stream-Endpunkt trotzdem versuchen await prepareRes.json().catch(() => null) if (ac.signal.aborted) return setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`) } catch { if (ac.signal.aborted) return setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`) } finally { if (!ac.signal.aborted) { setLivePreparing(false) } } } void loadLive() return () => { ac.abort() } }, [hoverOpen, jobId, liveStreamSrc]) 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) }, [jobId]) useEffect(() => { setImgError(false) }, [previewSrc]) useEffect(() => { if (!live) return if (typeof thumbTick === 'number') return if (!inView) return if (!pageVisible) return const id = window.setInterval(() => { setLocalTick((x) => x + 1) setImgError(false) }, autoTickMs) return () => window.clearInterval(id) }, [live, thumbTick, inView, pageVisible, autoTickMs]) return ( { setHoverOpen(open) if (!open) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) } }} content={(open, { close }) => open && (
{ setLiveReady(false) }} onReady={() => { setLiveReady(true) }} onVolumeChange={(nextVolume, nextMuted) => { setPopupVolume(nextVolume) setPopupMuted(nextMuted) }} className="w-full h-full object-contain object-center relative z-0" /> {livePreparing || !liveReady ? (
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
) : null} {showLiveBadge ? (
Live
) : null}
) } >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > {inView ? ( !imgError ? ( setImgError(false)} onError={() => setImgError(true)} /> ) : ( ) ) : (
)}
) }