1224 lines
38 KiB
TypeScript
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>
|
|
)
|
|
}
|