// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx 'use client' import { useEffect, useRef, useMemo, useCallback, memo, type ReactNode } from 'react' import type { RecordJob, GalleryModelFlags, FinishedPostworkSummary, } from '../../types' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { shouldObserveTeasers, } from './teaserPlayback' import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard' type Props = { rows: RecordJob[] selectedKeys: Set selectionModeActive: boolean onToggleSelected: (job: RecordJob) => void onSelectOnly: (job: RecordJob) => void isLoading?: boolean blurPreviews?: boolean durations: Record postworkByFile: Record getPostworkSummaryForOutput: ( output: string | undefined, summaries: Record ) => FinishedPostworkSummary | undefined teaserState: FinishedDownloadsTeaserState renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode cardScale?: number handleDuration: (job: RecordJob, seconds: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void resolutionLabelOf: (job: RecordJob) => string 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 deletedSuccessKeys: Set keptSuccessKeys: Set keepingKeys: Set removingKeys: 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 onRecommendedPageSizeChange?: (size: number) => void suspendAutoPageSize?: boolean forcePreviewMuted?: boolean mobileTeaserAudioUnlocked?: boolean onUnlockMobileTeaserAudio?: () => void assetNonce?: number assetNonceForJob?: (job: RecordJob) => number renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode } function snapTo(value: number, step: number, base = 0) { if (!Number.isFinite(value)) return base if (!Number.isFinite(step) || step <= 0) return value const snapped = Math.round((value - base) / step) * step + base return Number(snapped.toFixed(4)) } function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)) } function FinishedDownloadsGalleryView({ rows, isLoading, blurPreviews, durations, postworkByFile, getPostworkSummaryForOutput, teaserState, renderPostworkBadge, cardScale, selectedKeys, selectionModeActive, onToggleSelected, onSelectOnly, handleDuration, handleResolution, resolutionLabelOf, handleScrubberClickIndex, jobForDetails, keyFor, baseName, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, deletingKeys, deletedSuccessKeys, keptSuccessKeys, keepingKeys, removingKeys, registerTeaserHost, onHoverPreviewKeyChange, onOpenPlayer, deleteVideo, keepVideo, onToggleHot, lower, modelsByKey, activeTagSet, onToggleTagFilter, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, onRecommendedPageSizeChange, forcePreviewMuted, mobileTeaserAudioUnlocked, onUnlockMobileTeaserAudio, assetNonce, assetNonceForJob, renderIntegrityBadge, renderRatingOverlay, suspendAutoPageSize, }: Props) { const observeTeasers = shouldObserveTeasers(teaserState.mode) const registerTeaserHostIfNeeded = useCallback( (key: string) => (el: HTMLDivElement | null) => { if (!observeTeasers) { registerTeaserHost(key)(null) return } registerTeaserHost(key)(el) }, [registerTeaserHost, observeTeasers] ) const effectiveScaleRaw = typeof cardScale === 'number' ? cardScale : 1 const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35) const layoutScale = useMemo( () => snapTo(effectiveScale, 0.05, 0.8), [effectiveScale] ) const sizePreset = useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => { if (layoutScale >= 1.14) return 'xl' if (layoutScale >= 0.99) return 'lg' if (layoutScale >= 0.88) return 'md' return 'sm' }, [layoutScale]) const minCardWidth = useMemo(() => { return Math.round(270 * layoutScale) }, [layoutScale]) const rootRef = useRef(null) const lastRecommendedPageSizeRef = useRef(null) const shrinkTimerRef = useRef(null) const rafRef = useRef(null) const recommendPageSize = useCallback(() => { if (suspendAutoPageSize || !onRecommendedPageSizeChange) return const el = rootRef.current if (!el) return const measureAndApply = () => { const containerWidth = el.clientWidth if (!Number.isFinite(containerWidth) || containerWidth <= 0) return const gapPx = 12 const columns = Math.max( 1, Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx)) ) const grid = el.firstElementChild const itemEls = grid instanceof HTMLElement ? Array.from(grid.children).filter((child): child is HTMLElement => child instanceof HTMLElement) : [] const measuredCardHeight = itemEls.reduce((maxHeight, child) => { const height = child.getBoundingClientRect().height return Number.isFinite(height) && height > maxHeight ? height : maxHeight }, 0) const estimatedCardWidth = columns > 0 ? (containerWidth - gapPx * (columns - 1)) / columns : minCardWidth const fallbackFooterHeight = sizePreset === 'xl' ? 132 : sizePreset === 'lg' ? 124 : sizePreset === 'md' ? 118 : 110 const cardHeight = Math.max( 1, measuredCardHeight || Math.round(estimatedCardWidth * 9 / 16 + fallbackFooterHeight + 2) ) const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0 const top = Math.max(0, el.getBoundingClientRect().top) const bottomReservePx = 112 const availableHeight = Math.max(0, viewportHeight - top - bottomReservePx) const rowsPerPage = Math.max( 1, Math.floor((availableHeight + gapPx) / (cardHeight + gapPx)) ) const nextPageSize = clamp(columns * rowsPerPage, columns, 120) const currentPageSize = lastRecommendedPageSizeRef.current if (currentPageSize === nextPageSize) return // Größer werden: sofort übernehmen if (currentPageSize == null || nextPageSize > currentPageSize) { if (shrinkTimerRef.current !== null) { window.clearTimeout(shrinkTimerRef.current) shrinkTimerRef.current = null } lastRecommendedPageSizeRef.current = nextPageSize onRecommendedPageSizeChange(nextPageSize) return } // Kleiner werden: erst kurz stabilisieren lassen, // damit kurze Reflows / Scrollbars / Sidebar-Änderungen // nicht sofort 12 -> 8 zurückdrücken. if (shrinkTimerRef.current !== null) { window.clearTimeout(shrinkTimerRef.current) } shrinkTimerRef.current = window.setTimeout(() => { shrinkTimerRef.current = null const stableWidth = el.clientWidth if (!Number.isFinite(stableWidth) || stableWidth <= 0) return const stableColumns = Math.max( 1, Math.floor((stableWidth + gapPx) / (minCardWidth + gapPx)) ) const stablePageSize = clamp(stableColumns * rowsPerPage, stableColumns, 120) if (stablePageSize >= (lastRecommendedPageSizeRef.current ?? 0)) { return } lastRecommendedPageSizeRef.current = stablePageSize onRecommendedPageSizeChange(stablePageSize) }, 220) } if (rafRef.current !== null) { window.cancelAnimationFrame(rafRef.current) } rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null measureAndApply() }) }, [minCardWidth, onRecommendedPageSizeChange, suspendAutoPageSize]) useEffect(() => { if (suspendAutoPageSize) return recommendPageSize() const el = rootRef.current if (!el) return const ro = new ResizeObserver(() => { recommendPageSize() }) ro.observe(el) window.addEventListener('resize', recommendPageSize) return () => { ro.disconnect() window.removeEventListener('resize', recommendPageSize) if (shrinkTimerRef.current !== null) { window.clearTimeout(shrinkTimerRef.current) shrinkTimerRef.current = null } if (rafRef.current !== null) { window.cancelAnimationFrame(rafRef.current) rafRef.current = null } } }, [recommendPageSize, suspendAutoPageSize]) const contentPaddingClass = sizePreset === 'xl' ? 'px-4 py-3.5' : sizePreset === 'lg' ? 'px-3.5 py-3' : sizePreset === 'md' ? 'px-3 py-2.5' : 'px-2.5 py-2' const footerMinHeightClass = sizePreset === 'xl' ? 'min-h-[132px]' : sizePreset === 'lg' ? 'min-h-[124px]' : sizePreset === 'md' ? 'min-h-[118px]' : 'min-h-[110px]' const titleClass = sizePreset === 'xl' ? 'text-[15px]' : sizePreset === 'lg' ? 'text-sm' : sizePreset === 'md' ? 'text-[13px]' : 'text-xs' const sublineClass = sizePreset === 'xl' ? 'text-[13px]' : sizePreset === 'lg' ? 'text-xs' : 'text-[11px]' return (
{rows.map((j) => { const k = keyFor(j) const durationForRow = durations[k] return (
) })}
{isLoading && rows.length === 0 ? (
Lade…
) : null}
) } const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView) export default MemoFinishedDownloadsGalleryView