updated finished downloads

This commit is contained in:
Linrador 2026-03-19 21:46:24 +01:00
parent abdd071bfd
commit c1669b4eec
9 changed files with 931 additions and 138 deletions

View File

@ -15,5 +15,5 @@
"teaserPlayback": "hover",
"teaserAudio": true,
"enableNotifications": true,
"encryptedCookies": "Cq0QdJ72VtRxmtC+RGn1DVKb17COrGwrj+FEfgIwgeIn1hcrzvwFCfowqKgJVuPV1cAq9IFv4PnEN/+PiOIC5pxjO/+GWoSBb55E2OjSvknvWYbagMiTSRDvUaDQWzxvbP45VxXOIOelDneU1v5b8lT+cXR5IIY9tRK5hjYw7Yqam4fCkTjTP2EvgjVOvI7vmWfkQcbohhrs9fL06/5FFEuyUQJrsfESk+lge34rhxlJ3OYOqwF7sHke6NttG8CcX30cam5Hzl+/IgFkk34DYXcg4GZOrcgrRwfmEu4/8WeSBt7reBXglt2EkH4RMr9kdNNkXhMXTA9IKeoego9X22mwBXc0rPLT6fWhHykN90qZWtxOdy4JlRTSUaBswu0lnTLNfH0LrGUBzaU5Y5WFsrkFIa/TEEPWdI1EPH4b5GjH7MGjGBRr2U6Aqshy6PsrfHKrR4gIbhqCaoEaYxJjP9aUljFc3rqcw3UgAv1dp8eXo4lm+HPAdtVtNlD+pBsNZO5zXGkXFOk="
"encryptedCookies": "bs6T0hwuivNal4zH7v+yRK63wstZFvAnwGt1GEFDS2hMS4yWHXC/m2I0CnHtwgl7FS5ARSnY45tkMn9GJznNPUofuq2GDbHqZTwbRF3vNO/f/fnO9za4BQ+woU9303Act9U/UAQYi515LtRzTNdIczz1lkbahevMdyf0RLx7ajBgFmJ6UTbNtClwDRaLgdHxPWo8XMZRX9K0J1nWoJOAlvCWm60ggu+8UwnIEyPWDR15+kbFj1FjlwUOz8G/uPyJXnDYVA1fLpHSgUJHgarWMeTZNqDkONl6v6T7ksvp8PsWk/dmcrEz/vmEP8R78kLBpdgGPXvGfLo1sXfdt0MEvcfXm3RirzpsvpibJTlDUZv4eDth1kfppU3uEVH6TcQuwQ/0u3y1uecKRo9m9u40xAriM1ASFt4NUTLcCH5z6SgXxf0DT8jYZpIXWY/ma69aYW3laIuNx3+1Ybg1BUEJkdX+lFJieLrfWUwStO34bvRASapwXmSekvKpZe/53P91R4TKjbhBs0M="
}

View File

