// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx 'use client' import * as React from 'react' import type { RecordJob } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import { StarIcon as StarSolidIcon, HeartIcon as HeartSolidIcon, EyeIcon as EyeSolidIcon, } from '@heroicons/react/24/solid' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' import { formatResolution } from './formatters' import PreviewScrubber from './PreviewScrubber' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { shouldAnimateTeaser, shouldEnableTeaserAudio, shouldObserveTeasers, } from './teaserPlayback' type ModelFlags = { favorite?: boolean liked?: boolean | null watching?: boolean | null tags?: string // ✅ Optional für Model-Hover-Preview image?: string imageUrl?: string | null // ✅ Optional für stashapp-artigen Preview-Scrubber previewScrubberPath?: string previewScrubberCount?: number } type FinishedPostworkBadge = { file: string assetId?: string queue: 'postwork' | 'enrich' state: 'queued' | 'running' | 'done' | 'error' | 'missing' phase?: string label?: string position?: number waiting?: number running?: number maxParallel?: number ts: number } type Props = { rows: RecordJob[] isLoading?: boolean blurPreviews?: boolean durations: Record postworkByFile: Record teaserState: FinishedDownloadsTeaserState handleDuration: (job: RecordJob, seconds: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void jobForDetails: (job: RecordJob) => RecordJob keyFor: (j: RecordJob) => string baseName: (p: string) => string modelNameFromOutput: (output?: string) => string runtimeOf: (job: RecordJob) => string sizeBytesOf: (job: RecordJob) => number | null formatBytes: (bytes?: number | null) => string deletingKeys: Set keepingKeys: Set removingKeys: Set deletedKeys: Set registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void onOpenPlayer: (job: RecordJob) => void deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise lower: (s: string) => string modelsByKey: Record activeTagSet: Set onHoverPreviewKeyChange?: (key: string | null) => void onToggleTagFilter: (tag: string) => void onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onToggleHot: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise forcePreviewMuted?: boolean previewActivationNonce: number } function firstNonEmptyString(...values: unknown[]): string | undefined { for (const v of values) { if (typeof v === 'string') { const s = v.trim() if (s) return s } } return undefined } function parseJobMeta(metaRaw: unknown): any | null { if (!metaRaw) return null if (typeof metaRaw === 'string') { try { return JSON.parse(metaRaw) } catch { return null } } if (typeof metaRaw === 'object') return metaRaw return null } function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined // ms -> s Heuristik wie in FinishedVideoPreview return value > 24 * 60 * 60 ? value / 1000 : value } function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)) } const DEFAULT_SPRITE_STEP_SECONDS = 5 function chooseSpriteGrid(count: number): [number, number] { if (count <= 1) return [1, 1] const targetRatio = 16 / 9 let bestCols = 1 let bestRows = count let bestWaste = Number.POSITIVE_INFINITY let bestRatioScore = Number.POSITIVE_INFINITY for (let c = 1; c <= count; c++) { const r = Math.max(1, Math.ceil(count / c)) const waste = c * r - count const ratio = c / r const ratioScore = Math.abs(ratio - targetRatio) if ( waste < bestWaste || (waste === bestWaste && ratioScore < bestRatioScore) || (waste === bestWaste && ratioScore === bestRatioScore && r < bestRows) ) { bestWaste = waste bestRatioScore = ratioScore bestCols = c bestRows = r } } return [bestCols, bestRows] } export default function FinishedDownloadsGalleryView({ rows, isLoading, blurPreviews, durations, postworkByFile, teaserState, handleDuration, handleScrubberClickIndex, jobForDetails, keyFor, baseName, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, deletingKeys, keepingKeys, removingKeys, registerTeaserHost, onHoverPreviewKeyChange, onOpenPlayer, deleteVideo, keepVideo, onToggleHot, lower, modelsByKey, activeTagSet, onToggleTagFilter, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, forcePreviewMuted, previewActivationNonce, }: Props) { // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll const observeTeasers = shouldObserveTeasers(teaserState.mode) // ✅ Wrapper: bei still unregistrieren / nicht registrieren const registerTeaserHostIfNeeded = React.useCallback( (key: string) => (el: HTMLDivElement | null) => { if (!observeTeasers) { registerTeaserHost(key)(null) return } registerTeaserHost(key)(el) }, [registerTeaserHost, observeTeasers] ) const parseTags = (raw?: string): string[] => { const s = String(raw ?? '').trim() if (!s) return [] const parts = s .split(/[\n,;|]+/g) .map((p) => p.trim()) .filter(Boolean) const seen = new Set() const out: string[] = [] for (const p of parts) { const k = p.toLowerCase() if (seen.has(k)) continue seen.add(k) out.push(p) } return out } // ✅ Auflösung als {w,h} aus meta.json bevorzugen const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const w = (typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth) ? (j as any).meta.videoWidth : 0) || (typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth) ? (j as any).videoWidth : 0) const h = (typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight) ? (j as any).meta.videoHeight : 0) || (typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight) ? (j as any).videoHeight : 0) if (w > 0 && h > 0) return { w, h } return null }, []) // ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet) const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState(null) // ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte) const [scrubIndexByKey, setScrubIndexByKey] = React.useState>({}) const [hoveredThumbKey, setHoveredThumbKey] = React.useState(null) const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { setScrubIndexByKey((prev) => { if (index === undefined) { if (!(key in prev)) return prev const next = { ...prev } delete next[key] return next } if (prev[key] === index) return prev return { ...prev, [key]: index } }) }, []) const clearScrubIndex = React.useCallback((key: string) => { setScrubIndexForKey(key, undefined) }, [setScrubIndexForKey]) return (
{rows.map((j) => { const k = keyFor(j) const isPreviewActive = teaserState.activeKey === k || teaserState.hoverKey === k // Sound nur bei Hover auf genau diesem Teaser const allowSound = shouldEnableTeaserAudio({ state: teaserState, itemKey: k, }) const previewMuted = forcePreviewMuted ? true : !allowSound const model = modelNameFromOutput(j.output) const modelKey = lower(model) const flags = modelsByKey[modelKey] const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) const tags = parseTags(flags?.tags) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) const postworkBadge = postworkByFile[fileRaw] ?? postworkByFile[stripHotPrefix(fileRaw)] const showPostworkBadge = postworkBadge && (postworkBadge.state === 'queued' || postworkBadge.state === 'running' || postworkBadge.state === 'done' || postworkBadge.state === 'error') const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) const resObj = resolutionObjOf(j) const resLabel = formatResolution(resObj) const isRemoving = removingKeys.has(k) const isDeleting = deletingKeys.has(k) const isKeeping = keepingKeys.has(k) const busy = isDeleting || isKeeping || isRemoving // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat) const modelImageSrc = firstNonEmptyString( flags?.image, flags?.imageUrl, (j as any)?.meta?.modelImage, (j as any)?.meta?.modelImageUrl ) // Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen) const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim() // meta robust lesen (Objekt oder JSON-String) const meta = parseJobMeta((j as any)?.meta) // ------------------------------------------------------------ // ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position) // Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds } // ------------------------------------------------------------ const spritePathRaw = firstNonEmptyString( meta?.previewSprite?.path, (meta as any)?.previewSpritePath, flags?.previewScrubberPath, previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined ) const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const spriteStepSeconds = typeof spriteStepSecondsRaw === 'number' && Number.isFinite(spriteStepSecondsRaw) && spriteStepSecondsRaw > 0 ? spriteStepSecondsRaw : DEFAULT_SPRITE_STEP_SECONDS // ✅ Fallback-Dauer (meta -> job -> durations cache) const durationForSprite = normalizeDurationSeconds(meta?.durationSeconds) ?? normalizeDurationSeconds((j as any)?.durationSeconds) ?? normalizeDurationSeconds(durations[k]) // ✅ Count aus Meta/Flags ODER aus Dauer ableiten const inferredSpriteCountFromDuration = typeof durationForSprite === 'number' && durationForSprite > 0 ? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1)) : undefined const spriteCountRaw = meta?.previewSprite?.count ?? (meta as any)?.previewSpriteCount ?? flags?.previewScrubberCount ?? inferredSpriteCountFromDuration const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows const spriteCount = typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw) ? Math.max(0, Math.floor(spriteCountRaw)) : 0 // ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend) const [inferredCols, inferredRows] = spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0] const spriteCols = typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw) ? Math.max(0, Math.floor(spriteColsRaw)) : inferredCols const spriteRows = typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw) ? Math.max(0, Math.floor(spriteRowsRaw)) : inferredRows // Optionaler Cache-Buster (wenn du sowas in meta hast) const spriteVersion = (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) ? meta.updatedAtUnix : undefined) ?? (typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix) ? (meta as any).fileModUnix : undefined) ?? 0 const spriteUrl = spritePath && spriteVersion ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` : spritePath || undefined const hasScrubberUi = Boolean(spriteUrl) && spriteCount > 1 const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0 // Finales Scrubber-Setup const scrubberCount = hasScrubberUi ? spriteCount : 0 const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0 const hasScrubber = hasScrubberUi const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = typeof activeScrubIndex === 'number' && scrubberCount > 1 ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1) : undefined // Sprite-Overlay-Frame (kein Request pro Move) const spriteFrameStyle: React.CSSProperties | undefined = hasSpriteScrubber && typeof activeScrubIndex === 'number' ? (() => { const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1)) const col = idx % spriteCols const row = Math.floor(idx / spriteCols) const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100 const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100 return { backgroundImage: `url("${spriteUrl}")`, backgroundRepeat: 'no-repeat', backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`, backgroundPosition: `${posX}% ${posY}%`, } })() : undefined const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb return (
onOpenPlayer(j)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} >
{/* Thumb */}
{ setHoveredThumbKey(k) onHoverPreviewKeyChange?.(k) }} onMouseLeave={() => { setHoveredThumbKey((prev) => (prev === k ? null : prev)) onHoverPreviewKeyChange?.(null) clearScrubIndex(k) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) }} > {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
stripHotPrefix(baseName(p))} durationSeconds={durations[k] ?? (j as any)?.durationSeconds} onDuration={handleDuration} variant="fill" showPopover={false} blur={blurPreviews} animated={shouldAnimateTeaser({ state: teaserState, itemKey: k, disabled: hideTeaserUnderOverlay, })} animatedMode="teaser" animatedTrigger="always" muted={previewMuted} popoverMuted={previewMuted} scrubProgressRatio={scrubProgressRatio} preferScrubProgress={typeof activeScrubIndex === 'number'} forceActive={isPreviewActive} activationNonce={isPreviewActive ? previewActivationNonce : 0} teaserPreloadEnabled />
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */} {hasSpriteScrubber && spriteUrl ? ( ) : null} {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */} {showScrubberSpriteInThumb && spriteFrameStyle ? (
{/* Footer / Meta */}
{/* ✅ stashapp-like: Dateiname zuerst */}
{isHot ? ( HOT ) : null} {stripHotPrefix(file) || '—'}
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
{ e.stopPropagation() if (modelImageSrc) setHoveredModelPreviewKey(k) }} onMouseLeave={(e) => { e.stopPropagation() setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) }} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {model || '—'} {showPostworkBadge ? ( {postworkBadge.label || (postworkBadge.state === 'running' ? 'Nacharbeiten laufen…' : postworkBadge.state === 'queued' ? 'Warte auf Nacharbeiten…' : postworkBadge.state === 'done' ? 'Nacharbeiten abgeschlossen' : 'Nacharbeiten fehlgeschlagen')} ) : null}
{isWatching ? : null} {isLiked ? : null} {isFav ? : null}
{/* Actions (wie CardView: im Footer statt im Video) */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
{/* Tags */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
) })}
{isLoading && rows.length === 0 ? (
Lade…
) : null}
) }