361 lines
11 KiB
TypeScript
361 lines
11 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
|
|
className?: string
|
|
fit?: 'cover' | 'contain'
|
|
|
|
alignStartAt?: string | number | Date
|
|
alignEndAt?: string | number | Date | null
|
|
alignEveryMs?: number
|
|
|
|
fastRetryMs?: number
|
|
fastRetryMax?: number
|
|
fastRetryWindowMs?: number
|
|
|
|
thumbsWebpUrl?: string | null
|
|
thumbsCandidates?: Array<string | null | undefined>
|
|
}
|
|
|
|
export default function ModelPreview({
|
|
jobId,
|
|
thumbTick,
|
|
autoTickMs = 10_000,
|
|
blur = false,
|
|
className,
|
|
alignStartAt,
|
|
alignEndAt = null,
|
|
alignEveryMs,
|
|
fastRetryMs,
|
|
fastRetryMax,
|
|
fastRetryWindowMs,
|
|
thumbsWebpUrl,
|
|
thumbsCandidates,
|
|
}: Props) {
|
|
const blurCls = blur ? 'blur-md' : ''
|
|
const CONTROLBAR_H = 30
|
|
|
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
|
|
const pageVisibleRef = useRef(true)
|
|
|
|
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
|
|
const [inView, setInView] = useState(false)
|
|
const inViewRef = useRef(false)
|
|
|
|
const [localTick, setLocalTick] = useState(0)
|
|
const [directImgError, setDirectImgError] = useState(false)
|
|
const [apiImgError, setApiImgError] = useState(false)
|
|
|
|
const retryT = useRef<number | null>(null)
|
|
const fastTries = useRef(0)
|
|
const hadSuccess = useRef(false)
|
|
const enteredViewOnce = useRef(false)
|
|
|
|
const [pageVisible, setPageVisible] = useState(true)
|
|
|
|
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
|
|
}
|
|
|
|
const normalizeUrl = (u?: string | null): string => {
|
|
const s = String(u ?? '').trim()
|
|
if (!s) return ''
|
|
if (/^https?:\/\//i.test(s)) return s
|
|
if (s.startsWith('/')) return s
|
|
return `/${s}`
|
|
}
|
|
|
|
const thumbsCandidatesKey = useMemo(() => {
|
|
const list = [
|
|
thumbsWebpUrl,
|
|
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
|
|
]
|
|
.map(normalizeUrl)
|
|
.filter(Boolean)
|
|
|
|
// Reihenfolge behalten, nur dedupe
|
|
return Array.from(new Set(list)).join('|')
|
|
}, [thumbsWebpUrl, thumbsCandidates])
|
|
|
|
// ✅ visibilitychange -> nur REF updaten
|
|
useEffect(() => {
|
|
const onVis = () => {
|
|
const vis = !document.hidden
|
|
pageVisibleRef.current = vis
|
|
setPageVisible(vis) // ✅ sorgt dafür, dass Tick-Effect neu aufgebaut wird
|
|
}
|
|
const vis = !document.hidden
|
|
pageVisibleRef.current = vis
|
|
setPageVisible(vis)
|
|
|
|
document.addEventListener('visibilitychange', onVis)
|
|
return () => document.removeEventListener('visibilitychange', onVis)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (retryT.current) window.clearTimeout(retryT.current)
|
|
}
|
|
}, [])
|
|
|
|
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
|
|
useEffect(() => {
|
|
const el = rootRef.current
|
|
if (!el) return
|
|
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0]
|
|
const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0))
|
|
if (next === inViewRef.current) return
|
|
inViewRef.current = next
|
|
setInView(next)
|
|
},
|
|
{
|
|
root: null,
|
|
threshold: 0,
|
|
rootMargin: '300px 0px',
|
|
}
|
|
)
|
|
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
|
|
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
|
|
useEffect(() => {
|
|
if (typeof thumbTick === 'number') return
|
|
if (!inView) return
|
|
if (!pageVisibleRef.current) return
|
|
if (enteredViewOnce.current) return
|
|
enteredViewOnce.current = true
|
|
setLocalTick((x) => x + 1)
|
|
}, [inView, thumbTick])
|
|
|
|
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
|
|
useEffect(() => {
|
|
if (typeof thumbTick === 'number') return
|
|
if (!inView) return
|
|
if (!pageVisibleRef.current) 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
|
|
|
|
// aligned schedule
|
|
if (Number.isFinite(startMs)) {
|
|
let t: number | undefined
|
|
|
|
const schedule = () => {
|
|
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
|
|
if (!pageVisibleRef.current) return
|
|
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(() => {
|
|
// ✅ nochmal checken, falls inzwischen offscreen/hidden
|
|
if (!inViewRef.current) return
|
|
if (!pageVisibleRef.current) return
|
|
setLocalTick((x) => x + 1)
|
|
schedule()
|
|
}, wait)
|
|
}
|
|
|
|
schedule()
|
|
return () => {
|
|
if (t) window.clearTimeout(t)
|
|
}
|
|
}
|
|
|
|
// fallback interval
|
|
const id = window.setInterval(() => {
|
|
if (!inViewRef.current) return
|
|
if (!pageVisibleRef.current) return
|
|
setLocalTick((x) => x + 1)
|
|
}, period)
|
|
|
|
return () => window.clearInterval(id)
|
|
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
|
|
|
|
// ✅ tick Quelle
|
|
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
|
|
|
|
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
|
|
// Wir "freezen" den Tick, solange inView=false oder tab hidden
|
|
const frozenTickRef = useRef(0)
|
|
const [frozenTick, setFrozenTick] = useState(0)
|
|
|
|
useEffect(() => {
|
|
if (!inView) return
|
|
if (!pageVisibleRef.current) return
|
|
frozenTickRef.current = rawTick
|
|
setFrozenTick(rawTick)
|
|
}, [rawTick, inView])
|
|
|
|
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
|
|
useEffect(() => {
|
|
setDirectImgError(false)
|
|
setApiImgError(false)
|
|
}, [frozenTick])
|
|
|
|
// bei Job-Wechsel reset
|
|
useEffect(() => {
|
|
hadSuccess.current = false
|
|
fastTries.current = 0
|
|
enteredViewOnce.current = false
|
|
setDirectImgError(false)
|
|
setApiImgError(false)
|
|
|
|
if (inViewRef.current && pageVisibleRef.current) {
|
|
setLocalTick((x) => x + 1)
|
|
}
|
|
}, [jobId, thumbsCandidatesKey])
|
|
|
|
const thumb = useMemo(
|
|
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
|
|
[jobId, frozenTick]
|
|
)
|
|
|
|
const hq = useMemo(
|
|
() => `/api/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
|
|
[jobId]
|
|
)
|
|
|
|
const directThumbCandidates = useMemo(() => {
|
|
if (!thumbsCandidatesKey) return []
|
|
return thumbsCandidatesKey.split('|')
|
|
}, [thumbsCandidatesKey])
|
|
|
|
const directThumb = directThumbCandidates[0] || ''
|
|
const useDirectThumb = Boolean(directThumb) && !directImgError
|
|
const currentImgSrc = useMemo(() => {
|
|
if (useDirectThumb) {
|
|
const sep = directThumb.includes('?') ? '&' : '?'
|
|
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}`
|
|
}
|
|
return thumb
|
|
}, [useDirectThumb, directThumb, frozenTick, thumb])
|
|
|
|
return (
|
|
<HoverPopover
|
|
content={(open, { close }) =>
|
|
open && (
|
|
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
|
<div
|
|
className="relative rounded-lg overflow-hidden bg-black"
|
|
style={{
|
|
// 16:9 Videofläche + feste 30px Controlbar
|
|
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
|
|
}}
|
|
>
|
|
<div className="absolute inset-0">
|
|
<LiveHlsVideo src={hq} muted={false} className="w-full h-full object-contain object-bottom relative z-0" />
|
|
|
|
<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>
|
|
|
|
<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(' ')}
|
|
>
|
|
{!apiImgError ? (
|
|
<img
|
|
src={currentImgSrc}
|
|
loading={inView ? 'eager' : 'lazy'}
|
|
fetchPriority={inView ? 'high' : 'auto'}
|
|
decoding="async"
|
|
alt=""
|
|
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
|
|
onLoad={() => {
|
|
hadSuccess.current = true
|
|
fastTries.current = 0
|
|
if (retryT.current) window.clearTimeout(retryT.current)
|
|
|
|
// nur den aktuell genutzten Pfad als "ok" markieren
|
|
if (useDirectThumb) setDirectImgError(false)
|
|
else setApiImgError(false)
|
|
}}
|
|
onError={() => {
|
|
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten
|
|
if (useDirectThumb) {
|
|
setDirectImgError(true)
|
|
return
|
|
}
|
|
|
|
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
|
|
setApiImgError(true)
|
|
|
|
if (!fastRetryMs) return
|
|
if (!inViewRef.current || !pageVisibleRef.current) return
|
|
if (hadSuccess.current) return
|
|
|
|
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
|
|
const windowMs = Number(fastRetryWindowMs ?? 60_000)
|
|
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
|
|
if (!withinWindow) return
|
|
|
|
const max = Number(fastRetryMax ?? 25)
|
|
if (fastTries.current >= max) return
|
|
|
|
if (retryT.current) window.clearTimeout(retryT.current)
|
|
retryT.current = window.setTimeout(() => {
|
|
fastTries.current += 1
|
|
setApiImgError(false) // API erneut probieren
|
|
setLocalTick((x) => x + 1)
|
|
}, fastRetryMs)
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
|
|
keine Vorschau
|
|
</div>
|
|
)}
|
|
</div>
|
|
</HoverPopover>
|
|
)
|
|
}
|