nsfwapp/frontend/src/components/ui/ModelPreview.tsx
2026-04-07 07:20:08 +02:00

332 lines
10 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'
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<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,
live = false,
thumbTick,
autoTickMs = 10_000,
blur = false,
className,
fit = 'cover',
roomStatus,
fallbackSrc,
}: Props) {
const rootRef = useRef<HTMLDivElement | null>(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 (
<HoverPopover
onOpenChange={(open) => {
setHoverOpen(open)
if (!open) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}
}}
content={(open, { close }) =>
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={() => {
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 ? (
<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>
)
}
>
<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 ? (
<img
src={previewSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => setImgError(false)}
onError={() => setImgError(true)}
/>
) : (
<img
src={fallbackImgSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
/>
)
) : (
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
)}
</div>
</HoverPopover>
)
}