850 lines
26 KiB
TypeScript
850 lines
26 KiB
TypeScript
// frontend\src\components\ui\DownloadsCardRow.tsx
|
|
|
|
'use client'
|
|
|
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
import Button from './Button'
|
|
import ModelPreview from './ModelPreview'
|
|
import ProgressBar from './ProgressBar'
|
|
import RecordJobActions from './RecordJobActions'
|
|
import type { RecordJob } from '../../types'
|
|
import LazyMountWhenVisible from './LazyMountWhenVisible'
|
|
|
|
export type PendingWatchedRoom = WaitingModelRow & {
|
|
currentShow: string
|
|
}
|
|
|
|
type WaitingModelRow = {
|
|
id: string
|
|
modelKey: string
|
|
url: string
|
|
imageUrl?: string
|
|
currentShow?: string
|
|
}
|
|
|
|
export type DownloadRow =
|
|
| { kind: 'job'; job: RecordJob }
|
|
| { kind: 'pending'; pending: PendingWatchedRoom }
|
|
|
|
type ModelFlags = {
|
|
favorite?: boolean
|
|
liked?: boolean | null
|
|
watching?: boolean
|
|
roomStatus?: string
|
|
}
|
|
|
|
type Props = {
|
|
r: DownloadRow
|
|
nowMs: number
|
|
blurPreviews?: boolean
|
|
modelsByKey: Record<string, ModelFlags>
|
|
roomStatusByModelKey: Record<string, string>
|
|
stopRequestedIds: Record<string, true>
|
|
growingByJobId: Record<string, boolean>
|
|
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
|
|
markStopRequested: (ids: string | string[]) => void
|
|
onOpenPlayer: (job: RecordJob) => void
|
|
onStopJob: (id: string) => void | Promise<void>
|
|
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
|
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
|
}
|
|
|
|
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 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 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 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 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
|
|
}
|
|
|
|
function previewRoomStatusOfJob(
|
|
job: RecordJob,
|
|
roomStatusByModelKey?: Record<string, string>,
|
|
modelsByKey?: Record<string, { roomStatus?: string }>,
|
|
growingByJobId?: Record<string, boolean>
|
|
): string {
|
|
const postworkState = getEffectivePostworkState(job)
|
|
|
|
if (postworkState === 'running' || postworkState === 'queued') {
|
|
return 'Public'
|
|
}
|
|
|
|
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
|
|
}
|
|
|
|
function previewAlignEndAtOfJob(job: RecordJob): string | null {
|
|
const postworkState = getEffectivePostworkState(job)
|
|
|
|
if (postworkState === 'running' || postworkState === 'queued') {
|
|
return null
|
|
}
|
|
|
|
return job.endedAt ?? null
|
|
}
|
|
|
|
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 absUrlMaybe = (u?: string | null): string => {
|
|
const s = String(u ?? '').trim()
|
|
if (!s) return ''
|
|
if (/^https?:\/\//i.test(s)) return s
|
|
if (s.startsWith('/')) return s
|
|
return `/${s}`
|
|
}
|
|
|
|
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
|
|
const j = job as any
|
|
|
|
const direct = [
|
|
j.thumbsJPGUrl,
|
|
j.thumbsUrl,
|
|
j.previewThumbsUrl,
|
|
j.thumbnailSheetUrl,
|
|
]
|
|
|
|
const base = [
|
|
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
]
|
|
|
|
return [...direct, ...base]
|
|
.map((x) => absUrlMaybe(String(x ?? '')))
|
|
.filter(Boolean)
|
|
}
|
|
|
|
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 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 ''
|
|
}
|
|
}
|
|
|
|
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'
|
|
)
|
|
}
|
|
|
|
const isPostworkJob = (job: RecordJob): boolean => {
|
|
const anyJ = job as any
|
|
const phase = String(anyJ.phase ?? '').trim()
|
|
const pw = anyJ.postWork
|
|
const pwKey = String(anyJ.postWorkKey ?? '').trim()
|
|
|
|
if (pwKey) return true
|
|
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
|
|
if (job.endedAt && phase) return true
|
|
if (phase === 'postwork') return true
|
|
|
|
return false
|
|
}
|
|
|
|
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
|
|
const anyJ = job as any
|
|
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
|
|
const pw = anyJ.postWork
|
|
const pwState = String(pw?.state ?? '').trim().toLowerCase()
|
|
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
|
|
|
|
if (!isPostworkJob(job)) return 'none'
|
|
if (isTerminalStatus(anyJ?.status)) return 'none'
|
|
|
|
if (pwState === 'queued') return 'queued'
|
|
if (pwState === 'running') return 'running'
|
|
|
|
if (
|
|
phase === 'postwork' ||
|
|
phase === 'probe' ||
|
|
phase === 'remuxing' ||
|
|
phase === 'moving' ||
|
|
phase === 'assets'
|
|
) {
|
|
return 'running'
|
|
}
|
|
|
|
if (
|
|
typeof pw?.position === 'number' &&
|
|
Number.isFinite(pw.position) &&
|
|
pw.position > 0
|
|
) {
|
|
return 'queued'
|
|
}
|
|
|
|
if (
|
|
typeof pw?.running === 'number' &&
|
|
Number.isFinite(pw.running) &&
|
|
pw.running > 0 &&
|
|
(!Number.isFinite(pw?.position) || pw.position <= 0)
|
|
) {
|
|
return 'running'
|
|
}
|
|
|
|
if (hasPwKey) {
|
|
return 'queued'
|
|
}
|
|
|
|
if (anyJ.endedAt || job.endedAt) return 'queued'
|
|
|
|
return 'none'
|
|
}
|
|
|
|
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…'
|
|
}
|
|
|
|
export default function DownloadsCardRow({
|
|
r,
|
|
nowMs,
|
|
blurPreviews,
|
|
modelsByKey,
|
|
roomStatusByModelKey,
|
|
stopRequestedIds,
|
|
growingByJobId,
|
|
postworkInfoOf,
|
|
markStopRequested,
|
|
onOpenPlayer,
|
|
onStopJob,
|
|
onRemoveQueuedPostworkJob,
|
|
onToggleFavorite,
|
|
onToggleLike,
|
|
onToggleWatch,
|
|
}: Props) {
|
|
if (r.kind === 'pending') {
|
|
const p = r.pending
|
|
const name = pendingModelName(p)
|
|
const url = pendingUrl(p)
|
|
const show = normalizeRoomStatus(p.currentShow)
|
|
const img = pendingImageUrl(p)
|
|
|
|
return (
|
|
<div
|
|
className="
|
|
group relative isolate overflow-hidden rounded-2xl
|
|
border border-gray-200/90 bg-white shadow-sm
|
|
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
|
|
"
|
|
>
|
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 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/25 dark:bg-white/5" />
|
|
|
|
<div className="relative p-3">
|
|
<div className="flex items-start gap-3">
|
|
<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
|
|
"
|
|
>
|
|
{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 text-sm text-gray-500 dark:text-gray-300">
|
|
⏳
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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 ring-1',
|
|
roomStatusTone(show),
|
|
].join(' ')}
|
|
title={show}
|
|
>
|
|
{show}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
|
|
Wartend auf Autostart…
|
|
</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">
|
|
⏳
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{url ? (
|
|
<a
|
|
href={url}
|
|
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={url}
|
|
>
|
|
{url}
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<ProgressBar
|
|
label="Warte auf Online-Status…"
|
|
indeterminate
|
|
size="sm"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<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 opacity-60">
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
Wird automatisch gestartet, sobald das Model public ist.
|
|
</span>
|
|
</div>
|
|
|
|
<div className="hidden sm:block">
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
color="red"
|
|
className="shrink-0"
|
|
disabled
|
|
>
|
|
Wartend
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const j = r.job
|
|
const name = modelNameFromOutput(j.output)
|
|
const file = baseName(j.output || '')
|
|
|
|
const phase = String((j as any).phase ?? '').trim()
|
|
const phaseLower = phase.toLowerCase()
|
|
const isRecording = phaseLower === 'recording'
|
|
|
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
|
const rawStatus = String(j.status ?? '').toLowerCase()
|
|
|
|
const postworkState = getEffectivePostworkState(j)
|
|
const isQueuedPostwork = postworkState === 'queued'
|
|
|
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
|
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
|
|
|
const showRemoveQueuedButton = isQueuedPostwork
|
|
const disableStopButton = showRemoveQueuedButton ? false : isStopping
|
|
const actionsBusy = showRemoveQueuedButton ? false : isStopping
|
|
|
|
const roomStatus = effectiveRoomStatusOfJob(
|
|
j,
|
|
roomStatusByModelKey,
|
|
modelsByKey,
|
|
growingByJobId
|
|
)
|
|
|
|
let phaseText = ''
|
|
|
|
if (phaseLower === 'recording') {
|
|
phaseText = 'Recording läuft…'
|
|
} else if (isPostworkJob(j)) {
|
|
const pwState = getEffectivePostworkState(j)
|
|
|
|
if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') {
|
|
phaseText = phaseLabel(phase) || phase
|
|
} else if (pwState === 'queued') {
|
|
phaseText = postWorkLabel(j, postworkInfoOf(j))
|
|
} else if (pwState === 'running') {
|
|
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
|
|
}
|
|
} else {
|
|
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
|
}
|
|
|
|
const progressLabel = phaseText || roomStatus
|
|
|
|
const progress = Number((j as any).progress ?? 0)
|
|
const showBar =
|
|
!isRecording &&
|
|
Number.isFinite(progress) &&
|
|
progress > 0 &&
|
|
progress < 100
|
|
|
|
const showIndeterminate =
|
|
!isRecording &&
|
|
!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 isolate overflow-hidden rounded-2xl
|
|
border border-gray-200/90 bg-white shadow-sm
|
|
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
|
|
"
|
|
onClick={() => onOpenPlayer(j)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
|
}}
|
|
>
|
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 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/25 dark:bg-white/5" />
|
|
|
|
<div className="relative p-3">
|
|
<div className="flex items-start gap-3">
|
|
<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
|
|
"
|
|
>
|
|
<LazyMountWhenVisible
|
|
className="w-full h-full"
|
|
placeholder={<div className="h-full w-full bg-gray-100 dark:bg-white/10" />}
|
|
>
|
|
<ModelPreview
|
|
jobId={j.id}
|
|
blur={blurPreviews}
|
|
roomStatus={previewRoomStatusOfJob(
|
|
j,
|
|
roomStatusByModelKey,
|
|
modelsByKey,
|
|
growingByJobId
|
|
)}
|
|
alignStartAt={j.startedAt}
|
|
alignEndAt={previewAlignEndAtOfJob(j)}
|
|
alignEveryMs={10_000}
|
|
fastRetryMs={1000}
|
|
fastRetryMax={25}
|
|
fastRetryWindowMs={60_000}
|
|
thumbsCandidates={jobThumbsJPGCandidates(j)}
|
|
className="w-full h-full"
|
|
/>
|
|
</LazyMountWhenVisible>
|
|
</div>
|
|
|
|
<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 ring-1',
|
|
roomStatusTone(roomStatus),
|
|
].join(' ')}
|
|
title={roomStatus}
|
|
>
|
|
{roomStatus}
|
|
</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}
|
|
|
|
<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={actionsBusy}
|
|
isFavorite={isFav}
|
|
isLiked={isLiked}
|
|
isWatching={isWatching}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onToggleLike={onToggleLike}
|
|
onToggleWatch={onToggleWatch}
|
|
order={['watch', 'favorite', 'like', 'details']}
|
|
className="flex items-center gap-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="hidden sm:block">
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
color={isQueuedPostwork ? 'red' : 'indigo'}
|
|
disabled={disableStopButton}
|
|
className="shrink-0"
|
|
onClick={async (e) => {
|
|
e.stopPropagation()
|
|
|
|
if (showRemoveQueuedButton) {
|
|
await onRemoveQueuedPostworkJob?.(j.id)
|
|
return
|
|
}
|
|
|
|
if (isStopping) return
|
|
markStopRequested(j.id)
|
|
await onStopJob(j.id)
|
|
}}
|
|
>
|
|
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |