updated finished downloads
This commit is contained in:
parent
abdd071bfd
commit
c1669b4eec
@ -15,5 +15,5 @@
|
|||||||
"teaserPlayback": "hover",
|
"teaserPlayback": "hover",
|
||||||
"teaserAudio": true,
|
"teaserAudio": true,
|
||||||
"enableNotifications": 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 [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
||||||
const [stopInitiatedIds, setStopInitiatedIds] = 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 markStopRequested = useCallback((ids: string | string[]) => {
|
||||||
const arr = Array.isArray(ids) ? ids : [ids]
|
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(() => {
|
useEffect(() => {
|
||||||
setStopRequestedIds((prev) => {
|
setStopRequestedIds((prev) => {
|
||||||
const keys = Object.keys(prev)
|
const keys = Object.keys(prev)
|
||||||
@ -690,6 +723,38 @@ export default function Downloads({
|
|||||||
})
|
})
|
||||||
}, [jobs])
|
}, [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 [nowMs, setNowMs] = useState(() => Date.now())
|
||||||
|
|
||||||
const prevSizeBytesRef = useRef<Record<string, number>>({})
|
const prevSizeBytesRef = useRef<Record<string, number>>({})
|
||||||
@ -1058,19 +1123,30 @@ export default function Downloads({
|
|||||||
widthClassName: 'w-[320px] min-w-[300px]',
|
widthClassName: 'w-[320px] min-w-[300px]',
|
||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
if (r.kind !== 'job') {
|
if (r.kind !== 'job') {
|
||||||
|
const pendingKey = pendingRowKey(r.pending)
|
||||||
|
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end pr-2">
|
<div className="flex justify-end pr-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
color="red"
|
color="red"
|
||||||
|
disabled={isRemovingPending || !onRemovePending}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (isRemovingPending) return
|
||||||
|
|
||||||
|
markRemovePendingRequested(pendingKey)
|
||||||
|
try {
|
||||||
await onRemovePending?.(r.pending)
|
await onRemovePending?.(r.pending)
|
||||||
|
} catch {
|
||||||
|
clearRemovePendingRequested(pendingKey)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Entfernen
|
{isRemovingPending ? 'Entferne…' : 'Entfernen'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -1083,10 +1159,13 @@ export default function Downloads({
|
|||||||
|
|
||||||
const postworkState = getEffectivePostworkState(j)
|
const postworkState = getEffectivePostworkState(j)
|
||||||
const isQueuedPostwork = postworkState === 'queued'
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||||
|
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
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 key = modelKeyFromJob(j)
|
||||||
const flags = key ? modelsByKey[key] : undefined
|
const flags = key ? modelsByKey[key] : undefined
|
||||||
@ -1103,7 +1182,7 @@ export default function Downloads({
|
|||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={j}
|
job={j}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={isQueuedPostwork ? false : isStopping}
|
busy={buttonBusy}
|
||||||
isFavorite={isFav}
|
isFavorite={isFav}
|
||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
@ -1124,7 +1203,13 @@ export default function Downloads({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (isQueuedPostwork) {
|
if (isQueuedPostwork) {
|
||||||
|
if (isRemovingQueuedPostwork) return
|
||||||
|
markRemoveQueuedPostworkRequested(j.id)
|
||||||
|
try {
|
||||||
await onRemoveQueuedPostworkJob?.(j.id)
|
await onRemoveQueuedPostworkJob?.(j.id)
|
||||||
|
} catch {
|
||||||
|
clearRemoveQueuedPostworkRequested(j.id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1133,7 +1218,9 @@ export default function Downloads({
|
|||||||
await onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
{isQueuedPostwork
|
||||||
|
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
||||||
|
: (isStopping ? 'Stoppe…' : 'Stoppen')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -1247,6 +1334,33 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
|
}, [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 (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="sticky top-[56px] z-20">
|
<div className="sticky top-[56px] z-20">
|
||||||
@ -1338,7 +1452,7 @@ export default function Downloads({
|
|||||||
return (
|
return (
|
||||||
<SwipeActionRow
|
<SwipeActionRow
|
||||||
key={`dl:${j.id}`}
|
key={`dl:${j.id}`}
|
||||||
actionLabel="Stoppen"
|
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
|
||||||
actionColor="indigo"
|
actionColor="indigo"
|
||||||
disabled={isStopping || isQueuedPostwork}
|
disabled={isStopping || isQueuedPostwork}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
@ -1346,6 +1460,12 @@ export default function Downloads({
|
|||||||
markStopRequested(j.id)
|
markStopRequested(j.id)
|
||||||
await onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
@ -1364,6 +1484,7 @@ export default function Downloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</SwipeActionRow>
|
</SwipeActionRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -1378,15 +1499,30 @@ export default function Downloads({
|
|||||||
{pendingRows.map((r) => {
|
{pendingRows.map((r) => {
|
||||||
if (r.kind !== 'pending') return null
|
if (r.kind !== 'pending') return null
|
||||||
|
|
||||||
|
const pKey = pendingRowKey(r.pending)
|
||||||
|
const isRemovingPending = Boolean(removePendingRequestedKeys[pKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwipeActionRow
|
<SwipeActionRow
|
||||||
key={`pending:${pendingRowKey(r.pending)}`}
|
key={`pending:${pKey}`}
|
||||||
actionLabel="Entfernen"
|
actionLabel={isRemovingPending ? 'Entferne…' : 'Entfernen'}
|
||||||
actionColor="red"
|
actionColor="red"
|
||||||
disabled={!onRemovePending}
|
disabled={!onRemovePending || isRemovingPending}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
|
if (isRemovingPending) return
|
||||||
|
markRemovePendingRequested(pKey)
|
||||||
|
try {
|
||||||
await onRemovePending?.(r.pending)
|
await onRemovePending?.(r.pending)
|
||||||
|
} catch {
|
||||||
|
clearRemovePendingRequested(pKey)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
@ -1405,6 +1541,7 @@ export default function Downloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</SwipeActionRow>
|
</SwipeActionRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -1429,9 +1566,14 @@ export default function Downloads({
|
|||||||
const isBusyPhase = phase !== '' && phase !== 'recording'
|
const isBusyPhase = phase !== '' && phase !== 'recording'
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
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 color = isQueuedPostwork ? 'red' : 'indigo'
|
||||||
const disabled = isQueuedPostwork ? false : isStopping
|
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwipeActionRow
|
<SwipeActionRow
|
||||||
@ -1441,7 +1583,13 @@ export default function Downloads({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
if (isQueuedPostwork) {
|
if (isQueuedPostwork) {
|
||||||
|
if (isRemovingQueuedPostwork) return
|
||||||
|
markRemoveQueuedPostworkRequested(j.id)
|
||||||
|
try {
|
||||||
await onRemoveQueuedPostworkJob?.(j.id)
|
await onRemoveQueuedPostworkJob?.(j.id)
|
||||||
|
} catch {
|
||||||
|
clearRemoveQueuedPostworkRequested(j.id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1449,6 +1597,13 @@ export default function Downloads({
|
|||||||
markStopRequested(j.id)
|
markStopRequested(j.id)
|
||||||
await onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
@ -1467,6 +1622,7 @@ export default function Downloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</SwipeActionRow>
|
</SwipeActionRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -1483,6 +1639,7 @@ export default function Downloads({
|
|||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
rows={downloadJobRows}
|
rows={downloadJobRows}
|
||||||
|
rowClassName={rowClassName}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
|
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
|
||||||
striped
|
striped
|
||||||
|
|||||||
@ -305,15 +305,29 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
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 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 notify = useNotify()
|
||||||
|
|
||||||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||||||
const [hoverTeaserKey, setHoverTeaserKey] = 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 teaserIORef = React.useRef<IntersectionObserver | null>(null)
|
||||||
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
|
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
|
||||||
|
|
||||||
@ -335,12 +349,12 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
||||||
() => ({
|
() => ({
|
||||||
mode: teaserPlaybackMode,
|
mode: effectiveTeaserPlaybackMode,
|
||||||
activeKey: teaserKey,
|
activeKey: teaserKey,
|
||||||
hoverKey: hoverTeaserKey,
|
hoverKey: hoverTeaserKey,
|
||||||
audioEnabled: Boolean(teaserAudio),
|
audioEnabled: Boolean(teaserAudio),
|
||||||
}),
|
}),
|
||||||
[teaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
||||||
)
|
)
|
||||||
|
|
||||||
type UndoAction =
|
type UndoAction =
|
||||||
@ -1908,11 +1922,29 @@ export default function FinishedDownloads({
|
|||||||
if (page > totalPages) onPageChange(totalPages)
|
if (page > totalPages) onPageChange(totalPages)
|
||||||
}, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange])
|
}, [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)
|
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active =
|
const active =
|
||||||
!canHover &&
|
!canHover &&
|
||||||
teaserState.mode !== 'still' &&
|
effectiveTeaserPlaybackMode !== 'still' &&
|
||||||
(view === 'cards' || view === 'gallery' || view === 'table')
|
(view === 'cards' || view === 'gallery' || view === 'table')
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@ -1968,7 +2000,7 @@ export default function FinishedDownloads({
|
|||||||
io.disconnect()
|
io.disconnect()
|
||||||
if (teaserIORef.current === io) teaserIORef.current = null
|
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
|
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||||
const runtimeOf = (job: RecordJob): string => {
|
const runtimeOf = (job: RecordJob): string => {
|
||||||
@ -2039,7 +2071,7 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall || view !== 'cards') return
|
if (!isSmall || view !== 'cards') return
|
||||||
if (teaserState.mode === 'still') {
|
if (effectiveTeaserPlaybackMode === 'still') {
|
||||||
setTeaserKey(null)
|
setTeaserKey(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -2052,7 +2084,7 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const topKey = keyFor(top)
|
const topKey = keyFor(top)
|
||||||
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
||||||
}, [isSmall, view, pageRows, keyFor, teaserState.mode])
|
}, [isSmall, view, pageRows, keyFor, effectiveTeaserPlaybackMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall) {
|
if (!isSmall) {
|
||||||
@ -2474,6 +2506,7 @@ export default function FinishedDownloads({
|
|||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
|
forcePreviewMuted={forcePreviewMuted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -2520,6 +2553,8 @@ export default function FinishedDownloads({
|
|||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
|
forcePreviewMuted={forcePreviewMuted}
|
||||||
|
previewActivationNonce={listPreviewActivationNonce}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2559,6 +2594,8 @@ export default function FinishedDownloads({
|
|||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
|
forcePreviewMuted={forcePreviewMuted}
|
||||||
|
previewActivationNonce={listPreviewActivationNonce}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,8 @@ type Props = {
|
|||||||
maxParallel?: number
|
maxParallel?: number
|
||||||
ts: number
|
ts: number
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
forcePreviewMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
|
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
|
||||||
@ -587,7 +589,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
postworkByFile
|
postworkByFile,
|
||||||
|
forcePreviewMuted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||||
@ -718,6 +721,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
|
||||||
|
const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo)
|
||||||
const realInlineActive = inlinePlay?.key === k
|
const realInlineActive = inlinePlay?.key === k
|
||||||
const inlineActive = opts?.disableInline ? false : realInlineActive
|
const inlineActive = opts?.disableInline ? false : realInlineActive
|
||||||
|
|
||||||
@ -728,20 +733,28 @@ export default function FinishedDownloadsCardsView({
|
|||||||
disabled: Boolean(opts?.forceStill),
|
disabled: Boolean(opts?.forceStill),
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewMuted = !allowSound
|
const previewMuted = forcePreviewMuted
|
||||||
|
? true
|
||||||
|
: (
|
||||||
|
isSmall && opts?.mobileStackTopOnlyVideo
|
||||||
|
? true
|
||||||
|
: !allowSound
|
||||||
|
)
|
||||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||||
|
|
||||||
const forceLoadStill = Boolean(opts?.forceLoadStill)
|
const forceLoadStill = Boolean(opts?.forceLoadStill)
|
||||||
|
|
||||||
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
|
// Mobile-Stack:
|
||||||
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
|
// - TopCard: bei hover/all immer Teaser animieren
|
||||||
|
// - Backcards/Preloadcards: immer still
|
||||||
const allowTeaserAnimation =
|
const allowTeaserAnimation =
|
||||||
opts?.mobileStackTopOnlyVideo
|
Boolean(opts?.forceStill)
|
||||||
|
? false
|
||||||
|
: opts?.mobileStackTopOnlyVideo
|
||||||
? teaserState.mode !== 'still'
|
? teaserState.mode !== 'still'
|
||||||
: shouldAnimateTeaser({
|
: shouldAnimateTeaser({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
disabled: Boolean(opts?.forceStill),
|
disabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
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 inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
|
|
||||||
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
|
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
|
||||||
|
|
||||||
const previewInstanceKey = [
|
const previewInstanceKey = [
|
||||||
k,
|
k,
|
||||||
previewRenderRole,
|
previewRenderRole,
|
||||||
@ -1032,7 +1046,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||||
inlineNonce={inlineNonce}
|
inlineNonce={inlineNonce}
|
||||||
inlineControls={inlineActive}
|
inlineControls={realInlineActive}
|
||||||
inlineLoop={false}
|
inlineLoop={false}
|
||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
@ -1040,7 +1054,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
alwaysLoadStill={forceLoadStill}
|
alwaysLoadStill={forceLoadStill}
|
||||||
teaserPreloadEnabled={
|
teaserPreloadEnabled={
|
||||||
opts?.mobileStackTopOnlyVideo
|
opts?.mobileStackTopOnlyVideo
|
||||||
? true
|
? false
|
||||||
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
||||||
}
|
}
|
||||||
teaserPreloadRootMargin={
|
teaserPreloadRootMargin={
|
||||||
@ -1050,6 +1064,12 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
scrubProgressRatio={scrubProgressRatio}
|
||||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
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 */}
|
{/* ✅ 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 mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
|
||||||
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
|
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
|
// größerer Peek-Offset für stärkeren Stack-Effekt
|
||||||
const stackPeekOffsetPx = 15
|
const stackPeekOffsetPx = 15
|
||||||
|
|
||||||
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
||||||
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!isSmall ? (
|
{!isSmall ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{rows.map((j) => {
|
{rows.map((j) => {
|
||||||
const { k, cardInner } = renderCardItem(j)
|
const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={k}>
|
<React.Fragment key={`${k}__desktop`}>
|
||||||
{cardInner}
|
{cardInner}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
@ -1299,7 +1383,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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={{
|
style={{
|
||||||
zIndex: 20 - depth,
|
zIndex: 20 - depth,
|
||||||
@ -1350,7 +1434,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
{topRow ? (() => {
|
{topRow ? (() => {
|
||||||
const j = topRow
|
const j = topRow
|
||||||
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
||||||
forceLoadStill: true,
|
forceLoadStill: false,
|
||||||
|
forceStill: !mobileTopTeaserEnabled,
|
||||||
mobileStackTopOnlyVideo: true,
|
mobileStackTopOnlyVideo: true,
|
||||||
disableScrubber: true,
|
disableScrubber: true,
|
||||||
animateUnblurOnMount: true,
|
animateUnblurOnMount: true,
|
||||||
@ -1359,7 +1444,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={k}
|
key={`${k}__top`}
|
||||||
className="relative touch-pan-y"
|
className="relative touch-pan-y"
|
||||||
style={{ zIndex: 30 }}
|
style={{ zIndex: 30 }}
|
||||||
>
|
>
|
||||||
@ -1379,6 +1464,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
await onToggleHot?.(j)
|
await onToggleHot?.(j)
|
||||||
}}
|
}}
|
||||||
onTap={() => {
|
onTap={() => {
|
||||||
|
if (!mobileTopTapEnabled) return
|
||||||
|
|
||||||
startInline(k)
|
startInline(k)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!tryAutoplayInline(inlineDomId)) {
|
if (!tryAutoplayInline(inlineDomId)) {
|
||||||
@ -1386,8 +1473,14 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSwipeLeft={() => deleteVideo(j)}
|
onSwipeLeft={async () => {
|
||||||
onSwipeRight={() => keepVideo(j)}
|
setMobileTopTeaserEnabled(false)
|
||||||
|
return await deleteVideo(j)
|
||||||
|
}}
|
||||||
|
onSwipeRight={async () => {
|
||||||
|
setMobileTopTeaserEnabled(false)
|
||||||
|
return await keepVideo(j)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{cardInner}
|
{cardInner}
|
||||||
</SwipeCard>
|
</SwipeCard>
|
||||||
|
|||||||
@ -93,6 +93,9 @@ type Props = {
|
|||||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
|
forcePreviewMuted?: boolean
|
||||||
|
previewActivationNonce: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||||
@ -198,10 +201,14 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
|
forcePreviewMuted,
|
||||||
|
previewActivationNonce,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
||||||
const registerTeaserHostIfNeeded = React.useCallback(
|
const registerTeaserHostIfNeeded = React.useCallback(
|
||||||
(key: string) => (el: HTMLDivElement | null) => {
|
(key: string) => (el: HTMLDivElement | null) => {
|
||||||
@ -286,12 +293,15 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
{rows.map((j) => {
|
{rows.map((j) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
|
||||||
|
const isPreviewActive =
|
||||||
|
teaserState.activeKey === k || teaserState.hoverKey === k
|
||||||
|
|
||||||
// Sound nur bei Hover auf genau diesem Teaser
|
// Sound nur bei Hover auf genau diesem Teaser
|
||||||
const allowSound = shouldEnableTeaserAudio({
|
const allowSound = shouldEnableTeaserAudio({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
})
|
})
|
||||||
const previewMuted = !allowSound
|
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const modelKey = lower(model)
|
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 overflow-hidden rounded-t-lg">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
|
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||||
@ -533,6 +544,9 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
scrubProgressRatio={scrubProgressRatio}
|
||||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||||||
|
forceActive={isPreviewActive}
|
||||||
|
activationNonce={isPreviewActive ? previewActivationNonce : 0}
|
||||||
|
teaserPreloadEnabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,8 @@ type Props = {
|
|||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
forcePreviewMuted?: boolean
|
||||||
|
previewActivationNonce: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsTableView({
|
export default function FinishedDownloadsTableView({
|
||||||
@ -140,6 +142,8 @@ export default function FinishedDownloadsTableView({
|
|||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
|
forcePreviewMuted,
|
||||||
|
previewActivationNonce,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sort, setSort] = React.useState<SortState>(null)
|
const [sort, setSort] = React.useState<SortState>(null)
|
||||||
|
|
||||||
@ -235,11 +239,13 @@ export default function FinishedDownloadsTableView({
|
|||||||
widthClassName: 'w-[140px]',
|
widthClassName: 'w-[140px]',
|
||||||
cell: (j) => {
|
cell: (j) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
const isPreviewActive =
|
||||||
|
teaserState.activeKey === k || teaserState.hoverKey === k
|
||||||
const allowSound = shouldEnableTeaserAudio({
|
const allowSound = shouldEnableTeaserAudio({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
})
|
})
|
||||||
const previewMuted = !allowSound
|
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||||
const spriteInfo = previewSpriteInfoOf(j)
|
const spriteInfo = previewSpriteInfoOf(j)
|
||||||
const scrubActiveIndex = scrubActiveByKey[k]
|
const scrubActiveIndex = scrubActiveByKey[k]
|
||||||
|
|
||||||
@ -271,6 +277,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
|
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
||||||
job={jobForDetails(j)}
|
job={jobForDetails(j)}
|
||||||
getFileName={baseName}
|
getFileName={baseName}
|
||||||
durationSeconds={durations[k]}
|
durationSeconds={durations[k]}
|
||||||
@ -288,6 +295,9 @@ export default function FinishedDownloadsTableView({
|
|||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
assetNonce={assetNonce}
|
assetNonce={assetNonce}
|
||||||
|
forceActive={isPreviewActive}
|
||||||
|
activationNonce={isPreviewActive ? previewActivationNonce : 0}
|
||||||
|
teaserPreloadEnabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrub-Frame Overlay */}
|
{/* Scrub-Frame Overlay */}
|
||||||
@ -542,6 +552,8 @@ export default function FinishedDownloadsTableView({
|
|||||||
runtimeOf,
|
runtimeOf,
|
||||||
parseTags,
|
parseTags,
|
||||||
runtimeSecondsForSort,
|
runtimeSecondsForSort,
|
||||||
|
forcePreviewMuted,
|
||||||
|
previewActivationNonce,
|
||||||
])
|
])
|
||||||
|
|
||||||
const sortStateToMode = React.useCallback((s: SortState): SortMode => {
|
const sortStateToMode = React.useCallback((s: SortState): SortMode => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\components\ui\FinishedVideoPreview.tsx
|
// frontend\src\components\ui\FinishedVideoPreview.tsx
|
||||||
'use client'
|
'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 type { RecordJob } from '../../types'
|
||||||
import HoverPopover from './HoverPopover'
|
import HoverPopover from './HoverPopover'
|
||||||
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
|
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
|
||||||
@ -79,6 +79,10 @@ export type FinishedVideoPreviewProps = {
|
|||||||
|
|
||||||
scrubProgressRatio?: number
|
scrubProgressRatio?: number
|
||||||
preferScrubProgress?: boolean
|
preferScrubProgress?: boolean
|
||||||
|
|
||||||
|
forceActive?: boolean
|
||||||
|
activationNonce?: number
|
||||||
|
registerTeaserPlay?: (fn: (() => void) | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseName(path: string) {
|
function baseName(path: string) {
|
||||||
@ -124,6 +128,9 @@ export default function FinishedVideoPreview({
|
|||||||
teaserPreloadRootMargin = '700px 0px',
|
teaserPreloadRootMargin = '700px 0px',
|
||||||
scrubProgressRatio,
|
scrubProgressRatio,
|
||||||
preferScrubProgress = false,
|
preferScrubProgress = false,
|
||||||
|
forceActive= false,
|
||||||
|
activationNonce = 0,
|
||||||
|
registerTeaserPlay,
|
||||||
}: FinishedVideoPreviewProps) {
|
}: FinishedVideoPreviewProps) {
|
||||||
const file = baseName(job.output || '') || getFileName(job.output || '')
|
const file = baseName(job.output || '') || getFileName(job.output || '')
|
||||||
const blurCls = blur ? 'blur-md' : ''
|
const blurCls = blur ? 'blur-md' : ''
|
||||||
@ -350,6 +357,15 @@ export default function FinishedVideoPreview({
|
|||||||
// Hover-State (für inline hover ODER teaser hover)
|
// Hover-State (für inline hover ODER teaser hover)
|
||||||
const [hovered, setHovered] = useState(false)
|
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' =
|
const inlineMode: 'never' | 'always' | 'hover' =
|
||||||
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
||||||
|
|
||||||
@ -373,8 +389,38 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const inlineRef = useRef<HTMLVideoElement | null>(null)
|
const inlineRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
|
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const lastTeaserRefStateRef = useRef<'attached' | 'detached' | null>(null)
|
||||||
const clipsRef = useRef<HTMLVideoElement | 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<{
|
const lastMountedRef = useRef<{
|
||||||
inline: HTMLVideoElement | null
|
inline: HTMLVideoElement | null
|
||||||
teaser: HTMLVideoElement | null
|
teaser: HTMLVideoElement | null
|
||||||
@ -477,6 +523,33 @@ export default function FinishedVideoPreview({
|
|||||||
} catch {}
|
} 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(() => {
|
useEffect(() => {
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
setTeaserOk(true)
|
setTeaserOk(true)
|
||||||
@ -563,11 +636,11 @@ export default function FinishedVideoPreview({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!animated) return
|
if (!animated) return
|
||||||
if (animatedMode !== 'frames') return
|
if (animatedMode !== 'frames') return
|
||||||
if (!inView || document.hidden) return
|
if (!effectiveInView || !pageVisible) return
|
||||||
|
|
||||||
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [animated, animatedMode, inView, autoTickMs])
|
}, [animated, animatedMode, effectiveInView, pageVisible, autoTickMs])
|
||||||
|
|
||||||
// --- Thumbnail time (nur frames!)
|
// --- Thumbnail time (nur frames!)
|
||||||
const thumbTimeSec = useMemo(() => {
|
const thumbTimeSec = useMemo(() => {
|
||||||
@ -606,30 +679,36 @@ export default function FinishedVideoPreview({
|
|||||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
|
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
|
||||||
}, [previewId, v, noGenerateTeaser])
|
}, [previewId, v, noGenerateTeaser])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Inline Video sichtbar?
|
// --- Inline Video sichtbar?
|
||||||
const showingInlineVideo =
|
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 =
|
const teaserActive =
|
||||||
animated &&
|
animated &&
|
||||||
inView &&
|
effectiveInView &&
|
||||||
!document.hidden &&
|
pageVisible &&
|
||||||
videoOk &&
|
videoOk &&
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
(animatedTrigger === 'always' || hovered) &&
|
(animatedTrigger === 'always' || hovered) &&
|
||||||
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
|
(
|
||||||
|
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
|
||||||
|
(animatedMode === 'clips' && hasDuration)
|
||||||
|
)
|
||||||
|
|
||||||
const progressTotalSeconds =
|
const progressTotalSeconds =
|
||||||
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
|
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
|
||||||
|
|
||||||
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
|
// ✅ 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
|
// ✅ 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)
|
// ✅ 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)
|
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
|
||||||
@ -786,7 +865,7 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const showProgressBar =
|
const showProgressBar =
|
||||||
Boolean(progressVideoRef) &&
|
Boolean(progressVideoRef) &&
|
||||||
inView
|
effectiveInView
|
||||||
|
|
||||||
const progressKind: ProgressKind =
|
const progressKind: ProgressKind =
|
||||||
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
|
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
|
||||||
@ -888,18 +967,217 @@ export default function FinishedVideoPreview({
|
|||||||
if (!vv) return
|
if (!vv) return
|
||||||
|
|
||||||
const active = teaserActive && animatedMode === 'teaser'
|
const active = teaserActive && animatedMode === 'teaser'
|
||||||
|
|
||||||
|
const clearRetryTimer = () => {
|
||||||
|
if (teaserPlayRetryTimerRef.current != null) {
|
||||||
|
window.clearTimeout(teaserPlayRetryTimerRef.current)
|
||||||
|
teaserPlayRetryTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
|
clearRetryTimer()
|
||||||
try {
|
try {
|
||||||
vv.pause()
|
vv.pause()
|
||||||
} catch {}
|
} catch {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyInlineVideoPolicy(vv, { muted })
|
let cancelled = false
|
||||||
|
|
||||||
const p = vv.play?.()
|
const tryPlay = (opts?: { resetToStart?: boolean }) => {
|
||||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
if (cancelled) return
|
||||||
}, [teaserActive, animatedMode, teaserSrc, muted])
|
|
||||||
|
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)
|
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1008,7 +1286,7 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
// ✅ brauchen wir noch hidden-metadata-load?
|
// ✅ brauchen wir noch hidden-metadata-load?
|
||||||
const needHiddenMeta =
|
const needHiddenMeta =
|
||||||
(nearView || inView) &&
|
(forceActive || effectiveNearView || effectiveInView) &&
|
||||||
(onDuration || onResolution) &&
|
(onDuration || onResolution) &&
|
||||||
!metaLoaded &&
|
!metaLoaded &&
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
@ -1100,7 +1378,7 @@ export default function FinishedVideoPreview({
|
|||||||
teaserMp4Ref.current = el
|
teaserMp4Ref.current = el
|
||||||
bumpMountTickIfNew('teaser', el)
|
bumpMountTickIfNew('teaser', el)
|
||||||
}}
|
}}
|
||||||
key={`teaser-mp4-${previewId}`}
|
key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
|
||||||
src={teaserSrc}
|
src={teaserSrc}
|
||||||
className={[
|
className={[
|
||||||
'absolute inset-0 w-full h-full object-cover pointer-events-none',
|
'absolute inset-0 w-full h-full object-cover pointer-events-none',
|
||||||
@ -1116,8 +1394,6 @@ export default function FinishedVideoPreview({
|
|||||||
loop
|
loop
|
||||||
preload={teaserReady ? 'auto' : 'metadata'}
|
preload={teaserReady ? 'auto' : 'metadata'}
|
||||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||||
onLoadedData={() => setTeaserReady(true)}
|
|
||||||
onPlaying={() => setTeaserReady(true)}
|
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setTeaserOk(false)
|
setTeaserOk(false)
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import * as React from 'react'
|
|||||||
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
||||||
import { createRoot } from 'react-dom/client'
|
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>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
@ -198,6 +205,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
swipeDbg('commit start', { dir, runAction })
|
||||||
|
|
||||||
if (cardRef.current) {
|
if (cardRef.current) {
|
||||||
cardRef.current.style.touchAction = 'pan-y'
|
cardRef.current.style.touchAction = 'pan-y'
|
||||||
}
|
}
|
||||||
@ -210,18 +219,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
dxRef.current = outDx
|
dxRef.current = outDx
|
||||||
setDx(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) => {
|
const animPromise = new Promise<void>((resolve) => {
|
||||||
window.setTimeout(resolve, commitMs)
|
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) {
|
if (ok === false) {
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
@ -371,13 +385,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
[hotTargetSelector]
|
[hotTargetSelector]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
|
|
||||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@ -420,6 +427,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
|
|
||||||
|
swipeDbg('pointerdown', {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
})
|
||||||
|
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
|
|
||||||
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
||||||
@ -652,6 +665,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
|
||||||
|
swipeDbg('commit-left')
|
||||||
|
swipeDbg('commit-right')
|
||||||
|
|
||||||
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
|
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
|
||||||
void commit('right', true)
|
void commit('right', true)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -30,6 +30,17 @@ type ToastInternal = Toast & {
|
|||||||
open: boolean
|
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 = {
|
type ToastContextValue = {
|
||||||
push: (t: Omit<Toast, 'id'>) => string
|
push: (t: Omit<Toast, 'id'>) => string
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
@ -39,6 +50,9 @@ type ToastContextValue = {
|
|||||||
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
||||||
|
|
||||||
const TOAST_LEAVE_MS = 220
|
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) {
|
function iconFor(type: ToastType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -137,6 +151,9 @@ export function ToastProvider({
|
|||||||
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
|
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
|
||||||
const toastPausedRef = React.useRef<Record<string, boolean>>({})
|
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 clearTimersFor = React.useCallback((id: string) => {
|
||||||
const autoId = autoCloseTimersRef.current[id]
|
const autoId = autoCloseTimersRef.current[id]
|
||||||
if (autoId) {
|
if (autoId) {
|
||||||
@ -153,6 +170,7 @@ export function ToastProvider({
|
|||||||
delete toastStartedAtRef.current[id]
|
delete toastStartedAtRef.current[id]
|
||||||
delete toastRemainingMsRef.current[id]
|
delete toastRemainingMsRef.current[id]
|
||||||
delete toastPausedRef.current[id]
|
delete toastPausedRef.current[id]
|
||||||
|
delete swipeStateRef.current[id]
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadNotificationSetting = React.useCallback(async () => {
|
const loadNotificationSetting = React.useCallback(async () => {
|
||||||
@ -193,12 +211,23 @@ export function ToastProvider({
|
|||||||
|
|
||||||
const remove = React.useCallback(
|
const remove = React.useCallback(
|
||||||
(id: string) => {
|
(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)))
|
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
|
||||||
|
|
||||||
clearTimersFor(id)
|
clearTimersFor(id)
|
||||||
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
|
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
delete finalizeRemoveTimersRef.current[id]
|
delete finalizeRemoveTimersRef.current[id]
|
||||||
|
delete swipeStateRef.current[id]
|
||||||
}, TOAST_LEAVE_MS)
|
}, TOAST_LEAVE_MS)
|
||||||
},
|
},
|
||||||
[clearTimersFor]
|
[clearTimersFor]
|
||||||
@ -314,10 +343,134 @@ export function ToastProvider({
|
|||||||
[remove]
|
[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(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
|
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
|
||||||
Object.values(finalizeRemoveTimersRef.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 imgAlt = (t.imageAlt || title).trim()
|
||||||
const isClickable = typeof t.onClick === 'function'
|
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 (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@ -390,15 +571,22 @@ export function ToastProvider({
|
|||||||
'shadow-sm',
|
'shadow-sm',
|
||||||
'ring-1 ring-black/5 dark:ring-white/5',
|
'ring-1 ring-black/5 dark:ring-white/5',
|
||||||
isClickable
|
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),
|
borderFor(t.type),
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
style={swipeStyle}
|
||||||
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
|
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
|
||||||
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
|
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
|
||||||
onFocus={() => pauseAutoCloseTimer(t.id)}
|
onFocus={() => pauseAutoCloseTimer(t.id)}
|
||||||
onBlur={() => resumeAutoCloseTimer(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={() => {
|
onClick={() => {
|
||||||
|
if (swipe.dragging || Math.abs(swipe.dx) > 8) return
|
||||||
if (!t.onClick) return
|
if (!t.onClick) return
|
||||||
t.onClick()
|
t.onClick()
|
||||||
if (t.closeOnClick !== false) remove(t.id)
|
if (t.closeOnClick !== false) remove(t.id)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user