nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2026-05-29 08:43:13 +02:00

5624 lines
175 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// frontend\src\components\ui\FinishedDownloads.tsx
'use client'
import { useEffect, useRef, useState, useMemo, useCallback, type MouseEvent, useDeferredValue, type ReactNode } from 'react'
import Card from './Card'
import type {
ViewMode,
UndoAction,
PersistedUndoState,
RunFileMutationOptions,
RecordJob,
RecordJobMeta,
StoredModelFlags,
FinishedPostworkBadge,
FinishedPostworkState,
FinishedPostworkStepSummary,
FinishedPostworkSummary,
DoneSortMode,
} from '../../types'
import ButtonGroup from './ButtonGroup'
import {
ArrowUturnLeftIcon,
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
CheckIcon,
XMarkIcon,
ArchiveBoxIcon,
TrashIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy'
import Button from './Button'
import { useNotify } from './notify'
import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch'
import LoadingSpinner from './LoadingSpinner'
import HoverPopover from './HoverPopover'
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
import Slider from './Slider'
import TextInput from './TextInput'
import TagFilterBar from './TagFilterBar'
import { parseTagList, lowerTag } from './tagUtils'
import {
createFinishedSelectionStore,
useSelectionVersion,
type FinishedSelectionStore,
} from './finishedSelectionStore'
import { formatResolution, formatDuration, formatBytes } from './formatters'
import RatingOverlay from './RatingOverlay'
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
modelsByKey: Record<string, StoredModelFlags>
blurPreviews?: boolean
teaserPlayback?: TeaserPlaybackMode
teaserAudio?: boolean
pauseTeasers?: boolean
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
onDeleteJob?: (
job: RecordJob
) =>
| void
| { undoToken?: string; from?: string }
| Promise<void | { undoToken?: string; from?: string }>
onToggleHot?: (
job: RecordJob
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (
job: RecordJob
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
onSplitJob?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
doneTotal: number
page: number
pageSize: number
onPageSizeChange?: (size: number) => void
onPageChange: (page: number) => void
assetNonce?: number
sortMode: DoneSortMode
onSortModeChange: (m: DoneSortMode) => void
loadMode?: 'paged' | 'all'
includeKeep: boolean
onIncludeKeepChange: (checked: boolean) => void
splitProgressByFile?: Record<string, SplitProgressOverlay>
}
const norm = (p: string) => (p || '').replaceAll('\\', '/')
const baseName = (p: string) => {
const n = norm(p)
const parts = n.split('/')
return parts[parts.length - 1] || ''
}
const stemWithoutExt = (p: string) => {
const f = baseName(p || '')
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
}
const keyFor = (j: RecordJob) => {
const id = String((j as any)?.id ?? '').trim()
const stableFromOutput = stemWithoutExt(j.output || '')
// doneJobs aus dem Backend verwenden oft dateinamige IDs.
// Wenn die ID effektiv nur der Dateiname ist, HOT-normalisieren.
if (id) {
const normalizedId = stripHotPrefix(id)
if (stableFromOutput && normalizedId === stableFromOutput) {
return stableFromOutput
}
return id
}
return stableFromOutput || String((j as any)?.output || '')
}
const isTrashOutput = (output?: string) => {
const p = norm(String(output ?? ''))
// match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
return p.includes('/.trash/') || p.endsWith('/.trash')
}
const GALLERY_CARD_SCALE_PRESETS = [
{ label: 'Klein', scale: 0.8 },
{ label: 'Normal', scale: 1.0 },
{ label: 'XL', scale: 1.2 },
] as const
const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2
function clampPresetIndex(value: number) {
return Math.max(
0,
Math.min(GALLERY_CARD_SCALE_PRESETS.length - 1, Math.round(value))
)
}
function nearestPresetIndex(value: number) {
let bestIndex = 0
let bestDist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[0].scale)
for (let i = 1; i < GALLERY_CARD_SCALE_PRESETS.length; i++) {
const dist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[i].scale)
if (dist < bestDist) {
bestDist = dist
bestIndex = i
}
}
return bestIndex
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches)
onChange()
if (mql.addEventListener) mql.addEventListener('change', onChange)
else mql.addListener(onChange)
return () => {
if (mql.removeEventListener) mql.removeEventListener('change', onChange)
else mql.removeListener(onChange)
}
}, [query])
return matches
}
function modelHrefFromFlags(modelKey: string, flags?: StoredModelFlags): string | undefined {
const key = String(modelKey || '').trim()
if (!key) return undefined
const anyFlags = flags as any
const host = String(anyFlags?.host ?? anyFlags?.inputHost ?? '').trim().toLowerCase()
const input = String(anyFlags?.input ?? '').trim()
if (/^https?:\/\//i.test(input)) return input
if (host.includes('chaturbate')) {
return `https://chaturbate.com/${encodeURIComponent(key)}/`
}
if (host.includes('myfreecams')) {
return `https://www.myfreecams.com/#${encodeURIComponent(key)}`
}
return undefined
}
function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredModelFlags>): RecordJob {
const model = modelNameFromOutput(job.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const href = modelHrefFromFlags(model, flags)
if (job.sourceUrl || !href) return job
return {
...job,
sourceUrl: href,
}
}
const reModelFromFilename = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(reModelFromFilename)
if (m?.[1]?.trim()) {
return m[1].trim()
}
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = parseTagList
const lower = lowerTag
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
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
}
function sleep(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}
async function copyTextToClipboard(text: string): Promise<boolean> {
const value = String(text ?? '').trim()
if (!value) return false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return true
}
} catch {
// fallback below
}
try {
const ta = document.createElement('textarea')
ta.value = value
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.pointerEvents = 'none'
document.body.appendChild(ta)
ta.focus()
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
function CopyErrorButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const timeoutRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current)
}
}
}, [])
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
const ok = await copyTextToClipboard(text || 'Unbekannter Fehler')
if (!ok) return
setCopied(true)
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false)
timeoutRef.current = null
}, 1200)
}
return (
<button
type="button"
className="inline-flex items-center justify-center rounded p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
title={copied ? 'Kopiert' : 'Fehler kopieren'}
aria-label={copied ? 'Fehler kopiert' : 'Fehler kopieren'}
onClick={handleClick}
>
{copied ? (
<CheckIcon className="size-3.5 shrink-0" />
) : (
<ClipboardDocumentIcon className="size-3.5 shrink-0" />
)}
</button>
)
}
function errorTextOf(err: unknown): string {
if (err instanceof Error) return err.message || String(err)
return String(err ?? '')
}
function looksLikeFileInUseError(err: unknown): boolean {
const s = errorTextOf(err).toLowerCase()
return (
s.includes('wird gerade verwendet') ||
s.includes('wird gerade abgespielt') ||
s.includes('sharing violation') ||
s.includes('used by another process') ||
s.includes('file in use') ||
s.includes('cannot access the file') ||
s.includes('access is denied') ||
s.includes('zugriff verweigert') ||
s.includes('wird von einem anderen prozess verwendet') ||
s.includes('409')
)
}
async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
const res = await fetch(input, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
const msg = (text || `HTTP ${res.status}`).trim()
throw new Error(msg)
}
return res
}
function fileStemFromOutput(output?: string): string {
const file = baseName(output || '')
if (!file) return ''
return stripHotPrefix(file.replace(/\.[^.]+$/, ''))
}
function getPostworkSummaryForOutput(
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
): FinishedPostworkSummary | undefined {
const fileRaw = baseName(output || '')
if (!fileRaw) return undefined
const filePlain = stripHotPrefix(fileRaw)
return summaries[fileRaw] ?? summaries[filePlain] ?? undefined
}
function parseMetaObject(metaRaw: unknown): RecordJobMeta | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
return JSON.parse(metaRaw) as RecordJobMeta
} catch {
return null
}
}
return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null
}
function isPlainObject(value: unknown): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function mergeMetaPreferDone(
existingMetaRaw: string | RecordJobMeta | undefined,
incomingMetaRaw: string | RecordJobMeta | undefined
): string | RecordJobMeta | undefined {
const existingMeta = parseMetaObject(existingMetaRaw)
const incomingMeta = parseMetaObject(incomingMetaRaw)
if (!existingMeta && !incomingMeta) return existingMetaRaw ?? incomingMetaRaw
if (!existingMeta) return incomingMetaRaw
if (!incomingMeta) return existingMetaRaw
const merged: RecordJobMeta = {
...(incomingMeta as Record<string, any>),
...(existingMeta as Record<string, any>),
} as RecordJobMeta
const incomingPostwork = isPlainObject(incomingMeta.postwork) ? incomingMeta.postwork : {}
const existingPostwork = isPlainObject(existingMeta.postwork) ? existingMeta.postwork : {}
const incomingCompleted = isPlainObject(incomingPostwork.completed)
? incomingPostwork.completed
: {}
const existingCompleted = isPlainObject(existingPostwork.completed)
? existingPostwork.completed
: {}
merged.postwork = {
...incomingPostwork,
...existingPostwork,
completed: {
...incomingCompleted,
...existingCompleted,
},
history:
Array.isArray(existingPostwork.history) && existingPostwork.history.length > 0
? existingPostwork.history
: Array.isArray(incomingPostwork.history)
? incomingPostwork.history
: undefined,
} as any
const incomingPreview = isPlainObject(incomingMeta.preview) ? incomingMeta.preview : {}
const existingPreview = isPlainObject(existingMeta.preview) ? existingMeta.preview : {}
merged.preview = {
...incomingPreview,
...existingPreview,
clips:
Array.isArray(existingPreview.clips) && existingPreview.clips.length > 0
? existingPreview.clips
: Array.isArray(incomingPreview.clips)
? incomingPreview.clips
: undefined,
thumb: {
...(isPlainObject(incomingPreview.thumb) ? incomingPreview.thumb : {}),
...(isPlainObject(existingPreview.thumb) ? existingPreview.thumb : {}),
},
sprite: {
...(isPlainObject(incomingPreview.sprite) ? incomingPreview.sprite : {}),
...(isPlainObject(existingPreview.sprite) ? existingPreview.sprite : {}),
},
} as any
const incomingMedia = isPlainObject(incomingMeta.media) ? incomingMeta.media : {}
const existingMedia = isPlainObject(existingMeta.media) ? existingMeta.media : {}
merged.media = {
...incomingMedia,
...existingMedia,
durationSeconds:
typeof existingMedia.durationSeconds === 'number' && existingMedia.durationSeconds > 0
? existingMedia.durationSeconds
: typeof incomingMedia.durationSeconds === 'number' && incomingMedia.durationSeconds > 0
? incomingMedia.durationSeconds
: undefined,
video: {
...(isPlainObject(incomingMedia.video) ? incomingMedia.video : {}),
...(isPlainObject(existingMedia.video) ? existingMedia.video : {}),
},
} as any
const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {}
const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {}
const incomingHighlights = isPlainObject(incomingAnalysis.highlights)
? incomingAnalysis.highlights
: {}
const existingHighlights = isPlainObject(existingAnalysis.highlights)
? existingAnalysis.highlights
: {}
const incomingAi = isPlainObject(incomingAnalysis.ai)
? incomingAnalysis.ai
: {}
const existingAi = isPlainObject(existingAnalysis.ai)
? existingAnalysis.ai
: {}
merged.analysis = {
...incomingAnalysis,
...existingAnalysis,
highlights:
Object.keys(existingHighlights).length > 0
? {
...incomingHighlights,
...existingHighlights,
}
: Object.keys(incomingHighlights).length > 0
? incomingHighlights
: undefined,
// Altbestand weiter unterstützen.
ai:
Object.keys(existingAi).length > 0
? {
...incomingAi,
...existingAi,
}
: Object.keys(incomingAi).length > 0
? incomingAi
: undefined,
} as any
return merged
}
function metaDurationSeconds(meta: any): number | undefined {
const v =
meta?.media?.durationSeconds ??
meta?.file?.durationSeconds
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}
function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
const state = String(summary?.state ?? '').toLowerCase()
const label = String(summary?.label ?? '').toLowerCase()
const isPartialDone = state === 'done' && label.startsWith('fertig (')
if (isPartialDone) {
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
}
switch (state) {
case 'running':
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
case 'queued':
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
case 'done':
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
case 'error':
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
default:
return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
type SplitProgressOverlay = {
phase: 'preparing' | 'splitting' | 'done' | 'error'
total: number
current: number
completed: number
segmentProgress?: number
overallProgress?: number
message?: string
segment?: {
index: number
label: string
start: number
end: number
duration: number
}
startedAtMs?: number
sourceFile?: string
}
type DownloadIntegrityBadgeInfo = {
label: string
detail: string
blackTailFrames?: number
sampleCount?: number
}
function matchesCorruptOnlyFilter(metaRaw: unknown, enabled: boolean): boolean {
if (!enabled) return true
return readDownloadIntegrityInfo(metaRaw).state === 'corrupt'
}
function readDownloadIntegrityState(metaRaw: unknown): 'corrupt' | 'ok' | 'unchecked' {
const meta = parseMetaObject(metaRaw) as any
const node = meta?.validation?.tailBlackout
if (!node || typeof node !== 'object') return 'unchecked'
if (node?.isCorrupt === true) return 'corrupt'
return 'ok'
}
type DownloadIntegrityState = 'corrupt' | 'ok' | 'unchecked'
function readDownloadIntegrityInfo(metaRaw: unknown): {
state: DownloadIntegrityState
checked: boolean
node?: Record<string, any>
} {
const meta = parseMetaObject(metaRaw) as any
const node = meta?.validation?.tailBlackout
if (!node || typeof node !== 'object') {
return {
state: 'unchecked',
checked: false,
}
}
return {
state: node?.isCorrupt === true ? 'corrupt' : 'ok',
checked: true,
node,
}
}
function readDownloadIntegrityBadge(metaRaw: unknown): DownloadIntegrityBadgeInfo | null {
const info = readDownloadIntegrityInfo(metaRaw)
const node = info.node
if (!info.checked || info.state !== 'corrupt' || !node) return null
const blackTailFrames =
typeof node?.blackTailFrames === 'number' && Number.isFinite(node.blackTailFrames)
? node.blackTailFrames
: undefined
const sampleCount =
typeof node?.sampleCount === 'number' && Number.isFinite(node.sampleCount)
? node.sampleCount
: Array.isArray(node?.samples)
? node.samples.length
: undefined
const reason = String(node?.reason ?? '').trim()
const fallbackDetail =
typeof blackTailFrames === 'number' &&
typeof sampleCount === 'number' &&
sampleCount > 0
? `${blackTailFrames}/${sampleCount} Frames am Ende sind fast komplett schwarz`
: 'Mehrere Frames am Ende sind fast komplett schwarz'
return {
label: 'Fehlerhaft',
detail: reason || fallbackDetail,
blackTailFrames,
sampleCount,
}
}
function renderDownloadIntegrityBadge(metaRaw: unknown): ReactNode {
const info = readDownloadIntegrityBadge(metaRaw)
if (!info) return null
const badgeNode = (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30"
title={info.detail}
>
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
<span className="truncate">{info.label}</span>
</span>
)
return (
<HoverPopover
content={(open) =>
open && (
<div className="w-[280px] rounded-lg">
<div className="px-3 py-2 border-b border-gray-200 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Download-Prüfung
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
Verdacht auf beschädigtes Videoende
</div>
</div>
<div className="px-3 py-2 text-[12px] text-gray-800 dark:text-gray-200">
{info.detail}
</div>
</div>
)
}
>
{badgeNode}
</HoverPopover>
)
}
function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
const p = String(phase ?? '').trim().toLowerCase()
switch (`${queue}:${p}`) {
case 'postwork:meta':
return 'Meta'
case 'postwork:thumb':
return 'Vorschaubild'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:sprites':
return 'Sprites'
case 'enrich:analyze':
return 'Analyse'
default:
return p || (queue === 'enrich' ? 'Analyse' : 'Meta')
}
}
function shortRunningLabel(summary?: FinishedPostworkSummary): string {
if (!summary) return 'Läuft'
const runningStep = summary.steps.find((s) => s.state === 'running')
if (runningStep) return runningStep.label
const queuedStep = summary.steps.find((s) => s.state === 'queued')
if (queuedStep) return queuedStep.label
return 'Läuft'
}
function expectedStepsForBadges(): Array<{ key: string; queue: 'postwork' | 'enrich'; phase: string }> {
return defaultPostworkStepDefs({
includeAnalyze: true,
})
}
function defaultPostworkStepDefs(opts?: { includeAnalyze?: boolean }): Array<{
key: string
queue: 'postwork' | 'enrich'
phase: string
}> {
const includeAnalyze = opts?.includeAnalyze ?? true
const steps: Array<{
key: string
queue: 'postwork' | 'enrich'
phase: string
}> = [
{ key: 'postwork:meta', queue: 'postwork', phase: 'meta' },
{ key: 'postwork:thumb', queue: 'postwork', phase: 'thumb' },
{ key: 'postwork:teaser', queue: 'postwork', phase: 'teaser' },
{ key: 'postwork:sprites', queue: 'postwork', phase: 'sprites' },
]
if (includeAnalyze) {
steps.push({ key: 'enrich:analyze', queue: 'enrich', phase: 'analyze' })
}
return steps
}
function buildMissingPostworkSummary(
file: string,
assetId?: string,
label = 'Kein Status'
): FinishedPostworkSummary | null {
if (!file) return null
const steps: FinishedPostworkStepSummary[] = defaultPostworkStepDefs({
includeAnalyze: true,
}).map((stepDef) => ({
key: stepDef.key,
queue: stepDef.queue,
phase: stepDef.phase,
label: labelForStep(stepDef.queue, stepDef.phase),
state: 'missing',
ts: 0,
}))
return {
file,
assetId,
state: 'missing',
label,
steps,
ts: 0,
}
}
function hasNonEmptyObject(value: unknown): boolean {
return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length > 0
}
function firstNonEmptyPath(...values: unknown[]): string {
for (const v of values) {
if (typeof v === 'string') {
const s = v.trim()
if (s) return s
}
}
return ''
}
function readMetaVideoWidth(meta: any): number | undefined {
const v =
meta?.media?.video?.width ??
meta?.media?.video?.w ??
meta?.media?.width ??
meta?.file?.video?.width ??
meta?.file?.video?.w ??
meta?.file?.width ??
meta?.video?.width ??
meta?.video?.w ??
meta?.width
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}
function readMetaVideoHeight(meta: any): number | undefined {
const v =
meta?.media?.video?.height ??
meta?.media?.video?.h ??
meta?.media?.height ??
meta?.file?.video?.height ??
meta?.file?.video?.h ??
meta?.file?.height ??
meta?.video?.height ??
meta?.video?.h ??
meta?.height
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}
function readMetaDurationSeconds(meta: any): number | undefined {
const v =
meta?.media?.durationSeconds ??
meta?.media?.durationSec ??
meta?.media?.duration ??
meta?.file?.durationSeconds ??
meta?.file?.durationSec ??
meta?.file?.duration ??
meta?.durationSeconds ??
meta?.durationSec ??
meta?.duration
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}
function readCompletedFlag(meta: any, ...keys: string[]): boolean {
const completed = meta?.postwork?.completed
for (const key of keys) {
const v =
completed?.[key] ??
meta?.postwork?.[key] ??
meta?.preview?.[key] ??
meta?.[key]
if (v === true) return true
}
return false
}
function hasDoneHistoryForPhase(
meta: any,
queue: 'postwork' | 'enrich',
phase: string
): boolean {
const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []
return rawHistory.some((entry: any) => {
const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
const phaseRaw = normalizeFinishedPhase(
queueRaw === 'enrich' ? 'enrich' : 'postwork',
String(entry?.phase ?? '').trim().toLowerCase()
)
const stateRaw = String(entry?.state ?? '').trim().toLowerCase()
return queueRaw === queue && phaseRaw === phase && stateRaw === 'done'
})
}
function hasNonEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length > 0
}
function buildCompletedPostworkSummaryFromMeta(
file: string,
assetId: string | undefined,
meta: any
): FinishedPostworkSummary | null {
if (!file || !meta || typeof meta !== 'object') return null
const durationSeconds = readMetaDurationSeconds(meta)
const videoWidth = readMetaVideoWidth(meta)
const videoHeight = readMetaVideoHeight(meta)
const thumbPath = firstNonEmptyPath(
meta?.preview?.thumb?.path,
meta?.preview?.thumb?.url,
meta?.preview?.thumb?.src,
meta?.preview?.thumbPath,
meta?.previewThumb?.path,
meta?.previewThumb?.url,
meta?.previewThumbPath,
meta?.thumbPath,
meta?.thumbUrl,
meta?.coverPath,
meta?.coverUrl
)
const spritePath = firstNonEmptyPath(
meta?.preview?.sprite?.path,
meta?.preview?.sprite?.url,
meta?.preview?.sprite?.src,
meta?.preview?.spritePath,
meta?.previewSprite?.path,
meta?.previewSprite?.url,
meta?.previewSpritePath,
meta?.spritePath,
meta?.spriteUrl
)
const clips =
Array.isArray(meta?.preview?.clips) ? meta.preview.clips
: Array.isArray(meta?.preview?.teaserClips) ? meta.preview.teaserClips
: Array.isArray(meta?.preview?.segments) ? meta.preview.segments
: Array.isArray(meta?.preview?.teaser?.clips) ? meta.preview.teaser.clips
: Array.isArray(meta?.previewClips) ? meta.previewClips
: Array.isArray(meta?.teaserClips) ? meta.teaserClips
: []
const teaserPath = firstNonEmptyPath(
meta?.preview?.teaser?.path,
meta?.preview?.teaser?.url,
meta?.preview?.teaser?.src,
meta?.preview?.teaserPath,
meta?.previewTeaser?.path,
meta?.previewTeaser?.url,
meta?.previewTeaserPath,
meta?.teaserPath,
meta?.teaserUrl
)
const aiObject =
meta?.analysis?.highlights ??
meta?.analysis?.ai ??
meta?.ai
const hasAiHits =
Array.isArray(aiObject?.hits) && aiObject.hits.length > 0
const hasAiSegments =
Array.isArray(aiObject?.segments) && aiObject.segments.length > 0
const hasMeta =
readCompletedFlag(meta, 'meta') ||
hasDoneHistoryForPhase(meta, 'postwork', 'meta') ||
typeof durationSeconds === 'number' ||
(typeof videoWidth === 'number' && typeof videoHeight === 'number') ||
(typeof meta?.media?.video?.resolution === 'string' && meta.media.video.resolution.trim() !== '') ||
(typeof meta?.file?.video?.resolution === 'string' && meta.file.video.resolution.trim() !== '')
const hasTeaser =
readCompletedFlag(meta, 'teaser', 'assets') ||
hasDoneHistoryForPhase(meta, 'postwork', 'teaser') ||
teaserPath !== '' ||
hasNonEmptyArray(clips)
const hasThumb =
readCompletedFlag(meta, 'thumb', 'thumbnail', 'thumbnails') ||
hasDoneHistoryForPhase(meta, 'postwork', 'thumb') ||
meta?.preview?.thumb?.exists === true ||
thumbPath !== ''
const hasSprites =
readCompletedFlag(meta, 'sprites', 'sprite') ||
hasDoneHistoryForPhase(meta, 'postwork', 'sprites') ||
meta?.preview?.sprite?.exists === true ||
spritePath !== '' ||
(
typeof meta?.preview?.sprite?.count === 'number' &&
Number.isFinite(meta.preview.sprite.count) &&
meta.preview.sprite.count > 0
)
const hasAiAnalysis =
readCompletedFlag(meta, 'analyze', 'analysis', 'ai', 'highlights') ||
hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
hasNonEmptyObject(aiObject) ||
hasAiHits ||
hasAiSegments
const stepDefs = defaultPostworkStepDefs({
includeAnalyze: true,
})
const steps: FinishedPostworkStepSummary[] = stepDefs.map((stepDef) => {
let state: FinishedPostworkState = 'missing'
if (stepDef.key === 'postwork:meta') {
state = hasMeta ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:thumb') {
state = hasThumb ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:sprites') {
state = hasSprites ? 'done' : 'missing'
} else if (stepDef.key === 'enrich:analyze') {
state = hasAiAnalysis ? 'done' : 'missing'
}
return {
key: stepDef.key,
queue: stepDef.queue,
phase: stepDef.phase,
label: labelForStep(stepDef.queue, stepDef.phase),
state,
ts: Number(meta?.updatedAtUnix ?? 0),
}
})
const doneCount = steps.filter((s) => s.state === 'done').length
const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')
return {
file,
assetId,
state: allDone ? 'done' : doneCount > 0 ? 'done' : 'missing',
label: allDone
? 'Fertig'
: doneCount > 0
? `Fertig (${doneCount}/${steps.length})`
: 'Kein Status',
steps,
ts: Number(meta?.updatedAtUnix ?? 0),
}
}
function buildPostworkSummaryFromMetaHistory(
file: string,
assetId: string | undefined,
meta: any
): FinishedPostworkSummary | null {
if (!file || !meta || typeof meta !== 'object') return null
const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []
if (rawHistory.length === 0) return null
const badges: FinishedPostworkBadge[] = rawHistory
.map((entry: any): FinishedPostworkBadge | null => {
const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
const phase = normalizeFinishedPhase(
(queueRaw === 'enrich' ? 'enrich' : 'postwork'),
String(entry?.phase ?? '').trim().toLowerCase()
)
const state = String(entry?.state ?? '').trim().toLowerCase()
if ((queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phase) return null
if (
state !== 'queued' &&
state !== 'running' &&
state !== 'done' &&
state !== 'error' &&
state !== 'missing'
) {
return null
}
return {
file,
assetId,
queue: queueRaw as 'postwork' | 'enrich',
phase,
state: state as FinishedPostworkBadge['state'],
label: typeof entry?.label === 'string' ? entry.label : undefined,
position: typeof entry?.position === 'number' ? entry.position : undefined,
waiting: typeof entry?.waiting === 'number' ? entry.waiting : undefined,
running: typeof entry?.running === 'number' ? entry.running : undefined,
maxParallel: typeof entry?.maxParallel === 'number' ? entry.maxParallel : undefined,
ts: typeof entry?.ts === 'number' ? entry.ts : 0,
}
})
.filter(Boolean) as FinishedPostworkBadge[]
if (badges.length === 0) return null
return summarizePostworkSteps(file, badges)
}
function normalizeFinishedPhase(
queue: 'postwork' | 'enrich',
phase?: string
): string {
const p = String(phase ?? '').trim().toLowerCase()
if (queue === 'enrich') {
switch (p) {
case '':
case 'assets':
case 'analyze':
return 'analyze'
default:
return p
}
}
switch (p) {
case 'postwork':
case 'probe':
case 'remuxing':
case 'moving':
return 'meta'
case 'thumb':
return 'thumb'
case 'assets':
return 'teaser'
case 'sprites':
return 'sprites'
default:
return p || 'meta'
}
}
function summarizePostworkSteps(
file: string,
badges: FinishedPostworkBadge[]
): FinishedPostworkSummary | null {
if (!file) return null
const realBadges = badges.filter((b) => b && b.state !== 'missing')
if (realBadges.length === 0) return null
const expectedSteps = expectedStepsForBadges()
const byKey = new Map<string, FinishedPostworkStepSummary>()
for (const badge of realBadges) {
const queue = badge.queue
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}`
const badgeLabel = String(badge.label ?? '').trim()
const normalizedBadgeLabel = normalizeAnalysisProgressLabel(badgeLabel) ?? badgeLabel
const candidate: FinishedPostworkStepSummary = {
key,
queue,
phase,
label: normalizedBadgeLabel || labelForStep(queue, phase),
state: badge.state,
error: String(badge.error ?? '').trim() || undefined,
ts: badge.ts,
position: badge.position,
waiting: badge.waiting,
running: badge.running,
maxParallel: badge.maxParallel,
}
const prev = byKey.get(key)
if (prev) {
if ((prev.ts || 0) > (candidate.ts || 0)) {
byKey.set(key, preferProgressStepLabel(prev, candidate))
} else {
byKey.set(key, preferProgressStepLabel(candidate, prev))
}
continue
}
byKey.set(key, candidate)
}
// ✅ Erst alle erwarteten Schritte anlegen
for (const stepDef of expectedSteps) {
if (byKey.has(stepDef.key)) continue
byKey.set(stepDef.key, {
key: stepDef.key,
queue: stepDef.queue,
phase: stepDef.phase,
label: labelForStep(stepDef.queue, stepDef.phase),
state: 'missing',
ts: 0,
})
}
const order = expectedSteps.map((s) => s.key)
const steps = Array.from(byKey.values()).sort((a, b) => {
const ai = order.indexOf(a.key)
const bi = order.indexOf(b.key)
if (ai >= 0 && bi >= 0) return ai - bi
if (ai >= 0) return -1
if (bi >= 0) return 1
return a.key.localeCompare(b.key)
})
if (steps.length === 0) return null
const visibleSteps = steps.filter((s) => s.state !== 'missing')
const hasError = visibleSteps.some((s) => s.state === 'error')
const hasRunning = visibleSteps.some((s) => s.state === 'running')
const hasQueued = visibleSteps.some((s) => s.state === 'queued')
const doneCount = steps.filter((s) => s.state === 'done').length
const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')
const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError
const state: FinishedPostworkSummary['state'] =
hasError ? 'error'
: hasRunning ? 'running'
: hasQueued ? 'queued'
: allDone ? 'done'
: partiallyDone ? 'done'
: 'missing'
const label =
hasError
? 'Fehler'
: hasRunning
? shortRunningLabel({ file, steps, state: 'running', label: '', ts: 0 })
: hasQueued
? shortRunningLabel({ file, steps, state: 'queued', label: '', ts: 0 })
: allDone
? 'Fertig'
: partiallyDone
? `Fertig (${doneCount}/${steps.length})`
: 'Kein Status'
const ts = Math.max(0, ...steps.map((s) => s.ts || 0))
return {
file,
assetId: realBadges.find((b) => b.assetId)?.assetId,
state,
label,
steps,
ts,
}
}
function mergePostworkSummaries(
historySummary: FinishedPostworkSummary | null | undefined,
completedSummary: FinishedPostworkSummary | null | undefined,
fallbackFile: string,
fallbackAssetId?: string
): FinishedPostworkSummary | null {
if (!historySummary && !completedSummary) return null
if (!historySummary) return completedSummary ?? null
if (!completedSummary) return historySummary
const stepMap = new Map<string, FinishedPostworkStepSummary>()
for (const step of completedSummary.steps) {
stepMap.set(step.key, step)
}
for (const step of historySummary.steps) {
const prev = stepMap.get(step.key)
if (!prev) {
stepMap.set(step.key, step)
continue
}
// Live-/History-Zustände mit Aktivität/Fehler sollen sichtbar bleiben
if (
step.state === 'running' ||
step.state === 'queued' ||
step.state === 'error'
) {
if (!prev) {
stepMap.set(step.key, step)
continue
}
if ((step.ts || 0) >= (prev.ts || 0)) {
stepMap.set(step.key, preferProgressStepLabel(step, prev))
continue
}
// Auch wenn der ältere Step vom Polling kommt:
// Ein vorhandenes Analyse-Prozentlabel darf nicht durch "Analyse" verloren gehen.
stepMap.set(step.key, preferProgressStepLabel(prev, step))
continue
}
// "done" aus History ist ok, aber nur wenn Completed nicht schon auch done liefert.
// Wenn Completed mehr Informationen hat, füllt es die restlichen Steps ohnehin.
if (step.state === 'done' && prev.state === 'missing') {
stepMap.set(step.key, step)
continue
}
// missing aus History darf nie ein echtes Completed-Ergebnis überschreiben
}
const orderedSteps = defaultPostworkStepDefs({ includeAnalyze: true }).map((def) => {
return (
stepMap.get(def.key) ?? {
key: def.key,
queue: def.queue,
phase: def.phase,
label: labelForStep(def.queue, def.phase),
state: 'missing' as FinishedPostworkState,
ts: 0,
}
)
})
const visibleSteps = orderedSteps.filter((s) => s.state !== 'missing')
const hasError = visibleSteps.some((s) => s.state === 'error')
const hasRunning = visibleSteps.some((s) => s.state === 'running')
const hasQueued = visibleSteps.some((s) => s.state === 'queued')
const doneCount = orderedSteps.filter((s) => s.state === 'done').length
const allDone = orderedSteps.length > 0 && orderedSteps.every((s) => s.state === 'done')
const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError
const state: FinishedPostworkSummary['state'] =
hasError ? 'error'
: hasRunning ? 'running'
: hasQueued ? 'queued'
: allDone ? 'done'
: partiallyDone ? 'done'
: 'missing'
const label =
hasError
? 'Fehler'
: hasRunning
? shortRunningLabel({
file: historySummary.file || completedSummary.file || fallbackFile,
steps: orderedSteps,
state: 'running',
label: '',
ts: 0,
})
: hasQueued
? shortRunningLabel({
file: historySummary.file || completedSummary.file || fallbackFile,
steps: orderedSteps,
state: 'queued',
label: '',
ts: 0,
})
: allDone
? 'Fertig'
: partiallyDone
? `Fertig (${doneCount}/${orderedSteps.length})`
: 'Kein Status'
const ts = Math.max(
historySummary.ts || 0,
completedSummary.ts || 0,
...orderedSteps.map((s) => s.ts || 0)
)
return {
file: historySummary.file || completedSummary.file || fallbackFile,
assetId: historySummary.assetId || completedSummary.assetId || fallbackAssetId,
state,
label,
steps: orderedSteps,
ts,
}
}
function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): boolean {
return (
a.file === b.file &&
a.assetId === b.assetId &&
a.queue === b.queue &&
a.phase === b.phase &&
a.state === b.state &&
a.label === b.label &&
a.error === b.error &&
a.position === b.position &&
a.waiting === b.waiting &&
a.running === b.running &&
a.maxParallel === b.maxParallel
)
}
function analysisProgressPercent(label?: string): number | null {
const s = String(label ?? '').trim()
const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i)
if (percentMatch) {
const n = Number(percentMatch[1])
if (!Number.isFinite(n)) return null
return Math.max(0, Math.min(100, Math.round(n)))
}
const framesMatch = s.match(/^analyse\s+(\d+)\s*\/\s*(\d+)\s*frames?$/i)
if (framesMatch) {
const current = Number(framesMatch[1])
const total = Number(framesMatch[2])
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
return null
}
function postworkStepDisplayLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
return 'Analyse'
}
return step.label
}
function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
const percent = analysisProgressPercent(step.label)
if (percent != null) return `${percent}%`
}
return 'Läuft'
}
function normalizeAnalysisProgressLabel(label?: string): string | undefined {
const percent = analysisProgressPercent(label)
if (percent == null) return label
return `Analyse ${percent}%`
}
function isAnalyzeBadge(badge?: Pick<FinishedPostworkBadge, 'queue' | 'phase'> | null): boolean {
return (
badge?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', badge.phase) === 'analyze'
)
}
function isAnalyzeStep(step?: Pick<FinishedPostworkStepSummary, 'queue' | 'phase'> | null): boolean {
return (
step?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', step.phase) === 'analyze'
)
}
function preferProgressLabel(
incoming: FinishedPostworkBadge,
previous?: FinishedPostworkBadge
): FinishedPostworkBadge {
if (!isAnalyzeBadge(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkBadge = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label),
}
if (!previous || previous.state !== 'running' || !isAnalyzeBadge(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function preferProgressStepLabel(
incoming: FinishedPostworkStepSummary,
previous?: FinishedPostworkStepSummary
): FinishedPostworkStepSummary {
if (!isAnalyzeStep(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkStepSummary = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label) ?? incoming.label,
}
if (!previous || previous.state !== 'running' || !isAnalyzeStep(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function samePostworkBadgeList(
a?: FinishedPostworkBadge[],
b?: FinishedPostworkBadge[]
): boolean {
const aa = Array.isArray(a) ? a : []
const bb = Array.isArray(b) ? b : []
if (aa.length !== bb.length) return false
for (let i = 0; i < aa.length; i++) {
if (!samePostworkBadge(aa[i], bb[i])) return false
}
return true
}
function samePostworkBadgeMap(
a?: Record<string, FinishedPostworkBadge[]>,
b?: Record<string, FinishedPostworkBadge[]>
): boolean {
const aa = a ?? {}
const bb = b ?? {}
const aKeys = Object.keys(aa)
const bKeys = Object.keys(bb)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (!(key in bb)) return false
if (!samePostworkBadgeList(aa[key], bb[key])) return false
}
return true
}
function postworkSummaryTitle(summary: FinishedPostworkSummary): string {
switch (summary.state) {
case 'running':
return 'Postwork läuft'
case 'queued':
return 'Postwork wartet'
case 'done':
return 'Postwork fertig'
case 'error':
return 'Postwork Fehler'
case 'missing':
return 'Postwork fehlt'
default:
return 'Postwork-Status'
}
}
function postworkSummaryIcon(summary: FinishedPostworkSummary): ReactNode {
const iconClass = 'size-5 shrink-0'
switch (summary.state) {
case 'running':
return <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
case 'queued':
return <ClockIcon className={`${iconClass} text-amber-600 dark:text-amber-300`} />
case 'done':
return <CheckCircleIcon className={`${iconClass} text-emerald-600 dark:text-emerald-300`} />
case 'error':
return <ExclamationTriangleIcon className={`${iconClass} text-red-600 dark:text-red-300`} />
case 'missing':
return <span className="size-5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
default:
return <span className="size-5 shrink-0" />
}
}
function postworkDoneCount(summary: FinishedPostworkSummary): number {
return summary.steps.filter((step) => step.state === 'done').length
}
function postworkStepProgressValue(step: FinishedPostworkStepSummary): number {
if (step.state === 'done') return 1
if (step.state === 'running' && isAnalyzeStep(step)) {
const percent = analysisProgressPercent(step.label)
if (percent != null) return percent / 100
}
return 0
}
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
const total = summary.steps.length
if (total <= 0) return 0
const value = summary.steps.reduce(
(sum, step) => sum + postworkStepProgressValue(step),
0
)
return Math.max(0, Math.min(100, Math.round((value / total) * 100)))
}
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
if (step.state === 'running') return runningStepStateLabel(step)
if (step.state === 'queued') {
return typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'
}
if (step.state === 'done') return 'Fertig'
if (step.state === 'error') return 'Fehler'
if (step.state === 'missing') return 'Offen'
return '—'
}
function postworkStepTone(step: FinishedPostworkStepSummary): string {
switch (step.state) {
case 'running':
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
case 'queued':
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
case 'done':
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
case 'error':
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
default:
return 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10'
}
}
function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode {
if (step.state === 'running') {
return <LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
}
if (step.state === 'queued') {
return <ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
}
if (step.state === 'done') {
return <CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
}
if (step.state === 'error') {
return <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
}
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
}
function postworkPreviewSrc(summary: FinishedPostworkSummary): string | null {
const id = String(summary.assetId || fileStemFromOutput(summary.file)).trim()
if (!id) return null
const base = `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
const version = Number.isFinite(summary.ts) && summary.ts > 0 ? String(summary.ts) : ''
return version ? `${base}&v=${encodeURIComponent(version)}` : base
}
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
const done = postworkDoneCount(summary)
const total = summary.steps.length
const progress = postworkProgressPercent(summary)
const previewSrc = postworkPreviewSrc(summary)
return (
<div className="w-[320px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
<div className="relative overflow-hidden border-b border-gray-200/60 bg-gray-950 px-3 py-3 text-white dark:border-white/10">
{previewSrc ? (
<img
src={previewSrc}
alt=""
aria-hidden="true"
referrerPolicy="no-referrer"
className="absolute inset-0 h-full w-full object-cover blur-[1px]"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<div className="absolute inset-0 bg-gradient-to-br from-black/75 via-black/45 to-black/75" />
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="relative flex items-center gap-2.5">
<div className="grid size-8 shrink-0 place-items-center rounded-xl bg-white/90 shadow-sm ring-1 ring-white/40 backdrop-blur dark:bg-gray-950/75 dark:ring-white/15">
{postworkSummaryIcon(summary)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[13px] font-semibold text-white">
{postworkSummaryTitle(summary)}
</div>
<span
className={[
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1 backdrop-blur',
postworkBadgeTone(summary),
].join(' ')}
>
{summary.label}
</span>
</div>
<div className="mt-0.5 truncate text-[10px] text-white/70" title={summary.file}>
{summary.file}
</div>
</div>
</div>
<div className="relative mt-2.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/25">
<div
className={[
'h-full rounded-full transition-all duration-300',
summary.state === 'error'
? 'bg-red-400'
: summary.state === 'queued'
? 'bg-amber-400'
: summary.state === 'running'
? 'bg-indigo-400'
: 'bg-emerald-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-white/75">
{done}/{total}
</span>
</div>
</div>
<div className="px-2.5 py-2.5">
<div className="space-y-1">
{summary.steps.map((step) => (
<div
key={step.key}
className={[
'grid grid-cols-[minmax(0,1fr)_20px_auto] items-center gap-1.5 rounded-lg px-2 py-1.5',
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
].join(' ')}
>
<div className="min-w-0">
<div className="truncate text-[11px] font-medium text-gray-900 dark:text-gray-100">
{postworkStepDisplayLabel(step)}
</div>
{step.state === 'error' && step.error ? (
<div className="mt-0.5 truncate text-[11px] text-red-600 dark:text-red-300" title={step.error}>
{step.error}
</div>
) : null}
</div>
<div className="flex size-5 items-center justify-center">
{postworkStepIcon(step)}
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'inline-flex min-w-[54px] justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1',
postworkStepTone(step),
].join(' ')}
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
>
{postworkStepStateLabel(step)}
</span>
{step.state === 'error' && step.error ? (
<CopyErrorButton text={step.error} />
) : null}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary,
opts?: {
onClick?: () => void
clickable?: boolean
disablePopover?: boolean
}
): ReactNode {
if (!summary) return null
const clickable = Boolean(opts?.clickable && opts?.onClick)
const className = [
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
postworkBadgeTone(summary),
clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '',
].join(' ')
const badgeNode = clickable ? (
<button
type="button"
className={className}
title={summary.label}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
opts?.onClick?.()
}}
>
{summary.state === 'running' ? (
<LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
) : summary.state === 'queued' ? (
<ClockIcon className="size-3.5 shrink-0" />
) : summary.state === 'done' ? (
<CheckCircleIcon className="size-3.5 shrink-0" />
) : summary.state === 'error' ? (
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
) : summary.state === 'missing' ? (
<span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
) : null}
<span className="truncate">{summary.label}</span>
</button>
) : (
<span className={className} title={summary.label}>
{summary.state === 'running' ? (
<LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
) : summary.state === 'queued' ? (
<ClockIcon className="size-3.5 shrink-0" />
) : summary.state === 'done' ? (
<CheckCircleIcon className="size-3.5 shrink-0" />
) : summary.state === 'error' ? (
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
) : summary.state === 'missing' ? (
<span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
) : null}
<span className="truncate">{summary.label}</span>
</span>
)
if (opts?.disablePopover) {
return badgeNode
}
return (
<HoverPopover
content={(open) => open && renderPostworkPopover(summary)}
>
{badgeNode}
</HoverPopover>
)
}
function buildSplitProgressSummary(
file: string,
progress?: SplitProgressOverlay
): FinishedPostworkSummary | null {
if (!file || !progress) return null
const rawOverall =
typeof progress.overallProgress === 'number'
? progress.overallProgress
: progress.phase === 'done'
? 1
: 0
const overall = Math.max(0, Math.min(1, rawOverall))
const percent = Math.round(overall * 100)
const state: FinishedPostworkState =
progress.phase === 'error'
? 'error'
: progress.phase === 'done'
? 'done'
: 'running'
const label =
progress.phase === 'error'
? 'Split Fehler'
: progress.phase === 'done'
? 'Split fertig'
: `Split ${percent}%`
const stepLabel =
progress.segment?.label?.trim() ||
progress.message?.trim() ||
'Video wird gesplittet'
return {
file,
assetId: fileStemFromOutput(file),
state,
label,
ts: Number(progress.startedAtMs ?? Date.now()),
steps: [
{
key: 'postwork:split',
queue: 'postwork',
phase: 'split',
label: stepLabel,
state,
error:
progress.phase === 'error'
? String(progress.message ?? 'Split fehlgeschlagen')
: undefined,
ts: Number(progress.startedAtMs ?? Date.now()),
},
],
}
}
export default function FinishedDownloads({
jobs,
doneJobs,
blurPreviews,
teaserPlayback,
teaserAudio,
pauseTeasers = false,
onOpenPlayer,
onDeleteJob,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onKeepJob,
onSplitJob,
onAddToDownloads,
doneTotal,
page,
pageSize,
onPageChange,
onPageSizeChange,
assetNonce,
sortMode,
onSortModeChange,
modelsByKey,
loadMode = 'paged',
includeKeep,
onIncludeKeepChange,
splitProgressByFile = {},
}: Props) {
// -----------------------------------------------------------------------------
// local constants
// -----------------------------------------------------------------------------
const VIEW_KEY = 'finishedDownloads_view'
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1'
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
const notify = useNotify()
// -----------------------------------------------------------------------------
// media queries + simple derived flags
// -----------------------------------------------------------------------------
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')
const isSmall = useMediaQuery('(max-width: 639px)')
const effectiveTeaserPlaybackMode =
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
const cardsMobileOffsetTopClass = ''
const cardsMobileOffsetBottomClass = 'mb-2'
// -----------------------------------------------------------------------------
// state
// -----------------------------------------------------------------------------
const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = useState(false)
const [teaserKey, setTeaserKey] = useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = useState<string | null>(null)
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(() => new Set())
const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set())
const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
const [undoing, setUndoing] = useState(false)
const [bulkBusy, setBulkBusy] = useState(false)
const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false)
const [postworkByFile, setPostworkByFile] =
useState<Record<string, FinishedPostworkBadge[]>>({})
const [generateAssetsLiveByFile, setGenerateAssetsLiveByFile] =
useState<Record<string, FinishedPostworkBadge[]>>({})
const [renamedFiles, setRenamedFiles] = useState<Record<string, string>>({})
const [view, setView] = useState<ViewMode>('table')
const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false)
const [selectionActionsOpen, setSelectionActionsOpen] = useState(false)
const [galleryCardScaleIndex, setGalleryCardScaleIndex] = useState<number>(
DEFAULT_GALLERY_CARD_SCALE_INDEX
)
const selectionStoreRef = useRef<FinishedSelectionStore | null>(null)
const selectionStore =
selectionStoreRef.current ??
(selectionStoreRef.current = createFinishedSelectionStore())
const selectionVersion = useSelectionVersion(selectionStore)
const galleryCardScale =
GALLERY_CARD_SCALE_PRESETS[galleryCardScaleIndex]?.scale ??
GALLERY_CARD_SCALE_PRESETS[DEFAULT_GALLERY_CARD_SCALE_INDEX].scale
const handleGalleryScalePresetChange = useCallback((nextIndex: number) => {
setGalleryCardScaleIndex(clampPresetIndex(nextIndex))
}, [])
const [allDoneJobs, setAllDoneJobs] = useState<RecordJob[] | null>(null)
const [allDoneJobsLoading, setAllDoneJobsLoading] = useState(false)
const allDoneJobsRequestRef = useRef(0)
const [tagFilter, setTagFilter] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState<string>('')
const [durations, setDurations] = useState<Record<string, number>>({})
const [resolutions, setResolutions] =
useState<Record<string, { w: number; h: number }>>({})
const [inlinePlay, setInlinePlay] =
useState<{ key: string; nonce: number } | null>(null)
const [hotBusyKeys, setHotBusyKeys] = useState<Set<string>>(() => new Set())
const [lastAction, setLastAction] = useState<UndoAction | null>(() => {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(LAST_UNDO_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedUndoState
if (!parsed || parsed.v !== 1 || !parsed.action) return null
const ageMs = Date.now() - Number(parsed.ts || 0)
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
localStorage.removeItem(LAST_UNDO_KEY)
return null
}
return parsed.action
} catch {
return null
}
})
const resolutionOf = useCallback((job: RecordJob): { w: number; h: number } | null => {
const k = keyFor(job)
const cached = resolutions[k]
if (cached?.w > 0 && cached?.h > 0) {
return cached
}
const meta = parseMetaObject((job as any)?.meta)
const w = readMetaVideoWidth(meta)
const h = readMetaVideoHeight(meta)
if (w && h) {
return { w, h }
}
return null
}, [resolutions])
const resolutionLabelOf = useCallback((job: RecordJob): string => {
return formatResolution(resolutionOf(job)) || '—'
}, [resolutionOf])
// -----------------------------------------------------------------------------
// refs
// -----------------------------------------------------------------------------
const pointerPosRef = useRef<{ x: number; y: number } | null>(null)
const teaserHostsRef = useRef<Map<string, HTMLElement>>(new Map())
const teaserIORef = useRef<IntersectionObserver | null>(null)
const elToKeyRef = useRef<WeakMap<Element, string>>(new WeakMap())
const teaserKeyRafRef = useRef<number | null>(null)
const pendingTeaserKeyRef = useRef<string | null>(null)
const countHintRef = useRef({ pending: 0, t: 0, timer: 0 as any })
const swipeRefs = useRef<Map<string, SwipeCardHandle>>(new Map())
const fileToKeyRef = useRef<Map<string, string>>(new Map())
const durationsRef = useRef<Record<string, number>>({})
const durationsFlushTimerRef = useRef<number | null>(null)
const resolutionsRef = useRef<Record<string, { w: number; h: number }>>({})
const resolutionsFlushTimerRef = useRef<number | null>(null)
const removeTimersRef = useRef<Map<string, number>>(new Map())
const regenerateTaskFilesRef = useRef<Set<string>>(new Set())
const [doneMetaByFile, setDoneMetaByFile] = useState<Record<string, RecordJobMeta>>({})
const refreshDoneMetaForFile = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return
try {
const res = await fetch(
`/api/record/done/meta?file=${encodeURIComponent(cleanFile)}`,
{ cache: 'no-store' }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const meta = data?.meta
if (!meta || typeof meta !== 'object') return
setDoneMetaByFile((prev) => ({
...prev,
[cleanFile]: meta as RecordJobMeta,
}))
} catch {
// ignore
}
}, [])
const renderIntegrityBadge = useCallback((job: RecordJob) => {
return renderDownloadIntegrityBadge((job as any)?.meta)
}, [])
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
onOpenPlayer(job, startAtSec)
}, [onOpenPlayer])
const renderRatingOverlay = useCallback((job: RecordJob) => {
return (
<RatingOverlay
metaRaw={(job as any)?.meta}
variant="overlay"
onOpenAt={(seconds) => {
openPlayerAndPauseTeasers(job, seconds)
}}
/>
)
}, [openPlayerAndPauseTeasers])
// -----------------------------------------------------------------------------
// memoized derived data
// -----------------------------------------------------------------------------
const forcePreviewMuted =
Boolean(pauseTeasers) ||
(mobileNeedsTapForAudio && teaserAudio
? !mobileTeaserAudioUnlocked
: false)
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
const audibleTeaserKey =
previewMuted
? null
: canHover
? hoverTeaserKey
: inlinePlay?.key ?? teaserKey
const deferredSearchQuery = useDeferredValue(searchQuery)
const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
const allKnownTags = useMemo(() => {
const seen = new Set<string>()
const out: string[] = []
for (const flags of Object.values(modelsByKey ?? {})) {
for (const tag of parseTags(flags?.tags)) {
const k = lowerTag(tag)
if (!k || seen.has(k)) continue
seen.add(k)
out.push(tag)
}
}
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}, [modelsByKey])
const teaserState = useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
}),
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
)
const modelTags = useMemo(() => {
const tagsByModelKey: Record<string, string[]> = {}
const tagSetByModelKey: Record<string, Set<string>> = {}
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
const key = lowerTag(k)
const arr = parseTags(flags?.tags)
tagsByModelKey[key] = arr
tagSetByModelKey[key] = new Set(arr.map(lowerTag))
}
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
const searchTokens = useMemo(
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
[deferredSearchQuery]
)
const searchActiveForGlobalFetch =
activeTagSet.size > 0 ||
searchTokens.length > 0 ||
showOnlyCorrupt
const globalFilterActive = searchActiveForGlobalFetch
const mobileCardStackMode = isSmall && view === 'cards'
const shouldLoadAllDoneJobs =
globalFilterActive || loadMode === 'all' || mobileCardStackMode
const effectiveAllMode = shouldLoadAllDoneJobs
const DEFAULT_GALLERY_PAGE_SIZE = 8
const [galleryPageSize, setGalleryPageSize] = useState<number>(
DEFAULT_GALLERY_PAGE_SIZE
)
const handleGalleryRecommendedPageSizeChange = useCallback((rawSize: number) => {
const rounded = Math.round(Number(rawSize))
if (!Number.isFinite(rounded) || rounded <= 0) return
const next = Math.max(1, Math.min(24, rounded))
setGalleryPageSize((current) => {
if (current === next) return current
return next
})
onPageSizeChange?.(next)
if (page !== 1) {
onPageChange(1)
}
}, [onPageSizeChange, onPageChange, page])
const effectivePageSize =
view === 'gallery'
? galleryPageSize
: pageSize
const doneJobsPage =
shouldLoadAllDoneJobs
? (allDoneJobs ?? doneJobs)
: doneJobs
const doneTotalPage =
shouldLoadAllDoneJobs
? (allDoneJobs?.length ?? doneTotal)
: doneTotal
const applyRenamedOutputLocal = useCallback(
(job: RecordJob): RecordJob => {
const out = norm(job.output || '')
const file = baseName(out)
const override = renamedFiles[file]
if (!override) return job
const idx = out.lastIndexOf('/')
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
return { ...job, output: dir + override }
},
[renamedFiles]
)
const rows = useMemo(() => {
const map = new Map<string, RecordJob>()
const doneBaseKeys = new Set<string>()
for (const j of doneJobsPage) {
let jj = applyRenamedOutputLocal(j)
const file = baseName(jj.output || '')
const metaOverride = file ? doneMetaByFile[file] : undefined
if (metaOverride) {
jj = {
...jj,
meta: mergeMetaPreferDone(metaOverride, (jj as any)?.meta),
}
}
const k = keyFor(jj)
map.set(k, jj)
doneBaseKeys.add(k)
}
for (const j of jobs) {
const jj = applyRenamedOutputLocal(j)
const k = keyFor(jj)
if (map.has(k)) {
const existing = map.get(k)!
map.set(k, {
...existing,
...jj,
meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta),
})
continue
}
const file = baseName(jj.output || '')
if (!file) continue
for (const [existingKey, existingJob] of map.entries()) {
if (baseName(existingJob.output || '') === file) {
map.set(existingKey, {
...existingJob,
...jj,
meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta),
})
break
}
}
}
return Array.from(map.entries())
.map(([k, j]) => ({ k, j }))
.filter(({ k, j }) => {
const file = baseName(j.output || '')
if (deletedKeys.has(k)) return false
if (file && hiddenFiles.has(file)) return false
if (isTrashOutput(j.output)) return false
const anyJ = j as any
const status = String(anyJ?.status ?? '').trim().toLowerCase()
const endedAt = anyJ?.endedAt
if (doneBaseKeys.has(k)) return true
if (
status === 'finished' ||
status === 'failed' ||
status === 'stopped'
) {
return true
}
if (endedAt) return true
return false
})
.map(({ j }) => j)
}, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal, doneMetaByFile])
const effectivePostworkSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
const next: Record<string, FinishedPostworkSummary> = {}
for (const [file, badges] of Object.entries(postworkByFile)) {
const summary = summarizePostworkSteps(file, badges)
if (summary) {
next[file] = summary
const filePlain = stripHotPrefix(file)
if (filePlain && filePlain !== file) {
next[filePlain] = summary
}
}
}
for (const [file, badges] of Object.entries(generateAssetsLiveByFile)) {
const summary = summarizePostworkSteps(file, badges)
if (!summary) continue
const filePlain = stripHotPrefix(file)
const existing = next[file] ?? (filePlain ? next[filePlain] : undefined)
const merged =
mergePostworkSummaries(summary, existing ?? null, file, summary.assetId) ?? summary
next[file] = merged
if (filePlain && filePlain !== file) {
next[filePlain] = merged
}
}
for (const job of rows) {
const fileRaw = baseName(job.output || '')
const filePlain = stripHotPrefix(fileRaw)
if (!fileRaw) continue
const rawMeta = (job as any)?.meta
const meta = parseMetaObject(rawMeta)
const assetId = fileStemFromOutput(job.output)
const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta)
const summaryFromCompleted = buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta)
const mergedMetaSummary =
mergePostworkSummaries(
summaryFromHistory,
summaryFromCompleted,
fileRaw,
assetId
)
const metaSummary =
mergedMetaSummary ??
buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status')
const liveSummary = next[fileRaw] ?? next[filePlain]
const summary =
mergePostworkSummaries(
liveSummary ?? null,
metaSummary ?? null,
fileRaw,
assetId
) ?? metaSummary ?? liveSummary ?? null
if (summary) {
next[fileRaw] = summary
if (filePlain && filePlain !== fileRaw) {
next[filePlain] = summary
}
}
}
return next
}, [postworkByFile, generateAssetsLiveByFile, rows])
const effectiveBadgeSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
const next = { ...effectivePostworkSummaryByFile }
for (const [file, progress] of Object.entries(splitProgressByFile)) {
const summary = buildSplitProgressSummary(file, progress)
if (!summary) continue
next[file] = summary
const plain = stripHotPrefix(file)
if (plain && plain !== file) {
next[plain] = summary
}
}
return next
}, [effectivePostworkSummaryByFile, splitProgressByFile])
const viewRows = rows
const visibleRows = useMemo(() => {
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
const searched = searchTokens.length
? base.filter((j) => {
const file = baseName(j.output || '')
const model = modelNameFromOutput(j.output)
const modelKey = lowerTag(model)
const tags = modelTags.tagsByModelKey[modelKey] ?? []
const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
for (const t of searchTokens) {
if (!hay.includes(t)) return false
}
return true
})
: base
const tagged =
activeTagSet.size === 0
? searched
: searched.filter((j) => {
const modelKey = lowerTag(modelNameFromOutput(j.output))
const have = modelTags.tagSetByModelKey[modelKey]
if (!have || have.size === 0) return false
for (const t of activeTagSet) {
if (!have.has(t)) return false
}
return true
})
const corruptFiltered = tagged.filter((j) => {
return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt)
})
return corruptFiltered
}, [
viewRows,
deletedKeys,
activeTagSet,
modelTags,
searchTokens,
showOnlyCorrupt,
])
const finishedDownloadsLoading = allDoneJobsLoading
const totalItemsForPagination =
finishedDownloadsLoading
? 0
: effectiveAllMode
? visibleRows.length
: doneTotalPage
const pageRows = useMemo(() => {
if (effectiveAllMode) {
const start = (page - 1) * effectivePageSize
const end = start + effectivePageSize
return visibleRows.slice(start, end)
}
return visibleRows
}, [visibleRows, page, effectivePageSize, effectiveAllMode])
const cardRows = mobileCardStackMode ? visibleRows : pageRows
const activeVisibleRows = pageRows
const emptyFolder =
!finishedDownloadsLoading &&
!effectiveAllMode &&
totalItemsForPagination === 0
const emptyByFilter =
!finishedDownloadsLoading &&
globalFilterActive &&
visibleRows.length === 0
const selectedCount = useMemo(() => {
return selectionStore.getCount()
}, [selectionStore, selectionVersion])
const selectedFiles = useMemo(() => {
return selectionStore.getSelectedFiles()
}, [selectionStore, selectionVersion])
const allPageSelected = useMemo(() => {
return (
activeVisibleRows.length > 0 &&
activeVisibleRows.every((j) => selectionStore.isSelected(keyFor(j)))
)
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
const hasSearchQuery = (searchQuery || '').trim() !== ''
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
const selectionModeActive = selectedCount > 0
const selectedKeys = useMemo(() => {
const next = new Set<string>()
for (const j of activeVisibleRows) {
const k = keyFor(j)
if (selectionStore.isSelected(k)) {
next.add(k)
}
}
return next
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
// -----------------------------------------------------------------------------
// callbacks
// -----------------------------------------------------------------------------
const toggleSelected = useCallback((job: RecordJob) => {
const k = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
selectionStore.toggle({ key: k, file })
}, [selectionStore])
const clearSelection = useCallback(() => {
selectionStore.clear()
}, [selectionStore])
const selectPageRows = useCallback((rows: RecordJob[]) => {
const items = rows
.map((j) => {
const key = keyFor(j)
const file = baseName(j.output || '')
if (!file) return null
return { key, file }
})
.filter((x): x is { key: string; file: string } => Boolean(x))
selectionStore.selectMany(items)
}, [selectionStore])
const deselectPageRows = useCallback((rows: RecordJob[]) => {
selectionStore.deselectMany(rows.map((j) => keyFor(j)))
}, [selectionStore, keyFor])
const refreshHoverPreviewFromPointer = useCallback(() => {
if (!canHover) return
const pos = pointerPosRef.current
if (!pos) {
setHoverTeaserKey(null)
return
}
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
if (!el) {
setHoverTeaserKey(null)
return
}
const hoverHost = el.closest<HTMLElement>('[data-finished-hover-key]')
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
}, [canHover])
const emitCountHint = useCallback((delta: number) => {
if (!delta || !Number.isFinite(delta)) return
countHintRef.current.pending += delta
if (countHintRef.current.timer) return
countHintRef.current.timer = window.setTimeout(() => {
countHintRef.current.timer = 0
const d = countHintRef.current.pending
countHintRef.current.pending = 0
if (!d) return
window.dispatchEvent(
new CustomEvent('finished-downloads:count-hint', { detail: { delta: d } })
)
}, 120)
}, [])
const jobForDetails = useCallback(
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
[modelsByKey]
)
const clearSearch = useCallback(() => setSearchQuery(''), [])
const toggleTagFilter = useCallback((tag: string) => {
const k = lowerTag(tag)
setTagFilter((prev) => {
const has = prev.some((t) => lowerTag(t) === k)
return has ? prev.filter((t) => lowerTag(t) !== k) : [...prev, tag]
})
}, [])
const clearTagFilter = useCallback(() => {
setTagFilter([])
}, [])
const addTagFilter = useCallback((tag: string) => {
const clean = String(tag ?? '').trim()
if (!clean) return
setTagFilter((prev) =>
prev.some((t) => lowerTag(t) === lowerTag(clean)) ? prev : [...prev, clean]
)
}, [])
const removeTagFilter = useCallback((tag: string) => {
setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag)))
}, [])
const renderTagFilterControls = (opts?: { mobile?: boolean }): ReactNode => (
<TagFilterBar
mobile={Boolean(opts?.mobile)}
activeTags={tagFilter}
allTags={allKnownTags}
onAddTag={addTagFilter}
onRemoveTag={removeTagFilter}
onClear={clearTagFilter}
/>
)
const loadAllDoneJobs = useCallback(async (): Promise<RecordJob[]> => {
const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ 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)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
return items
}, [sortMode, includeKeep])
const flushResolutionsSoon = useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => {
resolutionsFlushTimerRef.current = null
setResolutions({ ...resolutionsRef.current })
}, 200)
}, [])
const flushDurationsSoon = useCallback(() => {
if (durationsFlushTimerRef.current != null) return
durationsFlushTimerRef.current = window.setTimeout(() => {
durationsFlushTimerRef.current = null
setDurations({ ...durationsRef.current })
}, 200)
}, [])
const tryAutoplayInline = useCallback((domId: string) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!host || !v) return false
const teaserHost =
host.closest<HTMLElement>('[data-finished-hover-key]') ??
host.querySelector<HTMLElement>('[data-finished-hover-key]')
const key = teaserHost?.dataset.finishedHoverKey ?? null
const muted = !audibleTeaserKey || key !== audibleTeaserKey
applyInlineVideoPolicy(v, { muted })
v.defaultMuted = muted
v.muted = muted
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
return true
}, [audibleTeaserKey])
const startInline = useCallback((key: string) => {
setInlinePlay((prev) => (
prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }
))
}, [])
const startInlineAt = useCallback((key: string, seconds: number, domId: string) => {
const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
setInlinePlay((prev) => (
prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }
))
const trySeekAndPlay = (retriesLeft: number) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) {
if (retriesLeft > 0) {
requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1))
}
return
}
const muted = !audibleTeaserKey || key !== audibleTeaserKey
applyInlineVideoPolicy(v, { muted })
v.defaultMuted = muted
v.muted = muted
const applySeek = () => {
try {
const dur = Number(v.duration)
const maxSeek =
Number.isFinite(dur) && dur > 0
? Math.max(0, dur - 0.05)
: safeSeconds
v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek))
} catch {}
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
if (v.readyState >= 1) {
applySeek()
return
}
const onLoadedMetadata = () => {
v.removeEventListener('loadedmetadata', onLoadedMetadata)
applySeek()
}
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
requestAnimationFrame(() => trySeekAndPlay(8))
}, [audibleTeaserKey])
const openPlayer = useCallback((job: RecordJob) => {
openPlayerAndPauseTeasers(job)
}, [openPlayerAndPauseTeasers])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
openPlayerAndPauseTeasers(job, s)
}, [openPlayerAndPauseTeasers])
const handleScrubberClickIndex = useCallback(
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0
const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0
if (count <= 0) {
openPlayer(job)
return
}
const k = keyFor(job)
const durationSec =
durations[k] ??
((job as any)?.meta?.file?.durationSeconds as number | undefined) ??
0
if (!Number.isFinite(durationSec) || durationSec <= 0) {
openPlayer(job)
return
}
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
const secPerSegment = durationSec / count
const startAtSec = clampedIdx * secPerSegment
openPlayerAt(job, startAtSec)
},
[durations, openPlayer, openPlayerAt]
)
const markDeleting = useCallback((key: string, value: boolean) => {
setDeletingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const markDeleted = useCallback((key: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
}, [])
const markKeeping = useCallback((key: string, value: boolean) => {
setKeepingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const markRemoving = useCallback((key: string, value: boolean) => {
setRemovingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const cancelRemoveTimer = useCallback((key: string) => {
const t = removeTimersRef.current.get(key)
if (t != null) {
window.clearTimeout(t)
removeTimersRef.current.delete(key)
}
}, [])
const restoreRow = useCallback(
(key: string, file?: string) => {
cancelRemoveTimer(key)
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
},
[cancelRemoveTimer]
)
const animateRemove = useCallback(
(key: string, file?: string) => {
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.add(file)
return next
})
}
markRemoving(key, true)
cancelRemoveTimer(key)
const t = window.setTimeout(() => {
removeTimersRef.current.delete(key)
markDeleted(key)
markRemoving(key, false)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshHoverPreviewFromPointer()
})
})
}, 320)
removeTimersRef.current.set(key, t)
},
[markDeleted, markRemoving, cancelRemoveTimer, refreshHoverPreviewFromPointer]
)
const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => {
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
}
await new Promise<void>((r) => requestAnimationFrame(() => r()))
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
await new Promise((r) => window.setTimeout(r, 260))
},
[]
)
const forceReleaseFileTasks = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
try {
const res = await fetch('/api/record/release-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({ file: cleanFile }),
})
if (!res.ok) return false
const data = (await res.json().catch(() => null)) as any
return Boolean(data?.releasedAny)
} catch {
return false
}
}, [])
const withFileReleaseRetry = useCallback(
async <T,>(
file: string,
run: () => Promise<T>,
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
): Promise<T> => {
const attempts = Math.max(1, opts?.attempts ?? 5)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
let taskReleaseTriggered = false
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
await releasePlayingFile(file, { close: opts?.close ?? true })
if (attempt > 1) {
await sleep(baseDelayMs * attempt)
} else {
await sleep(80)
}
return await run()
} catch (e) {
lastErr = e
if (!looksLikeFileInUseError(e)) {
throw e
}
// Beim ersten "file in use" zusätzlich Backend-Tasks abbrechen
if (!taskReleaseTriggered) {
taskReleaseTriggered = true
await releasePlayingFile(file, { close: true })
await forceReleaseFileTasks(file)
await sleep(450)
continue
}
if (attempt >= attempts) {
throw e
}
}
}
throw lastErr instanceof Error
? lastErr
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile, forceReleaseFileTasks]
)
const runFileMutation = useCallback(
async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
const {
file,
rowKey,
setBusy,
isBusyNow,
optimisticRemove,
alreadyRemoved,
run,
onSuccess,
onError,
labels,
} = opts
if (!file) {
notify.error(labels.invalidTitle, labels.invalidBody)
return { ok: false }
}
if (isBusyNow?.()) return { ok: false }
setBusy?.(true)
try {
if (optimisticRemove && !alreadyRemoved) {
animateRemove(rowKey)
}
const result = await run()
await onSuccess?.(result)
return { ok: true, result }
} catch (e: any) {
if (optimisticRemove) {
restoreRow(rowKey, file)
}
await onError?.(e)
if (looksLikeFileInUseError(e)) {
notify.error(
labels.inUseTitle,
`${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`
)
} else {
const suffix = e?.message ? `${String(e.message)}` : ''
notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
}
return { ok: false }
} finally {
setBusy?.(false)
}
},
[notify, animateRemove, restoreRow]
)
const deleteVideo = useCallback(
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
const file = baseName(job.output || '')
const key = keyFor(job)
const res = await runFileMutation({
kind: 'delete',
job,
file,
rowKey: key,
setBusy: (v) => markDeleting(key, v),
isBusyNow: () => deletingKeys.has(key),
optimisticRemove: true,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Löschen nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht löschen.',
inUseTitle: 'Löschen fehlgeschlagen',
failTitle: 'Löschen fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onDeleteJob) {
return await withFileReleaseRetry(
file,
async () => await onDeleteJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
const r = await withFileReleaseRetry(
file,
async () =>
await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (result: any) => {
selectionStore.deselect(key)
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
const from = typeof result?.from === 'string' ? result.from : undefined
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
} else {
setLastAction(null)
}
emitCountHint(-1)
}
})
return res.ok
},
[
deletingKeys,
markDeleting,
onDeleteJob,
withFileReleaseRetry,
runFileMutation,
emitCountHint,
selectionStore,
]
)
const keepVideo = useCallback(
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
const file = baseName(job.output || '')
const key = keyFor(job)
const res = await runFileMutation({
kind: 'keep',
job,
file,
rowKey: key,
setBusy: (v) => markKeeping(key, v),
isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
optimisticRemove: true,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Keep nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht behalten.',
inUseTitle: 'Keep fehlgeschlagen',
failTitle: 'Keep fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onKeepJob) {
return await withFileReleaseRetry(
file,
async () => await onKeepJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
const r = await withFileReleaseRetry(
file,
async () =>
await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
selectionStore.deselect(key)
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
emitCountHint(includeKeep ? 0 : -1)
}
})
return res.ok
},
[
markKeeping,
keepingKeys,
deletingKeys,
withFileReleaseRetry,
runFileMutation,
emitCountHint,
includeKeep,
selectionStore,
]
)
const applyRename = useCallback((oldFile: string, newFile: string) => {
if (!oldFile || !newFile || oldFile === newFile) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
}
next[oldFile] = newFile
return next
})
selectionStore.replaceFile(oldFile, newFile)
}, [selectionStore])
const clearRenamePair = useCallback((a: string, b: string) => {
if (!a && !b) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === a || k === b || v === a || v === b) delete next[k]
}
return next
})
}, [])
const selectOnly = useCallback((job: RecordJob) => {
const key = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
selectionStore.clear()
selectionStore.selectMany([{ key, file }])
}, [selectionStore])
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
const unhideRow = (keyOrToken: string, file?: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(keyOrToken)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(keyOrToken)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(keyOrToken)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(keyOrToken)
return next
})
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
}
try {
if (lastAction.kind === 'delete') {
const res = await fetch(
`/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideRow(restoredFile, restoredFile)
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta)
setLastAction(null)
return
}
if (lastAction.kind === 'keep') {
const res = await fetch(
`/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.newFile || lastAction.originalFile)
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideRow(restoredFile, restoredFile)
emitCountHint(includeKeep ? 0 : +1)
setLastAction(null)
return
}
if (lastAction.kind === 'hot') {
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = (await res.json().catch(() => null)) as any
const oldFile = String(data?.oldFile || lastAction.currentFile)
const newFile = String(data?.newFile || '')
if (newFile) {
applyRename(oldFile, newFile)
}
setLastAction(null)
return
}
} catch (e: any) {
notify.error('Undo fehlgeschlagen', String(e?.message || e))
} finally {
setUndoing(false)
}
}, [
lastAction,
undoing,
notify,
includeKeep,
emitCountHint,
applyRename,
])
const markHotBusy = useCallback((key: string, value: boolean) => {
setHotBusyKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const toggleHotVideo = useCallback(
async (job: RecordJob): Promise<void> => {
const currentFile = baseName(job.output || '')
const key = keyFor(job)
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
const oldFile = currentFile
const optimisticNew = toggledName(oldFile)
await runFileMutation({
kind: 'rename',
job,
file: currentFile,
rowKey: key,
setBusy: (v) => markHotBusy(key, v),
isBusyNow: () => hotBusyKeys.has(key),
optimisticRemove: false,
labels: {
invalidTitle: 'HOT nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht HOT togglen.',
inUseTitle: 'HOT umbenennen fehlgeschlagen',
failTitle: 'HOT umbenennen fehlgeschlagen',
failPrefix: oldFile,
},
run: async () => {
applyRename(oldFile, optimisticNew)
if (onToggleHot) {
const r = await onToggleHot(job)
return r as any
}
const r = await withFileReleaseRetry(
oldFile,
async () =>
await fetchWithTextError(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ method: 'POST' }
),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
if (apiOld && apiNew && apiOld !== apiNew) {
applyRename(apiOld, apiNew)
}
setLastAction({ kind: 'hot', currentFile: apiNew })
},
onError: async () => {
clearRenamePair(oldFile, optimisticNew)
selectionStore.replaceFile(optimisticNew, oldFile)
setLastAction(null)
},
})
},
[
hotBusyKeys,
markHotBusy,
runFileMutation,
applyRename,
clearRenamePair,
onToggleHot,
withFileReleaseRetry,
selectionStore,
]
)
const regenerateAssetsForFile = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
const assetId = fileStemFromOutput(cleanFile)
const now = Date.now()
// Nur den ersten Schritt optimistisch als queued setzen.
// Die weiteren Phasen sollen erst durch echte SSE-/Backend-Events auftauchen.
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId,
queue: 'postwork',
state: 'queued',
phase: 'meta',
label: 'Meta',
ts: now,
},
],
}))
try {
const res = await fetch('/api/record/regenerate-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: cleanFile }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
return true
} catch (e: any) {
console.error('regenerate-assets failed', {
file: cleanFile,
error: e?.message ? String(e.message) : String(e),
})
// Request/Enqueue ist fehlgeschlagen -> KEIN Sammelstatus, KEIN enrich:assets.
// Wir markieren nur den ersten Schritt als Fehler.
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId,
queue: 'postwork',
state: 'error',
phase: 'meta',
label: 'Fehler',
error: e?.message ? String(e.message) : String(e),
ts: Date.now(),
},
],
}))
notify.error(
'Assets neu generieren fehlgeschlagen',
e?.message ? String(e.message) : String(e)
)
return false
}
}, [notify])
const runtimeOf = useCallback((job: RecordJob): string => {
const k = keyFor(job)
const meta = parseMetaObject((job as any)?.meta)
const sec = durations[k] ?? metaDurationSeconds(meta)
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
return formatDuration(sec * 1000)
}
const start = Date.parse(String(job.startedAt || ''))
const end = Date.parse(String(job.endedAt || ''))
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
return formatDuration(end - start)
}
return '—'
}, [durations])
const teaserHostCallbackMapRef = useRef(
new Map<string, (el: HTMLDivElement | null) => void>()
)
const integrityMetaPendingRef = useRef<Set<string>>(new Set())
const registerTeaserHost = useCallback((key: string) => {
const existing = teaserHostCallbackMapRef.current.get(key)
if (existing) return existing
const cb = (el: HTMLDivElement | null) => {
const prev = teaserHostsRef.current.get(key)
if (prev && teaserIORef.current && prev !== el) {
teaserIORef.current.unobserve(prev)
}
if (el) {
teaserHostsRef.current.set(key, el)
elToKeyRef.current.set(el, key)
teaserIORef.current?.observe(el)
} else {
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
teaserHostsRef.current.delete(key)
}
}
teaserHostCallbackMapRef.current.set(key, cb)
return cb
}, [])
const handleDuration = useCallback((job: RecordJob, seconds: number) => {
if (!Number.isFinite(seconds) || seconds <= 0) return
const k = keyFor(job)
const old = durationsRef.current[k]
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return
durationsRef.current = { ...durationsRef.current, [k]: seconds }
flushDurationsSoon()
}, [flushDurationsSoon])
const handleResolution = useCallback((job: RecordJob, w: number, h: number) => {
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return
const k = keyFor(job)
const prev = resolutionsRef.current[k]
if (prev && prev.w === w && prev.h === h) return
resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } }
flushResolutionsSoon()
}, [flushResolutionsSoon])
const bulkDeleteSelected = useCallback(async () => {
if (bulkBusy) return
if (selectedFiles.length === 0) return
const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`)
if (!ok) return
setBulkBusy(true)
try {
const res = await fetch('/api/record/delete-many', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: selectedFiles }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
const results = Array.isArray(data?.results) ? data.results : []
for (const item of results) {
if (!item?.ok) continue
const file = String(item.file || '').trim()
if (!file) continue
const key =
fileToKeyRef.current.get(file) ||
file
animateRemove(key, file)
}
const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection()
emitCountHint(-deletedCount)
if (deletedCount > 0) {
notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`)
}
} catch (e: any) {
notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e))
} finally {
setBulkBusy(false)
}
}, [
bulkBusy,
selectedFiles,
animateRemove,
clearSelection,
emitCountHint,
notify,
])
const bulkKeepSelected = useCallback(async () => {
if (bulkBusy) return
if (selectedFiles.length === 0) return
const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`)
if (!ok) return
setBulkBusy(true)
try {
const res = await fetch('/api/record/keep-many', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: selectedFiles }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
const results = Array.isArray(data?.results) ? data.results : []
for (const item of results) {
if (!item?.ok) continue
const file = String(item.file || '').trim()
if (!file) continue
const key =
fileToKeyRef.current.get(file) ||
file
animateRemove(key, file)
}
const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection()
emitCountHint(includeKeep ? 0 : -keptCount)
if (keptCount > 0) {
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
}
} catch (e: any) {
notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e))
} finally {
setBulkBusy(false)
}
}, [
bulkBusy,
selectedFiles,
animateRemove,
clearSelection,
emitCountHint,
includeKeep,
notify,
])
// -----------------------------------------------------------------------------
// effects
// -----------------------------------------------------------------------------
useEffect(() => {
if (!shouldLoadAllDoneJobs) {
setAllDoneJobs(null)
setAllDoneJobsLoading(false)
return
}
let cancelled = false
const reqId = ++allDoneJobsRequestRef.current
setAllDoneJobsLoading(true)
void loadAllDoneJobs()
.then((items) => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobs(items)
})
.catch(() => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobs([])
})
.finally(() => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobsLoading(false)
})
return () => {
cancelled = true
}
}, [shouldLoadAllDoneJobs, loadAllDoneJobs])
useEffect(() => {
if (!showOnlyCorrupt) return
for (const job of viewRows) {
const file = baseName(job.output || '')
if (!file) continue
const state = readDownloadIntegrityState((job as any)?.meta)
if (state !== 'unchecked') continue
if (integrityMetaPendingRef.current.has(file)) continue
integrityMetaPendingRef.current.add(file)
void refreshDoneMetaForFile(file).finally(() => {
integrityMetaPendingRef.current.delete(file)
})
}
}, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile])
useEffect(() => {
onPageChange(1)
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
useEffect(() => {
if (view !== 'gallery') return
const wanted = galleryPageSize
if (pageSize !== wanted) {
onPageSizeChange?.(wanted)
}
}, [view, pageSize, galleryPageSize, onPageSizeChange])
useEffect(() => {
try {
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
const parsed = Number(raw)
if (!Number.isFinite(parsed)) {
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
return
}
// neuer Fall: gespeicherter Index
if (
Number.isInteger(parsed) &&
parsed >= 0 &&
parsed <= GALLERY_CARD_SCALE_PRESETS.length - 1
) {
setGalleryCardScaleIndex(clampPresetIndex(parsed))
return
}
// alter Fall: gespeicherter Scale-Wert wie 0.8 / 0.9 / 1 / 1.2
setGalleryCardScaleIndex(nearestPresetIndex(parsed))
} catch {
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScaleIndex))
} catch {}
}, [galleryCardScaleIndex])
useEffect(() => {
try {
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
setView(saved)
} else {
setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table')
}
} catch {
setView('table')
}
}, [])
useEffect(() => {
try {
localStorage.setItem(VIEW_KEY, view)
} catch {}
}, [view])
useEffect(() => {
try {
const raw = localStorage.getItem(MOBILE_OPTS_KEY)
setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setMobileOptionsOpen(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0')
} catch {}
}, [mobileOptionsOpen])
useEffect(() => {
try {
const raw = localStorage.getItem('finishedDownloads_pendingTags')
if (!raw) return
const arr = JSON.parse(raw)
const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
if (tags.length === 0) return
flushSync(() => setTagFilter(tags))
if (page !== 1) onPageChange(1)
} catch {
} finally {
try {
localStorage.removeItem('finishedDownloads_pendingTags')
} catch {}
}
}, [])
useEffect(() => {
return () => {
if (teaserKeyRafRef.current != null) {
cancelAnimationFrame(teaserKeyRafRef.current)
teaserKeyRafRef.current = null
}
}
}, [])
useEffect(() => {
resolutionsRef.current = resolutions
}, [resolutions])
useEffect(() => {
return () => {
if (resolutionsFlushTimerRef.current != null) {
window.clearTimeout(resolutionsFlushTimerRef.current)
resolutionsFlushTimerRef.current = null
}
}
}, [])
useEffect(() => {
durationsRef.current = durations
}, [durations])
useEffect(() => {
return () => {
if (durationsFlushTimerRef.current != null) {
window.clearTimeout(durationsFlushTimerRef.current)
durationsFlushTimerRef.current = null
}
}
}, [])
useEffect(() => {
return () => {
for (const t of removeTimersRef.current.values()) {
window.clearTimeout(t)
}
removeTimersRef.current.clear()
}
}, [])
useEffect(() => {
for (const [key, host] of teaserHostsRef.current.entries()) {
const muted = !audibleTeaserKey || key !== audibleTeaserKey
const videos = host.querySelectorAll('video')
videos.forEach((video) => {
applyInlineVideoPolicy(video, { muted })
video.defaultMuted = muted
video.muted = muted
})
}
}, [audibleTeaserKey, view, pageRows])
const refreshPostworkState = useCallback(async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!data) return
const generateAssets = data?.generateAssets
const gaFile = String(generateAssets?.currentFile ?? '').trim()
const gaQueueRaw = String(generateAssets?.currentQueue ?? '').trim().toLowerCase()
const gaPhaseRaw = String(generateAssets?.currentPhase ?? '').trim().toLowerCase()
const gaLabel = String(generateAssets?.currentLabel ?? '').trim()
const gaQueue: 'postwork' | 'enrich' | null =
gaQueueRaw === 'postwork'
? 'postwork'
: gaQueueRaw === 'enrich'
? 'enrich'
: null
const gaPhase =
gaQueue && gaPhaseRaw
? normalizeFinishedPhase(gaQueue, gaPhaseRaw)
: ''
const liveTs =
typeof generateAssets?.ts === 'number' && Number.isFinite(generateAssets.ts)
? generateAssets.ts
: 0
const nextGenerateAssetsLiveByFile: Record<string, FinishedPostworkBadge[]> =
(generateAssets?.running || generateAssets?.queued) && gaFile && gaQueue && gaPhase
? {
[gaFile]: [
{
file: gaFile,
assetId: fileStemFromOutput(gaFile),
queue: gaQueue,
phase: gaPhase,
state: generateAssets?.running ? 'running' : 'queued',
label: gaLabel || labelForStep(gaQueue, gaPhase),
ts: liveTs,
},
],
}
: {}
setGenerateAssetsLiveByFile((prev) => {
const patched: Record<string, FinishedPostworkBadge[]> = {}
for (const [file, badges] of Object.entries(nextGenerateAssetsLiveByFile)) {
const prevBadges = prev[file] ?? postworkByFile[file] ?? []
patched[file] = badges.map((badge) => {
const key = `${badge.queue}:${normalizeFinishedPhase(
badge.queue,
String(badge.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadges.find((p) => {
const prevKey = `${p.queue}:${normalizeFinishedPhase(
p.queue,
String(p.phase ?? '').trim().toLowerCase()
)}`
return prevKey === key
})
return preferProgressLabel(badge, previous)
})
}
return samePostworkBadgeMap(prev, patched) ? prev : patched
})
const jobs = Array.isArray(data?.regenerateAssetsJobs)
? data.regenerateAssetsJobs
: []
const syncedFiles = new Set<string>()
setPostworkByFile((prev) => {
let changed = false
const next = { ...prev }
for (const item of jobs) {
const file = String(item?.file ?? '').trim()
if (!file) continue
syncedFiles.add(file)
let nextBadges: FinishedPostworkBadge[] | undefined
const assetId =
String(item?.assetId ?? '').trim() || fileStemFromOutput(file)
const phases = Array.isArray(item?.phases) ? item.phases : []
if (phases.length > 0) {
const prevBadgesForFile = prev[file] ?? []
nextBadges = phases
.map((p: any): FinishedPostworkBadge | null => {
const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
const phaseRaw = String(p?.phase ?? '').trim().toLowerCase()
const rawState = String(p?.state ?? '').trim().toLowerCase()
const state: FinishedPostworkState =
rawState === 'queued' ||
rawState === 'running' ||
rawState === 'done' ||
rawState === 'error' ||
rawState === 'missing'
? rawState
: rawState === 'cancelled'
? 'missing'
: 'missing'
if (
(queueRaw !== 'postwork' && queueRaw !== 'enrich') ||
!phaseRaw
) {
return null
}
const queue = queueRaw as 'postwork' | 'enrich'
const phase = normalizeFinishedPhase(queue, phaseRaw)
const incoming: FinishedPostworkBadge = {
file,
assetId,
queue,
phase,
state,
label:
normalizeAnalysisProgressLabel(String(p?.label ?? '').trim()) ||
labelForStep(queue, phase),
position: typeof p?.position === 'number' ? p.position : undefined,
waiting: typeof p?.waiting === 'number' ? p.waiting : undefined,
running: typeof p?.running === 'number' ? p.running : undefined,
maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined,
ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0,
}
const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadgesForFile.find((old) => {
const oldKey = `${old.queue}:${normalizeFinishedPhase(
old.queue,
String(old.phase ?? '').trim().toLowerCase()
)}`
return oldKey === key
})
return preferProgressLabel(incoming, previous)
})
.filter(Boolean) as FinishedPostworkBadge[]
} else {
const rawState = String(item?.state ?? '').trim().toLowerCase()
const state: FinishedPostworkState =
rawState === 'queued' ||
rawState === 'running' ||
rawState === 'done' ||
rawState === 'error' ||
rawState === 'missing'
? rawState
: rawState === 'cancelled'
? 'missing'
: 'missing'
if (state === 'queued' || state === 'running' || state === 'error') {
nextBadges = [
{
file,
assetId,
queue: 'postwork',
state,
phase: 'meta',
label: 'Meta',
ts: 0,
},
]
}
}
const prevBadges = prev[file]
if (nextBadges) {
if (!samePostworkBadgeList(prevBadges, nextBadges)) {
next[file] = nextBadges
changed = true
}
} else if (file in next) {
delete next[file]
changed = true
}
}
// WICHTIG: stale cleanup erst NACH dem Einsammeln aller aktuellen Dateien
for (const oldFile of regenerateTaskFilesRef.current) {
if (!syncedFiles.has(oldFile) && oldFile in next) {
delete next[oldFile]
changed = true
}
}
return changed ? next : prev
})
regenerateTaskFilesRef.current = syncedFiles
} catch {
// ignore
}
}, [postworkByFile])
useEffect(() => {
void refreshPostworkState()
}, [refreshPostworkState])
useEffect(() => {
const timer = window.setInterval(() => {
void refreshPostworkState()
}, 1500)
return () => {
window.clearInterval(timer)
}
}, [refreshPostworkState])
useEffect(() => {
const onFocus = () => {
void refreshPostworkState()
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void refreshPostworkState()
}
}
const onPageShow = () => {
void refreshPostworkState()
}
window.addEventListener('focus', onFocus)
window.addEventListener('pageshow', onPageShow)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
window.removeEventListener('focus', onFocus)
window.removeEventListener('pageshow', onPageShow)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [refreshPostworkState])
useEffect(() => {
if (!canHover) {
pointerPosRef.current = null
setHoverTeaserKey(null)
return
}
let raf: number | null = null
const syncHoverFromPointer = () => {
raf = null
const pos = pointerPosRef.current
if (!pos) {
setHoverTeaserKey((prev) => (prev === null ? prev : null))
return
}
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
const hoverHost = el?.closest<HTMLElement>('[data-finished-hover-key]') ?? null
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
}
const scheduleSync = () => {
if (raf != null) return
raf = requestAnimationFrame(syncHoverFromPointer)
}
const clearHover = () => {
pointerPosRef.current = null
setHoverTeaserKey((prev) => (prev === null ? prev : null))
}
const onPointerMove = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY }
scheduleSync()
}
const onPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) {
clearHover()
}
}
const onVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
clearHover()
}
}
window.addEventListener('pointermove', onPointerMove, { passive: true })
window.addEventListener('pointerout', onPointerOut, { passive: true })
window.addEventListener('blur', clearHover)
window.addEventListener('scroll', scheduleSync, true)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (raf != null) cancelAnimationFrame(raf)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerout', onPointerOut)
window.removeEventListener('blur', clearHover)
window.removeEventListener('scroll', scheduleSync, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [canHover])
useEffect(() => {
try {
if (!lastAction) {
localStorage.removeItem(LAST_UNDO_KEY)
return
}
const payload: PersistedUndoState = {
v: 1,
action: lastAction,
ts: Date.now(),
}
localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload))
} catch {}
}, [lastAction])
useEffect(() => {
if (effectiveAllMode) return
if (totalItemsForPagination < 0) return
const totalPages = Math.max(1, Math.ceil(totalItemsForPagination / effectivePageSize))
if (page > totalPages) {
onPageChange(totalPages)
}
}, [
effectiveAllMode,
totalItemsForPagination,
effectivePageSize,
page,
onPageChange,
])
useEffect(() => {
const m = new Map<string, string>()
for (const j of viewRows) {
const f = baseName(j.output || '')
if (!f) continue
m.set(f, keyFor(j))
}
fileToKeyRef.current = m
}, [viewRows])
useEffect(() => {
const onPostwork = (ev: Event) => {
const detail = (ev as CustomEvent<FinishedPostworkBadge>).detail
const file = String(detail?.file ?? '').trim()
if (!file) return
const phase = String(detail.phase ?? '').trim().toLowerCase()
if (
detail.state === 'done' &&
(
phase === 'sprites' ||
phase === 'thumb' ||
phase === 'teaser' ||
phase === 'meta' ||
phase === 'analyze'
)
) {
void refreshDoneMetaForFile(file)
}
setPostworkByFile((prev) => {
const cur = Array.isArray(prev[file]) ? prev[file] : []
if (detail.state === 'missing') {
const detailQueue = detail.queue === 'enrich' ? 'enrich' : 'postwork'
const detailPhase = normalizeFinishedPhase(
detailQueue,
String(detail.phase ?? '').trim().toLowerCase()
)
const missingKey = `${detailQueue}:${detailPhase}`
const filtered = cur.filter((item) => {
const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
return itemKey !== missingKey
})
if (samePostworkBadgeList(cur, filtered)) {
return prev
}
const next = { ...prev }
if (filtered.length === 0) delete next[file]
else next[file] = filtered
return next
}
const incoming: FinishedPostworkBadge = {
...detail,
label: String(detail?.label ?? '').trim() === file ? undefined : detail?.label,
error: String(detail?.error ?? '').trim() || undefined,
}
const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
let replaced = false
const merged = cur.map((item) => {
const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
if (itemKey !== key) return item
replaced = true
if ((item.ts ?? 0) > (incoming.ts ?? 0)) {
return item
}
return preferProgressLabel(incoming, item)
})
const nextBadges = replaced ? merged : [...merged, incoming]
if (samePostworkBadgeList(cur, nextBadges)) {
return prev
}
return {
...prev,
[file]: nextBadges,
}
})
}
window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
}, [refreshDoneMetaForFile])
useEffect(() => {
const onIntegrityRefresh = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string; all?: boolean }>).detail
const file = String(detail?.file ?? '').trim()
if (file) {
const clean = baseName(file)
if (clean) {
void refreshDoneMetaForFile(clean)
}
return
}
const files = new Set(
rows
.map((job) => baseName(job.output || ''))
.filter(Boolean)
)
for (const f of files) {
void refreshDoneMetaForFile(f)
}
}
window.addEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener)
return () => {
window.removeEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener)
}
}, [rows, refreshDoneMetaForFile])
useEffect(() => {
const onExternalDelete = (ev: Event) => {
const detail = (ev as CustomEvent<{ file: string; phase: 'start' | 'success' | 'error' }>).detail
if (!detail?.file) return
const key = fileToKeyRef.current.get(detail.file) || detail.file
if (detail.phase === 'start') {
markDeleting(key, true)
animateRemove(key, detail.file)
return
}
if (detail.phase === 'error') {
restoreRow(key, detail.file)
swipeRefs.current.get(key)?.reset()
return
}
if (detail.phase === 'success') {
markDeleting(key, false)
}
}
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, restoreRow])
useEffect(() => {
const onExternalRename = (ev: Event) => {
const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
const oldFile = String(detail?.oldFile ?? '').trim()
const newFile = String(detail?.newFile ?? '').trim()
if (!oldFile || !newFile || oldFile === newFile) return
applyRename(oldFile, newFile)
}
window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
}, [applyRename])
useEffect(() => {
const shouldClampFromVisibleRows = effectiveAllMode
if (!shouldClampFromVisibleRows) return
const totalPages = Math.max(1, Math.ceil(visibleRows.length / effectivePageSize))
if (page > totalPages) {
onPageChange(totalPages)
}
}, [
effectiveAllMode,
visibleRows.length,
page,
effectivePageSize,
onPageChange,
])
useEffect(() => {
const active =
!canHover &&
effectiveTeaserPlaybackMode !== 'still' &&
(view === 'cards' || view === 'gallery' || view === 'table')
if (!active) {
setTeaserKey(null)
teaserIORef.current?.disconnect()
teaserIORef.current = null
return
}
if (view === 'cards' && inlinePlay?.key) {
setTeaserKey(inlinePlay.key)
return
}
teaserIORef.current?.disconnect()
const io = new IntersectionObserver(
(entries) => {
let bestKey: string | null = null
let bestRatio = 0
for (const ent of entries) {
if (!ent.isIntersecting) continue
const k = elToKeyRef.current.get(ent.target)
if (!k) continue
if (ent.intersectionRatio > bestRatio) {
bestRatio = ent.intersectionRatio
bestKey = k
}
}
if (bestKey) {
pendingTeaserKeyRef.current = bestKey
if (teaserKeyRafRef.current == null) {
teaserKeyRafRef.current = requestAnimationFrame(() => {
teaserKeyRafRef.current = null
const nextKey = pendingTeaserKeyRef.current
setTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
})
}
}
},
{
threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9],
rootMargin: '0px',
}
)
teaserIORef.current = io
for (const [k, el] of teaserHostsRef.current) {
elToKeyRef.current.set(el, k)
io.observe(el)
}
return () => {
io.disconnect()
if (teaserIORef.current === io) teaserIORef.current = null
}
}, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key])
useEffect(() => {
if (!isSmall || view !== 'cards') return
if (effectiveTeaserPlaybackMode === 'still') {
setTeaserKey(null)
return
}
const top = pageRows[0]
if (!top) {
setTeaserKey(null)
return
}
const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, [isSmall, view, pageRows, effectiveTeaserPlaybackMode])
useEffect(() => {
if (!isSmall) {
swipeRefs.current = new Map()
}
}, [isSmall])
useEffect(() => {
if (selectedCount === 0 && selectionActionsOpen) {
setSelectionActionsOpen(false)
}
}, [selectedCount, selectionActionsOpen])
const desktopToolbarControlsClass = [
'flex flex-col gap-3',
].join(' ')
const desktopToolbarLeftClass = [
'flex min-w-0 flex-col gap-2',
].join(' ')
const handleToggleSelectAllPage = useCallback((checked: boolean) => {
if (checked) {
selectPageRows(pageRows)
return
}
deselectPageRows(pageRows)
}, [pageRows, selectPageRows, deselectPageRows])
const renderClickablePostworkBadge = useCallback(
(badge?: FinishedPostworkSummary) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
}),
[isSmall, regenerateAssetsForFile]
)
const isCompactDesktop = useMediaQuery('(min-width: 640px) and (max-width: 1399px)')
const keepToggleLabel = 'Behaltene Downloads anzeigen'
const corruptToggleLabel = 'Nur fehlerhafte Downloads'
const scrollFinishedListToTop = useCallback(() => {
if (typeof document !== 'undefined') {
const scrollArea = document.querySelector<HTMLElement>('[data-app-scroll-area="true"]')
if (scrollArea) {
scrollArea.scrollTo({ top: 0, behavior: 'auto' })
return
}
}
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'auto' })
}
}, [])
// -----------------------------------------------------------------------------
// render
// -----------------------------------------------------------------------------
return (
<>
<div className="mx-auto grid w-full max-w-7xl gap-y-2">
{/* Toolbar links außerhalb des Tabellen-Containers */}
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[288px] xl:w-[304px] lg:-translate-x-[calc(100%+1rem)] lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden">
<div
className="
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/90
dark:border-white/10 dark:bg-gray-950/95 dark:supports-[backdrop-filter]:bg-gray-950/90
"
>
{/* Desktop */}
{/* Sidebar */}
<div className="hidden sm:block px-3 py-2.5">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{totalItemsForPagination} abgeschlossen
</div>
</div>
{selectedCount > 0 ? (
<Button
size="sm"
variant="soft"
disabled={bulkBusy}
onClick={() => setSelectionActionsOpen((v) => !v)}
title={
selectionActionsOpen
? 'Auswahl-Aktionen ausblenden'
: 'Auswahl-Aktionen anzeigen'
}
className={[
'shrink-0 whitespace-nowrap font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
</div>
<div
className={[
'overflow-hidden transition-[max-height,opacity,border-color] duration-200 ease-in-out',
selectionActionsVisible
? 'max-h-72 opacity-100 border-t border-gray-200/60 dark:border-white/10'
: 'max-h-0 opacity-0 border-t border-transparent dark:border-transparent',
].join(' ')}
aria-hidden={!selectionActionsVisible}
>
<div
className={[
'px-3 py-3 transition-transform duration-200 ease-in-out',
selectionActionsVisible
? 'translate-y-0'
: '-translate-y-1 pointer-events-none',
].join(' ')}
>
<div className="sm:hidden">
{bulkBusy ? (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
<span>Wird verarbeitet</span>
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="soft"
color={allPageSelected ? 'amber' : 'indigo'}
className="w-full justify-center"
leadingIcon={
allPageSelected
? <XMarkIcon className="size-4 shrink-0" />
: <CheckIcon className="size-4 shrink-0" />
}
disabled={bulkBusy}
onClick={() => {
if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows)
}}
>
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
</Button>
<Button
size="sm"
variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={clearSelection}
className="w-full justify-center"
>
Auswahl aufheben
</Button>
<Button
size="sm"
variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={() => void bulkKeepSelected()}
className="w-full justify-center"
>
Behalten
</Button>
<Button
size="sm"
variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={() => void bulkDeleteSelected()}
className="w-full justify-center"
>
Löschen
</Button>
</div>
</div>
<div className="hidden sm:grid sm:grid-cols-2 sm:gap-2">
{bulkBusy ? (
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
<span>Wird verarbeitet</span>
</div>
) : null}
<Button
size="sm"
variant="soft"
color={allPageSelected ? 'amber' : 'indigo'}
leadingIcon={
allPageSelected
? <XMarkIcon className="size-4 shrink-0" />
: <CheckIcon className="size-4 shrink-0" />
}
disabled={bulkBusy}
onClick={() => {
if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows)
}}
>
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
</Button>
<Button
size="sm"
variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={clearSelection}
>
Auswahl aufheben
</Button>
<div className="col-span-2 h-px bg-gray-200 dark:bg-white/10" />
<Button
size="sm"
variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={() => void bulkKeepSelected()}
>
Behalten
</Button>
<Button
size="sm"
variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={() => void bulkDeleteSelected()}
>
Löschen
</Button>
</div>
</div>
</div>
<div className="relative w-full">
<TextInput
value={searchQuery}
size="sm"
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className={hasSearchQuery ? 'w-full pr-9' : 'w-full'}
/>
<button
type="button"
onClick={clearSearch}
aria-label="Suche leeren"
title="Suche leeren"
className={[
'absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md transition-colors',
hasSearchQuery
? 'text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200'
: 'pointer-events-none opacity-0',
].join(' ')}
>
<XMarkIcon className="size-4" />
</button>
</div>
<div className="w-full">
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size={isCompactDesktop ? 'sm' : 'md'}
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: isCompactDesktop ? undefined : 'Tabelle',
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: isCompactDesktop ? undefined : 'Cards',
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: isCompactDesktop ? undefined : 'Galerie',
srLabel: 'Galerie',
},
]}
/>
</div>
<div className={desktopToolbarControlsClass}>
<div className={desktopToolbarLeftClass}>
<div className="grid w-full grid-cols-1 gap-2">
<div
className={[
'flex w-full min-w-0 items-center rounded-lg border border-gray-200/70 bg-white/60 dark:border-white/10 dark:bg-white/5',
'min-h-9 px-3 py-1 gap-2',
].join(' ')}
>
<LabeledSwitch
compact={true}
label={keepToggleLabel}
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
onIncludeKeepChange(checked)
}}
/>
</div>
<div
className={[
'flex w-full min-w-0 items-center rounded-lg border border-gray-200/70 bg-white/60 dark:border-white/10 dark:bg-white/5',
isCompactDesktop ? 'h-8 px-2 gap-1.5' : 'h-9 px-3 gap-2',
].join(' ')}
>
<LabeledSwitch
compact={true}
label={corruptToggleLabel}
checked={showOnlyCorrupt}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setShowOnlyCorrupt(checked)
}}
/>
</div>
<Button
size={isCompactDesktop ? 'sm' : 'md'}
variant={lastAction ? 'soft' : 'secondary'}
color="emerald"
disabled={!lastAction || undoing}
onClick={undoLastAction}
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
title={
!lastAction
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
className="w-full justify-center"
>
<span className={isCompactDesktop ? 'sr-only' : ''}>Undo</span>
</Button>
{view !== 'table' ? (
<div className="w-full min-w-0">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
<select
id="finished-sort"
value={sortMode}
onChange={(e) => {
const m = e.target.value as DoneSortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
) : null}
{view === 'gallery' ? (
<div className="w-full min-w-0">
<Slider
id="finished-gallery-card-scale"
label="Größe"
min={0}
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
step={1}
value={galleryCardScaleIndex}
onChange={handleGalleryScalePresetChange}
onChangeEnd={handleGalleryScalePresetChange}
valueFormatter={(v) => {
const idx = clampPresetIndex(v)
return GALLERY_CARD_SCALE_PRESETS[idx].label
}}
showValueLabel
compact
/>
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
{/* Mobile */}
<div className="sm:hidden">
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene 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">
{totalItemsForPagination}
</span>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
<LoadingSpinner
size="lg"
className={[
'text-indigo-500 transition-opacity duration-150 pointer-events-none',
allDoneJobsLoading ? 'opacity-100' : 'opacity-0',
].join(' ')}
srLabel="Lade Downloads…"
/>
</div>
</div>
<div className="px-3 pt-1.5 pb-2">
<div className="flex items-center justify-between gap-2">
<Button
size="md"
variant={lastAction ? 'soft' : 'secondary'}
color="emerald"
className="flex-1"
disabled={!lastAction || undoing}
onClick={undoLastAction}
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
title={
!lastAction
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
>
Undo
</Button>
<div className="flex-1">
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="sm"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-4" />,
label: undefined,
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-4" />,
label: undefined,
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-4" />,
label: undefined,
srLabel: 'Galerie',
},
]}
/>
</div>
{selectedCount > 0 ? (
<Button
size="md"
variant="soft"
disabled={bulkBusy}
onClick={() => setSelectionActionsOpen((v) => !v)}
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
className={[
'shrink-0 whitespace-nowrap px-3 font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<button
type="button"
className="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white shadow-sm
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
onClick={() => setMobileOptionsOpen((v) => !v)}
aria-expanded={mobileOptionsOpen}
aria-controls="finished-mobile-options"
aria-label="Filter & Optionen"
>
<AdjustmentsHorizontalIcon className="size-5" />
{globalFilterActive || includeKeep ? (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" />
) : null}
</button>
</div>
</div>
</div>
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</div>
<div className="min-w-0">
{renderTagFilterControls()}
</div>
</div>
</div>
) : null}
<div
id="finished-mobile-options"
className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
>
<div className="border-t border-gray-200/60 dark:border-white/10 p-3">
<div className="space-y-2">
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center gap-2">
<TextInput
value={searchQuery}
size="sm"
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
</div>
</div>
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Keep anzeigen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Behaltene Downloads in der Liste
</div>
</div>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
onIncludeKeepChange(checked)
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Nur fehlerhafte Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Zeigt nur auffällige Sprite-/Ende-Prüfungen
</div>
</div>
<Switch
checked={showOnlyCorrupt}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setShowOnlyCorrupt(checked)
}}
ariaLabel="Nur fehlerhafte Downloads anzeigen"
size="default"
/>
</div>
</div>
{view !== 'table' ? (
<div className="space-y-2">
{view === 'gallery' ? (
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<Slider
id="finished-gallery-card-scale-mobile"
label="Kartengröße"
min={0}
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
step={1}
value={galleryCardScaleIndex}
onChange={handleGalleryScalePresetChange}
onChangeEnd={handleGalleryScalePresetChange}
valueFormatter={(v) => {
const idx = clampPresetIndex(v)
return GALLERY_CARD_SCALE_PRESETS[idx].label
}}
showValueLabel
/>
</div>
) : null}
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Sortierung
</div>
<select
id="finished-sort-mobile"
value={sortMode}
onChange={(e) => {
const m = e.target.value as DoneSortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
</div>
) : null}
</div>
</div>
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">
Tag-Filter
</div>
{renderTagFilterControls({ mobile: true })}
</div>
) : null}
</div>
</div>
</aside>
<main className="relative z-0 min-w-0 lg:col-start-1 lg:row-start-1">
{finishedDownloadsLoading ? (
<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">
<LoadingSpinner size="md" className="text-indigo-500" srLabel="Lade abgeschlossene Downloads…" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Abgeschlossene Downloads werden geladen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte kurz warten, die Liste wird vom Backend geladen.
</div>
</div>
</div>
</Card>
) : emptyFolder ? (
<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 abgeschlossenen Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Im Zielordner ist aktuell nichts vorhanden.
</div>
</div>
</div>
</Card>
) : emptyByFilter ? (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Keine Treffer für die aktuellen Filter.
</div>
{tagFilter.length > 0 ||
(searchQuery || '').trim() !== '' ||
showOnlyCorrupt ? (
<div className="mt-2 flex flex-wrap gap-3">
{tagFilter.length > 0 ? (
<button
type="button"
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
onClick={clearTagFilter}
>
Tag-Filter zurücksetzen
</button>
) : null}
{(searchQuery || '').trim() !== '' ? (
<button
type="button"
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
onClick={clearSearch}
>
Suche zurücksetzen
</button>
) : null}
</div>
) : null}
{showOnlyCorrupt ? (
<button
type="button"
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
onClick={() => setShowOnlyCorrupt(false)}
>
Fehlerfilter zurücksetzen
</button>
) : null}
</Card>
) : (
<div className="pb-24 sm:pb-20">
{view === 'cards' && (
<div className={isSmall ? ['relative z-0 pt-10', cardsMobileOffsetTopClass, cardsMobileOffsetBottomClass].filter(Boolean).join(' ') : ''}>
<FinishedDownloadsCardsView
rows={cardRows}
selectionStore={selectionStore}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={handleToggleSelectAllPage}
onSelectOnly={selectOnly}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={allDoneJobsLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserState={teaserState}
inlinePlay={inlinePlay}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
swipeRefs={swipeRefs}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lowerTag}
onOpenPlayer={openPlayerAndPauseTeasers}
openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt}
handleScrubberClickIndex={handleScrubberClickIndex}
startInlineAt={startInlineAt}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
handleResolution={handleResolution}
resolutionLabelOf={resolutionLabelOf}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
postworkByFile={effectiveBadgeSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
renderPostworkBadge={renderClickablePostworkBadge}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
/>
</div>
)}
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={allDoneJobsLoading}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
jobForDetails={jobForDetails}
keyFor={keyFor}
baseName={baseName}
lower={lowerTag}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
resolutions={resolutions}
durations={durations}
canHover={canHover}
teaserState={teaserState}
setHoverTeaserKey={setHoverTeaserKey}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
handleResolution={handleResolution}
blurPreviews={blurPreviews}
assetNonce={assetNonce}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex}
onSortModeChange={onSortModeChange}
page={page}
onPageChange={onPageChange}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
postworkByFile={effectiveBadgeSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={renderClickablePostworkBadge}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
/>
)}
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
onToggleSelected={toggleSelected}
onSelectOnly={selectOnly}
isLoading={allDoneJobsLoading}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
handleResolution={handleResolution}
resolutionLabelOf={resolutionLabelOf}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lowerTag}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
postworkByFile={effectiveBadgeSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
cardScale={galleryCardScale}
renderPostworkBadge={renderClickablePostworkBadge}
onRecommendedPageSizeChange={handleGalleryRecommendedPageSizeChange}
assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
/>
)}
</div>
)}
</main>
</div>
{!mobileCardStackMode ? (
<div className="fixed inset-x-0 bottom-4 z-[70] flex justify-center px-4 pointer-events-none">
<div className="pointer-events-auto flex w-full max-w-fit flex-col items-center gap-3">
<div className="shadow-2xl">
<Pagination
page={page}
pageSize={effectivePageSize}
totalItems={totalItemsForPagination}
onPageChange={(p) => {
flushSync(() => {
setInlinePlay(null)
setTeaserKey(null)
setHoverTeaserKey(null)
})
scrollFinishedListToTop()
onPageChange(p)
}}
showSummary={false}
prevLabel="Zurück"
nextLabel="Weiter"
className="mt-0"
/>
</div>
</div>
</div>
) : null}
</>
)
}