332 lines
10 KiB
TypeScript
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>
|
|
)
|
|
} |