nsfwapp/frontend/src/components/ui/Downloads.tsx
2026-04-14 15:18:33 +02:00

1967 lines
65 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\Downloads.tsx
'use client'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { useMediaQuery } from '../../lib/useMediaQuery'
import DownloadsCardRow, {
PendingPreviewImage,
type DownloadRow,
type PendingWatchedRoom,
} from './DownloadsCardRow'
import SwipeActionRow from './SwipeActionRow'
import RecordJobActions from './RecordJobActions'
import LazyMountWhenVisible from './LazyMountWhenVisible'
import CountryFlag from './CountryFlag'
type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
autostartState?: AutostartState
onRefreshAutostartState?: () => Promise<void> | void
modelsByKey?: Record<
string,
{
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
imageUrl?: string
gender?: string | null
country?: string | null
}
>
roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void>
onStopAllJobs?: () => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void>
onRemovePending?: (pending: PendingWatchedRoom) => void | Promise<void>
enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number
}
const countryOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { country?: string | null }>
): string => {
const anyJ = job as any
const modelKey = modelKeyFromJob(job)
return String(
modelsByKey?.[modelKey]?.country ??
anyJ?.country ??
anyJ?.model?.country ??
anyJ?.room?.country ??
anyJ?.meta?.country ??
anyJ?.meta?.model?.country ??
''
).trim()
}
const countryOfPending = (
p: PendingWatchedRoom,
modelsByKey?: Record<string, { country?: string | null }>
): string => {
const anyP = p as any
const pendingKey = String(anyP?.modelKey ?? pendingModelName(p)).trim().toLowerCase()
return String(
modelsByKey?.[pendingKey]?.country ??
anyP?.country ??
anyP?.room?.country ??
''
).trim()
}
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): boolean => {
const anyJ = job as any
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
return (
src.includes('chaturbate.com') ||
chat.includes('chaturbate.com') ||
img.includes('highwebmedia') ||
img.includes('chaturbate')
)
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
if (!isChaturbateJob(job, modelsByKey)) return ''
return onlineImageUrlOfJob(job, modelsByKey)
}
const pendingModelName = (p: PendingWatchedRoom) => {
const anyP = p as any
return (
anyP.modelName ??
anyP.model ??
anyP.modelKey ??
anyP.username ??
anyP.name ??
'—'
)
}
const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
}
const pendingImageUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.imageUrl ?? anyP.image_url ?? '').trim()
}
const pendingRowKey = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.key ?? anyP.id ?? pendingModelName(p))
}
const normalizeRoomStatus = (v: unknown): string => {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'Public'
case 'private':
return 'Private'
case 'hidden':
return 'Hidden'
case 'away':
return 'Away'
case 'offline':
return 'Offline'
default:
return 'Unknown'
}
}
const modelKeyFromJob = (job: RecordJob): string => {
const j = job as any
const rawUrl = String(j.sourceUrl ?? '').trim()
if (rawUrl) {
const m = rawUrl.match(/chaturbate\.com\/([^/?#]+)/i)
if (m?.[1]) {
try {
return decodeURIComponent(m[1]).trim().toLowerCase()
} catch {
return String(m[1]).trim().toLowerCase()
}
}
}
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const onlineImageUrlOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
const anyJ = job as any
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()
if (direct) return direct
const modelKey = modelKeyFromJob(job)
if (!modelKey) return ''
return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>
): string => {
const j = job as any
const rawFromJob =
j.currentShow ??
j.roomStatus ??
j.modelStatus ??
j.show ??
j.statusText ??
j.model?.currentShow ??
j.model?.roomStatus ??
j.model?.status ??
j.room?.currentShow ??
j.room?.status
if (rawFromJob != null && String(rawFromJob).trim() !== '') {
return normalizeRoomStatus(rawFromJob)
}
const modelKey = modelKeyFromJob(job)
if (modelKey && roomStatusByModelKey?.[modelKey]) {
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
}
if (modelKey && modelsByKey?.[modelKey]?.roomStatus) {
return normalizeRoomStatus(modelsByKey[modelKey].roomStatus)
}
return 'Unknown'
}
function effectiveRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey)
if (raw !== 'Unknown') return raw
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
if (isActiveRecording && growingByJobId?.[job.id]) {
return 'Public'
}
return raw
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
return 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
case 'Private':
return 'bg-fuchsia-500/15 text-fuchsia-900 ring-fuchsia-500/30 dark:bg-fuchsia-400/10 dark:text-fuchsia-200 dark:ring-fuchsia-400/25'
case 'Hidden':
return 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
case 'Away':
return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
case 'Offline':
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
case 'Unknown':
default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
const toMs = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v < 1_000_000_000_000 ? v * 1000 : v
}
if (typeof v === 'string') {
const ms = Date.parse(v)
return Number.isFinite(ms) ? ms : 0
}
if (v instanceof Date) return v.getTime()
return 0
}
const addedAtMsOf = (r: DownloadRow): number => {
if (r.kind === 'job') {
const j = r.job as any
return (
toMs(j.addedAt) ||
toMs(j.createdAt) ||
toMs(j.enqueuedAt) ||
toMs(j.queuedAt) ||
toMs(j.requestedAt) ||
toMs(j.startedAt) ||
0
)
}
const p = r.pending as any
return (
toMs(p.addedAt) ||
toMs(p.createdAt) ||
toMs(p.enqueuedAt) ||
toMs(p.queuedAt) ||
toMs(p.requestedAt) ||
0
)
}
const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stop wird angefordert…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
return 'Konvertiere Container zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau/Thumbnails…'
case 'analyze':
return 'AI analysiert Segmente…'
case 'postwork':
return 'Nacharbeiten laufen…'
default:
return ''
}
}
function postWorkLabel(
job: RecordJob,
override?: { pos?: number; total?: number }
): string {
const anyJ = job as any
const pw = anyJ.postWork
const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'running') {
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…'
}
if (effectiveState === 'queued') {
const posServer = typeof pw?.position === 'number' ? pw.position : 0
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
const totalServer = Math.max(waitingServer, posServer)
const pos =
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
? override.pos
: posServer
const total =
typeof override?.total === 'number' && Number.isFinite(override.total) && override.total > 0
? override.total
: totalServer
return pos > 0 && total > 0
? `Warte auf Nacharbeiten… ${pos} / ${total}`
: 'Warte auf Nacharbeiten…'
}
return 'Warte auf Nacharbeiten…'
}
function StatusCell({
job,
postworkInfo,
}: {
job: RecordJob
postworkInfo?: { pos?: number; total?: number }
}) {
const anyJ = job as any
const phaseRaw = String(anyJ?.phase ?? '').trim()
const phase = phaseRaw.toLowerCase()
const progress = Number(anyJ?.progress ?? 0)
const isRecording = phase === 'recording'
const isPwJob = isPostworkJob(job)
const pwState = getEffectivePostworkState(job)
let text = ''
if (isRecording) {
text = 'Recording läuft…'
} else if (isPwJob) {
if (phase === 'probe' || phase === 'remuxing' || phase === 'moving' || phase === 'assets') {
text = phaseLabel(phaseRaw) || phaseRaw
} else if (pwState === 'queued') {
text = postWorkLabel(job, postworkInfo)
} else if (pwState === 'running') {
text = phaseLabel(phaseRaw) || postWorkLabel(job, postworkInfo)
}
}
if (!text) {
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
}
const showBar =
!isRecording &&
Number.isFinite(progress) &&
progress > 0 &&
progress < 100
const showIndeterminate =
!isRecording &&
!showBar &&
Boolean(text) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
return (
<div className="min-w-0">
{showBar ? (
<ProgressBar
label={text}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : showIndeterminate ? (
<ProgressBar
label={text}
indeterminate
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : (
<div className="truncate">
<span className="font-medium">{text}</span>
</div>
)}
</div>
)
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob, nowMs: number) => {
const anyJ = j as any
const start =
toMs(anyJ.startedAtMs) ||
toMs(j.startedAt)
if (!Number.isFinite(start) || start <= 0) return '—'
const end =
j.endedAt
? (toMs(anyJ.endedAtMs) || toMs(j.endedAt))
: nowMs
if (!Number.isFinite(end) || end <= 0) return '—'
return formatDuration(end - start)
}
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
const formatBytes = (bytes: number | null): string => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 10 ? 1 : 2
const s = v.toFixed(digits).replace(/\.0+$/, '')
return `${s} ${units[i]}`
}
const isPostworkJob = (job: RecordJob): boolean => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
if (phase === 'postwork') return true
return false
}
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
if (!isPostworkJob(job)) return 'none'
if (isTerminalStatus(anyJ?.status)) return 'none'
if (pwState === 'queued') return 'queued'
if (pwState === 'running') return 'running'
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
pw.position > 0
) {
return 'queued'
}
if (
typeof pw?.running === 'number' &&
Number.isFinite(pw.running) &&
pw.running > 0 &&
(!Number.isFinite(pw?.position) || pw.position <= 0)
) {
return 'running'
}
if (hasPwKey) {
return 'queued'
}
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'
}
const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
s === 'stopped' ||
s === 'finished' ||
s === 'failed' ||
s === 'done' ||
s === 'completed' ||
s === 'canceled' ||
s === 'cancelled'
)
}
function DiskEmergencyBadge() {
return (
<span
className="
inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold
bg-red-100 text-red-800 ring-1 ring-red-200
dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/25
"
title="Speicherplatz-Notbremse aktiv: Autostart gesperrt und Downloads wurden gestoppt"
>
Speicherplatz-Notbremse aktiv
</span>
)
}
export default function Downloads({
jobs,
pending = [],
autostartState,
onRefreshAutostartState,
onOpenPlayer,
onStopJob,
onStopAllJobs,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onAddToDownloads,
onRemovePending,
onRemoveQueuedPostworkJob,
modelsByKey = {},
roomStatusByModelKey = {},
blurPreviews,
enableConcurrentDownloadsLimit = false,
maxConcurrentDownloads = 20,
}: Props) {
const isDesktop = useMediaQuery('(min-width: 640px)', true)
const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false)
const [, setWatchedPausedByUser] = useState(false)
const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false)
const watchedPausedRef = useRef<boolean | null>(null)
const watchedPausedByUserRef = useRef<boolean | null>(null)
const watchedPausedByDiskRef = useRef<boolean | null>(null)
const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => {
try {
await onRefreshAutostartState?.()
} catch {
// ignore
}
}, [onRefreshAutostartState])
useEffect(() => {
const nextPaused = Boolean(autostartState?.paused)
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
const nextPausedByDisk = Boolean(autostartState?.pausedByDisk)
const unchanged =
watchedPausedRef.current === nextPaused &&
watchedPausedByUserRef.current === nextPausedByUser &&
watchedPausedByDiskRef.current === nextPausedByDisk
if (unchanged) return
watchedPausedRef.current = nextPaused
watchedPausedByUserRef.current = nextPausedByUser
watchedPausedByDiskRef.current = nextPausedByDisk
setWatchedPaused(nextPaused)
setWatchedPausedByUser(nextPausedByUser)
setWatchedPausedByDisk(nextPausedByDisk)
}, [autostartState])
const pauseWatched = useCallback(async () => {
if (watchedBusy || watchedPaused) return
setWatchedBusy(true)
try {
const res = await fetch('/api/autostart/pause', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await refreshWatchedState()
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused, refreshWatchedState])
const resumeWatched = useCallback(async () => {
if (watchedBusy || !watchedPaused || watchedPausedByDisk) return
setWatchedBusy(true)
try {
const res = await fetch('/api/autostart/resume', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await refreshWatchedState()
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState])
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
const markStopRequested = useCallback((ids: string | string[]) => {
const arr = Array.isArray(ids) ? ids : [ids]
setStopRequestedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
}
return next
})
setStopInitiatedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
}
return next
})
}, [])
const markRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
}, [])
const clearRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}, [])
const markRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => ({ ...prev, [id]: true }))
}, [])
const clearRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => {
if (!prev[id]) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
useEffect(() => {
setStopRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const next: Record<string, true> = {}
for (const id of keys) {
const j = jobs.find((x) => x.id === id)
if (!j) continue
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
if (isQueuedPostwork) {
continue
}
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running'
if (isStopping) {
next[id] = true
}
}
return next
})
}, [jobs])
useEffect(() => {
setRemoveQueuedPostworkRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveIds = new Set(jobs.map((j) => String(j.id ?? '').trim()))
const next: Record<string, true> = {}
for (const id of keys) {
if (liveIds.has(id)) next[id] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [jobs])
useEffect(() => {
setRemovePendingRequestedKeys((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveKeys = new Set(pending.map((p) => pendingRowKey(p)))
const next: Record<string, true> = {}
for (const key of keys) {
if (liveKeys.has(key)) next[key] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [pending])
const [nowMs, setNowMs] = useState(() => Date.now())
const prevSizeBytesRef = useRef<Record<string, number>>({})
const [growingByJobId, setGrowingByJobId] = useState<Record<string, boolean>>({})
const hasActive = useMemo(() => {
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
const activeDownloadCount = useMemo(() => {
return jobs.filter((j) => {
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
return String(j.status ?? '').trim().toLowerCase() === 'running'
}).length
}, [jobs])
const concurrentLimitHintVisible = enableConcurrentDownloadsLimit
const concurrentLimitReached =
enableConcurrentDownloadsLimit && activeDownloadCount >= maxConcurrentDownloads
useEffect(() => {
setGrowingByJobId((prev) => {
const next: Record<string, boolean> = { ...prev }
const seen = new Set<string>()
let changed = false
for (const job of jobs) {
const id = String(job.id ?? '').trim()
if (!id) continue
seen.add(id)
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
const currentSize = sizeBytesOf(job) ?? 0
const prevSize = prevSizeBytesRef.current[id] ?? 0
const grewNow = currentSize > prevSize
const nextGrowing = isActiveRecording ? (prev[id] === true || grewNow) : false
if (next[id] !== nextGrowing) {
next[id] = nextGrowing
changed = true
}
prevSizeBytesRef.current[id] = currentSize
}
for (const id of Object.keys(prevSizeBytesRef.current)) {
if (!seen.has(id)) {
delete prevSizeBytesRef.current[id]
}
}
for (const id of Object.keys(next)) {
if (!seen.has(id)) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [jobs, nowMs])
const postworkQueueInfoById = useMemo(() => {
const infoById = new Map<string, { pos: number; total: number }>()
const enqueueMsOf = (job: RecordJob): number => {
const anyJ = job as any
const pw = anyJ.postWork
return (
toMs(pw?.enqueuedAt) ||
toMs(anyJ.enqueuedAt) ||
toMs(anyJ.queuedAt) ||
toMs(anyJ.createdAt) ||
toMs(anyJ.addedAt) ||
toMs(job.endedAt) ||
toMs(job.startedAt) ||
0
)
}
const runningSortMsOf = (job: RecordJob): number => {
const anyJ = job as any
return (
toMs(anyJ.updatedAt) ||
toMs(anyJ.phaseUpdatedAt) ||
toMs(anyJ.progressUpdatedAt) ||
toMs(anyJ.postWork?.updatedAt) ||
enqueueMsOf(job)
)
}
const running: RecordJob[] = []
const queued: RecordJob[] = []
for (const j of jobs) {
if (!isPostworkJob(j)) continue
if (isTerminalStatus((j as any)?.status)) continue
const state = getEffectivePostworkState(j)
if (state === 'running') {
running.push(j)
} else if (state === 'queued') {
queued.push(j)
}
}
running.sort((a, b) => runningSortMsOf(b) - runningSortMsOf(a))
queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b))
const runningCount = running.length
const queuedCount = queued.length
const total = runningCount + queuedCount
for (let i = 0; i < running.length; i++) {
const id = String((running[i] as any)?.id ?? '')
if (!id) continue
infoById.set(id, { pos: i + 1, total })
}
for (let i = 0; i < queued.length; i++) {
const id = String((queued[i] as any)?.id ?? '')
if (!id) continue
infoById.set(id, { pos: i + 1, total: queuedCount })
}
return infoById
}, [jobs])
const postworkInfoOf = useCallback(
(job: RecordJob) => {
const id = String((job as any)?.id ?? '')
return id ? postworkQueueInfoById.get(id) : undefined
},
[postworkQueueInfoById]
)
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 5000)
return () => window.clearInterval(t)
}, [hasActive])
const stoppableIds = useMemo(() => {
return jobs
.filter((j) => {
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const phaseLower = phase.trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
return !isStopping
})
.map((j) => j.id)
}, [jobs, stopRequestedIds])
const columns = useMemo<Column<DownloadRow>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[96px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<LazyMountWhenVisible
className="w-full h-full"
placeholder={<div className="h-full w-full bg-gray-100 dark:bg-white/10" />}
>
<ModelPreview
jobId={j.id}
initialSrc={directPreviewSrc || undefined}
enableHoverLive={true}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full"
/>
</LazyMountWhenVisible>
</div>
)
}
const p = r.pending
const name = pendingModelName(p)
const img = pendingImageUrl(p)
if (img) {
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
<PendingPreviewImage
src={img}
alt={name}
blur={blurPreviews}
className="h-full w-full"
/>
</div>
)
}
return (
<div className="h-[60px] w-[96px] rounded-md bg-gray-100 dark:bg-white/10 grid place-items-center text-sm text-gray-500">
</div>
)
},
},
{
key: 'model',
header: 'Modelname',
widthClassName: 'w-[170px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output)
const modelKey = modelKeyFromJob(j)
const modelMeta = modelKey ? modelsByKey[modelKey] : undefined
const roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
return (
<>
<div className="flex items-center gap-2 min-w-0">
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<ModelGenderIcon
gender={modelMeta?.gender}
className="shrink-0"
/>
<CountryFlag
country={countryOfJob(j, modelsByKey)}
size="sm"
className="shrink-0"
/>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(roomStatus),
].join(' ')}
title={roomStatus}
>
{roomStatus}
</span>
</div>
<span className="block max-w-[220px] truncate" title={j.output}>
{f}
</span>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
title={j.sourceUrl}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
}
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
const pendingKey = String((p as any)?.modelKey ?? name).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
return (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<ModelGenderIcon
gender={pendingFlags?.gender}
className="shrink-0"
/>
<CountryFlag
country={countryOfPending(p, modelsByKey)}
size="sm"
className="shrink-0"
/>
</div>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
title={url}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{url}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
},
},
{
key: 'status',
header: 'Status',
widthClassName: 'w-[260px] min-w-[240px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} />
}
const p = r.pending
const show = normalizeRoomStatus(p.currentShow)
return (
<div className="min-w-0">
<div className="truncate">
<span className="font-medium">
{show}
</span>
</div>
</div>
)
},
},
{
key: 'runtime',
header: 'Dauer',
widthClassName: 'w-[90px]',
cell: (r) => {
if (r.kind === 'job') return runtimeOf(r.job, nowMs)
return '—'
},
},
{
key: 'size',
header: 'Größe',
align: 'right',
widthClassName: 'w-[110px]',
cell: (r) => {
if (r.kind === 'job') {
return (
<span className="tabular-nums text-sm text-gray-900 dark:text-white">
{formatBytes(sizeBytesOf(r.job))}
</span>
)
}
return (
<span className="tabular-nums text-sm text-gray-500 dark:text-gray-400">
</span>
)
},
},
{
key: 'actions',
header: 'Aktion',
srOnlyHeader: true,
align: 'right',
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
if (r.kind !== 'job') {
const pendingKey = String(
(r.pending as any)?.modelKey ??
pendingModelName(r.pending)
).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
const pendingActionJob = {
id: `pending:${pendingKey || pendingRowKey(r.pending)}`,
output: `${pendingModelName(r.pending)}_pending.mp4`,
sourceUrl: pendingUrl(r.pending),
status: 'pending',
endedAt: null,
} as unknown as RecordJob
const isFav = Boolean(pendingFlags?.favorite)
const isLiked = pendingFlags?.liked === true
const isWatching = Boolean(pendingFlags?.watching)
const removeKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[removeKey])
return (
<div
className="flex items-center justify-end gap-2 pr-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={pendingActionJob}
variant="table"
busy={false}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
<Button
size="sm"
variant="primary"
color="red"
disabled={isRemovingPending || !onRemovePending}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (isRemovingPending) return
markRemovePendingRequested(removeKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(removeKey)
}
}}
>
{isRemovingPending ? 'Entferne…' : 'Entfernen'}
</Button>
</div>
)
}
const j = r.job
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const phaseLower = phase.trim().toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<div
className="flex flex-wrap items-center justify-end gap-2 pr-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
busy={buttonBusy}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
<Button
size="sm"
variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (isQueuedPostwork) {
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
{isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')}
</Button>
</div>
)
},
},
]
}, [
blurPreviews,
markStopRequested,
modelsByKey,
roomStatusByModelKey,
growingByJobId,
nowMs,
onStopJob,
onRemoveQueuedPostworkJob,
onRemovePending,
stopRequestedIds,
stopInitiatedIds,
postworkInfoOf,
onAddToDownloads,
])
const downloadJobRows = useMemo<DownloadRow[]>(() => {
const seen = new Set<string>()
const list = jobs
.filter((j) => {
if (isPostworkJob(j)) return false
if (isTerminalStatus((j as any)?.status)) return false
if (Boolean((j as any)?.endedAt)) return false
const id = String((j as any)?.id ?? '').trim()
if (!id) return false
if (seen.has(id)) return false
seen.add(id)
return true
})
.map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs])
const postworkRows = useMemo<DownloadRow[]>(() => {
const list = jobs
.filter((j) => {
if (!isPostworkJob(j)) return false
if (isTerminalStatus((j as any)?.status)) return false
return true
})
.map((job) => ({ kind: 'job', job }) as const)
const stateRank = (j: RecordJob): number => {
const state = getEffectivePostworkState(j)
if (state === 'running') return 0
if (state === 'queued') return 1
return 2
}
const queuePosOf = (j: RecordJob): number => {
const id = String((j as any)?.id ?? '')
const info = id ? postworkQueueInfoById.get(id) : undefined
return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER
}
const runningSortMsOf = (j: RecordJob): number => {
const anyJ = j as any
return (
toMs(anyJ.updatedAt) ||
toMs(anyJ.phaseUpdatedAt) ||
toMs(anyJ.progressUpdatedAt) ||
toMs(anyJ.postWork?.updatedAt) ||
addedAtMsOf({ kind: 'job', job: j })
)
}
list.sort((a, b) => {
const aj = a.job
const bj = b.job
const sr = stateRank(aj) - stateRank(bj)
if (sr !== 0) return sr
if (getEffectivePostworkState(aj) === 'running' && getEffectivePostworkState(bj) === 'running') {
const ra = runningSortMsOf(aj)
const rb = runningSortMsOf(bj)
if (ra !== rb) return rb - ra
}
const pa = queuePosOf(aj)
const pb = queuePosOf(bj)
if (pa !== pb) return pa - pb
return addedAtMsOf(a) - addedAtMsOf(b)
})
return list
}, [jobs, postworkQueueInfoById])
const pendingRows = useMemo<DownloadRow[]>(() => {
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [pending])
const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length
const stopAll = useCallback(async () => {
if (stopAllBusy) return
if (stoppableIds.length === 0) return
setStopAllBusy(true)
try {
markStopRequested(stoppableIds)
if (onStopAllJobs) {
await onStopAllJobs()
} else {
await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id))))
}
} finally {
setStopAllBusy(false)
}
}, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') {
const key = pendingRowKey(r.pending)
const removing = Boolean(removePendingRequestedKeys[key])
return [
'transition-all duration-300',
removing && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}
const j = r.job
const stopRequested = Boolean(stopRequestedIds[j.id])
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
return [
'transition-all duration-300',
stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
return (
<div className="grid gap-3">
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="p-3">
{/* Mobile */}
<div className="sm:hidden">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
{concurrentLimitHintVisible ? (
<div
className={[
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
'max-w-[55%] truncate',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
title={
concurrentLimitReached
? `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads} · Limit erreicht`
: `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads}`
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
</div>
) : null}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="w-full justify-center"
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="w-full justify-center"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
{/* Desktop */}
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
<div className="min-w-0 flex items-center gap-2">
{concurrentLimitHintVisible ? (
<div
className={[
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
title={
concurrentLimitReached
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
{concurrentLimitReached ? ' · Limit erreicht' : ''}
</div>
) : null}
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
</div>
</div>
</div>
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
<>
{!isDesktop ? (
<div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? (
<>
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
{downloadJobRows.map((r) => {
if (r.kind !== 'job') return null
const j = r.job
const phase = String((j as any).phase ?? '').trim().toLowerCase()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
return (
<SwipeActionRow
key={`dl:${j.id}`}
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
actionColor="indigo"
disabled={isStopping || isQueuedPostwork}
onAction={async () => {
if (isStopping || isQueuedPostwork) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
<div
className={[
'transition-all duration-300',
isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
</>
) : null}
{pendingRows.length > 0 ? (
<>
<div className="mt-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
Wartend ({pendingRows.length})
</div>
{pendingRows.map((r) => {
if (r.kind !== 'pending') return null
const pKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pKey])
return (
<SwipeActionRow
key={`pending:${pKey}`}
actionLabel={isRemovingPending ? 'Entferne…' : 'Entfernen'}
actionColor="red"
disabled={!onRemovePending || isRemovingPending}
onAction={async () => {
if (isRemovingPending) return
markRemovePendingRequested(pKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pKey)
}
}}
>
<div
className={[
'transition-all duration-300',
isRemovingPending && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
</>
) : null}
{postworkRows.length > 0 ? (
<>
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
{postworkRows.map((r) => {
if (r.kind !== 'job') return null
const j = r.job
const phase = String((j as any).phase ?? '').trim().toLowerCase()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const label = isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')
const color = isQueuedPostwork ? 'red' : 'indigo'
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
return (
<SwipeActionRow
key={`pw:${j.id}`}
actionLabel={label}
actionColor={color}
disabled={disabled}
onAction={async () => {
if (isQueuedPostwork) {
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
<div
className={[
'transition-all duration-300',
isQueuedPostwork && isRemovingQueuedPostwork && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
!isQueuedPostwork && isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
</>
) : null}
</div>
) : (
<div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
<Table
rows={downloadJobRows}
rowClassName={rowClassName}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
) : null}
{postworkRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-amber-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
<Table
rows={postworkRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `pw:job:${r.job.id}` : `pw:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
) : null}
{pendingRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-slate-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-800 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Wartend ({pendingRows.length})
</div>
<Table
rows={pendingRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `wa:job:${r.job.id}` : `wa:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
/>
</div>
) : null}
</div>
)}
</>
) : (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<span className="text-lg"></span>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine laufenden Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Starte oben eine URL hier siehst du Live-Status und kannst Jobs stoppen.
</div>
</div>
</div>
</Card>
)}
</div>
)
}