453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
// 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<string>
|
|
selectionModeActive: boolean
|
|
onToggleSelected: (job: RecordJob) => void
|
|
onSelectOnly: (job: RecordJob) => void
|
|
isLoading?: boolean
|
|
blurPreviews?: boolean
|
|
durations: Record<string, number>
|
|
postworkByFile: Record<string, FinishedPostworkSummary>
|
|
getPostworkSummaryForOutput: (
|
|
output: string | undefined,
|
|
summaries: Record<string, FinishedPostworkSummary>
|
|
) => 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<string>
|
|
deletedSuccessKeys: Set<string>
|
|
keptSuccessKeys: Set<string>
|
|
keepingKeys: Set<string>
|
|
removingKeys: Set<string>
|
|
|
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
|
|
|
onOpenPlayer: (job: RecordJob) => void
|
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
|
|
|
lower: (s: string) => string
|
|
|
|
modelsByKey: Record<string, GalleryModelFlags>
|
|
activeTagSet: Set<string>
|
|
onHoverPreviewKeyChange?: (key: string | null) => void
|
|
onToggleTagFilter: (tag: string) => void
|
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
|
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<HTMLDivElement | null>(null)
|
|
const lastRecommendedPageSizeRef = useRef<number | null>(null)
|
|
const shrinkTimerRef = useRef<number | null>(null)
|
|
const rafRef = useRef<number | null>(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 (
|
|
<div ref={rootRef} className="relative w-full">
|
|
<div
|
|
className="grid w-full justify-start gap-3"
|
|
style={{
|
|
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
|
|
}}
|
|
>
|
|
{rows.map((j) => {
|
|
const k = keyFor(j)
|
|
const durationForRow = durations[k]
|
|
|
|
return (
|
|
<div
|
|
key={k}
|
|
className="min-w-0 w-full"
|
|
>
|
|
<FinishedDownloadsGalleryCard
|
|
job={j}
|
|
selectedKeys={selectedKeys}
|
|
selectionModeActive={selectionModeActive}
|
|
rowKey={k}
|
|
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
|
|
forcePreviewMuted={forcePreviewMuted}
|
|
blurPreviews={blurPreviews}
|
|
postworkByFile={postworkByFile}
|
|
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
|
renderPostworkBadge={renderPostworkBadge}
|
|
durationSeconds={durationForRow}
|
|
teaserState={teaserState}
|
|
baseName={baseName}
|
|
modelNameFromOutput={modelNameFromOutput}
|
|
runtimeOf={runtimeOf}
|
|
sizeBytesOf={sizeBytesOf}
|
|
formatBytes={formatBytes}
|
|
lower={lower}
|
|
modelsByKey={modelsByKey}
|
|
activeTagSet={activeTagSet}
|
|
deletingKeys={deletingKeys}
|
|
deletedSuccessKeys={deletedSuccessKeys}
|
|
keptSuccessKeys={keptSuccessKeys}
|
|
keepingKeys={keepingKeys}
|
|
removingKeys={removingKeys}
|
|
registerTeaserHost={registerTeaserHostIfNeeded}
|
|
onToggleSelected={onToggleSelected}
|
|
onSelectOnly={onSelectOnly}
|
|
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
|
onToggleTagFilter={onToggleTagFilter}
|
|
onOpenPlayer={onOpenPlayer}
|
|
handleDuration={handleDuration}
|
|
handleResolution={handleResolution}
|
|
resolutionLabelOf={resolutionLabelOf}
|
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
|
deleteVideo={deleteVideo}
|
|
keepVideo={keepVideo}
|
|
onToggleHot={onToggleHot}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onToggleLike={onToggleLike}
|
|
onToggleWatch={onToggleWatch}
|
|
onSplit={onSplit}
|
|
onAddToDownloads={onAddToDownloads}
|
|
jobForDetails={jobForDetails}
|
|
contentPaddingClass={contentPaddingClass}
|
|
footerMinHeightClass={footerMinHeightClass}
|
|
titleClass={titleClass}
|
|
sublineClass={sublineClass}
|
|
assetNonce={assetNonceForJob?.(j) ?? assetNonce ?? 0}
|
|
renderIntegrityBadge={renderIntegrityBadge}
|
|
renderRatingOverlay={renderRatingOverlay}
|
|
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
|
onUnlockMobileTeaserAudio={onUnlockMobileTeaserAudio}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{isLoading && rows.length === 0 ? (
|
|
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
|
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
|
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade…</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView)
|
|
export default MemoFinishedDownloadsGalleryView
|