updated finished downloads
This commit is contained in:
parent
abdd071bfd
commit
c1669b4eec
@ -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="
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user