updated
This commit is contained in:
parent
32a74e0be0
commit
abdd071bfd
@ -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') {
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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']}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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 ''
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\notify.ts
|
||||
|
||||
'use client'
|
||||
|
||||
import type { Toast } from './ToastProvider'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user