From abdd071bfda2b1ca7265f28c1fb7cdda91637478 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:42:17 +0100 Subject: [PATCH] updated --- frontend/src/App.tsx | 115 +++- .../src/components/ui/FinishedDownloads.tsx | 248 ++------- .../ui/FinishedDownloadsCardsView.tsx | 37 +- .../ui/FinishedDownloadsGalleryView.tsx | 493 +++++++++--------- .../ui/FinishedDownloadsTableView.tsx | 36 +- frontend/src/components/ui/Player.tsx | 52 +- frontend/src/components/ui/formatters.ts | 6 + frontend/src/components/ui/notify.ts | 2 + 8 files changed, 456 insertions(+), 533 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f4010f..40e88f2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,6 +65,7 @@ type RecorderSettingsState = { teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean lowDiskPauseBelowGB?: number + enableNotifications?: boolean } type JobEvent = { @@ -124,6 +125,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { teaserPlayback: 'hover', teaserAudio: false, lowDiskPauseBelowGB: 5, + enableNotifications: true, } type StoredModel = { @@ -203,6 +205,27 @@ function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'aw } } +function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { + return normalizePendingShow(v) +} + +function roomStatusLabelDE(v: unknown): string { + switch (normalizeRoomStatusText(v)) { + case 'public': + return 'öffentlich' + case 'private': + return 'privat' + case 'hidden': + return 'hidden' + case 'away': + return 'away' + case 'offline': + return 'offline' + default: + return 'unbekannt' + } +} + function normalizeHttpUrl(raw: string): string | null { let v = (raw ?? '').trim() if (!v) return null @@ -450,6 +473,9 @@ export default function App() { const notifyRef = useRef(notify) + const lastKnownRoomStatusByKeyRef = useRef>({}) + const roomStatusBaselineReadyRef = useRef(false) + // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen) const cookieProblemLastAtRef = useRef(0) @@ -1131,6 +1157,67 @@ export default function App() { } }, [buildModelsByKey]) + const maybeNotifyWatchedModelStatusChange = useCallback(( + keyLower: string, + nextStatusRaw: unknown, + patch?: { + imageUrl?: string + } + ) => { + if (!keyLower) return + if (!recSettingsRef.current.enableNotifications) return + if (!roomStatusBaselineReadyRef.current) return + + const model = modelsByKeyRef.current[keyLower] + if (!model?.watching) { + lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw) + return + } + + const nextStatus = normalizeRoomStatusText(nextStatusRaw) + const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) + + // keine Änderung + if (prevStatus === nextStatus) return + + // keine "erste Sichtung" als Wechsel melden + const hadPreviousRealStatus = + Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) && + prevStatus !== 'unknown' + + lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus + + if (!hadPreviousRealStatus) return + + const displayName = + String(model?.modelKey ?? keyLower).trim() || keyLower + + const imageUrl = + String(patch?.imageUrl ?? '').trim() || + String(model?.imageUrl ?? '').trim() || + undefined + + const title = + nextStatus === 'public' + ? `${displayName} ist jetzt öffentlich` + : `${displayName} Status geändert` + + const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}` + + notifyRef.current?.info(title, message, { + imageUrl, + imageAlt: displayName, + durationMs: 5000, + onClick: () => { + window.dispatchEvent( + new CustomEvent('open-model-details', { + detail: { modelKey: keyLower }, + }) + ) + }, + }) + }, []) + const enqueueStart = useCallback( (item: StartQueueItem) => { const norm0 = normalizeHttpUrl(item.url) @@ -1223,6 +1310,10 @@ export default function App() { ? Boolean(msg.isOnline) : roomStatus !== 'offline' + maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, { + imageUrl: String(msg?.modelImageUrl ?? '').trim(), + }) + applyRoomStateToModel(modelKey, { roomStatus, isOnline, @@ -1247,7 +1338,7 @@ export default function App() { // ignore } } - }, [applyJobDelta, applyRoomStateToModel, enqueueStart]) + }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange]) useEffect(() => { const t = window.setInterval(() => setNowMs(Date.now()), 1000) @@ -1614,6 +1705,24 @@ export default function App() { modelsByKeyRef.current = modelsByKey }, [modelsByKey]) + useEffect(() => { + const next: Record = {} + + for (const [rawKey, model] of Object.entries(modelsByKey || {})) { + const key = String(rawKey ?? '').trim().toLowerCase() + if (!key) continue + + const status = normalizeRoomStatusText((model as any)?.roomStatus) + next[key] = status + } + + lastKnownRoomStatusByKeyRef.current = next + + if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) { + roomStatusBaselineReadyRef.current = true + } + }, [modelsByKey]) + const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey) useEffect(() => { pendingAutoStartByKeyRef.current = pendingAutoStartByKey @@ -1707,6 +1816,10 @@ export default function App() { for (const key of keys) { const snap = byKey[key] ?? { show: 'offline' as const } + maybeNotifyWatchedModelStatusChange(key, snap.show, { + imageUrl: snap.imageUrl, + }) + applyPendingRoomSnapshot(key, snap) if (snap.show === 'public') { diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index cbe898c..f60b6a9 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -276,81 +276,6 @@ async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) return res } -type QueuedMutationTask = { - id: string - run: () => Promise -} - -function useMutationQueue() { - const queueRef = React.useRef([]) - const runningRef = React.useRef(false) - const scheduledRef = React.useRef(false) - - // verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion - const pendingIdsRef = React.useRef>(new Set()) - - const schedulePump = React.useCallback(() => { - if (scheduledRef.current) return - scheduledRef.current = true - - const kick = () => { - scheduledRef.current = false - void pump() - } - - // best-effort "im Hintergrund" - if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { - ;(window as any).requestIdleCallback(kick, { timeout: 250 }) - } else { - setTimeout(kick, 0) - } - }, []) - - const pump = React.useCallback(async () => { - if (runningRef.current) return - runningRef.current = true - - try { - while (queueRef.current.length > 0) { - const task = queueRef.current.shift() - if (!task) continue - - try { - await task.run() - } finally { - pendingIdsRef.current.delete(task.id) - } - - // Yield zwischen Tasks -> UI bleibt responsiver - await new Promise((resolve) => setTimeout(resolve, 0)) - } - } finally { - runningRef.current = false - - // falls währenddessen neue Tasks reinkamen - if (queueRef.current.length > 0) { - schedulePump() - } - } - }, [schedulePump]) - - const enqueue = React.useCallback((id: string, run: () => Promise) => { - if (!id) return false - if (pendingIdsRef.current.has(id)) return false - - pendingIdsRef.current.add(id) - queueRef.current.push({ id, run }) - schedulePump() - return true - }, [schedulePump]) - - const isQueued = React.useCallback((id: string) => { - return pendingIdsRef.current.has(id) - }, []) - - return { enqueue, isQueued } -} - export default function FinishedDownloads({ jobs, doneJobs, @@ -385,8 +310,6 @@ export default function FinishedDownloads({ const notify = useNotify() - const mutationQueue = useMutationQueue() - const teaserHostsRef = React.useRef>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) @@ -398,6 +321,8 @@ export default function FinishedDownloads({ const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) + const [hiddenFiles, setHiddenFiles] = React.useState>(() => new Set()) + const [isLoading, setIsLoading] = React.useState(false) const refillInFlightRef = React.useRef(false) @@ -1126,37 +1051,54 @@ export default function FinishedDownloads({ }, []) const restoreRow = useCallback( - (key: string) => { - // Timer stoppen (falls die "commit delete"-Phase noch aussteht) + (key: string, file?: string) => { cancelRemoveTimer(key) - // wieder sichtbar machen setDeletedKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) + setRemovingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) + setDeletingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) + setKeepingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) + + if (file) { + setHiddenFiles((prev) => { + const next = new Set(prev) + next.delete(file) + return next + }) + } }, [cancelRemoveTimer] ) const animateRemove = useCallback( - (key: string) => { + (key: string, file?: string) => { + if (file) { + setHiddenFiles((prev) => { + const next = new Set(prev) + next.add(file) + return next + }) + } + markRemoving(key, true) cancelRemoveTimer(key) @@ -1301,7 +1243,7 @@ export default function FinishedDownloads({ } catch (e: any) { // Optimistik zurückrollen if (optimisticRemove) { - restoreRow(rowKey) + restoreRow(rowKey, file) } await onError?.(e) @@ -1497,27 +1439,35 @@ export default function FinishedDownloads({ if (!lastAction || undoing) return setUndoing(true) - const unhideByToken = (token: string) => { + const unhideRow = (keyOrToken: string, file?: string) => { setDeletedKeys((prev) => { const next = new Set(prev) - next.delete(token) + next.delete(keyOrToken) return next }) setDeletingKeys((prev) => { const next = new Set(prev) - next.delete(token) + next.delete(keyOrToken) return next }) setKeepingKeys((prev) => { const next = new Set(prev) - next.delete(token) + next.delete(keyOrToken) return next }) setRemovingKeys((prev) => { const next = new Set(prev) - next.delete(token) + next.delete(keyOrToken) return next }) + + if (file) { + setHiddenFiles((prev) => { + const next = new Set(prev) + next.delete(file) + return next + }) + } } try { @@ -1533,10 +1483,10 @@ export default function FinishedDownloads({ const data = await res.json().catch(() => null) as any const restoredFile = String(data?.restoredFile || lastAction.originalFile) - if (lastAction.rowKey) unhideByToken(lastAction.rowKey) + if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) // Fallbacks (falls alte lastAction ohne rowKey existiert) - unhideByToken(lastAction.originalFile) - unhideByToken(restoredFile) + unhideRow(lastAction.originalFile, lastAction.originalFile) + unhideRow(restoredFile, restoredFile) const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1 emitCountHint(visibleDelta) @@ -1557,10 +1507,10 @@ export default function FinishedDownloads({ const data = await res.json().catch(() => null) as any const restoredFile = String(data?.newFile || lastAction.originalFile) - if (lastAction.rowKey) unhideByToken(lastAction.rowKey) + if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) // Fallbacks (für ältere Actions / Sonderfälle) - unhideByToken(lastAction.originalFile) - unhideByToken(restoredFile) + unhideRow(lastAction.originalFile, lastAction.originalFile) + unhideRow(restoredFile, restoredFile) emitCountHint(+1) queueRefill() @@ -1697,93 +1647,6 @@ export default function FinishedDownloads({ ] ) - const enqueueDeleteVideo = useCallback((job: RecordJob): boolean => { - const key = keyFor(job) - const file = baseName(job.output || '') - if (!key || !file) return false - - // bereits aktiv? dann nicht nochmal - if (deletingKeys.has(key) || keepingKeys.has(key)) return false - - // sofort visuelles Busy (leichtgewichtig) - markDeleting(key, true) - - // ✅ sofort raus aus dem Stack (optimistisch) - animateRemove(key) - - const qid = `delete:${key}` - const accepted = mutationQueue.enqueue(qid, async () => { - try { - await deleteVideo(job, { alreadyRemoved: true }) - } finally { - // deleteVideo setzt markDeleting(false) selbst im finally, - // daher hier nichts zusätzlich nötig. - } - }) - - if (!accepted) { - restoreRow(key) // ✅ macht markDeleting false + removing/deleted rollback - } - - return accepted - }, [ - mutationQueue, - keyFor, - baseName, - deletingKeys, - keepingKeys, - markDeleting, - deleteVideo, - ]) - - const enqueueKeepVideo = useCallback((job: RecordJob): boolean => { - const key = keyFor(job) - const file = baseName(job.output || '') - if (!key || !file) return false - - if (keepingKeys.has(key) || deletingKeys.has(key)) return false - - markKeeping(key, true) - - // ✅ sofort aus dem sichtbaren Stack raus - animateRemove(key) - - const qid = `keep:${key}` - const accepted = mutationQueue.enqueue(qid, async () => { - try { - await keepVideo(job, { alreadyRemoved: true }) - } finally { - // keepVideo macht markKeeping(false) im finally - } - }) - - if (!accepted) { - restoreRow(key) - } - - return accepted - }, [ - mutationQueue, - keyFor, - baseName, - keepingKeys, - deletingKeys, - markKeeping, - keepVideo, - ]) - - const enqueueToggleHotVideo = useCallback((job: RecordJob): boolean => { - const key = keyFor(job) - if (!key) return false - - if (hotBusyKeys.has(key)) return false - - const qid = `hot:${key}` - return mutationQueue.enqueue(qid, async () => { - await toggleHotVideo(job) - }) - }, [mutationQueue, keyFor, toggleHotVideo, hotBusyKeys]) - const applyRenamedOutput = useCallback( (job: RecordJob): RecordJob => { const out = norm(job.output || '') @@ -1840,7 +1703,9 @@ export default function FinishedDownloads({ const list = Array.from(map.entries()) .map(([k, j]) => ({ k, j })) .filter(({ k, j }) => { + const file = baseName(j.output || '') if (deletedKeys.has(k)) return false + if (file && hiddenFiles.has(file)) return false // .trash niemals anzeigen if (isTrashOutput(j.output)) return false @@ -1871,7 +1736,7 @@ export default function FinishedDownloads({ .map(({ j }) => j) return list - }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput, keyFor, baseName]) + }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName]) const viewRows = rows @@ -1927,14 +1792,14 @@ export default function FinishedDownloads({ markDeleting(key, true) // ✅ Optimistik: überall gleich -> animiert raus - animateRemove(key) + animateRemove(key, detail.file) return } if (detail.phase === 'error') { // ✅ alles zurückrollen -> wieder sichtbar - restoreRow(key) + restoreRow(key, detail.file) // ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht) swipeRefs.current.get(key)?.reset() @@ -2608,9 +2473,6 @@ export default function FinishedDownloads({ onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} assetNonce={assetNonce ?? 0} - enqueueDeleteVideo={enqueueDeleteVideo} - enqueueKeepVideo={enqueueKeepVideo} - enqueueToggleHot={enqueueToggleHotVideo} postworkByFile={postworkByFile} /> @@ -2657,9 +2519,6 @@ export default function FinishedDownloads({ onAddToDownloads={onAddToDownloads} deleteVideo={deleteVideo} keepVideo={keepVideo} - enqueueDeleteVideo={enqueueDeleteVideo} - enqueueKeepVideo={enqueueKeepVideo} - enqueueToggleHot={enqueueToggleHotVideo} postworkByFile={postworkByFile} /> )} @@ -2699,9 +2558,6 @@ export default function FinishedDownloads({ activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} - enqueueDeleteVideo={enqueueDeleteVideo} - enqueueKeepVideo={enqueueKeepVideo} - enqueueToggleHot={enqueueToggleHotVideo} postworkByFile={postworkByFile} /> )} @@ -2716,14 +2572,6 @@ export default function FinishedDownloads({ setTeaserKey(null) setHoverTeaserKey(null) }) - - for (const j of pageRows) { - const f = baseName(j.output || '') - if (!f) continue - window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } })) - window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } })) - } - window.scrollTo({ top: 0, behavior: 'auto' }) onPageChange(p) }} diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 4833e2f..1fc1652 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -84,10 +84,6 @@ type Props = { onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise - enqueueDeleteVideo?: (job: RecordJob) => boolean - enqueueKeepVideo?: (job: RecordJob) => boolean - enqueueToggleHot?: (job: RecordJob) => boolean - postworkByFile: Record { const k = keyFor(j) @@ -922,6 +915,14 @@ export default function FinishedDownloadsCardsView({ const inlineDomId = `inline-prev-${encodeURIComponent(k)}` + const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop') + const previewInstanceKey = [ + k, + previewRenderRole, + inlineActive ? 'inline' : 'teaser', + allowTeaserAnimation ? 'anim' : 'still', + ].join('::') + // ✅ Shell an Gallery angelehnt const shellCls = [ 'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', @@ -1017,6 +1018,7 @@ export default function FinishedDownloadsCardsView({ ) : null} stripHotPrefix(baseName(p))} className="h-full w-full" @@ -1287,6 +1289,7 @@ export default function FinishedDownloadsCardsView({ forceLoadStill: true, blur: true, preloadTeaserWhenStill: true, + renderRole: 'back', }) const depth = idx @@ -1323,6 +1326,7 @@ export default function FinishedDownloadsCardsView({ isDecorative: true, forceLoadStill: true, preloadTeaserWhenStill: true, + renderRole: 'preload', }) return ( @@ -1350,6 +1354,7 @@ export default function FinishedDownloadsCardsView({ mobileStackTopOnlyVideo: true, disableScrubber: true, animateUnblurOnMount: true, + renderRole: 'top', }) return ( @@ -1371,12 +1376,6 @@ export default function FinishedDownloadsCardsView({ doubleTapMaxMovePx={48} onDoubleTap={async () => { if (isHot) return - - if (enqueueToggleHot) { - enqueueToggleHot(j) - return - } - await onToggleHot?.(j) }} onTap={() => { @@ -1387,14 +1386,8 @@ export default function FinishedDownloadsCardsView({ } }) }} - onSwipeLeft={() => { - if (enqueueDeleteVideo) return enqueueDeleteVideo(j) - return deleteVideo(j) - }} - onSwipeRight={() => { - if (enqueueKeepVideo) return enqueueKeepVideo(j) - return keepVideo(j) - }} + onSwipeLeft={() => deleteVideo(j)} + onSwipeRight={() => keepVideo(j)} > {cardInner} diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 68f7761..552dcc6 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -93,11 +93,6 @@ type Props = { onToggleHot: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise - - // optional queued actions (bevorzugt verwenden, falls vorhanden) - enqueueDeleteVideo?: (job: RecordJob) => boolean - enqueueKeepVideo?: (job: RecordJob) => boolean - enqueueToggleHot?: (job: RecordJob) => boolean } function firstNonEmptyString(...values: unknown[]): string | undefined { @@ -186,7 +181,6 @@ export default function FinishedDownloadsGalleryView({ deletingKeys, keepingKeys, removingKeys, - deletedKeys, registerTeaserHost, @@ -204,9 +198,6 @@ export default function FinishedDownloadsGalleryView({ onToggleWatch, onSplit, onAddToDownloads, - enqueueDeleteVideo, - enqueueKeepVideo, - enqueueToggleHot, }: Props) { // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll const observeTeasers = shouldObserveTeasers(teaserState.mode) @@ -331,8 +322,10 @@ export default function FinishedDownloadsGalleryView({ const resObj = resolutionObjOf(j) const resLabel = formatResolution(resObj) - const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) - const deleted = deletedKeys.has(k) + const isRemoving = removingKeys.has(k) + const isDeleting = deletingKeys.has(k) + const isKeeping = keepingKeys.has(k) + const busy = isDeleting || isKeeping || isRemoving // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat) const modelImageSrc = firstNonEmptyString( @@ -474,146 +467,152 @@ export default function FinishedDownloadsGalleryView({ showModelPreviewInThumb || showScrubberSpriteInThumb return ( -
onOpenPlayer(j)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) - }} - > - {/* Thumb */} +
{ - setHoveredThumbKey(k) - onHoverPreviewKeyChange?.(k) - }} - onMouseLeave={() => { - setHoveredThumbKey((prev) => (prev === k ? null : prev)) - onHoverPreviewKeyChange?.(null) - clearScrubIndex(k) - setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) + role="button" + tabIndex={0} + className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500" + onClick={() => onOpenPlayer(j)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} > - {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */} -
-
- stripHotPrefix(baseName(p))} - durationSeconds={durations[k] ?? (j as any)?.durationSeconds} - onDuration={handleDuration} - variant="fill" - showPopover={false} - blur={blurPreviews} - animated={shouldAnimateTeaser({ - state: teaserState, - itemKey: k, - disabled: hideTeaserUnderOverlay, - })} - animatedMode="teaser" - animatedTrigger="always" - muted={previewMuted} - popoverMuted={previewMuted} - scrubProgressRatio={scrubProgressRatio} - preferScrubProgress={typeof activeScrubIndex === 'number'} - /> -
- - {/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */} - {hasSpriteScrubber && spriteUrl ? ( - - ) : null} - - {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */} - {showScrubberSpriteInThumb && spriteFrameStyle ? ( -