// frontend\src\components\ui\FinishedDownloadsDesktopCard.tsx 'use client' import { memo, useCallback, useMemo, useRef, useState, type ReactNode } from 'react' import Card from './Card' import Checkbox from './Checkbox' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import FinishedVideoPreview from './FinishedVideoPreview' import ModelGenderIcon from './ModelGenderIcon' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags' import { isHotName, stripHotPrefix } from './hotName' import { type FinishedSelectionStore, useSelectionChecked, } from './finishedSelectionStore' import type { FinishedPostworkSummary, GalleryModelFlags, InlinePlayState, RecordJob, } from '../../types' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { shouldAnimateTeaser, shouldEnableTeaserAudio, } from './teaserPlayback' import { buildSpriteFrameStyle, parseJobMeta, readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' import PreviewScrubber from './PreviewScrubber' import SpritePreloader from './SpritePreloader' import { buildVideoHeatmapSegments, heatmapDurationForJob, } from './videoHeatmap' import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' import DeletingPreviewOverlay from './DeletingPreviewOverlay' import { autoTagsForJob, mergeTags } from './autoTags' export type FinishedDownloadsDesktopCardProps = { job: RecordJob selectionStore: FinishedSelectionStore selectionModeActive: boolean isDeleting: boolean isDeleted: boolean isKeeping: boolean isRemoving: boolean postworkBadge?: FinishedPostworkSummary blurPreviews?: boolean durationSeconds?: number teaserState: FinishedDownloadsTeaserState inlinePlay: InlinePlayState assetNonce?: number forcePreviewMuted?: boolean 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 lower: (s: string) => string jobForDetails: (job: RecordJob) => RecordJob handleScrubberClickIndex: ( job: RecordJob, segmentIndex: number, segmentCount: number ) => void handleDuration: (job: RecordJob, seconds: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void resolutionLabelOf: (job: RecordJob) => string onToggleSelected: (job: RecordJob) => void onHoverPreviewKeyChange?: (key: string | null) => void onOpenPlayer: (job: RecordJob) => void openPlayer: (job: RecordJob) => void startInline: (key: string) => void registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void modelsByKey: Record activeTagSet: Set onToggleTagFilter: (tag: string) => void 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 deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode } function samePostworkSummaryLite( a?: FinishedPostworkSummary, b?: FinishedPostworkSummary ) { if (a === b) return true if (!a || !b) return !a && !b return ( a.file === b.file && a.state === b.state && a.label === b.label && a.ts === b.ts ) } function isPreviewActiveForKey( state: FinishedDownloadsTeaserState, key: string ) { return state.activeKey === key || state.hoverKey === key } function inlineStateForKey( inlinePlay: InlinePlayState, key: string ) { const active = inlinePlay?.key === key return { active, nonce: active ? inlinePlay?.nonce ?? 0 : 0, } } const FinishedDownloadsDesktopCard = memo( function FinishedDownloadsDesktopCard({ job: j, selectionStore, selectionModeActive, isDeleting, isDeleted, isKeeping, isRemoving, postworkBadge, blurPreviews, durationSeconds, teaserState, inlinePlay, assetNonce, forcePreviewMuted, keyFor, baseName, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, lower, jobForDetails, handleScrubberClickIndex, handleDuration, handleResolution, resolutionLabelOf, onToggleSelected, onHoverPreviewKeyChange, onOpenPlayer, openPlayer, startInline, registerTeaserHost, modelsByKey, activeTagSet, onToggleTagFilter, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, deleteVideo, keepVideo, renderPostworkBadge, renderIntegrityBadge, renderRatingOverlay, }: FinishedDownloadsDesktopCardProps) { const k = keyFor(j) const busy = isDeleting || isDeleted || isKeeping || isRemoving const checked = useSelectionChecked(selectionStore, k) const longPressTimerRef = useRef(null) const longPressTriggeredRef = useRef(false) const inlineActive = inlinePlay?.key === k const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const [scrubActiveIndex, setScrubActiveIndex] = useState() const [scrubHovering, setScrubHovering] = useState(false) const allowSound = shouldEnableTeaserAudio({ state: teaserState, itemKey: k, inlineActive, disabled: false, }) const handleSelectOrOpen = useCallback(() => { if (selectionModeActive) { onToggleSelected(j) return true } return false }, [selectionModeActive, onToggleSelected, j]) const clearLongPressTimer = useCallback(() => { if (longPressTimerRef.current !== null) { window.clearTimeout(longPressTimerRef.current) longPressTimerRef.current = null } }, []) const startLongPressSelect = useCallback(() => { clearLongPressTimer() if (selectionModeActive) return longPressTimerRef.current = window.setTimeout(() => { longPressTimerRef.current = null longPressTriggeredRef.current = true onToggleSelected(j) }, 450) }, [clearLongPressTimer, selectionModeActive, onToggleSelected, j]) const previewMuted = forcePreviewMuted ? true : !allowSound const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const { flags } = resolveFinishedModelFlags( j, modelsByKey, modelNameFromOutput, lower ) const tags = mergeTags( parseFinishedTags(flags?.tags), autoTagsForJob(j) ) const modelCountry = resolveFinishedCountry(flags, j) const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) const meta = parseJobMeta((j as any)?.meta) const resObj = (() => { 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 })() const resLabelRaw = resolutionLabelOf(j) const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : '' 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 = hasScrubberUi ? sprite.count : 0 const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0 const heatmapDuration = useMemo( () => heatmapDurationForJob(j, durationSeconds ?? (j as any)?.durationSeconds), [(j as any)?.meta, (j as any)?.durationSeconds, durationSeconds] ) const heatmapSegments = useMemo( () => buildVideoHeatmapSegments(j, heatmapDuration), [(j as any)?.meta, heatmapDuration] ) const spriteScrubbingActive = hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number' const scrubProgressRatio = scrubProgressRatioFromIndex( scrubActiveIndex, scrubberCount ) const spriteFrameStyle = useMemo( () => buildSpriteFrameStyle({ spriteUrl, spriteCols: sprite.cols, spriteRows: sprite.rows, spriteCount: sprite.count, activeIndex: scrubActiveIndex, }), [spriteUrl, sprite.cols, sprite.rows, sprite.count, scrubActiveIndex] ) const inlineDomId = `inline-prev-${encodeURIComponent(k)}` return (
{ e.preventDefault() e.stopPropagation() if (longPressTriggeredRef.current) { longPressTriggeredRef.current = false return } if (handleSelectOrOpen()) return openPlayer(j) }} onMouseEnter={() => onHoverPreviewKeyChange?.(k)} onMouseLeave={() => { clearLongPressTimer() onHoverPreviewKeyChange?.(null) setScrubActiveIndex(undefined) setScrubHovering(false) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() if (handleSelectOrOpen()) return onOpenPlayer(j) } }} onPointerDown={(e) => { if (e.pointerType === 'mouse') return startLongPressSelect() }} onPointerUp={clearLongPressTimer} onPointerCancel={clearLongPressTimer} onPointerLeave={clearLongPressTimer} onContextMenu={(e) => { if (longPressTriggeredRef.current) { e.preventDefault() } }} >
{ e.preventDefault() e.stopPropagation() if (handleSelectOrOpen()) return startInline(k) }} >
{ e.preventDefault() e.stopPropagation() if (handleSelectOrOpen()) return openPlayer(j) }} >
{model}
{(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
{isHot ? ( HOT ) : null} {stripHotPrefix(fileRaw) || '—'}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
) }, (prev, next) => { const prevKey = prev.keyFor(prev.job) const nextKey = next.keyFor(next.job) const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey) const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey) const prevInline = inlineStateForKey(prev.inlinePlay, prevKey) const nextInline = inlineStateForKey(next.inlinePlay, nextKey) return ( prev.job === next.job && prev.isDeleting === next.isDeleting && prev.isDeleted === next.isDeleted && prev.isKeeping === next.isKeeping && prev.isRemoving === next.isRemoving && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && prev.durationSeconds === next.durationSeconds && prev.assetNonce === next.assetNonce && prev.forcePreviewMuted === next.forcePreviewMuted && prev.blurPreviews === next.blurPreviews && prev.teaserState.mode === next.teaserState.mode && prev.teaserState.audioEnabled === next.teaserState.audioEnabled && prevPreviewActive === nextPreviewActive && prevInline.active === nextInline.active && prevInline.nonce === nextInline.nonce && prev.modelsByKey === next.modelsByKey && prev.activeTagSet === next.activeTagSet && prev.renderPostworkBadge === next.renderPostworkBadge && prev.handleScrubberClickIndex === next.handleScrubberClickIndex ) } ) export default FinishedDownloadsDesktopCard