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'
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<Record<string, string>>({})
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<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)
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') {

View File

@ -276,81 +276,6 @@ async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit)
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({
jobs,
doneJobs,
@ -385,8 +310,6 @@ export default function FinishedDownloads({
const notify = useNotify()
const mutationQueue = useMutationQueue()
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
@ -398,6 +321,8 @@ export default function FinishedDownloads({
const [deletedKeys, setDeletedKeys] = 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 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}
/>
</div>
@ -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)
}}

View File

@ -84,10 +84,6 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
postworkByFile: Record<string, {
file: string
assetId?: string
@ -591,10 +587,6 @@ export default function FinishedDownloadsCardsView({
onToggleWatch,
onSplit,
onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
postworkByFile
}: Props) {
@ -722,6 +714,7 @@ export default function FinishedDownloadsCardsView({
blur?: boolean
animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean
renderRole?: 'top' | 'back' | 'preload' | 'desktop'
}
) => {
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}
<FinishedVideoPreview
key={previewInstanceKey}
job={j}
getFileName={(p) => 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}
</SwipeCard>

View File

@ -93,11 +93,6 @@ type Props = {
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
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 {
@ -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 (
<div
key={k}
role="button"
tabIndex={0}
className={[
'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)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Thumb */}
<div key={k} className="relative">
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => {
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ü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => 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'}
/>
</div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div
className="h-full w-full"
style={spriteFrameStyle}
<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 */}
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => {
setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k)
}}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
onHoverPreviewKeyChange?.(null)
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => 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'}
/>
</div>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
) : null}
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div
className="h-full w-full"
style={spriteFrameStyle}
/>
</div>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
</div>
) : null}
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
</div>
</div>
</div>
{/* Footer / Meta */}
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
@ -635,140 +634,124 @@ export default function FinishedDownloadsGalleryView({
</div>
</div>
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span
className={[
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
e.stopPropagation()
if (modelImageSrc) setHoveredModelPreviewKey(k)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{model || '—'}
</span>
{showPostworkBadge ? (
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span
className={[
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')
}
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
e.stopPropagation()
if (modelImageSrc) setHoveredModelPreviewKey(k)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
{model || '—'}
</span>
{showPostworkBadge ? (
<span
className={[
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
: 'Nacharbeiten fehlgeschlagen')
}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
</span>
) : null}
</div>
) : null}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={async (job) => {
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
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}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
</div>
</div>
)
})}
})}
</div>
{isLoading && rows.length === 0 ? (

View File

@ -92,11 +92,6 @@ type Props = {
deleteVideo: (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({
@ -145,9 +140,6 @@ export default function FinishedDownloadsTableView({
deleteVideo,
keepVideo,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) {
const [sort, setSort] = React.useState<SortState>(null)
@ -502,31 +494,9 @@ export default function FinishedDownloadsTableView({
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={
onToggleHot
? async (job) => {
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)
}}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
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 { apiUrl, apiFetch } from '../../lib/api'
import LiveVideo from './LiveVideo'
import { formatResolution, formatFps } from './formatters'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -103,17 +104,6 @@ const pickNum = (...vals: any[]): number | 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 {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
@ -432,19 +422,31 @@ export default function Player({
setPreviewSrc(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(
() => 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]
)
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])
React.useEffect(() => {
@ -541,9 +543,15 @@ export default function Player({
const updateIntrinsicDims = React.useCallback(() => {
const p: any = playerRef.current
if (!p || p.isDisposed?.()) return
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 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 =
pickNum(
intrH,
intrSize?.h,
(playerRef.current as any)?.videoHeight?.(),
anyJob.videoHeight,
anyJob.height,
@ -1211,7 +1219,7 @@ export default function Player({
// Fallback wenn nur Höhe bekannt ist
// (lieber stabiler fallback als kaputt)
return 16 / 9
}, [job, intrH])
}, [job, intrSize])
const clampMiniRect = React.useCallback(
(r: { x: number; y: number; w: number; h: number }) => {

View File

@ -1,5 +1,11 @@
// 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 {
if (!r) return ''

View File

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