1390 lines
50 KiB
TypeScript
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 |