// 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' type Props = { jobId: string thumbTick?: number autoTickMs?: number blur?: boolean className?: string fit?: 'cover' | 'contain' roomStatus?: string alignStartAt?: string | number | Date alignEndAt?: string | number | Date | null alignEveryMs?: number fastRetryMs?: number fastRetryMax?: number fastRetryWindowMs?: number thumbsWebpUrl?: string | null thumbsCandidates?: Array } export default function ModelPreview({ jobId, thumbTick, autoTickMs = 10_000, blur = false, className, roomStatus, alignStartAt, alignEndAt = null, alignEveryMs, fastRetryMs, fastRetryMax, fastRetryWindowMs, thumbsWebpUrl, thumbsCandidates, }: Props) { const blurCls = blur ? 'blur-md' : '' const CONTROLBAR_H = 0 const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase() const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' const rootRef = useRef(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(null) const fastTries = useRef(0) const hadSuccess = useRef(false) const enteredViewOnce = useRef(false) const [pageVisible, setPageVisible] = useState(true) const [popupMuted, setPopupMuted] = useState(true) const [popupVolume, setPopupVolume] = useState(1) 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/live?id=${encodeURIComponent(jobId)}&hover=1`, [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 ( open && (
{ setPopupVolume(nextVolume) setPopupMuted(nextMuted) }} className="w-full h-full object-contain object-bottom relative z-0" /> {showLiveBadge ? (
Live
) : null}
) } >
{ e.stopPropagation() }} onMouseDown={(e) => { e.stopPropagation() }} onTouchStart={(e) => { e.stopPropagation() }} onPointerDown={(e) => { e.stopPropagation() }} > {!apiImgError ? ( { 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) }} /> ) : (
keine Vorschau
)}
) }