183 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|