nsfwapp/frontend/src/components/ui/FinishedDownloadsCardsView.tsx
2026-05-12 17:25:55 +02:00

1390 lines
50 KiB
TypeScript

// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client'
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import Card from './Card'
import Button from './Button'
import type {
RecordJob,
FinishedPostworkSummary,
GalleryModelFlags,
InlinePlayState
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
import {
buildSpriteFrameStyle,
parseJobMeta,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import {
type FinishedSelectionStore,
} from './finishedSelectionStore'
import SpritePreloader from './SpritePreloader'
import FinishedDownloadsDesktopCard, {
SelectionCheckboxOverlay,
} from './FinishedDownloadsDesktopCard'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay'
type SwipeRefs = {
current: Map<string, SwipeCardHandle>
}
type Props = {
rows: RecordJob[]
isLoading?: boolean
isSmall: boolean
selectionStore: FinishedSelectionStore
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
onSelectOnly: (job: RecordJob) => void
blurPreviews?: boolean
durations: Record<string, number>
inlinePlay: InlinePlayState
teaserState: FinishedDownloadsTeaserState
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
swipeRefs: SwipeRefs
assetNonce?: number
jobForDetails: (job: RecordJob) => RecordJob
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
// helpers
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
// callbacks/actions
onHoverPreviewKeyChange?: (key: string | null) => void
onOpenPlayer: (job: RecordJob) => void
openPlayer: (job: RecordJob) => void
onOpenPlayerAt?: (job: RecordJob, seconds: number) => void
startInlineAt?: (key: string, seconds: number, domId: string) => void
startInline: (key: string) => void
tryAutoplayInline: (domId: string) => boolean
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<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>
postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
}
function CardBlurWrapper({
blurred,
animateUnblurOnMount,
children,
}: {
blurred?: boolean
animateUnblurOnMount?: boolean
children: ReactNode
}) {
const [entered, setEntered] = useState(!animateUnblurOnMount)
useEffect(() => {
if (!animateUnblurOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateUnblurOnMount])
const className = [
'relative will-change-[filter,transform] transition-[filter,transform,opacity] duration-180 ease-out',
blurred
? 'blur-[1.5px] saturate-90 scale-[0.995]'
: entered
? 'blur-0 saturate-100 scale-100'
: 'blur-[1.5px] saturate-90 scale-[0.995]',
]
.filter(Boolean)
.join(' ')
return <div className={className}>{children}</div>
}
function PromoteToFrontWrapper({
animateOnMount,
children,
}: {
animateOnMount?: boolean
children: ReactNode
}) {
const [entered, setEntered] = useState(!animateOnMount)
useEffect(() => {
if (!animateOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateOnMount])
return (
<div
className="will-change-[transform,opacity] transition-[transform,opacity] duration-160 ease-out motion-reduce:transition-none"
style={{
transform: entered
? 'translateY(0px) scale(1) translateZ(0)'
: 'translateY(-15px) scale(0.97) translateZ(0)',
opacity: entered ? 1 : 0.92,
transformOrigin: 'top center',
}}
>
{children}
</div>
)
}
function LoadingCardSkeleton({ blurred = false }: { blurred?: boolean }) {
return (
<div
className={[
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
blurred ? 'blur-[1.5px] saturate-90 scale-[0.995]' : '',
].join(' ')}
aria-hidden="true"
>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div className="animate-pulse">
{/* Preview */}
<div className="relative aspect-video rounded-t-lg bg-gray-200/80 dark:bg-white/10">
<div className="absolute inset-0 bg-gradient-to-br from-white/20 via-transparent to-black/5 dark:from-white/5 dark:to-white/10" />
{/* Meta-Overlay unten rechts */}
<div className="absolute right-2 bottom-2 z-10">
<div className="h-4 w-28 rounded bg-black/25 dark:bg-black/35" />
</div>
</div>
{/* Footer / Meta */}
<div className="relative min-h-[112px] overflow-hidden border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-white/10" />
<div className="mt-2 h-3 w-48 rounded bg-gray-100 dark:bg-white/5" />
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
</div>
</div>
<div className="mt-3 rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center gap-1.5">
<div className="h-8 flex-1 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="h-5 w-16 rounded-full bg-gray-100 dark:bg-white/5" />
<div className="h-5 w-20 rounded-full bg-gray-100 dark:bg-white/5" />
<div className="h-5 w-14 rounded-full bg-gray-100 dark:bg-white/5" />
</div>
</div>
</div>
</Card>
</div>
)
}
function MobileLoadingStack({
stackPeekOffsetPx,
stackExtraTopPx,
}: {
stackPeekOffsetPx: number
stackExtraTopPx: number
}) {
const backDepths = [2, 1]
return (
<div className="relative mx-auto w-full max-w-[560px] overflow-y-visible" style={{ overflowX: 'clip' }}>
<div
className="relative overflow-visible"
style={{
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
>
{backDepths.map((depth) => {
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={`loading-back-${depth}`}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity]"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
<LoadingCardSkeleton blurred />
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})}
</div>
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
<LoadingCardSkeleton />
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade</div>
</div>
</div>
</div>
</div>
)
}
function FinishedDownloadsCardsView({
rows,
isSmall,
selectionStore,
onToggleSelected,
isLoading,
blurPreviews,
durations,
teaserState,
inlinePlay,
deletingKeys,
keepingKeys,
removingKeys,
swipeRefs,
assetNonce,
jobForDetails,
handleScrubberClickIndex,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
lower,
onHoverPreviewKeyChange,
onOpenPlayer,
openPlayer,
startInlineAt,
startInline,
tryAutoplayInline,
registerTeaserHost,
handleDuration,
handleResolution,
resolutionLabelOf,
deleteVideo,
keepVideo,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
postworkByFile,
getPostworkSummaryForOutput,
renderPostworkBadge,
forcePreviewMuted,
mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio,
renderIntegrityBadge,
renderRatingOverlay,
}: Props) {
const footerTapRef = useRef<Record<string, boolean>>({})
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
const [speedHoldKey, setSpeedHoldKey] = useState<string | null>(null)
const registerTeaserPlayForKey = useCallback(
(key: string) => (fn: (() => void) | null) => {
if (fn) teaserPlayFnsRef.current[key] = fn
else delete teaserPlayFnsRef.current[key]
},
[]
)
const parseMeta = useCallback((j: RecordJob): any | null => {
return parseJobMeta((j as any)?.meta)
}, [])
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => {
setScrubActiveByKey((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 clearScrubActiveIndex = useCallback((key: string) => {
setScrubActiveIndex(key, undefined)
}, [setScrubActiveIndex])
const setScrubHovering = useCallback((key: string, hovering: boolean | undefined) => {
setScrubHoveringByKey((prev) => {
if (hovering === undefined) {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
}
if (prev[key] === hovering) return prev
return { ...prev, [key]: hovering }
})
}, [])
const renderCardItem = (
j: RecordJob,
opts?: {
forceStill?: boolean
disableInline?: boolean
disablePreviewHover?: boolean
isDecorative?: boolean
forceLoadStill?: boolean
mobileStackTopOnlyVideo?: boolean
disableScrubber?: boolean
blur?: boolean
animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean
renderRole?: 'top' | 'back' | 'preload' | 'desktop'
}
) => {
const k = keyFor(j)
const handleSelectOrOpen = () => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}
const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
inlineActive,
disabled: Boolean(opts?.forceStill),
})
const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo)
const previewMuted = forcePreviewMuted
? true
: (
isSmall && opts?.mobileStackTopOnlyVideo
? teaserAudioEnabledForKey !== k
: !allowSound
)
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const forceLoadStill = Boolean(opts?.forceLoadStill)
// Mobile-Stack:
// - TopCard: bei hover/all immer Teaser animieren
// - Backcards/Preloadcards: immer still
const allowTeaserAnimation =
Boolean(opts?.forceStill)
? false
: opts?.mobileStackTopOnlyVideo
? teaserState.mode !== 'still'
: shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: false,
})
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile)
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const scrubActiveIndex = scrubActiveByKey[k]
const scrubHovering = scrubHoveringByKey[k] === true
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount,
versionFallback: assetNonce ?? 0,
})
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 teaserPreloadEnabled =
opts?.mobileStackTopOnlyVideo
? false
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
const shouldPreloadSprite =
Boolean(teaserPreloadEnabled && hasSpriteScrubber && spriteUrl)
const spriteScrubbingActive =
hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
const spriteFrameStyle = buildSpriteFrameStyle({
spriteUrl,
spriteCols: sprite.cols,
spriteRows: sprite.rows,
spriteCount: sprite.count,
activeIndex: scrubActiveIndex,
})
const isHot = isHotName(fileRaw)
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseFinishedTags(flags?.tags)
const modelCountry = resolveFinishedCountry(flags, j)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const meta = parseMeta(j)
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 inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
const showMobileAudioButton =
Boolean(onUnlockMobileTeaserAudio) &&
isMobileTopCard &&
!inlineActive &&
!opts?.forceStill &&
allowTeaserAnimation &&
audioButtonDismissedForKey !== k
// ✅ Shell an Gallery angelehnt
const shellCls = [
'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',
!isSmall && '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',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')
const cardInner = (
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className={shellCls}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!isSmall) {
openPlayer(j)
}
}}
onMouseEnter={
isSmall
? undefined
: () => {
onHoverPreviewKeyChange?.(k)
}
}
onMouseLeave={
isSmall
? undefined
: () => {
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubActiveIndex(k)
setScrubHovering(k, false)
}
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j)
}
}}
>
<CardBlurWrapper
blurred={opts?.blur}
animateUnblurOnMount={opts?.animateUnblurOnMount}
>
{!isSmall ? (
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
) : null}
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
id={inlineDomId}
ref={
opts?.disableInline || opts?.isDecorative
? undefined
: registerTeaserHost(k)
}
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={
isSmall
? undefined
: () => {
setHoveredThumbKey(k)
}
}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubActiveIndex(k)
setScrubHovering(k, false)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall || opts?.disableInline) return
startInline(k)
}}
>
{/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{isSmall ? (
!inlineActive && renderRatingOverlay ? (
<div
className="pointer-events-auto absolute bottom-2 left-2 z-[45]"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onTouchStart={(e) => {
e.stopPropagation()
}}
>
{renderRatingOverlay(j)}
</div>
) : null
) : (
<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={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation && !spriteScrubbingActive}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={realInlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={teaserPreloadEnabled}
teaserPreloadRootMargin={
opts?.preloadTeaserWhenStill
? '1200px 0px'
: (isSmall ? '900px 0px' : '700px 0px')
}
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
preferScrubProgress={
!hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
}
forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)}
activationNonce={
previewRenderRole === 'top'
? mobileTopActivationNonce
: 0
}
registerTeaserPlay={registerTeaserPlayForKey(k)}
holdSpeedActive={speedHoldKey === k}
/>
{showMobileAudioButton ? (
<button
type="button"
className="
absolute top-2 right-2 z-[35] inline-flex items-center gap-1.5 rounded-full
px-2.5 py-1.5 text-[11px] font-medium backdrop-blur transition
bg-white/95 text-gray-900 ring-1 ring-gray-200 shadow-sm hover:bg-white
dark:bg-black/70 dark:text-white dark:ring-white/10 dark:hover:bg-black/80
focus:outline-none focus:ring-2 focus:ring-indigo-500
"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setAudioButtonDismissedForKey(k)
setTeaserAudioEnabledForKey(k)
onUnlockMobileTeaserAudio?.()
teaserPlayFnsRef.current[k]?.()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
aria-label="Teaser mit Ton abspielen"
title="Ton aktivieren"
>
<SpeakerWaveIcon className="size-3.5 shrink-0" />
<span>Ton aktivieren</span>
</button>
) : null}
<SpritePreloader
src={spriteUrl}
enabled={shouldPreloadSprite}
/>
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{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}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative &&
!opts?.disableScrubber &&
!inlineActive &&
scrubberCount > 1 &&
hoveredThumbKey === k ? (
<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()}
onMouseEnter={() => setScrubHovering(k, true)}
onMouseLeave={() => {
setScrubHovering(k, false)
setScrubActiveIndex(k, undefined)
}}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
onIndexClick={(index) => {
if (isSmall || opts?.disableInline) {
handleScrubberClickIndex(j, index, scrubberCount)
return
}
const seconds = scrubberStepSeconds > 0 ? index * scrubberStepSeconds : 0
if (startInlineAt) {
startInlineAt(k, seconds, inlineDomId)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
return
}
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
</div>
</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onPointerDownCapture={() => {
footerTapRef.current[k] = true
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall) return
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
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 items-center justify-between gap-2 min-w-0">
<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 && livePostwork)) ? (
<div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold 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>
{/* Meta + Actions (nicht im Video) */}
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Actions: volle Breite */}
<div className="w-full">
<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={isFav}
isLiked={isLiked}
isWatching={isWatching}
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>
{/* Tags */}
<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>
</CardBlurWrapper>
</div>
)
return {
k,
j,
busy,
isHot,
inlineDomId,
cardInner,
inlineActive,
allowTeaserAnimation,
}
}
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
const mobileOrderedRows = useMemo(() => {
if (!isSmall || skippedMobileKeys.length === 0) return rows
const skippedSet = new Set(skippedMobileKeys)
const front = rows.filter((row) => !skippedSet.has(keyFor(row)))
const byKey = new Map(rows.map((row) => [keyFor(row), row] as const))
const back = skippedMobileKeys
.map((key) => byKey.get(key))
.filter((row): row is RecordJob => Boolean(row))
return [...front, ...back]
}, [isSmall, rows, skippedMobileKeys, keyFor])
const skipTopMobileCard = useCallback(() => {
if (!isSmall || mobileOrderedRows.length <= 1) return
const topRow = mobileOrderedRows[0]
if (!topRow) return
const topKey = keyFor(topRow)
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
setSkippedMobileKeys((prev) => [
...prev.filter((key) => key !== topKey),
topKey,
])
}, [isSmall, mobileOrderedRows, keyFor])
const mobileStackDepth = 3
const mobilePreloadDepth = mobileStackDepth + 3
const mobileLoadedStackRows = isSmall ? mobileOrderedRows.slice(0, mobilePreloadDepth) : []
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
const mobileTopRow = mobileVisibleStackRows[0] ?? null
const mobileTopKey = isSmall && mobileVisibleStackRows[0]
? keyFor(mobileVisibleStackRows[0])
: ''
const [mobileTopActivationNonce, setMobileTopActivationNonce] = useState(0)
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = useState(false)
const mobileTopTeaserEnableTimerRef = useRef<number | null>(null)
const [mobileTopTapEnabled, setMobileTopTapEnabled] = useState(true)
const mobileTopTapEnableTimerRef = useRef<number | null>(null)
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = useState<string | null>(null)
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = useState<string | null>(null)
useEffect(() => {
if (!isSmall) {
setSkippedMobileKeys([])
return
}
const validKeys = new Set(rows.map((row) => keyFor(row)))
setSkippedMobileKeys((prev) => {
const next = prev.filter((key) => validKeys.has(key))
return next.length === prev.length ? prev : next
})
}, [isSmall, rows, keyFor])
useEffect(() => {
if (!isSmall) return
if (!mobileTopKey) return
if (teaserAudioEnabledForKey !== mobileTopKey) return
if (!mobileTeaserAudioUnlocked) return
requestAnimationFrame(() => {
requestAnimationFrame(() => {
teaserPlayFnsRef.current[mobileTopKey]?.()
})
})
}, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked])
useEffect(() => {
if (mobileTopTeaserEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
mobileTopTeaserEnableTimerRef.current = null
}
if (mobileTopTapEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTapEnableTimerRef.current)
mobileTopTapEnableTimerRef.current = null
}
setAudioButtonDismissedForKey(null)
setTeaserAudioEnabledForKey(null)
if (!isSmall || !mobileTopKey) {
setMobileTopTeaserEnabled(true)
setMobileTopTapEnabled(true)
return
}
// neue Top-Card kurz still + taps sperren
setMobileTopTeaserEnabled(false)
setMobileTopTapEnabled(false)
mobileTopTeaserEnableTimerRef.current = window.setTimeout(() => {
setMobileTopTeaserEnabled(true)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMobileTopActivationNonce((n) => n + 1)
})
})
mobileTopTeaserEnableTimerRef.current = null
}, 220)
// Taps etwas später wieder erlauben
mobileTopTapEnableTimerRef.current = window.setTimeout(() => {
setMobileTopTapEnabled(true)
mobileTopTapEnableTimerRef.current = null
}, 520)
return () => {
if (mobileTopTeaserEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
mobileTopTeaserEnableTimerRef.current = null
}
if (mobileTopTapEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTapEnableTimerRef.current)
mobileTopTapEnableTimerRef.current = null
}
}
}, [isSmall, mobileTopKey])
// größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15
// weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
return (
<div className="relative">
{!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const k = keyFor(j)
return (
<FinishedDownloadsDesktopCard
key={`${k}__desktop`}
job={j}
selectionStore={selectionStore}
isDeleting={deletingKeys.has(k)}
isKeeping={keepingKeys.has(k)}
isRemoving={removingKeys.has(k)}
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
blurPreviews={blurPreviews}
durationSeconds={durations[k]}
teaserState={teaserState}
inlinePlay={inlinePlay}
assetNonce={assetNonce}
forcePreviewMuted={forcePreviewMuted}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
jobForDetails={jobForDetails}
handleDuration={handleDuration}
handleResolution={handleResolution}
resolutionLabelOf={resolutionLabelOf}
onToggleSelected={onToggleSelected}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
startInline={startInline}
registerTeaserHost={registerTeaserHost}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={onToggleTagFilter}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
renderPostworkBadge={renderPostworkBadge}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
/>
)
})}
</div>
) : (
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{mobileOrderedRows.length === 0 ? (
isLoading ? (
<MobileLoadingStack
stackPeekOffsetPx={stackPeekOffsetPx}
stackExtraTopPx={stackExtraTopPx}
/>
) : null
) : (
<div
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
style={{ overflowX: 'clip' }}
>
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div
className="relative overflow-visible"
style={{
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
>
{(() => {
const visible = mobileVisibleStackRows
const topRow = visible[0]
const backRows = visible.slice(1)
const preloadRow = mobilePreloadedRow
return (
<>
{/* Hintere Karten zuerst (absolut, dekorativ) */}
{backRows
.map((j, backIdx) => {
const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
blur: true,
preloadTeaserWhenStill: true,
renderRole: 'back',
})
const depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={`${k}__back_${depth}`}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})
.reverse()}
{preloadRow ? (() => {
const { k, cardInner } = renderCardItem(preloadRow, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
preloadTeaserWhenStill: true,
renderRole: 'preload',
})
return (
<div
key={`${k}__preload`}
className="absolute inset-x-0 top-0 pointer-events-none"
style={{
zIndex: 1,
opacity: 0,
transform: `translateY(${-(mobileStackDepth * stackPeekOffsetPx)}px) scale(${1 - mobileStackDepth * 0.03}) translateZ(0)`,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
{cardInner}
</div>
)
})() : null}
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => {
const j = topRow
const { k, busy, isHot, cardInner, inlineDomId, inlineActive, allowTeaserAnimation } = renderCardItem(j, {
forceLoadStill: false,
forceStill: !mobileTopTeaserEnabled,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
renderRole: 'top',
})
return (
<div
key={`${k}__top`}
className="relative touch-pan-y"
style={{ zIndex: 30 }}
>
<PromoteToFrontWrapper animateOnMount>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
pressDelayMs={220}
enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
if (!mobileTopTapEnabled) return
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return
}
if (footerTapRef.current[k]) {
delete footerTapRef.current[k]
openPlayer(j)
return
}
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
onSwipeLeft={async () => {
setMobileTopTeaserEnabled(false)
return await deleteVideo(j)
}}
onSwipeRight={async () => {
setMobileTopTeaserEnabled(false)
return await keepVideo(j)
}}
onPressStart={(info) => {
if (info.pointerType === 'mouse' && info.button !== 0) return
setSpeedHoldKey(k)
}}
onPressEnd={() => {
setSpeedHoldKey((prev) => (prev === k ? null : prev))
}}
>
{cardInner}
</SwipeCard>
</PromoteToFrontWrapper>
</div>
)
})() : null}
</>
)
})()}
</div>
</div>
)}
{mobileTopRow && mobileOrderedRows.length > 1 ? (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="soft"
onClick={skipTopMobileCard}
className="w-full"
>
Überspringen
</Button>
</div>
) : null}
</div>
)}
{!isSmall && 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 MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView)
export default MemoFinishedDownloadsCardsView