639 lines
22 KiB
TypeScript
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
|