This commit is contained in:
Linrador 2026-03-19 11:42:17 +01:00
parent 32a74e0be0
commit abdd071bfd
8 changed files with 456 additions and 533 deletions

View File

@ -65,6 +65,7 @@ type RecorderSettingsState = {
teaserPlayback?: 'still' | 'hover' | 'all' teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
lowDiskPauseBelowGB?: number lowDiskPauseBelowGB?: number
enableNotifications?: boolean
} }
type JobEvent = { type JobEvent = {
@ -124,6 +125,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
teaserPlayback: 'hover', teaserPlayback: 'hover',
teaserAudio: false, teaserAudio: false,
lowDiskPauseBelowGB: 5, lowDiskPauseBelowGB: 5,
enableNotifications: true,
} }
type StoredModel = { 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 { function normalizeHttpUrl(raw: string): string | null {
let v = (raw ?? '').trim() let v = (raw ?? '').trim()
if (!v) return null if (!v) return null
@ -450,6 +473,9 @@ export default function App() {
const notifyRef = useRef(notify) const notifyRef = useRef(notify)
const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
const roomStatusBaselineReadyRef = useRef(false)
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen) // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
const cookieProblemLastAtRef = useRef(0) const cookieProblemLastAtRef = useRef(0)
@ -1131,6 +1157,67 @@ export default function App() {
} }
}, [buildModelsByKey]) }, [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( const enqueueStart = useCallback(
(item: StartQueueItem) => { (item: StartQueueItem) => {
const norm0 = normalizeHttpUrl(item.url) const norm0 = normalizeHttpUrl(item.url)
@ -1223,6 +1310,10 @@ export default function App() {
? Boolean(msg.isOnline) ? Boolean(msg.isOnline)
: roomStatus !== 'offline' : roomStatus !== 'offline'
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
})
applyRoomStateToModel(modelKey, { applyRoomStateToModel(modelKey, {
roomStatus, roomStatus,
isOnline, isOnline,
@ -1247,7 +1338,7 @@ export default function App() {
// ignore // ignore
} }
} }
}, [applyJobDelta, applyRoomStateToModel, enqueueStart]) }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
useEffect(() => { useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000) const t = window.setInterval(() => setNowMs(Date.now()), 1000)
@ -1614,6 +1705,24 @@ export default function App() {
modelsByKeyRef.current = modelsByKey modelsByKeyRef.current = modelsByKey
}, [modelsByKey]) }, [modelsByKey])
useEffect(() => {
const next: Record<string, string> = {}
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) const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
useEffect(() => { useEffect(() => {
pendingAutoStartByKeyRef.current = pendingAutoStartByKey pendingAutoStartByKeyRef.current = pendingAutoStartByKey
@ -1707,6 +1816,10 @@ export default function App() {
for (const key of keys) { for (const key of keys) {
const snap = byKey[key] ?? { show: 'offline' as const } const snap = byKey[key] ?? { show: 'offline' as const }
maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl,
})
applyPendingRoomSnapshot(key, snap) applyPendingRoomSnapshot(key, snap)
if (snap.show === 'public') { if (snap.show === 'public') {

View File

@ -276,81 +276,6 @@ async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit)
return res return res
} }
type QueuedMutationTask = {
id: string
run: () => Promise<void>
}
function useMutationQueue() {
const queueRef = React.useRef<QueuedMutationTask[]>([])
const runningRef = React.useRef(false)
const scheduledRef = React.useRef(false)
// verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion
const pendingIdsRef = React.useRef<Set<string>>(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<void>((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<void>) => {
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({ export default function FinishedDownloads({
jobs, jobs,
doneJobs, doneJobs,
@ -385,8 +310,6 @@ export default function FinishedDownloads({
const notify = useNotify() const notify = useNotify()
const mutationQueue = useMutationQueue()
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)
@ -398,6 +321,8 @@ export default function FinishedDownloads({
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set()) const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
const [isLoading, setIsLoading] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false) const refillInFlightRef = React.useRef(false)
@ -1126,37 +1051,54 @@ export default function FinishedDownloads({
}, []) }, [])
const restoreRow = useCallback( const restoreRow = useCallback(
(key: string) => { (key: string, file?: string) => {
// Timer stoppen (falls die "commit delete"-Phase noch aussteht)
cancelRemoveTimer(key) cancelRemoveTimer(key)
// wieder sichtbar machen
setDeletedKeys((prev) => { setDeletedKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(key) next.delete(key)
return next return next
}) })
setRemovingKeys((prev) => { setRemovingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(key) next.delete(key)
return next return next
}) })
setDeletingKeys((prev) => { setDeletingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(key) next.delete(key)
return next return next
}) })
setKeepingKeys((prev) => { setKeepingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(key) next.delete(key)
return next return next
}) })
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
}, },
[cancelRemoveTimer] [cancelRemoveTimer]
) )
const animateRemove = useCallback( 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) markRemoving(key, true)
cancelRemoveTimer(key) cancelRemoveTimer(key)
@ -1301,7 +1243,7 @@ export default function FinishedDownloads({
} catch (e: any) { } catch (e: any) {
// Optimistik zurückrollen // Optimistik zurückrollen
if (optimisticRemove) { if (optimisticRemove) {
restoreRow(rowKey) restoreRow(rowKey, file)
} }
await onError?.(e) await onError?.(e)
@ -1497,27 +1439,35 @@ export default function FinishedDownloads({
if (!lastAction || undoing) return if (!lastAction || undoing) return
setUndoing(true) setUndoing(true)
const unhideByToken = (token: string) => { const unhideRow = (keyOrToken: string, file?: string) => {
setDeletedKeys((prev) => { setDeletedKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(token) next.delete(keyOrToken)
return next return next
}) })
setDeletingKeys((prev) => { setDeletingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(token) next.delete(keyOrToken)
return next return next
}) })
setKeepingKeys((prev) => { setKeepingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(token) next.delete(keyOrToken)
return next return next
}) })
setRemovingKeys((prev) => { setRemovingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(token) next.delete(keyOrToken)
return next return next
}) })
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
} }
try { try {
@ -1533,10 +1483,10 @@ export default function FinishedDownloads({
const data = await res.json().catch(() => null) as any const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.restoredFile || lastAction.originalFile) 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) // Fallbacks (falls alte lastAction ohne rowKey existiert)
unhideByToken(lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideByToken(restoredFile) unhideRow(restoredFile, restoredFile)
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1 const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta) emitCountHint(visibleDelta)
@ -1557,10 +1507,10 @@ export default function FinishedDownloads({
const data = await res.json().catch(() => null) as any const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.newFile || lastAction.originalFile) 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) // Fallbacks (für ältere Actions / Sonderfälle)
unhideByToken(lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideByToken(restoredFile) unhideRow(restoredFile, restoredFile)
emitCountHint(+1) emitCountHint(+1)
queueRefill() 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( const applyRenamedOutput = useCallback(
(job: RecordJob): RecordJob => { (job: RecordJob): RecordJob => {
const out = norm(job.output || '') const out = norm(job.output || '')
@ -1840,7 +1703,9 @@ export default function FinishedDownloads({
const list = Array.from(map.entries()) const list = Array.from(map.entries())
.map(([k, j]) => ({ k, j })) .map(([k, j]) => ({ k, j }))
.filter(({ k, j }) => { .filter(({ k, j }) => {
const file = baseName(j.output || '')
if (deletedKeys.has(k)) return false if (deletedKeys.has(k)) return false
if (file && hiddenFiles.has(file)) return false
// .trash niemals anzeigen // .trash niemals anzeigen
if (isTrashOutput(j.output)) return false if (isTrashOutput(j.output)) return false
@ -1871,7 +1736,7 @@ export default function FinishedDownloads({
.map(({ j }) => j) .map(({ j }) => j)
return list return list
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput, keyFor, baseName]) }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName])
const viewRows = rows const viewRows = rows
@ -1927,14 +1792,14 @@ export default function FinishedDownloads({
markDeleting(key, true) markDeleting(key, true)
// ✅ Optimistik: überall gleich -> animiert raus // ✅ Optimistik: überall gleich -> animiert raus
animateRemove(key) animateRemove(key, detail.file)
return return
} }
if (detail.phase === 'error') { if (detail.phase === 'error') {
// ✅ alles zurückrollen -> wieder sichtbar // ✅ alles zurückrollen -> wieder sichtbar
restoreRow(key) restoreRow(key, detail.file)
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht) // ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
swipeRefs.current.get(key)?.reset() swipeRefs.current.get(key)?.reset()
@ -2608,9 +2473,6 @@ export default function FinishedDownloads({
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile} postworkByFile={postworkByFile}
/> />
</div> </div>
@ -2657,9 +2519,6 @@ export default function FinishedDownloads({
onAddToDownloads={onAddToDownloads} onAddToDownloads={onAddToDownloads}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile} postworkByFile={postworkByFile}
/> />
)} )}
@ -2699,9 +2558,6 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile} postworkByFile={postworkByFile}
/> />
)} )}
@ -2716,14 +2572,6 @@ export default function FinishedDownloads({
setTeaserKey(null) setTeaserKey(null)
setHoverTeaserKey(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' }) window.scrollTo({ top: 0, behavior: 'auto' })
onPageChange(p) onPageChange(p)
}} }}

View File

@ -84,10 +84,6 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void> onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
postworkByFile: Record<string, { postworkByFile: Record<string, {
file: string file: string
assetId?: string assetId?: string
@ -591,10 +587,6 @@ export default function FinishedDownloadsCardsView({
onToggleWatch, onToggleWatch,
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
postworkByFile postworkByFile
}: Props) { }: Props) {
@ -722,6 +714,7 @@ export default function FinishedDownloadsCardsView({
blur?: boolean blur?: boolean
animateUnblurOnMount?: boolean animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean preloadTeaserWhenStill?: boolean
renderRole?: 'top' | 'back' | 'preload' | 'desktop'
} }
) => { ) => {
const k = keyFor(j) const k = keyFor(j)
@ -922,6 +915,14 @@ export default function FinishedDownloadsCardsView({
const inlineDomId = `inline-prev-${encodeURIComponent(k)}` 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 // ✅ Shell an Gallery angelehnt
const shellCls = [ const shellCls = [
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', '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} ) : null}
<FinishedVideoPreview <FinishedVideoPreview
key={previewInstanceKey}
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full" className="h-full w-full"
@ -1287,6 +1289,7 @@ export default function FinishedDownloadsCardsView({
forceLoadStill: true, forceLoadStill: true,
blur: true, blur: true,
preloadTeaserWhenStill: true, preloadTeaserWhenStill: true,
renderRole: 'back',
}) })
const depth = idx const depth = idx
@ -1323,6 +1326,7 @@ export default function FinishedDownloadsCardsView({
isDecorative: true, isDecorative: true,
forceLoadStill: true, forceLoadStill: true,
preloadTeaserWhenStill: true, preloadTeaserWhenStill: true,
renderRole: 'preload',
}) })
return ( return (
@ -1350,6 +1354,7 @@ export default function FinishedDownloadsCardsView({
mobileStackTopOnlyVideo: true, mobileStackTopOnlyVideo: true,
disableScrubber: true, disableScrubber: true,
animateUnblurOnMount: true, animateUnblurOnMount: true,
renderRole: 'top',
}) })
return ( return (
@ -1371,12 +1376,6 @@ export default function FinishedDownloadsCardsView({
doubleTapMaxMovePx={48} doubleTapMaxMovePx={48}
onDoubleTap={async () => { onDoubleTap={async () => {
if (isHot) return if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j) await onToggleHot?.(j)
}} }}
onTap={() => { onTap={() => {
@ -1387,14 +1386,8 @@ export default function FinishedDownloadsCardsView({
} }
}) })
}} }}
onSwipeLeft={() => { onSwipeLeft={() => deleteVideo(j)}
if (enqueueDeleteVideo) return enqueueDeleteVideo(j) onSwipeRight={() => keepVideo(j)}
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
> >
{cardInner} {cardInner}
</SwipeCard> </SwipeCard>

View File

@ -93,11 +93,6 @@ 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>
// 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 { function firstNonEmptyString(...values: unknown[]): string | undefined {
@ -186,7 +181,6 @@ export default function FinishedDownloadsGalleryView({
deletingKeys, deletingKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
deletedKeys,
registerTeaserHost, registerTeaserHost,
@ -204,9 +198,6 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch, onToggleWatch,
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: 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)
@ -331,8 +322,10 @@ export default function FinishedDownloadsGalleryView({
const resObj = resolutionObjOf(j) const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj) const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const isRemoving = removingKeys.has(k)
const deleted = deletedKeys.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) // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
const modelImageSrc = firstNonEmptyString( const modelImageSrc = firstNonEmptyString(
@ -474,28 +467,34 @@ export default function FinishedDownloadsGalleryView({
showModelPreviewInThumb || showScrubberSpriteInThumb showModelPreviewInThumb || showScrubberSpriteInThumb
return ( return (
<div key={k} className="relative">
<div <div
key={k}
role="button" role="button"
tabIndex={0} tabIndex={0}
className={[ className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)} onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}} }}
>
<div
className={[
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
: 'opacity-100 translate-y-0 scale-100',
]
.filter(Boolean)
.join(' ')}
> >
{/* Thumb */} {/* Thumb */}
<div <div
@ -725,27 +724,9 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleHot={async (job) => { onToggleHot={onToggleHot}
if (enqueueToggleHot) { onKeep={keepVideo}
const accepted = enqueueToggleHot(job) onDelete={deleteVideo}
if (accepted) return
}
return onToggleHot(job)
}}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
onSplit={onSplit} onSplit={onSplit}
onAddToDownloads={onAddToDownloads} onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
@ -767,6 +748,8 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
) )
})} })}
</div> </div>

