1110 lines
37 KiB
TypeScript
1110 lines
37 KiB
TypeScript
// frontend\src\components\ui\Downloads.tsx
|
||
'use client'
|
||
|
||
import { useMemo, useState, useCallback, useEffect } from 'react'
|
||
import Table, { type Column } from './Table'
|
||
import Card from './Card'
|
||
import Button from './Button'
|
||
import ModelPreview from './ModelPreview'
|
||
import type { RecordJob } from '../../types'
|
||
import ProgressBar from './ProgressBar'
|
||
import RecordJobActions from './RecordJobActions'
|
||
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
|
||
import { subscribeSSE } from '../../lib/sseSingleton'
|
||
|
||
type PendingWatchedRoom = WaitingModelRow & {
|
||
currentShow: string // public / private / hidden / away / unknown
|
||
}
|
||
|
||
type WaitingModelRow = {
|
||
id: string
|
||
modelKey: string
|
||
url: string
|
||
imageUrl?: string
|
||
currentShow?: string // public / private / hidden / away / unknown
|
||
}
|
||
|
||
type AutostartState = { paused?: boolean }
|
||
|
||
type Props = {
|
||
jobs: RecordJob[]
|
||
pending?: PendingWatchedRoom[]
|
||
modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
|
||
onOpenPlayer: (job: RecordJob) => void
|
||
onStopJob: (id: string) => void
|
||
blurPreviews?: boolean
|
||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||
}
|
||
|
||
type DownloadRow =
|
||
| { kind: 'job'; job: RecordJob }
|
||
| { kind: 'pending'; pending: PendingWatchedRoom }
|
||
|
||
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 toMs = (v: unknown): number => {
|
||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||
// Heuristik: 10-stellige Unix-Sekunden -> ms
|
||
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) || // Fallback
|
||
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) {
|
||
case 'stopping':
|
||
return 'Stop wird angefordert…'
|
||
case 'remuxing':
|
||
return 'Remux zu MP4…'
|
||
case 'moving':
|
||
return 'Verschiebe nach Done…'
|
||
case 'assets':
|
||
return 'Erstelle Vorschau…'
|
||
default:
|
||
return ''
|
||
}
|
||
}
|
||
|
||
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(url, init)
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
return res.json() as Promise<T>
|
||
}
|
||
|
||
function StatusCell({ job }: { job: RecordJob }) {
|
||
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
||
const progress = Number((job as any)?.progress ?? 0)
|
||
|
||
const phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
|
||
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
|
||
|
||
// ✅ ProgressBar unabhängig vom Text
|
||
// ✅ determinate nur wenn sinnvoll (0..100)
|
||
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
|
||
|
||
// ✅ wenn wir in einer Phase sind, aber noch kein Progress da ist -> indeterminate
|
||
const showIndeterminate =
|
||
!showBar &&
|
||
Boolean(phaseRaw) &&
|
||
(!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>
|
||
)
|
||
}
|
||
|
||
function DownloadsCardRow({
|
||
r,
|
||
nowMs,
|
||
blurPreviews,
|
||
modelsByKey,
|
||
stopRequestedIds,
|
||
markStopRequested,
|
||
onOpenPlayer,
|
||
onStopJob,
|
||
onToggleFavorite,
|
||
onToggleLike,
|
||
onToggleWatch,
|
||
}: {
|
||
r: DownloadRow
|
||
nowMs: number
|
||
blurPreviews?: boolean
|
||
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
|
||
stopRequestedIds: Record<string, true>
|
||
markStopRequested: (ids: string | string[]) => void
|
||
onOpenPlayer: (job: RecordJob) => void
|
||
onStopJob: (id: string) => void
|
||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||
}) {
|
||
// ---------- Pending ----------
|
||
if (r.kind === 'pending') {
|
||
const p = r.pending
|
||
const name = pendingModelName(p)
|
||
const url = pendingUrl(p)
|
||
const show = (p.currentShow || 'unknown').toLowerCase()
|
||
|
||
return (
|
||
<div
|
||
className="
|
||
relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
|
||
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
|
||
ring-1 ring-black/5
|
||
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
|
||
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
|
||
"
|
||
>
|
||
{/* subtle gradient */}
|
||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
|
||
|
||
<div className="relative p-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white" title={name}>
|
||
{name}
|
||
</div>
|
||
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-900/5 px-2 py-0.5 font-medium dark:bg-white/10">
|
||
Wartend
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:bg-white/10 dark:text-gray-200">
|
||
{show}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<span className="shrink-0 rounded-full bg-gray-900/5 px-2.5 py-1 text-xs font-medium text-gray-800 dark:bg-white/10 dark:text-gray-200">
|
||
⏳
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
{(() => {
|
||
const img = pendingImageUrl(p)
|
||
return (
|
||
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-xl bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
||
{img ? (
|
||
<img
|
||
src={img}
|
||
alt={name}
|
||
className={[
|
||
"h-full w-full object-cover",
|
||
blurPreviews ? "blur-md" : "",
|
||
].join(" ")}
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
onError={(e) => {
|
||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="grid h-full w-full place-items-center">
|
||
<div className="text-sm text-gray-500 dark:text-gray-300">Waiting…</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{url ? (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-3 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
|
||
onClick={(e) => e.stopPropagation()}
|
||
title={url}
|
||
>
|
||
{url}
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- Job ----------
|
||
const j = r.job
|
||
const name = modelNameFromOutput(j.output)
|
||
const file = baseName(j.output || '')
|
||
|
||
const phase = String((j as any).phase ?? '').trim()
|
||
const phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||
|
||
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
|
||
const rawStatus = String(j.status ?? '').toLowerCase()
|
||
|
||
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
||
|
||
// ✅ Badge hinter Modelname: IMMER Backend-Status
|
||
const statusText = rawStatus || 'unknown'
|
||
|
||
// ✅ Progressbar Label: Phase (gemappt), fallback auf Status
|
||
const progressLabel = phaseText || statusText
|
||
|
||
const progress = Number((j as any).progress ?? 0)
|
||
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
|
||
const showIndeterminate =
|
||
!showBar &&
|
||
Boolean(phase) &&
|
||
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
||
|
||
const key = name && name !== '—' ? name.toLowerCase() : ''
|
||
const flags = key ? modelsByKey[key] : undefined
|
||
const isFav = Boolean(flags?.favorite)
|
||
const isLiked = flags?.liked === true
|
||
const isWatching = Boolean(flags?.watching)
|
||
|
||
return (
|
||
<div
|
||
className="
|
||
group relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
|
||
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
|
||
ring-1 ring-black/5
|
||
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
|
||
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
|
||
"
|
||
onClick={() => onOpenPlayer(j)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||
}}
|
||
>
|
||
{/* subtle gradient */}
|
||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
|
||
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/40 dark:bg-white/10" />
|
||
|
||
<div className="relative p-3">
|
||
{/* Header: kleines Preview links + Infos rechts */}
|
||
<div className="flex items-start gap-3">
|
||
{/* Thumbnail */}
|
||
<div
|
||
className="
|
||
relative
|
||
shrink-0 overflow-hidden rounded-lg
|
||
w-[112px] h-[64px]
|
||
bg-gray-100 ring-1 ring-black/5
|
||
dark:bg-white/10 dark:ring-white/10
|
||
"
|
||
>
|
||
<ModelPreview
|
||
jobId={j.id}
|
||
blur={blurPreviews}
|
||
alignStartAt={j.startedAt}
|
||
alignEndAt={j.endedAt ?? null}
|
||
alignEveryMs={10_000}
|
||
fastRetryMs={1000}
|
||
fastRetryMax={25}
|
||
fastRetryWindowMs={60_000}
|
||
className="w-full h-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Infos */}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
|
||
{name}
|
||
</div>
|
||
|
||
<span
|
||
className={[
|
||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||
'bg-gray-900/5 text-gray-800 dark:bg-white/10 dark:text-gray-200',
|
||
isStopping ? 'ring-1 ring-amber-500/30' : 'ring-1 ring-emerald-500/25',
|
||
].join(' ')}
|
||
title={statusText}
|
||
>
|
||
{statusText}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
|
||
{file || '—'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
||
{runtimeOf(j, nowMs)}
|
||
</span>
|
||
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
||
{formatBytes(sizeBytesOf(j))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{j.sourceUrl ? (
|
||
<a
|
||
href={j.sourceUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
|
||
onClick={(e) => e.stopPropagation()}
|
||
title={j.sourceUrl}
|
||
>
|
||
{j.sourceUrl}
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{(showBar || showIndeterminate) ? (
|
||
<div className="mt-3">
|
||
{showBar ? (
|
||
<ProgressBar
|
||
label={progressLabel}
|
||
value={Math.max(0, Math.min(100, progress))}
|
||
showPercent
|
||
size="sm"
|
||
className="w-full"
|
||
/>
|
||
) : (
|
||
<ProgressBar
|
||
label={progressLabel}
|
||
indeterminate
|
||
size="sm"
|
||
className="w-full"
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Footer */}
|
||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
|
||
<div
|
||
className="flex items-center gap-1"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<RecordJobActions
|
||
job={j}
|
||
variant="table"
|
||
busy={isStopping}
|
||
isFavorite={isFav}
|
||
isLiked={isLiked}
|
||
isWatching={isWatching}
|
||
showHot={false}
|
||
showKeep={false}
|
||
showDelete={false}
|
||
showFavorite
|
||
showLike
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
order={['watch', 'favorite', 'like', 'details']}
|
||
className="flex items-center gap-1"
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
disabled={isStopping}
|
||
className="shrink-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (isStopping) return
|
||
markStopRequested(j.id)
|
||
onStopJob(j.id)
|
||
}}
|
||
>
|
||
{isStopping ? 'Stoppe…' : 'Stop'}
|
||
</Button>
|
||
</div>
|
||
</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(/\.[^.]+$/, '')
|
||
|
||
// <model>_MM_DD_YYYY__HH-MM-SS
|
||
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 start = Date.parse(String(j.startedAt || ''))
|
||
if (!Number.isFinite(start)) return '—'
|
||
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
|
||
if (!Number.isFinite(end)) 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]}`
|
||
}
|
||
|
||
|
||
export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, modelsByKey = {}, blurPreviews }: Props) {
|
||
|
||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||
|
||
const [watchedPaused, setWatchedPaused] = useState(false)
|
||
const [watchedBusy, setWatchedBusy] = useState(false)
|
||
|
||
const refreshWatchedState = useCallback(async () => {
|
||
try {
|
||
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
||
setWatchedPaused(Boolean(s?.paused))
|
||
} catch {
|
||
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
// initial: einmal fetchen (schneller first paint)
|
||
void refreshWatchedState()
|
||
|
||
// danach: Stream (Singleton)
|
||
const unsub = subscribeSSE<AutostartState>(
|
||
'/api/autostart/state/stream',
|
||
'autostart',
|
||
(data) => setWatchedPaused(Boolean((data as any)?.paused))
|
||
)
|
||
|
||
return () => {
|
||
unsub()
|
||
}
|
||
}, [refreshWatchedState])
|
||
|
||
const pauseWatched = useCallback(async () => {
|
||
if (watchedBusy || watchedPaused) return
|
||
setWatchedBusy(true)
|
||
try {
|
||
await fetch('/api/autostart/pause', { method: 'POST' })
|
||
setWatchedPaused(true)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setWatchedBusy(false)
|
||
}
|
||
}, [watchedBusy, watchedPaused])
|
||
|
||
const resumeWatched = useCallback(async () => {
|
||
if (watchedBusy || !watchedPaused) return
|
||
setWatchedBusy(true)
|
||
try {
|
||
await fetch('/api/autostart/resume', { method: 'POST' })
|
||
setWatchedPaused(false)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setWatchedBusy(false)
|
||
}
|
||
}, [watchedBusy, watchedPaused])
|
||
|
||
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
|
||
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
||
|
||
// ✅ Merkt sich dauerhaft: Stop wurde vom User ausgelöst (damit wir später "Stopped" anzeigen können)
|
||
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
|
||
|
||
const markStopRequested = useCallback((ids: string | string[]) => {
|
||
const arr = Array.isArray(ids) ? ids : [ids]
|
||
|
||
// UI-Zwischenzustand ("Stoppe…")
|
||
setStopRequestedIds((prev) => {
|
||
const next = { ...prev }
|
||
for (const id of arr) {
|
||
if (id) next[id] = true
|
||
}
|
||
return next
|
||
})
|
||
|
||
// dauerhaft merken: User hat Stop ausgelöst
|
||
setStopInitiatedIds((prev) => {
|
||
const next = { ...prev }
|
||
for (const id of arr) {
|
||
if (id) next[id] = true
|
||
}
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
// Cleanup: sobald Job nicht mehr "running ohne phase" ist, brauchen wir das Flag nicht mehr
|
||
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 phase = String((j as any).phase ?? '').trim()
|
||
const isStopping = Boolean(phase) || j.status !== 'running'
|
||
if (!isStopping) next[id] = true
|
||
}
|
||
return next
|
||
})
|
||
}, [jobs])
|
||
|
||
|
||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||
|
||
const hasActive = useMemo(() => {
|
||
// tickt solange mind. ein Job noch nicht beendet ist
|
||
return jobs.some((j) => !j.endedAt && j.status === 'running')
|
||
}, [jobs])
|
||
|
||
useEffect(() => {
|
||
if (!hasActive) return
|
||
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||
return () => window.clearInterval(t)
|
||
}, [hasActive])
|
||
|
||
const stoppableIds = useMemo(() => {
|
||
return jobs
|
||
.filter((j) => {
|
||
const phase = String((j as any).phase ?? '').trim()
|
||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||
const isStopping = Boolean(phase) || 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
|
||
return (
|
||
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
|
||
<ModelPreview
|
||
jobId={j.id}
|
||
blur={blurPreviews}
|
||
alignStartAt={j.startedAt}
|
||
alignEndAt={j.endedAt ?? null}
|
||
alignEveryMs={10_000}
|
||
fastRetryMs={1000}
|
||
fastRetryMax={25}
|
||
fastRetryWindowMs={60_000}
|
||
className="w-full h-full"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// pending: kein jobId → Platzhalter
|
||
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">
|
||
<img
|
||
src={img}
|
||
alt={name}
|
||
className={[
|
||
"h-full w-full object-cover",
|
||
blurPreviews ? "blur-md" : "",
|
||
].join(" ")}
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
onError={(e) => {
|
||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||
}}
|
||
/>
|
||
</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 phase = String((j as any).phase ?? '').trim()
|
||
|
||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||
const stopInitiated = Boolean(stopInitiatedIds[j.id])
|
||
const rawStatus = String(j.status ?? '').toLowerCase()
|
||
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
|
||
|
||
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
||
const statusText = rawStatus || 'unknown'
|
||
|
||
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>
|
||
<span
|
||
className={[
|
||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
||
// Status-Farben
|
||
isStopping
|
||
? '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'
|
||
: j.status === 'finished'
|
||
? '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'
|
||
: j.status === 'failed'
|
||
? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25'
|
||
: isStoppedFinal
|
||
? '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'
|
||
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
|
||
].join(' ')}
|
||
title={statusText}
|
||
>
|
||
{statusText}
|
||
</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)
|
||
|
||
return (
|
||
<>
|
||
<span className="block max-w-[170px] truncate font-medium" title={name}>
|
||
{name}
|
||
</span>
|
||
{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} />
|
||
}
|
||
|
||
const p = r.pending
|
||
const show = (p.currentShow || 'unknown').toLowerCase()
|
||
|
||
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) => {
|
||
// actions nur für echte Jobs (Stop/Icons). Pending ist “read-only” in der Tabelle.
|
||
if (r.kind !== 'job') {
|
||
return <div className="pr-2 text-right text-sm text-gray-500 dark:text-gray-400">—</div>
|
||
}
|
||
|
||
const j = r.job
|
||
const phase = String((j as any).phase ?? '').trim()
|
||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
|
||
|
||
const key = modelNameFromOutput(j.output || '')
|
||
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : 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">
|
||
<RecordJobActions
|
||
job={j}
|
||
variant="table"
|
||
busy={isStopping}
|
||
isFavorite={isFav}
|
||
isLiked={isLiked}
|
||
isWatching={isWatching}
|
||
showHot={false}
|
||
showKeep={false}
|
||
showDelete={false}
|
||
showFavorite
|
||
showLike
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
order={['watch', 'favorite', 'like', 'details']}
|
||
className="flex items-center gap-1"
|
||
/>
|
||
|
||
{(() => {
|
||
const phase = String((j as any).phase ?? '').trim()
|
||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
|
||
|
||
return (
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
disabled={isStopping}
|
||
className="shrink-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (isStopping) return
|
||
markStopRequested(j.id)
|
||
onStopJob(j.id)
|
||
}}
|
||
>
|
||
{isStopping ? 'Stoppe…' : 'Stop'}
|
||
</Button>
|
||
)
|
||
})()}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds])
|
||
|
||
const hasAnyPending = pending.length > 0
|
||
const hasJobs = jobs.length > 0
|
||
|
||
const rows = useMemo<DownloadRow[]>(() => {
|
||
const list: DownloadRow[] = [
|
||
...jobs.map((job) => ({ kind: 'job', job }) as const),
|
||
...pending.map((p) => ({ kind: 'pending', pending: p }) as const),
|
||
]
|
||
|
||
// ✅ Neueste zuerst (Hinzugefügt am DESC)
|
||
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
|
||
return list
|
||
}, [jobs, pending])
|
||
|
||
const stopAll = useCallback(async () => {
|
||
if (stopAllBusy) return
|
||
if (stoppableIds.length === 0) return
|
||
|
||
setStopAllBusy(true)
|
||
try {
|
||
// UI sofort auf "Stoppe…" stellen
|
||
markStopRequested(stoppableIds)
|
||
|
||
// Stop anfordern (parallel)
|
||
await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id))))
|
||
} finally {
|
||
setStopAllBusy(false)
|
||
}
|
||
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
|
||
|
||
return (
|
||
<div className="grid gap-3">
|
||
{(hasAnyPending || hasJobs) ? (
|
||
<>
|
||
{/* Toolbar (sticky) wie FinishedDownloads */}
|
||
<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="flex items-center justify-between gap-2 p-3">
|
||
{/* Title + Count */}
|
||
<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">
|
||
{rows.length}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Actions + View */}
|
||
<div className="shrink-0 flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant={watchedPaused ? 'secondary' : 'primary'}
|
||
disabled={watchedBusy}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||
}}
|
||
className="hidden sm:inline-flex"
|
||
title={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="hidden sm:inline-flex"
|
||
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
||
>
|
||
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{/* Mobile: Cards */}
|
||
<div className="mt-3 grid gap-4 sm:hidden">
|
||
{rows.map((r) => (
|
||
<DownloadsCardRow
|
||
key={r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`}
|
||
r={r}
|
||
nowMs={nowMs}
|
||
blurPreviews={blurPreviews}
|
||
modelsByKey={modelsByKey}
|
||
stopRequestedIds={stopRequestedIds}
|
||
markStopRequested={markStopRequested}
|
||
onOpenPlayer={onOpenPlayer}
|
||
onStopJob={onStopJob}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tablet/Desktop: Tabelle */}
|
||
<div className="mt-3 hidden sm:block overflow-x-auto">
|
||
<Table
|
||
rows={rows}
|
||
columns={columns}
|
||
getRowKey={(r) => (r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`)}
|
||
striped
|
||
fullWidth
|
||
stickyHeader
|
||
compact={false}
|
||
card
|
||
onRowClick={(r) => {
|
||
if (r.kind === 'job') onOpenPlayer(r.job)
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{!hasAnyPending && !hasJobs ? (
|
||
<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>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|