1999 lines
66 KiB
TypeScript
1999 lines
66 KiB
TypeScript
// 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?: (next?: AutostartState) => 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 => {
|
||
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?.avatar ??
|
||
anyJ?.previewImageUrl ??
|
||
anyJ?.imageUrl ??
|
||
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'
|
||
|
||
// 1) Server-Status ist die Wahrheit
|
||
if (pwState === 'running') return 'running'
|
||
if (pwState === 'queued') return 'queued'
|
||
|
||
// 2) Queue-Positionsdaten aus dem Backend
|
||
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'
|
||
}
|
||
|
||
// 3) Wenn ein PostWorkKey existiert, aber noch kein running-Status da ist:
|
||
// NICHT als running anzeigen, sondern als queued.
|
||
if (hasPwKey) {
|
||
return 'queued'
|
||
}
|
||
|
||
// 4) Nur echte konkrete Arbeitsphasen sind running.
|
||
// "postwork" alleine bedeutet nur: Job ist im Nacharbeitsbereich.
|
||
if (
|
||
phase === 'probe' ||
|
||
phase === 'remuxing' ||
|
||
phase === 'moving' ||
|
||
phase === 'assets' ||
|
||
phase === 'analyze'
|
||
) {
|
||
return 'running'
|
||
}
|
||
|
||
if (phase === 'postwork') {
|
||
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: 1024px)', 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 (next?: AutostartState) => {
|
||
try {
|
||
await onRefreshAutostartState?.(next)
|
||
} 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',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store' as any,
|
||
body: JSON.stringify({ paused: true }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
await refreshWatchedState(data ?? undefined)
|
||
} catch (e) {
|
||
console.warn('Autostart pausieren fehlgeschlagen:', e)
|
||
} 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',
|
||
cache: 'no-store' as any,
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
await refreshWatchedState(data ?? undefined)
|
||
} catch (e) {
|
||
console.warn('Autostart fortsetzen fehlgeschlagen:', e)
|
||
} 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-2">
|
||
<div className="sticky 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
|
||
? <PlayIcon className="size-4 shrink-0" />
|
||
: <PauseIcon 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
|
||
? <PlayIcon className="size-4 shrink-0" />
|
||
: <PauseIcon 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>
|
||
)
|
||
} |