View File

@ -92,11 +92,6 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
@ -145,9 +140,6 @@ export default function FinishedDownloadsTableView({
deleteVideo, deleteVideo,
keepVideo, keepVideo,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
const [sort, setSort] = React.useState<SortState>(null) const [sort, setSort] = React.useState<SortState>(null)
@ -502,31 +494,9 @@ export default function FinishedDownloadsTableView({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleHot={ onToggleHot={onToggleHot}
onToggleHot onKeep={keepVideo}
? async (job) => { onDelete={deleteVideo}
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}
: undefined
}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
onSplit={onSplit} onSplit={onSplit}
onAddToDownloads={onAddToDownloads} onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}

View File

@ -20,6 +20,7 @@ import RecordJobActions from './RecordJobActions'
import Button from './Button' import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api' import { apiUrl, apiFetch } from '../../lib/api'
import LiveVideo from './LiveVideo' import LiveVideo from './LiveVideo'
import { formatResolution, formatFps } from './formatters'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -103,17 +104,6 @@ const pickNum = (...vals: any[]): number | null => {
return null return null
} }
function formatFps(n?: number | null): string {
if (!n || !Number.isFinite(n)) return '—'
const digits = n >= 10 ? 0 : 2
return `${n.toFixed(digits)} fps`
}
function formatResolution(h?: number | null): string {
if (!h || !Number.isFinite(h)) return '—'
return `${Math.round(h)}p`
}
function parseDateFromOutput(output?: string): Date | null { function parseDateFromOutput(output?: string): Date | null {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
@ -432,19 +422,31 @@ export default function Player({
setPreviewSrc(previewA) setPreviewSrc(previewA)
}, [previewA]) }, [previewA])
const videoH = React.useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const fps = React.useMemo( const fps = React.useMemo(
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate), () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate] [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
) )
const [intrH, setIntrH] = React.useState<number | null>(null) const [intrSize, setIntrSize] = React.useState<{ w: number; h: number } | null>(null)
const resolutionLabel = React.useMemo(() => formatResolution(intrH ?? videoH), [intrH, videoH]) const videoW = React.useMemo(
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
)
const videoH = React.useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const resolutionLabel = React.useMemo(
() =>
formatResolution(
intrSize ??
(videoW && videoH ? { w: videoW, h: videoH } : null)
),
[intrSize, videoW, videoH]
)
const fpsLabel = React.useMemo(() => formatFps(fps), [fps]) const fpsLabel = React.useMemo(() => formatFps(fps), [fps])
React.useEffect(() => { React.useEffect(() => {
@ -541,9 +543,15 @@ export default function Player({
const updateIntrinsicDims = React.useCallback(() => { const updateIntrinsicDims = React.useCallback(() => {
const p: any = playerRef.current const p: any = playerRef.current
if (!p || p.isDisposed?.()) return if (!p || p.isDisposed?.()) return
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 0
const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0 const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
if (typeof h === 'number' && h > 0 && Number.isFinite(h)) {
setIntrH(h) if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
setIntrSize({ w, h })
} }
}, []) }, [])
@ -1199,7 +1207,7 @@ export default function Player({
const h = const h =
pickNum( pickNum(
intrH, intrSize?.h,
(playerRef.current as any)?.videoHeight?.(), (playerRef.current as any)?.videoHeight?.(),
anyJob.videoHeight, anyJob.videoHeight,
anyJob.height, anyJob.height,
@ -1211,7 +1219,7 @@ export default function Player({
// Fallback wenn nur Höhe bekannt ist // Fallback wenn nur Höhe bekannt ist
// (lieber stabiler fallback als kaputt) // (lieber stabiler fallback als kaputt)
return 16 / 9 return 16 / 9
}, [job, intrH]) }, [job, intrSize])
const clampMiniRect = React.useCallback( const clampMiniRect = React.useCallback(
(r: { x: number; y: number; w: number; h: number }) => { (r: { x: number; y: number; w: number; h: number }) => {

View File

@ -1,5 +1,11 @@
// frontend/src/components/ui/formatters.ts // frontend/src/components/ui/formatters.ts
export function formatFps(n?: number | null): string {
if (!n || !Number.isFinite(n)) return '—'
const digits = n >= 10 ? 0 : 2
return `${n.toFixed(digits)} fps`
}
export function formatResolution(r?: { w: number; h: number } | null): string { export function formatResolution(r?: { w: number; h: number } | null): string {
if (!r) return '' if (!r) return ''

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\notify.ts
'use client' 'use client'
import type { Toast } from './ToastProvider' import type { Toast } from './ToastProvider'