149 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|