nsfwapp/frontend/src/components/ui/ModelPreview.tsx
2026-01-02 13:13:03 +01:00

183 lines
5.4 KiB
TypeScript

// frontend/src/components/ui/ModelPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
jobId: string
thumbTick?: number
autoTickMs?: number
blur?: boolean
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
}
export default function ModelPreview({
jobId,
thumbTick,
autoTickMs = 10_000,
blur = false,
alignStartAt,
alignEndAt = null,
alignEveryMs,
}: Props) {
const blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
const ms = Date.parse(String(v ?? ''))
return Number.isFinite(ms) ? ms : NaN
}
useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || document.hidden) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// 1) ✅ Aligned: tick genau auf Vielfachen von period seit startMs
if (Number.isFinite(startMs)) {
let t: number | undefined
const schedule = () => {
const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return
const elapsed = Math.max(0, now - startMs)
const rem = elapsed % period
const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => {
setLocalTick((x) => x + 1)
schedule()
}, wait)
}
schedule()
return () => {
if (t) window.clearTimeout(t)
}
}
// 2) Fallback: normales Interval (nicht aligned)
const id = window.setInterval(() => {
setLocalTick((x) => x + 1)
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry?.isIntersecting))
},
{
root: null,
threshold: 0.1,
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
const tick = typeof thumbTick === 'number' ? thumbTick : localTick
// bei neuem Tick Error-Flag zurücksetzen (damit wir retries erlauben)
useEffect(() => {
setImgError(false)
}, [tick])
// Thumbnail mit Cache-Buster (?v=...)
const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
[jobId, tick]
)
// HLS nur für große Vorschau im Popover
const hq = useMemo(
() =>
`/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
[jobId]
)
return (
<HoverPopover
content={(open, { close }) =>
open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
{/* LIVE badge */}
<div className="absolute left-2 top-2 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>
{/* Close */}
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
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
ref={rootRef}
className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden flex items-center justify-center"
>
{!imgError ? (
<img
src={thumb}
loading="lazy"
alt=""
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setImgError(true)}
onLoad={() => setImgError(false)}
/>
) : (
<div className="text-[10px] text-gray-500 dark:text-gray-400 px-1 text-center">
keine Vorschau
</div>
)}
</div>
</HoverPopover>
)
}