nsfwapp/frontend/src/components/ui/FinishedVideoPreview.tsx
2026-02-25 17:46:15 +01:00

1224 lines
38 KiB
TypeScript

// frontend\src\components\ui\FinishedVideoPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips' | 'teaser'
type AnimatedTrigger = 'always' | 'hover'
type ProgressKind = 'inline' | 'teaser' | 'clips'
export type FinishedVideoPreviewProps = {
job: RecordJob
getFileName: (path: string) => string
/** optional legacy override (z.B. aus Cache im Parent) */
durationSeconds?: number
/** Callbacks für Parent-State */
onDuration?: (job: RecordJob, seconds: number) => void
onResolution?: (job: RecordJob, w: number, h: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */
animated?: boolean
animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger
/** nur für frames */
autoTickMs?: number
thumbStepSec?: number
thumbSpread?: boolean
thumbSamples?: number
/** nur für clips */
clipSeconds?: number
clipCount?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant
className?: string
showPopover?: boolean
blur?: boolean
/**
* inline video:
* - false: nur Bild/Teaser
* - true/'always': immer inline abspielen (wenn inView)
* - 'hover': nur bei Hover/Focus abspielen, sonst Bild
*/
inlineVideo?: InlineVideoMode
/** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */
inlineNonce?: number
/** Inline-Playback: Controls anzeigen? */
inlineControls?: boolean
/** Inline-Playback: loopen? */
inlineLoop?: boolean
assetNonce?: number
/** alle Inline/Teaser/Clips muted? (Default: true) */
muted?: boolean
/** Popover-Video muted? (Default: true) */
popoverMuted?: boolean
noGenerateTeaser?: boolean
/** Still-Preview (Bild) unabhängig vom inView-Gating laden */
alwaysLoadStill?: boolean
/** Teaser-Datei vorladen, bevor das Element wirklich im Viewport ist */
teaserPreloadEnabled?: boolean
/** Vorlade-Zone für Teaser (IntersectionObserver rootMargin) */
teaserPreloadRootMargin?: string
scrubProgressRatio?: number
preferScrubProgress?: boolean
}
export default function FinishedVideoPreview({
job,
getFileName,
durationSeconds,
onDuration,
onResolution,
animated = false,
animatedMode = 'frames',
animatedTrigger = 'always',
autoTickMs = 15000,
thumbStepSec,
thumbSpread,
thumbSamples,
clipSeconds = 0.75,
clipCount = 12,
variant = 'thumb',
className,
showPopover = true,
blur = false,
inlineVideo = false,
inlineNonce = 0,
inlineControls = false,
inlineLoop = true,
assetNonce = 0,
muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
alwaysLoadStill = false,
teaserPreloadEnabled = false,
teaserPreloadRootMargin = '700px 0px',
scrubProgressRatio,
preferScrubProgress = false,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
// ✅ meta robust normalisieren (job.meta kann string sein)
const meta = useMemo(() => {
const m: any = (job as any)?.meta
if (!m) return null
if (typeof m === 'string') {
try {
return JSON.parse(m)
} catch {
return null
}
}
return m
}, [job])
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
// ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz
const fetchedMetaFilesRef = useRef<Set<string>>(new Set())
// ✅ merge statt "meta ?? fetchedMeta"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = useMemo(() => {
if (!meta && !fetchedMeta) return null
if (!meta) return fetchedMeta
if (!fetchedMeta) return meta
return { ...meta, ...fetchedMeta }
}, [meta, fetchedMeta])
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
const hasPreviewClipsInAnyMeta = useMemo(() => {
let pcs: any = (metaForPreview as any)?.previewClips
if (typeof pcs === 'string') {
const s = pcs.trim()
if (s.length > 0) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed) && parsed.length > 0) return true
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
return true
} catch {
return true
}
}
}
if (Array.isArray(pcs) && pcs.length > 0) return true
const nested = (metaForPreview as any)?.preview?.clips
if (Array.isArray(nested) && nested.length > 0) return true
return false
}, [metaForPreview])
const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
type PreviewClip = { startSeconds: number; durationSeconds: number }
type PreviewClipMap = { start: number; dur: number; cumStart: number; cumEnd: number }
const previewClipMap = useMemo<PreviewClipMap[] | null>(() => {
let pcsAny: any = (metaForPreview as any)?.previewClips
// ✅ falls previewClips als JSON-string gespeichert ist
if (typeof pcsAny === 'string') {
try {
pcsAny = JSON.parse(pcsAny)
} catch {
pcsAny = null
}
}
// ✅ falls es verschachtelt ist (falls du sowas irgendwo hast)
if (!Array.isArray(pcsAny) && Array.isArray((metaForPreview as any)?.preview?.clips)) {
pcsAny = (metaForPreview as any).preview.clips
}
const pcs = pcsAny as PreviewClip[] | null
if (!Array.isArray(pcs) || pcs.length === 0) return null
let cum = 0
const out: PreviewClipMap[] = []
for (const c of pcs) {
const start = Number((c as any)?.startSeconds)
const dur = Number((c as any)?.durationSeconds)
if (!Number.isFinite(start) || start < 0) continue
if (!Number.isFinite(dur) || dur <= 0) continue
const cumStart = cum
const cumEnd = cum + dur
out.push({ start, dur, cumStart, cumEnd })
cum = cumEnd
}
return out.length ? out : null
}, [metaForPreview])
const previewClipMapKey = useMemo(() => {
if (!previewClipMap) return ''
// stabiler key für effect-deps
return previewClipMap.map((c) => `${c.start.toFixed(3)}:${c.dur.toFixed(3)}`).join('|')
}, [previewClipMap])
// (aktuell ungenutzt, aber bewusst drin gelassen)
const mapPreviewTimeToGlobalTime = (tPreview: number, totalSeconds?: number) => {
const m = previewClipMap
if (!m || !m.length) return tPreview
// clamp
if (tPreview <= 0) return m[0].start
const last = m[m.length - 1]
if (tPreview >= last.cumEnd) {
// ✅ Wenn wir die Vollvideo-Dauer kennen: Teaser-Ende = 100% (Vollvideo-Ende)
if (typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0) return totalSeconds
// Fallback
return last.start + last.dur
}
// binary search nach cumStart/cumEnd
let lo = 0
let hi = m.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
// innerhalb des Segments
return c.start + (tPreview - c.cumStart)
}
}
// Fallback: nächster Clip
const idx = Math.max(0, Math.min(m.length - 1, lo))
const c = m[idx]
return c.start
}
void mapPreviewTimeToGlobalTime
const effectiveW =
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) && meta.videoWidth > 0 ? meta.videoWidth : undefined) ??
(typeof job.videoWidth === 'number' && Number.isFinite(job.videoWidth) && job.videoWidth > 0 ? job.videoWidth : undefined)
const effectiveH =
(typeof meta?.videoHeight === 'number' &&
Number.isFinite(meta.videoHeight) &&
meta.videoHeight > 0
? meta.videoHeight
: undefined) ??
(typeof job.videoHeight === 'number' && Number.isFinite(job.videoHeight) && job.videoHeight > 0 ? job.videoHeight : undefined)
const effectiveSizeBytes =
(typeof meta?.fileSize === 'number' && Number.isFinite(meta.fileSize) && meta.fileSize > 0 ? meta.fileSize : undefined) ??
(typeof job.sizeBytes === 'number' && Number.isFinite(job.sizeBytes) && job.sizeBytes > 0 ? job.sizeBytes : undefined)
const effectiveFPS =
(typeof meta?.fps === 'number' && Number.isFinite(meta.fps) && meta.fps > 0 ? meta.fps : undefined) ??
(typeof job.fps === 'number' && Number.isFinite(job.fps) && job.fps > 0 ? job.fps : undefined)
// --- Duration normalisieren: manche Quellen liefern ms statt s
const rawDuration =
(typeof (metaForPreview as any)?.durationSeconds === 'number' &&
Number.isFinite((metaForPreview as any).durationSeconds) &&
(metaForPreview as any).durationSeconds > 0
? (metaForPreview as any).durationSeconds
: undefined) ??
(typeof job.durationSeconds === 'number' && Number.isFinite(job.durationSeconds) && job.durationSeconds > 0
? job.durationSeconds
: undefined) ??
(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : undefined)
// Heuristik: > 24h in Sekunden ist unplausibel für Clips; außerdem sieht ms typischerweise wie 600000 aus.
const effectiveDurationSec =
typeof rawDuration === 'number' && Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration > 24 * 60 * 60
? rawDuration / 1000
: rawDuration
: undefined
const hasDuration = typeof effectiveDurationSec === 'number' && Number.isFinite(effectiveDurationSec) && effectiveDurationSec > 0
const hasResolution =
typeof effectiveW === 'number' &&
typeof effectiveH === 'number' &&
Number.isFinite(effectiveW) &&
Number.isFinite(effectiveH) &&
effectiveW > 0 &&
effectiveH > 0
const commonVideoProps = {
muted,
playsInline: true,
preload: 'metadata' as const,
}
const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
const [teaserReady, setTeaserReady] = useState(false)
const [teaserOk, setTeaserOk] = useState(true)
// ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent)
const emittedDurationRef = useRef<string>('')
const emittedResolutionRef = useRef<string>('')
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [nearView, setNearView] = useState(false)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false)
// Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0)
// Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false)
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
const wantsHover =
inlineMode === 'hover' || (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const previewId = useMemo(() => {
const f = getFileName(job.output || '')
if (!f) return ''
const base = f.replace(/\.[^.]+$/, '') // ext weg
return stripHot(base).trim()
}, [job.output, getFileName])
// Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata)
const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file])
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
const inlineRef = useRef<HTMLVideoElement | null>(null)
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null)
const lastMountedRef = useRef<{
inline: HTMLVideoElement | null
teaser: HTMLVideoElement | null
clips: HTMLVideoElement | null
}>({
inline: null,
teaser: null,
clips: null,
})
const bumpMountTickIfNew = (slot: 'inline' | 'teaser' | 'clips', el: HTMLVideoElement | null) => {
if (!el) return
if (lastMountedRef.current[slot] === el) return // ✅ gleicher DOM-Node -> NICHT nochmal tickern
lastMountedRef.current[slot] = el // ✅ erst merken, dann state setzen
setProgressMountTick((x) => x + 1)
}
// ▶️ Progressbar für abgespieltes Preview/Inline-Video
const [playRatio, setPlayRatio] = useState(0)
const [, setPlayGlobalSec] = useState(0)
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
// ✅ FIX: Teaser-Mapping darf NICHT von currentSrc abhängen.
// Ratio basiert auf vvDur (z.B. 2/18) — unabhängig von totalSeconds.
const readProgressStepped = (
vv: HTMLVideoElement | null,
totalSeconds: number | undefined,
stepSec = clipSeconds,
forceTeaserMap = false
): { ratio: number; globalSec: number; vvDur: number } => {
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
const vvDur = Number(vv.duration)
const vvDurOk = Number.isFinite(vvDur) && vvDur > 0
if (!vvDurOk) return { ratio: 0, globalSec: 0, vvDur: 0 }
const tPreview = Number(vv.currentTime)
if (!Number.isFinite(tPreview) || tPreview < 0) return { ratio: 0, globalSec: 0, vvDur }
const m = previewClipMap
let globalSec = 0
if (forceTeaserMap && Array.isArray(m) && m.length > 0) {
const last = m[m.length - 1]
if (tPreview >= last.cumEnd) {
globalSec =
typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0
? totalSeconds
: last.start + last.dur
} else {
let lo = 0
let hi = m.length - 1
let segIdx = 0
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
segIdx = mid
break
}
}
const seg = m[segIdx]
globalSec = seg.start
// ✅ Sichtbarer Teaser-Progress exakt am Segmentindex ausrichten
// springt auf Beginn des aktuell aktiven Preview-Segments (kein floor-lag über currentTime)
const ratio = m.length > 0 ? clamp01(segIdx / m.length) : 0
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
}
}
// inline/clips fallback
if (Number.isFinite(stepSec) && stepSec > 0) {
globalSec = Math.floor(tPreview / stepSec) * stepSec
} else {
globalSec = tPreview
}
const ratio = clamp01(Math.min(globalSec, vvDur) / vvDur)
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
}
const hardStop = (v: HTMLVideoElement | null) => {
if (!v) return
try {
v.pause()
} catch {}
try {
v.removeAttribute('src')
// @ts-ignore
v.src = ''
v.load()
} catch {}
}
useEffect(() => {
setTeaserReady(false)
setTeaserOk(true)
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = ''
emittedResolutionRef.current = ''
setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser])
useEffect(() => {
const onRelease = (ev: any) => {
const f = String(ev?.detail?.file ?? '')
if (!f || f !== file) return
hardStop(inlineRef.current)
hardStop(teaserMp4Ref.current)
hardStop(clipsRef.current)
}
window.addEventListener('player:release', onRelease as EventListener)
window.addEventListener('player:close', onRelease as EventListener)
return () => {
window.removeEventListener('player:release', onRelease as EventListener)
window.removeEventListener('player:close', onRelease as EventListener)
}
}, [file])
// --- IntersectionObserver: echtes inView (für Playback)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const hit = Boolean(entries[0]?.isIntersecting)
setInView(hit)
if (hit) setEverInView(true)
},
{
threshold: 0.01,
rootMargin: '0px',
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// --- IntersectionObserver: nearView (für Teaser-Preload / Vorwärmen)
useEffect(() => {
const el = rootRef.current
if (!el) return
// Falls Preload deaktiviert ist: nearView folgt inView
if (!teaserPreloadEnabled) {
setNearView(inView)
return
}
let armed = true
const obs = new IntersectionObserver(
(entries) => {
const hit = Boolean(entries[0]?.isIntersecting)
if (hit) {
setNearView(true)
// einmal "armed" reicht (wir wollen nicht wieder entladen)
if (armed) {
armed = false
obs.disconnect()
}
}
},
{
threshold: 0,
rootMargin: teaserPreloadRootMargin,
}
)
obs.observe(el)
return () => obs.disconnect()
}, [teaserPreloadEnabled, teaserPreloadRootMargin, inView])
// --- Tick für "frames"
useEffect(() => {
if (!animated) return
if (animatedMode !== 'frames') return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs])
// --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => {
if (!animated) return null
if (animatedMode !== 'frames') return null
if (!hasDuration) return null
const dur = effectiveDurationSec!
const step = Math.max(0.25, thumbStepSec ?? 3)
if (thumbSpread) {
const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur)))
const idx = localTick % count
const span = Math.max(0.1, dur - step)
const base = Math.min(0.25, span * 0.02)
const t = (idx / count) * span + base
return Math.min(dur - 0.05, Math.max(0.05, t))
}
const total = Math.max(dur - 0.1, step)
const t = (localTick * step) % total
return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples])
const v = assetNonce ?? 0
const thumbSrc = useMemo(() => {
if (!previewId) return ''
if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
}, [previewId, thumbTimeSec, v])
const teaserSrc = useMemo(() => {
if (!previewId) return ''
const noGen = noGenerateTeaser ? '&noGenerate=1' : ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
const teaserActive =
animated &&
inView &&
!document.hidden &&
videoOk &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
const progressTotalSeconds =
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered)
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
useEffect(() => {
let did = false
if (onDuration && hasDuration) {
const secs = Number(effectiveDurationSec)
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
did = true
}
}
if (onResolution && hasResolution) {
const w = Number(effectiveW)
const h = Number(effectiveH)
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h)
did = true
}
}
if (did) setMetaLoaded(true)
}, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
// und pro Datei in dieser Komponenteninstanz nur 1x
useEffect(() => {
if (!previewId) return
if (!file) return
if (!animated || animatedMode !== 'teaser') return
if (!shouldPreloadAnimatedAssets) return
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
if (hasPreviewClipsInAnyMeta) return
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
if (fetchedMetaFilesRef.current.has(file)) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
if (aborted) return
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
fetchedMetaFilesRef.current.add(file)
if (byFile) {
setFetchedMeta(byFile)
}
})()
return () => {
aborted = true
ctrl.abort()
}
}, [
previewId,
file,
animated,
animatedMode,
shouldPreloadAnimatedAssets,
hasPreviewClipsInAnyMeta,
])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
const vv = e.currentTarget
if (onDuration && !hasDuration) {
const secs = Number(vv.duration)
if (Number.isFinite(secs) && secs > 0) {
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
}
}
}
if (onResolution && !hasResolution) {
const w = Number(vv.videoWidth)
const h = Number(vv.videoHeight)
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h)
}
}
}
}
useEffect(() => {
setThumbOk(true)
setVideoOk(true)
// ✅ Mount-Guards zurücksetzen
lastMountedRef.current.inline = null
lastMountedRef.current.teaser = null
lastMountedRef.current.clips = null
}, [previewId, assetNonce])
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
const shouldLoadAssets = shouldPreloadAnimatedAssets
const teaserCanPrewarm =
animated &&
animatedMode === 'teaser' &&
teaserOk &&
Boolean(teaserSrc) &&
!showingInlineVideo &&
shouldPreloadAnimatedAssets
// ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef =
showingInlineVideo
? inlineRef
: !showingInlineVideo && teaserActive && animatedMode === 'teaser'
? teaserMp4Ref
: !showingInlineVideo && teaserActive && animatedMode === 'clips'
? clipsRef
: null
const showProgressBar =
Boolean(progressVideoRef) &&
inView
const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
// ✅ Frames-Progress: zeigt Position des aktuellen Thumbnails relativ zur Gesamtdauer
const showFrameProgress =
animated &&
animatedMode === 'frames' &&
hasDuration &&
typeof thumbTimeSec === 'number' &&
Number.isFinite(thumbTimeSec) &&
thumbTimeSec >= 0
const frameRatio = showFrameProgress ? clamp01(thumbTimeSec! / effectiveDurationSec!) : 0
const hasScrubProgress =
!showingInlineVideo &&
preferScrubProgress &&
typeof scrubProgressRatio === 'number' &&
Number.isFinite(scrubProgressRatio)
// finaler Balken:
// 1) externer Scrub-Progress (wenn aktiv)
// 2) Video-Progress
// 3) Frames-Progress
const progressRatio = hasScrubProgress
? clamp01(scrubProgressRatio!)
: showProgressBar
? playRatio
: showFrameProgress
? frameRatio
: 0
const hasBasePreviewProgress = showProgressBar || showFrameProgress
const showAnyProgress =
!showingInlineVideo && (hasScrubProgress || hasBasePreviewProgress)
const clipOverlay = useMemo(() => {
if (!hasDuration) return null
const total = effectiveDurationSec!
if (!(total > 0)) return null
const pcsAny: any = (metaForPreview as any)?.previewClips
let pcs: any = pcsAny
if (typeof pcs === 'string') {
try {
pcs = JSON.parse(pcs)
} catch {
pcs = null
}
}
if (!Array.isArray(pcs) || pcs.length === 0) return null
const clips = pcs
.map((c: any) => ({
start: Number(c?.startSeconds),
dur: Number(c?.durationSeconds),
}))
.filter((c: any) => Number.isFinite(c.start) && c.start >= 0 && Number.isFinite(c.dur) && c.dur > 0)
if (!clips.length) return null
return clips.map((c: any) => {
const left = clamp01(c.start / total)
const width = clamp01(c.dur / total)
return { left, width, start: c.start, dur: c.dur }
})
}, [metaForPreview, hasDuration, effectiveDurationSec])
// --- Legacy "clips" Logik
const clipTimes = useMemo(() => {
if (!animated) return []
if (animatedMode !== 'clips') return []
if (!hasDuration) return []
const dur = effectiveDurationSec!
const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(clipCount ?? thumbSamples ?? 12, Math.floor(dur)))
const span = Math.max(0.1, dur - clipLen)
const base = Math.min(0.25, span * 0.02)
const times: number[] = []
for (let i = 0; i < count; i++) {
const t = (i / count) * span + base
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
}
return times
}, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds, clipCount])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
const clipIdxRef = useRef(0)
const clipStartRef = useRef(0)
useEffect(() => {
const vv = teaserMp4Ref.current
if (!vv) return
const active = teaserActive && animatedMode === 'teaser'
if (!active) {
try {
vv.pause()
} catch {}
return
}
applyInlineVideoPolicy(vv, { muted })
const p = vv.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => {
if (!showProgressBar) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
const vv = progressVideoRef?.current ?? null
if (!vv) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
let stopped = false
let timer: number | null = null
const sync = () => {
if (stopped) return
if (!vv.isConnected) return
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
const p = readProgressStepped(vv, progressTotalSeconds, clipSeconds, forceMap)
setPlayRatio(p.ratio)
setPlayGlobalSec(p.globalSec)
}
// initial sofort
sync()
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
timer = window.setInterval(sync, 100)
// optional: bei metadata/timeupdate sofort einmal syncen
const onLoaded = () => sync()
const onTime = () => sync()
vv.addEventListener('loadedmetadata', onLoaded)
vv.addEventListener('durationchange', onLoaded)
vv.addEventListener('timeupdate', onTime)
return () => {
stopped = true
if (timer != null) window.clearInterval(timer)
vv.removeEventListener('loadedmetadata', onLoaded)
vv.removeEventListener('durationchange', onLoaded)
vv.removeEventListener('timeupdate', onTime)
}
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
useEffect(() => {
if (!showingInlineVideo) return
applyInlineVideoPolicy(inlineRef.current, { muted })
}, [showingInlineVideo, muted])
// Legacy: "clips" spielt 0.75s Segmente aus dem Vollvideo per seek
useEffect(() => {
const vv = clipsRef.current
if (!vv) return
if (!(teaserActive && animatedMode === 'clips')) {
if (!teaserActive) vv.pause()
return
}
if (!clipTimes.length) return
clipIdxRef.current = clipIdxRef.current % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
const start = () => {
try {
vv.currentTime = clipStartRef.current
} catch {}
const p = vv.play()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}
const onLoaded = () => start()
const onTimeUpdate = () => {
if (!clipTimes.length) return
if (vv.currentTime - clipStartRef.current >= clipSeconds) {
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
try {
vv.currentTime = clipStartRef.current + 0.01
} catch {}
}
}
vv.addEventListener('loadedmetadata', onLoaded)
vv.addEventListener('timeupdate', onTimeUpdate)
if (vv.readyState >= 1) start()
return () => {
vv.removeEventListener('loadedmetadata', onLoaded)
vv.removeEventListener('timeupdate', onTimeUpdate)
vv.pause()
}
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
(nearView || inView) &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
((onDuration && !hasDuration) || (onResolution && !hasResolution))
const showTeaserSegments =
Boolean(clipOverlay) &&
(
progressKind === 'teaser' ||
(!showingInlineVideo && hasScrubProgress && animatedMode === 'teaser')
)
const previewNode = (
<div
ref={rootRef}
className={['group bg-gray-100 dark:bg-white/5 overflow-hidden relative', sizeClass, className ?? ''].join(' ')}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined}
data-duration={hasDuration ? String(effectiveDurationSec) : undefined}
data-res={hasResolution ? `${effectiveW}x${effectiveH}` : undefined}
data-size={typeof effectiveSizeBytes === 'number' ? String(effectiveSizeBytes) : undefined}
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
>
{/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadStill && thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading={alwaysLoadStill ? 'eager' : 'lazy'}
decoding="async"
alt={file}
className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)}
/>
) : (
<div className="absolute inset-0 bg-black/10 dark:bg-white/10" />
)}
{/* ✅ Inline Full Video (nur wenn sichtbar/aktiv) */}
{showingInlineVideo ? (
<video
{...commonVideoProps}
ref={(el) => {
inlineRef.current = el
bumpMountTickIfNew('inline', el)
}}
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc}
className={[
'absolute inset-0 w-full h-full object-cover',
blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
]
.filter(Boolean)
.join(' ')}
autoPlay
muted={muted}
controls={inlineControls}
loop={inlineLoop}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : null}
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
<video
key={`teaser-prewarm-${previewId}`}
src={teaserSrc}
className="hidden"
muted
playsInline
preload="auto"
onLoadedData={() => setTeaserReady(true)}
onCanPlay={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
}}
/>
) : null}
{/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video
ref={(el) => {
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}`}
src={teaserSrc}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
teaserReady ? 'opacity-100' : 'opacity-0',
'transition-opacity duration-150',
]
.filter(Boolean)
.join(' ')}
muted={muted}
playsInline
autoPlay
loop
preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
}}
/>
) : null}
{/* ✅ Legacy clips */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video
ref={(el) => {
clipsRef.current = el
bumpMountTickIfNew('clips', el)
}}
key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc}
className={['absolute inset-0 w-full h-full object-cover pointer-events-none', blurCls].filter(Boolean).join(' ')}
muted={muted}
playsInline
preload="metadata"
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onError={() => setVideoOk(false)}
/>
) : null}
{/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
{showAnyProgress ? (
<div
aria-hidden="true"
className={[
'absolute left-0 right-0 bottom-0 z-40 pointer-events-none',
// etwas höher + bei hover deutlich
'h-0.5 group-hover:h-1',
'transition-[height] duration-150 ease-out',
// Track: heller + border/inset für Kontrast
'rounded-none group-hover:rounded-full',
'bg-black/35 dark:bg-white/10',
// darf beim Hover raus “glowen”
'overflow-hidden group-hover:overflow-visible',
].join(' ')}
>
{/* 1) Segmente (previewClips) als Markierungen */}
{showTeaserSegments ? (
<div className="absolute inset-0">
{clipOverlay!.map((c, i) => (
<div
key={`seg-${i}-${c.left.toFixed(6)}-${c.width.toFixed(6)}`}
className="absolute top-0 bottom-0 bg-white/15 dark:bg-white/20"
style={{
left: `${c.left * 100}%`,
width: `${c.width * 100}%`,
}}
/>
))}
</div>
) : null}
{/* 2) Kontinuierlicher Fortschritt (SOLID, kein Gradient) */}
<div
className={[
'absolute inset-0 origin-left',
progressKind === 'teaser' ? '' : 'transition-transform duration-150 ease-out',
].join(' ')}
style={{
transform: `scaleX(${clamp01(progressRatio)})`,
background: 'rgba(99,102,241,0.95)', // indigo-500-ish, kräftig
}}
/>
</div>
) : null}
{/* ✅ Metadaten-Fallback nur wenn nötig (und nicht inline) */}
{needHiddenMeta ? (
<video src={videoSrc} preload="metadata" muted={muted} playsInline className="hidden" onLoadedMetadata={handleLoadedMetadata} />
) : null}
</div>
)
if (!showPopover) return previewNode
return (
<HoverPopover
content={(open) =>
open && (
<div className="w-[420px]">
<div className="aspect-video">
<video
src={videoSrc}
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
muted={popoverMuted}
playsInline
preload="metadata"
controls
autoPlay
loop
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
>
{previewNode}
</HoverPopover>
)
}