@ -642,6 +642,9 @@ export default function Downloads({
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
const markStopRequested = useCallback((ids: string | string[]) => {
const arr = Array.isArray(ids) ? ids : [ids]
@ -662,6 +665,36 @@ export default function Downloads({
})
}, [])
const markRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
}, [])
const clearRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}, [])
const markRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => ({ ...prev, [id]: true }))
}, [])
const clearRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => {
if (!prev[id]) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
useEffect(() => {
setStopRequestedIds((prev) => {
const keys = Object.keys(prev)
@ -690,6 +723,38 @@ export default function Downloads({
})
}, [jobs])
useEffect(() => {
setRemoveQueuedPostworkRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveIds = new Set(jobs.map((j) => String(j.id ?? '').trim()))
const next: Record<string, true> = {}
for (const id of keys) {
if (liveIds.has(id)) next[id] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [jobs])
useEffect(() => {
setRemovePendingRequestedKeys((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveKeys = new Set(pending.map((p) => pendingRowKey(p)))
const next: Record<string, true> = {}
for (const key of keys) {
if (liveKeys.has(key)) next[key] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [pending])
const [nowMs, setNowMs] = useState(() => Date.now())
const prevSizeBytesRef = useRef<Record<string, number>>({})
@ -1058,19 +1123,30 @@ export default function Downloads({
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
if (r.kind !== 'job') {
const pendingKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
return (
<div className="flex justify-end pr-2">
<Button
size="sm"
variant="primary"
color="red"
disabled={isRemovingPending || !onRemovePending}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
await onRemovePending?.(r.pending)
if (isRemovingPending) return
markRemovePendingRequested(pendingKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pendingKey)
}
}}
>
Entfernen
{isRemovingPending ? 'Entferne…' : 'Entfernen'}
</Button>
</div>
)
@ -1083,10 +1159,13 @@ export default function Downloads({
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const disableStopButton = isQueuedPostwork ? false : isStopping
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined
@ -1103,7 +1182,7 @@ export default function Downloads({
<RecordJobActions
job={j}
variant="table"
busy={isQueuedPostwork ? false : isStopping}
busy={buttonBusy}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
@ -1124,7 +1203,13 @@ export default function Downloads({
e.stopPropagation()
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
@ -1133,7 +1218,9 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
{isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')}
</Button>
</div>
)
@ -1247,6 +1334,33 @@ export default function Downloads({
}
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') {
const key = pendingRowKey(r.pending)
const removing = Boolean(removePendingRequestedKeys[key])
return [
'transition-all duration-300',
removing && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}
const j = r.job
const stopRequested = Boolean(stopRequestedIds[j.id])
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
return [
'transition-all duration-300',
stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
return (
<div className="grid gap-3">
<div className="sticky top-[56px] z-20">
@ -1338,7 +1452,7 @@ export default function Downloads({
return (
<SwipeActionRow
key={`dl:${j.id}`}
actionLabel="Stoppen"
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
actionColor="indigo"
disabled={isStopping || isQueuedPostwork}
onAction={async () => {
@ -1347,23 +1461,30 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1378,33 +1499,49 @@ export default function Downloads({
{pendingRows.map((r) => {
if (r.kind !== 'pending') return null
const pKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pKey])
return (
<SwipeActionRow
key={`pending:${pendingRowKey(r.pending)}`}
actionLabel="Entfernen"
key={`pending:${pKey}`}
actionLabel={isRemovingPending ? 'Entferne…' : 'Entfernen'}
actionColor="red"
disabled={!onRemovePending}
disabled={!onRemovePending || isRemovingPending}
onAction={async () => {
await onRemovePending?.(r.pending)
if (isRemovingPending) return
markRemovePendingRequested(pKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pKey)
}
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
isRemovingPending && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1429,9 +1566,14 @@ export default function Downloads({
const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const label = isQueuedPostwork ? 'Entfernen' : 'Stoppen'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const label = isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')
const color = isQueuedPostwork ? 'red' : 'indigo'
const disabled = isQueuedPostwork ? false : isStopping
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
return (
<SwipeActionRow
@ -1441,7 +1583,13 @@ export default function Downloads({
disabled={disabled}
onAction={async () => {
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
@ -1450,23 +1598,31 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
isQueuedPostwork && isRemovingQueuedPostwork && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
!isQueuedPostwork && isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1483,6 +1639,7 @@ export default function Downloads({
</div>
<Table
rows={downloadJobRows}
rowClassName={rowClassName}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
striped

View File

@ -305,15 +305,29 @@ export default function FinishedDownloads({
const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
// Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> Teaser nur bei Hover, Mobile -> im Viewport
// Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover
// Mobile: "hover" wie "all" behandeln
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
const effectiveTeaserPlaybackMode: TeaserPlaybackMode =
!canHover && teaserPlaybackMode === 'hover'
? 'all'
: teaserPlaybackMode
const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')
const forcePreviewMuted = isCoarsePointer || isTabletOrSmaller
const notify = useNotify()
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
const [listPreviewActivationNonce, setListPreviewActivationNonce] = React.useState(0)
const lastListPreviewKeyRef = React.useRef<string | null>(null)
const teaserIORef = React.useRef<IntersectionObserver | null>(null)
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
@ -335,12 +349,12 @@ export default function FinishedDownloads({
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: teaserPlaybackMode,
mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
}),
[teaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
)
type UndoAction =
@ -1908,11 +1922,29 @@ export default function FinishedDownloads({
if (page > totalPages) onPageChange(totalPages)
}, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange])
useEffect(() => {
if (view !== 'table' && view !== 'gallery') {
lastListPreviewKeyRef.current = null
return
}
const nextKey = teaserState.activeKey ?? teaserState.hoverKey ?? null
if (!nextKey) {
lastListPreviewKeyRef.current = null
return
}
if (lastListPreviewKeyRef.current === nextKey) return
lastListPreviewKeyRef.current = nextKey
setListPreviewActivationNonce((n) => n + 1)
}, [view, teaserState.activeKey, teaserState.hoverKey])
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
useEffect(() => {
const active =
!canHover &&
teaserState.mode !== 'still' &&
effectiveTeaserPlaybackMode !== 'still' &&
(view === 'cards' || view === 'gallery' || view === 'table')
if (!active) {
@ -1968,7 +2000,7 @@ export default function FinishedDownloads({
io.disconnect()
if (teaserIORef.current === io) teaserIORef.current = null
}
}, [view, teaserState.mode, canHover, inlinePlay?.key])
}, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => {
@ -2039,7 +2071,7 @@ export default function FinishedDownloads({
useEffect(() => {
if (!isSmall || view !== 'cards') return
if (teaserState.mode === 'still') {
if (effectiveTeaserPlaybackMode === 'still') {
setTeaserKey(null)
return
}
@ -2052,7 +2084,7 @@ export default function FinishedDownloads({
const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, [isSmall, view, pageRows, keyFor, teaserState.mode])
}, [isSmall, view, pageRows, keyFor, effectiveTeaserPlaybackMode])
useEffect(() => {
if (!isSmall) {
@ -2474,6 +2506,7 @@ export default function FinishedDownloads({
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
postworkByFile={postworkByFile}
forcePreviewMuted={forcePreviewMuted}
/>
</div>
)}
@ -2520,6 +2553,8 @@ export default function FinishedDownloads({
deleteVideo={deleteVideo}
keepVideo={keepVideo}
postworkByFile={postworkByFile}
forcePreviewMuted={forcePreviewMuted}
previewActivationNonce={listPreviewActivationNonce}
/>
)}
@ -2559,6 +2594,8 @@ export default function FinishedDownloads({
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
postworkByFile={postworkByFile}
forcePreviewMuted={forcePreviewMuted}
previewActivationNonce={listPreviewActivationNonce}
/>
)}

View File

@ -97,6 +97,8 @@ type Props = {
maxParallel?: number
ts: number
}>
forcePreviewMuted?: boolean
}
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
@ -587,7 +589,8 @@ export default function FinishedDownloadsCardsView({
onToggleWatch,
onSplit,
onAddToDownloads,
postworkByFile
postworkByFile,
forcePreviewMuted,
}: Props) {
const parseMeta = React.useCallback((j: RecordJob): any | null => {
@ -718,6 +721,8 @@ export default function FinishedDownloadsCardsView({
}
) => {
const k = keyFor(j)
const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo)
const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive
@ -728,21 +733,29 @@ export default function FinishedDownloadsCardsView({
disabled: Boolean(opts?.forceStill),
})
const previewMuted = !allowSound
const previewMuted = forcePreviewMuted
? true
: (
isSmall && opts?.mobileStackTopOnlyVideo
? true
: !allowSound
)
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const forceLoadStill = Boolean(opts?.forceLoadStill)
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
// Mobile-Stack:
// - TopCard: bei hover/all immer Teaser animieren
// - Backcards/Preloadcards: immer still
const allowTeaserAnimation =
opts?.mobileStackTopOnlyVideo
? teaserState.mode !== 'still'
: shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: Boolean(opts?.forceStill),
})
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)
@ -916,6 +929,7 @@ export default function FinishedDownloadsCardsView({
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
const previewInstanceKey = [
k,
previewRenderRole,
@ -1032,7 +1046,7 @@ export default function FinishedDownloadsCardsView({
animatedTrigger="always"
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineControls={realInlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
@ -1040,7 +1054,7 @@ export default function FinishedDownloadsCardsView({
alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={
opts?.mobileStackTopOnlyVideo
? true
? false
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
}
teaserPreloadRootMargin={
@ -1050,6 +1064,12 @@ export default function FinishedDownloadsCardsView({
}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)}
activationNonce={
previewRenderRole === 'top'
? mobileTopActivationNonce
: 0
}
/>
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
@ -1228,21 +1248,85 @@ export default function FinishedDownloadsCardsView({
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
const mobileTopKey = isSmall && mobileVisibleStackRows[0]
? keyFor(mobileVisibleStackRows[0])
: ''
const [mobileTopActivationNonce, setMobileTopActivationNonce] = React.useState(0)
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = React.useState(false)
const mobileTopTeaserEnableTimerRef = React.useRef<number | null>(null)
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true)
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null)
React.useEffect(() => {
if (mobileTopTeaserEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
mobileTopTeaserEnableTimerRef.current = null
}
if (mobileTopTapEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTapEnableTimerRef.current)
mobileTopTapEnableTimerRef.current = 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, cardInner } = renderCardItem(j)
const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' })
return (
<React.Fragment key={k}>
<React.Fragment key={`${k}__desktop`}>
{cardInner}
</React.Fragment>
)
@ -1299,7 +1383,7 @@ export default function FinishedDownloadsCardsView({
return (
<div
key={k}
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,
@ -1350,7 +1434,8 @@ export default function FinishedDownloadsCardsView({
{topRow ? (() => {
const j = topRow
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
forceLoadStill: true,
forceLoadStill: false,
forceStill: !mobileTopTeaserEnabled,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
@ -1359,7 +1444,7 @@ export default function FinishedDownloadsCardsView({
return (
<div
key={k}
key={`${k}__top`}
className="relative touch-pan-y"
style={{ zIndex: 30 }}
>
@ -1379,6 +1464,8 @@ export default function FinishedDownloadsCardsView({
await onToggleHot?.(j)
}}
onTap={() => {
if (!mobileTopTapEnabled) return
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
@ -1386,8 +1473,14 @@ export default function FinishedDownloadsCardsView({
}
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
onSwipeLeft={async () => {
setMobileTopTeaserEnabled(false)
return await deleteVideo(j)
}}
onSwipeRight={async () => {
setMobileTopTeaserEnabled(false)
return await keepVideo(j)
}}
>
{cardInner}
</SwipeCard>

View File

@ -93,6 +93,9 @@ type Props = {
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
forcePreviewMuted?: boolean
previewActivationNonce: number
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
@ -198,9 +201,13 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch,
onSplit,
onAddToDownloads,
forcePreviewMuted,
previewActivationNonce,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode)
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
@ -286,12 +293,15 @@ export default function FinishedDownloadsGalleryView({
{rows.map((j) => {
const k = keyFor(j)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
@ -515,6 +525,7 @@ export default function FinishedDownloadsGalleryView({
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
@ -533,6 +544,9 @@ export default function FinishedDownloadsGalleryView({
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
activationNonce={isPreviewActive ? previewActivationNonce : 0}
teaserPreloadEnabled
/>
</div>

View File

@ -92,6 +92,8 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
forcePreviewMuted?: boolean
previewActivationNonce: number
}
export default function FinishedDownloadsTableView({
@ -140,6 +142,8 @@ export default function FinishedDownloadsTableView({
deleteVideo,
keepVideo,
forcePreviewMuted,
previewActivationNonce,
}: Props) {
const [sort, setSort] = React.useState<SortState>(null)
@ -235,11 +239,13 @@ export default function FinishedDownloadsTableView({
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const previewMuted = forcePreviewMuted ? true : !allowSound
const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k]
@ -271,6 +277,7 @@ export default function FinishedDownloadsTableView({
}}
>
<FinishedVideoPreview
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={jobForDetails(j)}
getFileName={baseName}
durationSeconds={durations[k]}
@ -288,6 +295,9 @@ export default function FinishedDownloadsTableView({
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
forceActive={isPreviewActive}
activationNonce={isPreviewActive ? previewActivationNonce : 0}
teaserPreloadEnabled
/>
{/* Scrub-Frame Overlay */}
@ -542,6 +552,8 @@ export default function FinishedDownloadsTableView({
runtimeOf,
parseTags,
runtimeSecondsForSort,
forcePreviewMuted,
previewActivationNonce,
])
const sortStateToMode = React.useCallback((s: SortState): SortMode => {

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\FinishedVideoPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
@ -79,6 +79,10 @@ export type FinishedVideoPreviewProps = {
scrubProgressRatio?: number
preferScrubProgress?: boolean
forceActive?: boolean
activationNonce?: number
registerTeaserPlay?: (fn: (() => void) | null) => void
}
function baseName(path: string) {
@ -124,6 +128,9 @@ export default function FinishedVideoPreview({
teaserPreloadRootMargin = '700px 0px',
scrubProgressRatio,
preferScrubProgress = false,
forceActive= false,
activationNonce = 0,
registerTeaserPlay,
}: FinishedVideoPreviewProps) {
const file = baseName(job.output || '') || getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
@ -341,7 +348,7 @@ export default function FinishedVideoPreview({
const [inView, setInView] = useState(false)
const [nearView, setNearView] = useState(false)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false)
// Tick nur für frames-Mode
@ -350,6 +357,15 @@ export default function FinishedVideoPreview({
// Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false)
const [pageVisible, setPageVisible] = useState(() => {
if (typeof document === 'undefined') return true
return !document.hidden
})
const effectiveInView = forceActive || inView
const effectiveNearView = forceActive || nearView
const effectiveEverInView = forceActive || everInView
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -373,7 +389,37 @@ export default function FinishedVideoPreview({
const inlineRef = useRef<HTMLVideoElement | null>(null)
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const lastTeaserRefStateRef = useRef<'attached' | 'detached' | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null)
const playTeaserFromGesture = useCallback(() => {
const el = teaserMp4Ref.current
if (!el) return
applyInlineVideoPolicy(el, { muted })
try {
el.muted = muted
el.defaultMuted = muted
el.playsInline = true
el.loop = true
el.autoplay = true
} catch {}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}, [muted])
useEffect(() => {
if (!registerTeaserPlay) return
registerTeaserPlay(playTeaserFromGesture)
return () => registerTeaserPlay(null)
}, [registerTeaserPlay, playTeaserFromGesture])
const teaserPlayRetryTimerRef = useRef<number | null>(null)
const teaserLastProgressTimeRef = useRef<number>(0)
const lastMountedRef = useRef<{
inline: HTMLVideoElement | null
@ -477,6 +523,33 @@ export default function FinishedVideoPreview({
} catch {}
}
useEffect(() => {
return () => {
if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current)
teaserPlayRetryTimerRef.current = null
}
}
}, [])
useEffect(() => {
if (typeof document === 'undefined') return
const onVisibilityChange = () => {
setPageVisible(!document.hidden)
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pageshow', onVisibilityChange)
window.addEventListener('focus', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pageshow', onVisibilityChange)
window.removeEventListener('focus', onVisibilityChange)
}
}, [])
useEffect(() => {
setTeaserReady(false)
setTeaserOk(true)
@ -560,14 +633,14 @@ export default function FinishedVideoPreview({
}, [teaserPreloadEnabled, teaserPreloadRootMargin, inView])
// --- Tick für "frames"
useEffect(() => {
useEffect(() => {
if (!animated) return
if (animatedMode !== 'frames') return
if (!inView || document.hidden) return
if (!effectiveInView || !pageVisible) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs])
}, [animated, animatedMode, effectiveInView, pageVisible, autoTickMs])
// --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => {
@ -606,30 +679,36 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
inlineMode !== 'never' &&
effectiveInView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
// --- Teaser/Clips aktiv? (sichtbar/erzwungen aktiv, nicht inline, optional nur hover)
const teaserActive =
animated &&
inView &&
!document.hidden &&
effectiveInView &&
pageVisible &&
videoOk &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
(
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(animatedMode === 'clips' && hasDuration)
)
const progressTotalSeconds =
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered)
const shouldLoadStill =
forceActive || alwaysLoadStill || effectiveInView || effectiveEverInView || (wantsHover && hovered)
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
const shouldPreloadAnimatedAssets =
forceActive || effectiveNearView || effectiveInView || effectiveEverInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
@ -784,9 +863,9 @@ export default function FinishedVideoPreview({
? clipsRef
: null
const showProgressBar =
Boolean(progressVideoRef) &&
inView
const showProgressBar =
Boolean(progressVideoRef) &&
effectiveInView
const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
@ -888,18 +967,217 @@ export default function FinishedVideoPreview({
if (!vv) return
const active = teaserActive && animatedMode === 'teaser'
const clearRetryTimer = () => {
if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current)
teaserPlayRetryTimerRef.current = null
}
}
if (!active) {
clearRetryTimer()
try {
vv.pause()
} catch {}
return
}
applyInlineVideoPolicy(vv, { muted })
let cancelled = false
const p = vv.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
const tryPlay = (opts?: { resetToStart?: boolean }) => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) {
return
}
applyInlineVideoPolicy(el, { muted })
try {
el.muted = muted
el.defaultMuted = muted
el.playsInline = true
el.loop = true
el.autoplay = true
} catch {}
if (opts?.resetToStart) {
try {
el.currentTime = 0
} catch {}
}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
const scheduleRetry = (delayMs: number, resetToStart = false) => {
clearRetryTimer()
teaserPlayRetryTimerRef.current = window.setTimeout(() => {
teaserPlayRetryTimerRef.current = null
tryPlay({ resetToStart })
}, delayMs)
}
const onPlaying = () => {
teaserLastProgressTimeRef.current = performance.now()
setTeaserReady(true)
clearRetryTimer()
}
const onTimeUpdate = () => {
teaserLastProgressTimeRef.current = performance.now()
setTeaserReady(true)
}
const onLoadedData = () => {
setTeaserReady(true)
tryPlay()
scheduleRetry(120)
}
const onCanPlay = () => {
tryPlay()
scheduleRetry(120)
}
const onPause = () => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) return
if (!active) return
if (el.ended) return
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
scheduleRetry(120)
}
const onWaiting = () => {
if (cancelled) return
scheduleRetry(120)
}
vv.addEventListener('playing', onPlaying)
vv.addEventListener('timeupdate', onTimeUpdate)
vv.addEventListener('loadeddata', onLoadedData)
vv.addEventListener('canplay', onCanPlay)
vv.addEventListener('pause', onPause)
vv.addEventListener('waiting', onWaiting)
// frischer Start für neue TopCard
tryPlay({ resetToStart: true })
const raf1 = requestAnimationFrame(() => {
tryPlay()
const raf2 = requestAnimationFrame(() => {
tryPlay()
})
;(tryPlay as any).__raf2 = raf2
})
// Watchdog: falls currentTime nicht vorläuft, nochmal anstoßen
const watchdog = window.setInterval(() => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) return
if (!active) return
const now = performance.now()
const stalledFor = now - teaserLastProgressTimeRef.current
const ct = Number(el.currentTime)
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > 180) {
tryPlay({ resetToStart: true })
return
}
if (el.paused && !el.ended && stalledFor > 180) {
tryPlay()
}
}, 180)
return () => {
cancelled = true
clearRetryTimer()
vv.removeEventListener('playing', onPlaying)
vv.removeEventListener('timeupdate', onTimeUpdate)
vv.removeEventListener('loadeddata', onLoadedData)
vv.removeEventListener('canplay', onCanPlay)
vv.removeEventListener('pause', onPause)
vv.removeEventListener('waiting', onWaiting)
cancelAnimationFrame(raf1)
const raf2 = (tryPlay as any).__raf2
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
window.clearInterval(watchdog)
}
}, [teaserActive, animatedMode, teaserSrc, muted, pageVisible])
useEffect(() => {
if (!activationNonce) return
if (!teaserActive) return
if (animatedMode !== 'teaser') return
if (showingInlineVideo) return
const vv = teaserMp4Ref.current
if (!vv) return
let cancelled = false
let retryTimer: number | null = null
const tryRestart = (resetToStart = false) => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) {
return
}
applyInlineVideoPolicy(el, { muted })
try {
el.muted = muted
el.defaultMuted = muted
el.playsInline = true
el.loop = true
el.autoplay = true
} catch {}
if (resetToStart) {
try {
el.pause()
} catch {}
try {
el.currentTime = 0
} catch {}
}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
retryTimer = window.setTimeout(() => {
tryRestart(true)
requestAnimationFrame(() => {
tryRestart()
requestAnimationFrame(() => {
tryRestart()
})
})
}, 40)
return () => {
cancelled = true
if (retryTimer != null) {
window.clearTimeout(retryTimer)
}
}
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo, muted])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => {
@ -1008,7 +1286,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
(nearView || inView) &&
(forceActive || effectiveNearView || effectiveInView) &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
@ -1100,7 +1378,7 @@ export default function FinishedVideoPreview({
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}`}
key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
src={teaserSrc}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
@ -1116,8 +1394,6 @@ export default function FinishedVideoPreview({
loop
preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)

View File

@ -6,6 +6,13 @@ import * as React from 'react'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client'
const DEBUG_SWIPE = true
function swipeDbg(...args: any[]) {
if (!DEBUG_SWIPE) return
console.log('[SwipeCard]', ...args)
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
@ -198,6 +205,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null
}
swipeDbg('commit start', { dir, runAction })
if (cardRef.current) {
cardRef.current.style.touchAction = 'pan-y'
}
@ -210,18 +219,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dxRef.current = outDx
setDx(outDx)
let actionPromise: Promise<boolean | void>
if (runAction) {
actionPromise = Promise.resolve(dir === 'right' ? onSwipeRight() : onSwipeLeft()).catch(() => false)
} else {
actionPromise = Promise.resolve(true)
}
const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs)
})
const [, ok] = await Promise.all([animPromise, actionPromise])
// Wichtig: erst rausanimieren lassen, dann Aktion starten
await animPromise
swipeDbg('commit after anim', { dir })
let ok: boolean | void = true
if (runAction) {
ok = await Promise.resolve(
dir === 'right' ? onSwipeRight() : onSwipeLeft()
).catch(() => false)
}
swipeDbg('commit action result', { dir, ok })
if (ok === false) {
setAnimMs(snapMs)
@ -371,13 +385,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[hotTargetSelector]
)
React.useEffect(() => {
return () => {
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
}
}, [])
React.useImperativeHandle(
ref,
() => ({
@ -420,6 +427,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onPointerDown={(e) => {
if (!enabled || disabled) return
swipeDbg('pointerdown', {
pointerId: e.pointerId,
x: e.clientX,
y: e.clientY,
})
const target = e.target as HTMLElement | null
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
@ -652,6 +665,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dxRef.current = 0
swipeDbg('commit-left')
swipeDbg('commit-right')
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
void commit('right', true)
return

View File

@ -30,6 +30,17 @@ type ToastInternal = Toast & {
open: boolean
}
type SwipeState = {
pointerId: number | null
startX: number
startY: number
dx: number
dy: number
dragging: boolean
lock: 'none' | 'x' | 'y'
dismissing: boolean
}
type ToastContextValue = {
push: (t: Omit<Toast, 'id'>) => string
remove: (id: string) => void
@ -39,6 +50,9 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220
const TOAST_SWIPE_DISMISS_PX = 96
const TOAST_SWIPE_LOCK_PX = 10
const TOAST_SWIPE_MAX_ROTATION_DEG = 3
function iconFor(type: ToastType) {
switch (type) {
@ -137,6 +151,9 @@ export function ToastProvider({
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
const toastPausedRef = React.useRef<Record<string, boolean>>({})
const swipeStateRef = React.useRef<Record<string, SwipeState>>({})
const [, forceSwipeRender] = React.useState(0)
const clearTimersFor = React.useCallback((id: string) => {
const autoId = autoCloseTimersRef.current[id]
if (autoId) {
@ -153,8 +170,9 @@ export function ToastProvider({
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
delete swipeStateRef.current[id]
}, [])
const loadNotificationSetting = React.useCallback(async () => {
try {
const r = await fetch('/api/settings', { cache: 'no-store' })
@ -193,12 +211,23 @@ export function ToastProvider({
const remove = React.useCallback(
(id: string) => {
const cur = swipeStateRef.current[id]
if (cur) {
swipeStateRef.current[id] = {
...cur,
dismissing: true,
dragging: false,
}
forceSwipeRender((n) => n + 1)
}
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
delete swipeStateRef.current[id]
}, TOAST_LEAVE_MS)
},
[clearTimersFor]
@ -314,10 +343,134 @@ export function ToastProvider({
[remove]
)
const isTouchLikePointer = (e: React.PointerEvent) => {
return e.pointerType === 'touch' || e.pointerType === 'pen'
}
const getSwipeState = (id: string): SwipeState => {
return (
swipeStateRef.current[id] ?? {
pointerId: null,
startX: 0,
startY: 0,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
)
}
const handleToastPointerDown = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
if (!isTouchLikePointer(e)) return
swipeStateRef.current[id] = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
pauseAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [pauseAutoCloseTimer])
const handleToastPointerMove = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (s.pointerId !== e.pointerId) return
if (s.dismissing) return
const dx = e.clientX - s.startX
const dy = e.clientY - s.startY
let lock = s.lock
if (lock === 'none') {
const absX = Math.abs(dx)
const absY = Math.abs(dy)
if (absX < TOAST_SWIPE_LOCK_PX && absY < TOAST_SWIPE_LOCK_PX) {
swipeStateRef.current[id] = { ...s, dx, dy }
forceSwipeRender((n) => n + 1)
return
}
lock = absX > absY ? 'x' : 'y'
}
if (lock === 'y') {
swipeStateRef.current[id] = { ...s, dx, dy, lock, dragging: false }
forceSwipeRender((n) => n + 1)
return
}
swipeStateRef.current[id] = {
...s,
dx,
dy,
lock,
dragging: true,
}
forceSwipeRender((n) => n + 1)
}, [])
const handleToastPointerEnd = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (e && s.pointerId !== e.pointerId) return
const shouldDismiss = s.lock === 'x' && s.dx <= -TOAST_SWIPE_DISMISS_PX
if (shouldDismiss) {
remove(id)
return
}
swipeStateRef.current[id] = {
...s,
pointerId: null,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
resumeAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [remove, resumeAutoCloseTimer])
const handleToastPointerCancel = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (e && s.pointerId !== e.pointerId) return
swipeStateRef.current[id] = {
...s,
pointerId: null,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
resumeAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [resumeAutoCloseTimer])
React.useEffect(() => {
return () => {
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
swipeStateRef.current= {}
}
}, [])
@ -370,6 +523,34 @@ export function ToastProvider({
const imgAlt = (t.imageAlt || title).trim()
const isClickable = typeof t.onClick === 'function'
const swipe = getSwipeState(t.id)
const absDx = Math.abs(swipe.dx)
const dragOpacity = swipe.dragging
? Math.max(0.35, 1 - absDx / 220)
: 1
const dragScale = swipe.dragging
? Math.max(0.985, 1 - absDx / 1600)
: 1
const dragRotate = swipe.dragging
? Math.max(
-TOAST_SWIPE_MAX_ROTATION_DEG,
Math.min(TOAST_SWIPE_MAX_ROTATION_DEG, swipe.dx / 40)
)
: 0
const swipeStyle: React.CSSProperties = {
transform: `translateX(${swipe.dx}px) scale(${dragScale}) rotate(${dragRotate}deg)`,
opacity: dragOpacity,
transition: swipe.dragging
? 'none'
: swipe.dismissing
? 'transform 180ms ease-out, opacity 180ms ease-out'
: 'transform 220ms ease, opacity 220ms ease',
touchAction: 'pan-y',
}
return (
<Transition
key={t.id}
@ -390,15 +571,22 @@ export function ToastProvider({
'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5',
isClickable
? 'cursor-pointer transition-[box-shadow,transform,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10 active:scale-[0.998]'
? 'cursor-pointer transition-[box-shadow,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10'
: '',
borderFor(t.type),
].join(' ')}
style={swipeStyle}
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
onFocus={() => pauseAutoCloseTimer(t.id)}
onBlur={() => resumeAutoCloseTimer(t.id)}
onPointerDown={(e) => handleToastPointerDown(t.id, e)}
onPointerMove={(e) => handleToastPointerMove(t.id, e)}
onPointerUp={(e) => handleToastPointerEnd(t.id, e)}
onPointerCancel={(e) => handleToastPointerCancel(t.id, e)}
onLostPointerCapture={(e) => handleToastPointerCancel(t.id, e)}
onClick={() => {
if (swipe.dragging || Math.abs(swipe.dx) > 8) return
if (!t.onClick) return
t.onClick()
if (t.closeOnClick !== false) remove(t.id)