From c1669b4eec205991525de57ce814d2cc56226c3b Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:46:24 +0100 Subject: [PATCH] updated finished downloads --- backend/recorder_settings.json | 2 +- frontend/src/components/ui/Downloads.tsx | 287 +++++++++++---- .../src/components/ui/FinishedDownloads.tsx | 51 ++- .../ui/FinishedDownloadsCardsView.tsx | 135 ++++++-- .../ui/FinishedDownloadsGalleryView.tsx | 16 +- .../ui/FinishedDownloadsTableView.tsx | 14 +- .../components/ui/FinishedVideoPreview.tsx | 326 ++++++++++++++++-- frontend/src/components/ui/SwipeCard.tsx | 46 ++- frontend/src/components/ui/ToastProvider.tsx | 192 ++++++++++- 9 files changed, 931 insertions(+), 138 deletions(-) diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 79b8a62..887c901 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -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=" } diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 340fc37..64fd64d 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -642,6 +642,9 @@ export default function Downloads({ const [stopRequestedIds, setStopRequestedIds] = useState>({}) const [stopInitiatedIds, setStopInitiatedIds] = useState>({}) + const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState>({}) + const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState>({}) + 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 = {} + + 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 = {} + + 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>({}) @@ -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 (
) @@ -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({ - {isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} + {isQueuedPostwork + ? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen') + : (isStopping ? 'Stoppe…' : 'Stoppen')} ) @@ -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 (
@@ -1338,7 +1452,7 @@ export default function Downloads({ return ( { @@ -1347,23 +1461,30 @@ export default function Downloads({ await onStopJob(j.id) }} > - +
+ +
) })} @@ -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 ( { - await onRemovePending?.(r.pending) + if (isRemovingPending) return + markRemovePendingRequested(pKey) + try { + await onRemovePending?.(r.pending) + } catch { + clearRemovePendingRequested(pKey) + } }} > - +
+ +
) })} @@ -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 ( { 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) }} > - +
+ +
) })} @@ -1483,6 +1639,7 @@ export default function Downloads({
(r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)} striped diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index f60b6a9..25fe289 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -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>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) + const [listPreviewActivationNonce, setListPreviewActivationNonce] = React.useState(0) + const lastListPreviewKeyRef = React.useRef(null) + const teaserIORef = React.useRef(null) const elToKeyRef = React.useRef>(new WeakMap()) @@ -335,12 +349,12 @@ export default function FinishedDownloads({ const teaserState = React.useMemo( () => ({ - 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} /> )} @@ -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} /> )} diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 1fc1652..7fd9c48 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -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(null) + + const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true) + const mobileTopTapEnableTimerRef = React.useRef(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 (
{!isSmall ? (
{rows.map((j) => { - const { k, cardInner } = renderCardItem(j) + const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' }) return ( - + {cardInner} ) @@ -1299,7 +1383,7 @@ export default function FinishedDownloadsCardsView({ return (
{ 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 (
@@ -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} diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 552dcc6..861a8e4 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -93,6 +93,9 @@ type Props = { onToggleHot: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise + + 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({
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 />
diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index 3daf70a..eec7ff9 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -92,6 +92,8 @@ type Props = { deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise + 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(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({ }} > {/* Scrub-Frame Overlay */} @@ -542,6 +552,8 @@ export default function FinishedDownloadsTableView({ runtimeOf, parseTags, runtimeSecondsForSort, + forcePreviewMuted, + previewActivationNonce, ]) const sortStateToMode = React.useCallback((s: SortState): SortMode => { diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 63bcbcb..c56a5d7 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -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(null) const teaserMp4Ref = useRef(null) + const lastTeaserRefStateRef = useRef<'attached' | 'detached' | null>(null) const clipsRef = useRef(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).catch(() => {}) + } + }, [muted]) + + useEffect(() => { + if (!registerTeaserPlay) return + registerTeaserPlay(playTeaserFromGesture) + return () => registerTeaserPlay(null) +}, [registerTeaserPlay, playTeaserFromGesture]) + + const teaserPlayRetryTimerRef = useRef(null) + const teaserLastProgressTimeRef = useRef(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