// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx 'use client' import { useState, useMemo, useCallback, memo, type ReactNode, type SyntheticEvent } from 'react' import type { RecordJob, GalleryModelFlags, FinishedPostworkSummary, } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' import PreviewScrubber from './PreviewScrubber' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { shouldAnimateTeaser, shouldEnableTeaserAudio, } from './teaserPlayback' import Checkbox from './Checkbox' import { buildSpriteFrameStyle, parseJobMeta, readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' import ModelGenderIcon from './ModelGenderIcon' import { resolveFinishedModelFlags } from './finishedModelFlags' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import SpritePreloader from './SpritePreloader' import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined return value > 24 * 60 * 60 ? value / 1000 : value } type Props = { job: RecordJob selectedKeys: Set selectionModeActive: boolean rowKey: string isPreviewActive: boolean forcePreviewMuted?: boolean blurPreviews?: boolean postworkByFile: Record getPostworkSummaryForOutput: ( output: string | undefined, summaries: Record ) => FinishedPostworkSummary | undefined renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode durationSeconds?: number teaserState: FinishedDownloadsTeaserState baseName: (p: string) => string modelNameFromOutput: (output?: string) => string runtimeOf: (job: RecordJob) => string sizeBytesOf: (job: RecordJob) => number | null formatBytes: (bytes?: number | null) => string lower: (s: string) => string modelsByKey: Record activeTagSet: Set deletingKeys: Set keepingKeys: Set removingKeys: Set registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void onToggleSelected: (job: RecordJob) => void onSelectOnly: (job: RecordJob) => void onHoverPreviewKeyChange?: (key: string | null) => void onToggleTagFilter: (tag: string) => void onOpenPlayer: (job: RecordJob) => void 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 deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise onToggleHot: (job: RecordJob) => void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise jobForDetails: (job: RecordJob) => RecordJob renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode contentPaddingClass: string footerMinHeightClass: string titleClass: string sublineClass: string assetNonce?: number } function FinishedDownloadsGalleryCardInner({ job: j, selectedKeys, selectionModeActive, rowKey, isPreviewActive, forcePreviewMuted, blurPreviews, postworkByFile, getPostworkSummaryForOutput, renderPostworkBadge, durationSeconds, teaserState, baseName, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, lower, modelsByKey, activeTagSet, deletingKeys, keepingKeys, removingKeys, registerTeaserHost, onToggleSelected, onHoverPreviewKeyChange, onToggleTagFilter, onOpenPlayer, handleDuration, handleResolution, resolutionLabelOf, handleScrubberClickIndex, deleteVideo, keepVideo, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, jobForDetails, renderIntegrityBadge, renderRatingOverlay, contentPaddingClass, footerMinHeightClass, titleClass, sublineClass, assetNonce }: Props) { const k = rowKey const checked = selectedKeys.has(rowKey) const [scrubIndexByKey, setScrubIndexByKey] = useState>({}) const [hoveredThumbKey, setHoveredThumbKey] = useState(null) const handlePrimaryClick = useCallback( (e?: SyntheticEvent) => { e?.preventDefault() e?.stopPropagation() if (selectionModeActive) { onToggleSelected(j) return } onOpenPlayer(j) }, [selectionModeActive, onToggleSelected, onOpenPlayer, j] ) const setScrubIndexForKey = 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 = useCallback((key: string) => { setScrubIndexForKey(key, undefined) }, [setScrubIndexForKey]) const allowSound = shouldEnableTeaserAudio({ state: teaserState, itemKey: k, }) const previewMuted = forcePreviewMuted ? true : !allowSound const model = modelNameFromOutput(j.output) const { flags } = resolveFinishedModelFlags( j, modelsByKey, modelNameFromOutput, lower ) const tags = useMemo(() => { const s = String(flags?.tags ?? '').trim() if (!s) return [] as string[] 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 kk = p.toLowerCase() if (seen.has(kk)) continue seen.add(kk) out.push(p) } return out }, [flags?.tags]) const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) const country = resolveFinishedCountry(flags, j) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) const showPostworkBadge = !!postworkBadge const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) const meta = useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta]) const resObj = useMemo(() => { const w = meta?.media?.video?.width ?? meta?.file?.video?.width const h = meta?.media?.video?.height ?? meta?.file?.video?.height if ( typeof w === 'number' && Number.isFinite(w) && w > 0 && typeof h === 'number' && Number.isFinite(h) && h > 0 ) { return { w, h } } return null }, [meta]) const resLabelRaw = resolutionLabelOf(j) const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : '' const isRemoving = removingKeys.has(k) const isDeleting = deletingKeys.has(k) const isKeeping = keepingKeys.has(k) const busy = isDeleting || isKeeping || isRemoving const sprite = useMemo( () => readPreviewSpriteInfo((j as any)?.meta, { fallbackPath: flags?.previewScrubberPath, fallbackCount: flags?.previewScrubberCount, versionFallback: assetNonce ?? 0, }), [(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount, assetNonce] ) const spriteUrl = sprite.url const hasScrubberUi = sprite.hasScrubberUi const hasSpriteScrubber = sprite.hasSpriteScrubber const scrubberCount = sprite.count const scrubberStepSeconds = sprite.stepSeconds const hasScrubber = hasScrubberUi const shouldPreloadSprite = Boolean(hasSpriteScrubber && spriteUrl) const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) const spriteFrameStyle = useMemo( () => buildSpriteFrameStyle({ spriteUrl, spriteCols: sprite.cols, spriteRows: sprite.rows, spriteCount: sprite.count, activeIndex: activeScrubIndex, }), [spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex] ) const showScrubberSpriteInThumb = Boolean(spriteFrameStyle) const previewDurationSeconds = durationSeconds ?? normalizeDurationSeconds(meta?.media?.durationSeconds) ?? normalizeDurationSeconds(meta?.file?.durationSeconds) const spriteScrubbingActive = hasSpriteScrubber && typeof activeScrubIndex === 'number' return (
{ handlePrimaryClick(e) }} onMouseEnter={() => onHoverPreviewKeyChange?.(k)} onMouseLeave={() => { onHoverPreviewKeyChange?.(null) setHoveredThumbKey((prev) => (prev === k ? null : prev)) clearScrubIndex(k) }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return handlePrimaryClick(e) }} >
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > onToggleSelected(j)} />
setHoveredThumbKey(k)} onMouseLeave={() => { setHoveredThumbKey((prev) => (prev === k ? null : prev)) clearScrubIndex(k) }} >
stripHotPrefix(baseName(p))} durationSeconds={previewDurationSeconds} onDuration={handleDuration} onResolution={handleResolution} variant="fill" showPopover={false} blur={blurPreviews} animated={shouldAnimateTeaser({ state: teaserState, itemKey: k, disabled: spriteScrubbingActive, })} animatedMode="teaser" animatedTrigger="always" muted={previewMuted} popoverMuted={previewMuted} assetNonce={assetNonce ?? 0} scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio} preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'} forceActive={isPreviewActive} teaserPreloadEnabled />
{renderRatingOverlay ? renderRatingOverlay(j) : null} {showScrubberSpriteInThumb && spriteFrameStyle ? (
{ handlePrimaryClick(e) }} >
{model}
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
{isHot ? ( HOT ) : null} {stripHotPrefix(file) || '—'}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
) } const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner) export default FinishedDownloadsGalleryCard