514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
// 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<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,
|
|
enableHoverLive = false,
|
|
refreshKey,
|
|
blur = false,
|
|
className,
|
|
fit = 'cover',
|
|
roomStatus,
|
|
fallbackSrc,
|
|
initialSrc,
|
|
}: Props) {
|
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
const [inView, setInView] = useState(false)
|
|
const [imgError, setImgError] = useState(false)
|
|
const fadeTimerRef = useRef<number | null>(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 (
|
|
<HoverPopover
|
|
onOpenChange={(open) => {
|
|
const nextOpen = enableHoverLive && open
|
|
|
|
setHoverOpen(nextOpen)
|
|
|
|
if (!nextOpen) {
|
|
setLiveSrc('')
|
|
setLivePreparing(false)
|
|
setLiveReady(false)
|
|
setLiveUnavailable(false)
|
|
}
|
|
}}
|
|
content={(open, { close }) =>
|
|
enableHoverLive && 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={handleLiveLoadingStart}
|
|
onReady={handleLiveReady}
|
|
onUnavailable={handleLiveUnavailable}
|
|
onVolumeChange={handleLiveVolumeChange}
|
|
className="w-full h-full object-contain object-center relative z-0"
|
|
/>
|
|
|
|
{!liveUnavailable && (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>
|
|
) : null
|
|
}
|
|
>
|
|
<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 ? (
|
|
<div className="relative w-full h-full">
|
|
{displaySrc ? (
|
|
<img
|
|
src={displaySrc}
|
|
loading="lazy"
|
|
decoding="async"
|
|
alt=""
|
|
referrerPolicy="no-referrer"
|
|
className={[
|
|
'block w-full h-full',
|
|
objectFitCls,
|
|
'object-center',
|
|
blurCls,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onLoad={handleDisplayLoad}
|
|
onError={handleDisplayError}
|
|
/>
|
|
) : null}
|
|
|
|
{incomingSrc ? (
|
|
<img
|
|
src={incomingSrc}
|
|
loading="lazy"
|
|
decoding="async"
|
|
alt=""
|
|
referrerPolicy="no-referrer"
|
|
className={[
|
|
'absolute inset-0 block w-full h-full transition-opacity duration-300 ease-in-out',
|
|
objectFitCls,
|
|
'object-center',
|
|
blurCls,
|
|
showIncoming ? 'opacity-100' : 'opacity-0',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onLoad={handleIncomingLoad}
|
|
onError={handleIncomingError}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
|
)
|
|
) : (
|
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
|
)}
|
|
</div>
|
|
</HoverPopover>
|
|
)
|
|
}
|