nsfwapp/frontend/src/components/ui/FinishedVideoPreview.tsx
2025-12-19 23:06:40 +01:00

149 lines
4.3 KiB
TypeScript

// frontend/src/components/ui/FinishedVideoPreview.tsx
'use client'
import { useMemo, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
type Props = {
job: RecordJob
getFileName: (path: string) => string
// 🔹 optional: bereits bekannte Dauer (Sekunden)
durationSeconds?: number
// 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben
onDuration?: (job: RecordJob, seconds: number) => void
thumbTick?: number
}
export default function FinishedVideoPreview({
job,
getFileName,
durationSeconds,
onDuration,
thumbTick
}: Props) {
const file = getFileName(job.output || '')
const [thumbOk, setThumbOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
// id für /api/record/preview: Dateiname ohne Extension
const previewId = useMemo(() => {
if (!file) return ''
const dot = file.lastIndexOf('.')
return dot > 0 ? file.slice(0, dot) : file
}, [file])
const videoSrc = useMemo(
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
[file]
)
const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const tick = thumbTick ?? 0
// Zeitposition im Video: alle 3s ein Schritt, modulo Videolänge
const thumbTimeSec = useMemo(() => {
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
// Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben
return 0
}
const step = 3 // Sekunden pro Schritt
const steps = Math.max(0, Math.floor(tick))
// kleine Reserve, damit wir nicht exakt auf das letzte Frame springen
const total = Math.max(durationSeconds - 0.1, step)
return (steps * step) % total
}, [durationSeconds, tick])
// Thumbnail (immer mit t=..., auch wenn t=0 → erster Frame)
const thumbSrc = useMemo(() => {
if (!previewId) return ''
const params: string[] = []
// ⬅️ immer Zeitposition mitgeben, auch bei 0
params.push(`t=${encodeURIComponent(thumbTimeSec.toFixed(2))}`)
// Versionierung für den Browser-Cache / Animation
if (typeof thumbTick === 'number') {
params.push(`v=${encodeURIComponent(String(thumbTick))}`)
}
const qs = params.length ? `&${params.join('&')}` : ''
return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}`
}, [previewId, thumbTimeSec, thumbTick])
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
if (!onDuration) return
const secs = e.currentTarget.duration
if (Number.isFinite(secs) && secs > 0) {
onDuration(job, secs)
}
}
if (!videoSrc) {
return (
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
)
}
return (
<HoverPopover
// ⚠️ Großes Video nur rendern, wenn Popover offen ist
content={(open) =>
open && (
<div className="w-[420px]">
<div className="aspect-video">
<video
src={videoSrc}
className="w-full h-full bg-black"
muted
playsInline
preload="metadata"
controls
autoPlay
loop
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
>
{/* 🔹 Inline nur Thumbnail / Platzhalter */}
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
{thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading="lazy"
alt={file}
className="w-full h-full object-cover"
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* 🔍 Unsichtbares Video nur zum Metadaten-Laden (Dauer),
wird genau EINMAL pro Datei geladen */}
{onDuration && !hasDuration && !metaLoaded && (
<video
src={videoSrc}
preload="metadata"
muted
playsInline
className="hidden"
onLoadedMetadata={handleLoadedMetadata}
/>
)}
</div>
</HoverPopover>
)
}