nsfwapp/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx
2026-06-22 13:57:33 +02:00

639 lines
22 KiB
TypeScript

// 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<string, GalleryModelFlags>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
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<number | null>(null)
const longPressTriggeredRef = useRef(false)
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const [scrubActiveIndex, setScrubActiveIndex] = useState<number | undefined>()
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 (
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className={[
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={(e) => {
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()
}
}}
>
<div
className={[
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
checked || selectionModeActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
startInline(k)
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<PreviewRatingOverlay hidden={inlineActive}>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
<PreviewMetaOverlay
hidden={inlineActive}
dur={dur}
resObj={resObj}
resLabel={resLabel}
size={size}
/>
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: spriteScrubbingActive,
})}
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
preferScrubProgress={
!hasSpriteScrubber &&
scrubHovering &&
typeof scrubActiveIndex === 'number'
}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
<SpritePreloader
src={spriteUrl}
enabled={Boolean(hasSpriteScrubber && spriteUrl)}
/>
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} />
</div>
) : null}
{!inlineActive && scrubberCount > 1 ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => {
setScrubActiveIndex(idx)
setScrubHovering(typeof idx === 'number')
}}
onIndexClick={(index) => {
setScrubActiveIndex(index)
setScrubHovering(true)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
durationSeconds={heatmapDuration}
heatmapSegments={heatmapSegments}
/>
</div>
) : null}
{isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} />
) : null}
</div>
</div>
<div
className={[
'relative min-h-[112px] overflow-visible border-t px-4 py-3 transition-colors',
checked
? [
'border-indigo-200 bg-indigo-50/90',
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
].join(' ')
: [
'border-black/5 bg-white',
'dark:border-white/10 dark:bg-gray-900',
].join(' '),
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
>
<div className="flex min-h-6 items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-h-6 min-w-0 items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
</div>
{(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
<div className="relative z-20 flex h-6 shrink-0 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div>
) : null}
</div>
<div className="mt-0.5 flex min-w-0 items-start gap-2">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={Boolean(flags?.favorite)}
isLiked={flags?.liked === true}
isWatching={Boolean(flags?.watching)}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add', 'training']}
className="w-full gap-1.5"
/>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
},
(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