nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2026-03-26 07:38:24 +01:00

4375 lines
139 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 * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type {
ViewMode,
SelectedItem,
UndoAction,
PersistedUndoState,
RunFileMutationOptions,
RecordJob,
RecordJobMeta,
StoredModelFlags,
FinishedPostworkBadge,
FinishedPostworkState,
FinishedPostworkStepSummary,
FinishedPostworkSummary,
DoneSortMode,
} from '../../types'
import ButtonGroup from './ButtonGroup'
import {
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
SparklesIcon,
CheckIcon,
XMarkIcon,
ArchiveBoxIcon,
TrashIcon,
} 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 TagBadge from './TagBadge'
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'
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 } | Promise<void | { undoToken?: 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 | Promise<void>
onSplitJob?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
doneTotal: number
page: number
pageSize: number
onPageChange: (page: number) => void
assetNonce?: number
sortMode: DoneSortMode
onSortModeChange: (m: DoneSortMode) => void
loadMode?: 'paged' | 'all'
}
const norm = (p: string) => (p || '').replaceAll('\\', '/')
const baseName = (p: string) => {
const n = norm(p)
const parts = n.split('/')
return parts[parts.length - 1] || ''
}
const keyFor = (j: RecordJob) => {
// ✅ Primär: stabile Job-ID (bleibt gleich bei Rename/HOT)
const id = (j as any)?.id
if (id != null && String(id).trim() !== '') return String(id)
// Fallback (sollte selten sein)
const f = baseName(j.output || '')
return f || 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')
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
function formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
function useMediaQuery(query: string) {
const [matches, setMatches] = React.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)
return {
...job,
sourceUrl: job.sourceUrl || href,
}
}
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const lower = (s: string) => (s || '').trim().toLowerCase()
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
// stable dedupe (case-insensitive), aber original casing behalten
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
// wie in ModelsTab: alphabetisch (case-insensitive)
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}
// 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))
}
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('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 : {}
merged.analysis = {
...incomingAnalysis,
...existingAnalysis,
ai:
isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0
? {
...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}),
...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}),
}
: isPlainObject(incomingAnalysis.ai)
? incomingAnalysis.ai
: 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('teilweise 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'
}
}
function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
const p = String(phase ?? '').trim().toLowerCase()
switch (`${queue}:${p}`) {
case 'postwork:meta':
return 'Meta'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:thumb':
return 'Vorschaubild'
case 'postwork:sprites':
return 'Sprites'
case 'enrich:analyze':
return 'KI-Analyse'
default:
return p || (queue === 'enrich' ? 'KI-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?.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') ||
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:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:thumb') {
state = hasThumb ? '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
? `Teilweise 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 isAggregateAssetsPhase(
queue: 'postwork' | 'enrich',
phase?: string
): boolean {
return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets'
}
function normalizeFinishedPhase(
queue: 'postwork' | 'enrich',
phase?: string
): string {
const p = String(phase ?? '').trim().toLowerCase()
if (queue === 'enrich') {
if (p === 'analyze') return 'analyze'
return p || 'analyze'
}
switch (p) {
case 'postwork':
case 'probe':
case 'remuxing':
case 'moving':
return 'meta'
case 'assets':
return 'teaser'
case 'thumb':
return 'thumb'
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>()
let aggregateAssetsBadge: FinishedPostworkBadge | null = null
for (const badge of realBadges) {
const queue = badge.queue
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
// ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen
// sondern später auf die bekannten Schritte verteilen.
if (isAggregateAssetsPhase(queue, rawPhase)) {
if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) {
aggregateAssetsBadge = badge
}
continue
}
const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}`
const prev = byKey.get(key)
if (prev && prev.ts > badge.ts) continue
byKey.set(key, {
key,
queue,
phase,
label: labelForStep(queue, phase),
state: badge.state,
ts: badge.ts,
position: badge.position,
waiting: badge.waiting,
running: badge.running,
maxParallel: badge.maxParallel,
})
}
// ✅ 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,
})
}
// ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden
if (aggregateAssetsBadge) {
for (const stepDef of expectedSteps) {
const cur = byKey.get(stepDef.key)
if (!cur) continue
// Bereits fertige Schritte beibehalten
if (cur.state === 'done') continue
byKey.set(stepDef.key, {
...cur,
state: aggregateAssetsBadge.state,
ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0),
position: aggregateAssetsBadge.position,
waiting: aggregateAssetsBadge.waiting,
running: aggregateAssetsBadge.running,
maxParallel: aggregateAssetsBadge.maxParallel,
})
}
}
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
? `Teilweise 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'
) {
stepMap.set(step.key, 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
? `Teilweise 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,
}
}
export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary,
opts?: {
onClick?: () => void
clickable?: boolean
disablePopover?: boolean
}
): React.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' ? (
<SparklesIcon 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' ? (
<SparklesIcon 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 && (
<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">
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate">
{summary.file}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{summary.steps.map((step) => (
<div key={step.key} className="flex items-center justify-between gap-3">
<div className="min-w-0 text-[12px] text-gray-800 dark:text-gray-200">
{step.label}
</div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px]">
{step.state === 'running' ? (
<>
<LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
<span className="text-indigo-700 dark:text-indigo-200">Läuft</span>
</>
) : step.state === 'queued' ? (
<>
<SparklesIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="text-amber-700 dark:text-amber-200">
{typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'}
</span>
</>
) : step.state === 'done' ? (
<>
<CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
</>
) : step.state === 'error' ? (
<>
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
<span className="text-red-700 dark:text-red-200">Fehler</span>
</>
) : step.state === 'missing' ? (
<>
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
<span className="text-gray-500 dark:text-gray-400">Kein Status</span>
</>
) : (
<span className="text-gray-500 dark:text-gray-400"></span>
)}
</div>
</div>
))}
</div>
</div>
)
}
>
{badgeNode}
</HoverPopover>
)
}
export default function FinishedDownloads({
jobs,
doneJobs,
blurPreviews,
teaserPlayback,
teaserAudio,
pauseTeasers = false,
onOpenPlayer,
onDeleteJob,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onKeepJob,
onSplitJob,
onAddToDownloads,
doneTotal,
page,
pageSize,
onPageChange,
assetNonce,
sortMode,
onSortModeChange,
modelsByKey,
loadMode = 'paged',
}: Props) {
const VIEW_KEY = 'finishedDownloads_view'
const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
const allMode = loadMode === 'all'
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode
// Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover
// Mobile: "hover" wie "all" behandeln
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
const effectiveTeaserPlaybackMode: TeaserPlaybackMode =
!canHover && teaserPlaybackMode === 'hover'
? 'all'
: teaserPlaybackMode
const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false)
const forcePreviewMuted =
mobileNeedsTapForAudio && teaserAudio
? !mobileTeaserAudioUnlocked
: false
const notify = useNotify()
const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null)
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
const teaserIORef = React.useRef<IntersectionObserver | null>(null)
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0)
const [postworkByFile, setPostworkByFile] = React.useState<Record<string, FinishedPostworkBadge[]>>({})
const [undoing, setUndoing] = React.useState(false)
const [selectedItems, setSelectedItems] = React.useState<Record<string, SelectedItem>>({})
const [bulkBusy, setBulkBusy] = React.useState(false)
// 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
// 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken"
const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
const [refillTick, setRefillTick] = React.useState(0)
const refillTimerRef = React.useRef<number | null>(null)
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const [view, setView] = React.useState<ViewMode>('table')
const [includeKeep, setIncludeKeep] = React.useState(false)
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
// 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
}),
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
)
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])
const toggleSelected = useCallback((job: RecordJob) => {
const k = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
setSelectedItems((prev) => {
const next = { ...prev }
if (next[k]) {
delete next[k]
} else {
next[k] = { key: k, file }
}
return next
})
}, [keyFor])
const clearSelection = useCallback(() => {
setSelectedItems({})
}, [])
const selectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
const k = keyFor(j)
const file = baseName(j.output || '')
if (!file) continue
next[k] = { key: k, file }
}
return next
})
}, [keyFor])
const deselectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
delete next[keyFor(j)]
}
return next
})
}, [keyFor])
const [lastAction, setLastAction] = React.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
// optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird
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 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])
useEffect(() => {
if (!pauseTeasers) return
setTeaserKey(null)
setHoverTeaserKey(null)
setInlinePlay(null)
}, [pauseTeasers])
useEffect(() => {
let alive = true
const loadInitialPostworkState = async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const regen = data?.regenerateAssets
const file = String(regen?.file ?? '').trim()
if (!file) return
if (regen?.running) {
setPostworkByFile((prev) => ({
...prev,
[file]: [
{
file,
assetId: fileStemFromOutput(file),
queue: 'enrich',
state: 'running',
phase: 'assets',
label: 'Vorschaubild',
ts: Date.now(),
},
],
}))
return
}
if (regen?.error) {
setPostworkByFile((prev) => ({
...prev,
[file]: [
{
file,
assetId: fileStemFromOutput(file),
queue: 'enrich',
state: 'error',
phase: 'assets',
label: 'Fehler',
ts: Date.now(),
},
],
}))
return
}
const bulk = data?.generateAssets
const bulkFile = String(bulk?.currentFile ?? '').trim()
const bulkQueue =
String(bulk?.currentQueue ?? '').trim().toLowerCase() === 'enrich'
? 'enrich'
: 'postwork'
const bulkPhase = String(bulk?.currentPhase ?? '').trim().toLowerCase()
const bulkLabel = String(bulk?.currentLabel ?? '').trim()
if (bulk?.running && bulkFile && bulkPhase) {
setPostworkByFile((prev) => ({
...prev,
[bulkFile]: [
{
file: bulkFile,
assetId: fileStemFromOutput(bulkFile),
queue: bulkQueue,
state: 'running',
phase: bulkPhase,
label: bulkLabel || undefined,
ts: Date.now(),
},
],
}))
}
// optional: wenn du "done" kurz nach Reload noch sichtbar haben willst
if (regen?.text) {
setPostworkByFile((prev) => ({
...prev,
[file]: [
{
file,
assetId: fileStemFromOutput(file),
queue: 'enrich',
state: 'done',
phase: 'assets',
label: 'Fertig',
ts: Date.now(),
},
],
}))
}
} catch {
// ignore
}
}
void loadInitialPostworkState()
return () => {
alive = false
}
}, [])
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY }
}
window.addEventListener('pointermove', onPointerMove, { passive: true })
return () => window.removeEventListener('pointermove', onPointerMove)
}, [])
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 {
// ignore
}
}, [lastAction])
const emitCountHint = React.useCallback((delta: number) => {
if (!delta || !Number.isFinite(delta)) return
countHintRef.current.pending += delta
// coalesce mehrere Aktionen sehr kurz hintereinander
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 modelTags = useMemo(() => {
const tagsByModelKey: Record<string, string[]> = {}
const tagSetByModelKey: Record<string, Set<string>> = {}
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
const key = lower(k)
const arr = parseTags(flags?.tags)
tagsByModelKey[key] = arr
tagSetByModelKey[key] = new Set(arr.map(lower))
}
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
const jobForDetails = useCallback(
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
[modelsByKey]
)
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const deferredSearchQuery = React.useDeferredValue(searchQuery)
const searchTokens = useMemo(
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
[deferredSearchQuery]
)
const clearSearch = useCallback(() => setSearchQuery(''), [])
const toggleTagFilter = useCallback((tag: string) => {
const k = lower(tag)
setTagFilter((prev) => {
const has = prev.some((t) => lower(t) === k)
return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
})
}, [])
// ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
const searchActiveForGlobalFetch =
activeTagSet.size > 0 ||
searchTokens.some((t) => t.length >= 2)
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => {
// ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
const shouldShowLoading = overrideDoneJobs == null
if (shouldShowLoading) setIsLoading(true)
try {
const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
{
cache: 'no-store' as any,
signal,
}
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally {
if (shouldShowLoading) setIsLoading(false)
}
},
[sortMode, includeKeep]
)
const clearTagFilter = useCallback(() => setTagFilter([]), [])
useEffect(() => {
// ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
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 {
// ignore
} finally {
try {
localStorage.removeItem('finishedDownloads_pendingTags')
} catch {}
}
}, []) // nur einmal beim Mount
const queueRefill = useCallback(() => {
// ✅ Schon geplant? Dann nicht nochmal planen.
if (refillTimerRef.current != null) return
refillTimerRef.current = window.setTimeout(() => {
refillTimerRef.current = null
setRefillTick((n) => n + 1)
}, 80)
}, [page, pageSize, sortMode, includeKeep])
useEffect(() => {
if (!effectiveAllMode) return
const ac = new AbortController()
const t = window.setTimeout(() => {
fetchAllDoneJobs(ac.signal).catch(() => {})
}, 250)
return () => {
window.clearTimeout(t)
ac.abort()
}
}, [effectiveAllMode, fetchAllDoneJobs])
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
useEffect(() => {
if (refillTick === 0) return
const ac = new AbortController()
let alive = true
let finished = false // ✅ pro Effect-Instanz idempotent
// ✅ eigene Session-ID für diese Effect-Instanz
const mySession = ++refillSessionRef.current
const finishRefill = () => {
if (finished) return
finished = true
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
if (refillSessionRef.current !== mySession) {
return
}
refillInFlightRef.current = false
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
}
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
if (refillSessionRef.current === mySession) {
refillInFlightRef.current = true
}
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) {
;(async () => {
try {
// fetchAllDoneJobs setzt isLoading selbst
await fetchAllDoneJobs(ac.signal)
if (alive) {
refillRetryRef.current = 0
}
} catch {
// ignore (Abort/Netzwerk)
} finally {
if (alive) finishRefill()
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}
// ✅ paged refill → hier Loading setzen
setIsLoading(true)
;(async () => {
try {
// 1) Liste + optional count in EINEM Request holen
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!alive || ac.signal.aborted) return
const okAll = listRes.ok
if (listRes.ok) {
const data = await listRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? data
: []
setOverrideDoneJobs(items)
// ✅ Count direkt aus /done lesen (withCount=1)
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneTotal(count)
const totalPages = Math.max(1, Math.ceil(count / pageSize))
if (page > totalPages) {
onPageChange(totalPages)
setOverrideDoneJobs(null)
return
}
}
if (okAll) {
refillRetryRef.current = 0
} else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
refillRetryRef.current += 1
const retryNo = refillRetryRef.current
window.setTimeout(() => {
if (!ac.signal.aborted) {
setRefillTick((n) => n + 1)
}
}, 400 * retryNo)
}
} catch {
// Abort / Fehler ignorieren
} finally {
if (alive) {
setIsLoading(false)
finishRefill()
}
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}, [
refillTick,
effectiveAllMode,
fetchAllDoneJobs,
page,
pageSize,
sortMode,
includeKeep,
onPageChange,
])
useEffect(() => {
// Override-Daten nur dann zurücksetzen, wenn wir wirklich wieder auf
// die normalen /done-Props zurückfallen wollen.
if (effectiveAllMode || includeKeep) return
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
useEffect(() => {
if (effectiveAllMode) return
// Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen.
if (!includeKeep) return
const ac = new AbortController()
let alive = true
setIsLoading(true)
;(async () => {
try {
const res = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok || !alive || ac.signal.aborted) return
const data = await res.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setOverrideDoneJobs(items)
setOverrideDoneTotal(count)
} finally {
if (alive) setIsLoading(false)
}
})()
return () => {
alive = false
ac.abort()
}
}, [effectiveAllMode, includeKeep, page, pageSize, sortMode])
useEffect(() => {
try {
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
setView(saved)
} else {
// Default: Mobile -> Cards, sonst Tabelle
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(KEEP_KEY)
setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setIncludeKeep(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0')
} catch {}
}, [includeKeep])
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])
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({})
// ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount)
const durationsRef = React.useRef<Record<string, number>>({})
const durationsFlushTimerRef = React.useRef<number | null>(null)
// 🔹 hier sammeln wir die Videoauflösung pro Job/Datei
const [resolutions, setResolutions] = React.useState<Record<string, { w: number; h: number }>>({})
// ✅ Perf: resolutions gesammelt flushen (wie durations)
const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
const resolutionsFlushTimerRef = React.useRef<number | null>(null)
const refillRetryRef = React.useRef(0)
React.useEffect(() => {
resolutionsRef.current = resolutions
}, [resolutions])
const flushResolutionsSoon = React.useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => {
resolutionsFlushTimerRef.current = null
setResolutions({ ...resolutionsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (resolutionsFlushTimerRef.current != null) {
window.clearTimeout(resolutionsFlushTimerRef.current)
resolutionsFlushTimerRef.current = null
}
}
}, [])
React.useEffect(() => {
durationsRef.current = durations
}, [durations])
const flushDurationsSoon = React.useCallback(() => {
if (durationsFlushTimerRef.current != null) return
durationsFlushTimerRef.current = window.setTimeout(() => {
durationsFlushTimerRef.current = null
// neue Objekt-Referenz, damit React aktualisiert
setDurations({ ...durationsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (durationsFlushTimerRef.current != null) {
window.clearTimeout(durationsFlushTimerRef.current)
durationsFlushTimerRef.current = null
}
}
}, [])
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
const previewMuted = !Boolean(teaserAudio)
const tryAutoplayInline = useCallback((domId: string) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false
applyInlineVideoPolicy(v, { muted: previewMuted })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
return true
}, [previewMuted])
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
// Inline-Preview aktivieren / remount erzwingen
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
// Nach dem Rendern das Video suchen, seeken und autoplay versuchen
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
}
applyInlineVideoPolicy(v, { muted: previewMuted })
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 {
// ignore
}
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
// Wenn Metadaten schon da sind -> direkt seeken
if (v.readyState >= 1) {
applySeek()
return
}
// Sonst warten bis metadata da sind
const onLoadedMetadata = () => {
v.removeEventListener('loadedmetadata', onLoadedMetadata)
applySeek()
}
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
// zusätzlich sofort play versuchen (hilft manchmal)
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
requestAnimationFrame(() => trySeekAndPlay(8))
}, [previewMuted])
const openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
setInlinePlay(null)
onOpenPlayer(job, s)
}, [onOpenPlayer])
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) {
// Fallback: Player normal öffnen
openPlayer(job)
return
}
// Dauer bevorzugt aus Preview-Metadaten, sonst aus Job
const k = keyFor(job)
const durationSec =
durations[k] ??
((job as any)?.meta?.file?.durationSeconds as number | undefined) ??
0
if (!Number.isFinite(durationSec) || durationSec <= 0) {
// Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp)
openPlayer(job)
return
}
// Segment-Index -> Startsekunde
// Beispiel: 10 Segmente, Klick auf Index 0..9
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
const secPerSegment = durationSec / count
const startAtSec = clampedIdx * secPerSegment
openPlayerAt(job, startAtSec)
},
[durations, keyFor, 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 [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
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
})
}, [])
// neben deletedKeys / deletingKeys
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
// ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
const removeTimersRef = React.useRef<Map<string, number>>(new Map())
useEffect(() => {
return () => {
for (const t of removeTimersRef.current.values()) {
window.clearTimeout(t)
}
removeTimersRef.current.clear()
}
}, [])
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 }) => {
// 1) App-/Overlay-Player freigeben
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
}
// 2) Einmal auf den nächsten Frame warten (React/DOM cleanup)
await new Promise<void>((r) => requestAnimationFrame(() => r()))
// 3) Nochmals release senden (hilft bei race zwischen close/unmount)
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
await new Promise((r) => window.setTimeout(r, 260))
},
[]
)
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 ?? 4)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
// vor JEDEM Versuch freigeben (nicht nur einmal)
await releasePlayingFile(file, { close: opts?.close ?? true })
// kurzer Tick extra (DOM/video cleanup, OS handle release)
if (attempt > 1) {
await sleep(baseDelayMs * attempt)
} else {
await sleep(80)
}
return await run()
} catch (e) {
lastErr = e
// nur bei "Datei in Verwendung" retryen
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
throw e
}
// nächster Versuch
continue
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile]
)
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) {
// Optimistik zurückrollen
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) => {
// Fall 1: externer Handler (App) liefert { undoToken }
if (onDeleteJob) {
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
}
return
}
// Fall 2: lokaler API-Call (liefert from + undoToken)
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
} else {
setLastAction(null)
}
queueRefill()
emitCountHint(-1)
},
})
return res.ok
},
[
baseName,
keyFor,
deletingKeys,
markDeleting,
onDeleteJob,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
]
)
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) => {
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
queueRefill()
emitCountHint(includeKeep ? 0 : -1)
},
})
return res.ok
},
[
baseName,
keyFor,
markKeeping,
keepingKeys,
deletingKeys,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
includeKeep,
]
)
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
})
setSelectedItems((prev) => {
let changed = false
const next: Record<string, SelectedItem> = {}
for (const [k, item] of Object.entries(prev)) {
if (item.file === oldFile) {
next[k] = { ...item, file: newFile }
changed = true
} else {
next[k] = item
}
}
return changed ? next : prev
})
}, [])
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 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)
// Fallbacks (falls alte lastAction ohne rowKey existiert)
unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideRow(restoredFile, restoredFile)
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta)
queueRefill()
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)
// Fallbacks (für ältere Actions / Sonderfälle)
unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideRow(restoredFile, restoredFile)
emitCountHint(+1)
queueRefill()
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)
}
queueRefill()
setLastAction(null)
return
}
} catch (e: any) {
notify.error('Undo fehlgeschlagen', String(e?.message || e))
} finally {
setUndoing(false)
}
}, [
lastAction,
undoing,
notify,
queueRefill,
includeKeep,
emitCountHint,
applyRename,
])
const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())
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 () => {
// Optimistik sofort anwenden
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 })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
},
onError: async () => {
// Rename-Optimistik rollback
clearRenamePair(oldFile, optimisticNew)
setLastAction(null)
},
})
},
[
baseName,
keyFor,
hotBusyKeys,
markHotBusy,
runFileMutation,
applyRename,
clearRenamePair,
onToggleHot,
withFileReleaseRetry,
queueRefill,
sortMode,
]
)
const regenerateAssetsForFile = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
state: 'running',
phase: 'assets',
label: 'Wird verarbeitet…',
ts: Date.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) {
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
state: 'error',
phase: 'assets',
label: 'Fehler',
ts: Date.now(),
},
],
}))
notify.error(
'Assets neu generieren fehlgeschlagen',
e?.message ? String(e.message) : String(e)
)
return false
}
}, [notify])
const applyRenamedOutput = 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 doneJobsPage = overrideDoneJobs ?? doneJobs
const doneTotalPage = overrideDoneTotal ?? doneTotal
const rows = useMemo(() => {
const map = new Map<string, RecordJob>()
const doneBaseKeys = new Set<string>()
// Basis: Files aus dem Done-Ordner
for (const j of doneJobsPage) {
const jj = applyRenamedOutput(j)
const k = keyFor(jj)
map.set(k, jj)
doneBaseKeys.add(k)
}
// Jobs aus /list drübermergen, damit aktuelle Live-Felder
// (phase, postWork, progress, duration, size, etc.) sichtbar bleiben.
for (const j of jobs) {
const jj = applyRenamedOutput(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
}
// Fallback: gleicher Dateiname, aber evtl. anderer key/ID
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
}
}
}
const list = 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
// .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
const anyJ = j as any
const status = String(anyJ?.status ?? '').trim().toLowerCase()
const endedAt = anyJ?.endedAt
// ✅ Wichtig:
// Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben
// auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist.
if (doneBaseKeys.has(k)) return true
// Fallback: terminale Jobs ebenfalls anzeigen
if (
status === 'finished' ||
status === 'failed' ||
status === 'stopped'
) {
return true
}
// Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist
if (endedAt) return true
return false
})
.map(({ j }) => j)
return list
}, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName])
const effectivePostworkSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
const next: Record<string, FinishedPostworkSummary> = {}
// 1) Erst Live-Zustände sammeln
for (const [file, badges] of Object.entries(postworkByFile)) {
const summary = summarizePostworkSteps(file, badges)
if (summary) {
next[file] = summary
}
}
// 2) Dann für jede sichtbare Datei Meta auswerten und ggf. Live-Zustand überschreiben
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, rows])
const viewRows = rows
const fileToKeyRef = React.useRef<Map<string, string>>(new Map())
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
setPostworkByFile((prev) => {
const next = { ...prev }
if (detail.state === 'missing') {
const cur = Array.isArray(prev[file]) ? prev[file] : []
const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}`
const filtered = cur.filter((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
return itemKey !== missingKey
})
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,
}
const cur = Array.isArray(prev[file]) ? prev[file] : []
const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}`
let replaced = false
const merged = cur.map((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
if (itemKey !== key) return item
replaced = true
return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming
})
next[file] = replaced ? merged : [...merged, incoming]
return next
})
}
window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
}, [])
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)
// ✅ Optimistik: überall gleich -> animiert raus
animateRemove(key, detail.file)
return
}
if (detail.phase === 'error') {
// ✅ alles zurückrollen -> wieder sichtbar
restoreRow(key, detail.file)
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
swipeRefs.current.get(key)?.reset()
return
}
if (detail.phase === 'success') {
markDeleting(key, false)
return
}
}
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, queueRefill, restoreRow])
useEffect(() => {
const onReload = () => {
// Nur reagieren, wenn FinishedDownloads wirklich sichtbar/mounted ist
if (document.hidden) return
if (refillInFlightRef.current) {
if (!refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
}
return
}
if (refillTimerRef.current != null) return
queueRefill()
}
window.addEventListener('finished-downloads:reload', onReload as EventListener)
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
}, [queueRefill])
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
// ✅ nutzt eure bestehende Logik inkl. Aufräumen + durations-move
applyRename(oldFile, newFile)
}
window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
}, [applyRename])
const visibleRows = useMemo(() => {
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
// 🔎 Suche (AND über Tokens)
const searched = searchTokens.length
? base.filter((j) => {
const file = baseName(j.output || '')
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const tags = modelTags.tagsByModelKey[modelKey] ?? []
const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
for (const t of searchTokens) {
if (!hay.includes(t)) return false
}
return true
})
: base
if (activeTagSet.size === 0) return searched
// AND-Filter: alle ausgewählten Tags müssen vorhanden sein
return searched.filter((j) => {
const modelKey = lower(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
})
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
const selectedFiles = useMemo(() => {
return Object.values(selectedItems)
.map((x) => String(x.file || '').trim())
.filter(Boolean)
}, [selectedItems])
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
const pageRows = useMemo(() => {
if (!effectiveAllMode) return visibleRows
const start = (page - 1) * pageSize
const end = start + pageSize
return visibleRows.slice(Math.max(0, start), Math.max(0, end))
}, [effectiveAllMode, visibleRows, page, pageSize])
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyByFilter = globalFilterActive && visibleRows.length === 0
// Optional zusätzlich: wenn allMode und wirklich gar nix da:
const emptyAll = allMode && visibleRows.length === 0
const showLoadingCard = isLoading && (emptyFolder || emptyAll || emptyByFilter)
useEffect(() => {
if (!globalFilterActive) return
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
if (page > totalPages) onPageChange(totalPages)
}, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange])
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
useEffect(() => {
const active =
!canHover &&
effectiveTeaserPlaybackMode !== 'still' &&
(view === 'cards' || view === 'gallery' || view === 'table')
if (!active) {
setTeaserKey(null)
teaserIORef.current?.disconnect()
teaserIORef.current = null
return
}
// Cards: Inline-Player soll “sticky” bleiben
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) {
setTeaserKey((prev) => (prev === bestKey ? prev : bestKey))
}
},
{
// eher “welche Card ist wirklich sichtbar”
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])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => {
const k = keyFor(job)
// ✅ 1) echte Videodauer (Preview-Metadaten oder Backend durationSeconds)
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)
}
// 2) Fallback
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 '—'
}
const registerTeaserHost = useCallback(
(key: string) => (el: HTMLDivElement | null) => {
const prev = teaserHostsRef.current.get(key)
if (prev && teaserIORef.current) 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)
}
},
[]
)
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
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 =
Object.values(selectedItems).find((x) => x.file === file)?.key ||
fileToKeyRef.current.get(file) ||
file
animateRemove(key, file)
}
const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection()
queueRefill()
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,
selectedItems,
animateRemove,
clearSelection,
queueRefill,
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 =
Object.values(selectedItems).find((x) => x.file === file)?.key ||
fileToKeyRef.current.get(file) ||
file
animateRemove(key, file)
}
const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection()
queueRefill()
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,
selectedItems,
animateRemove,
clearSelection,
queueRefill,
emitCountHint,
includeKeep,
notify,
])
// ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)')
// ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar)
const cardsMobileOffsetTopClass = 'mt-10'
const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4'
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, keyFor, effectiveTeaserPlaybackMode])
useEffect(() => {
if (!isSmall) {
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
swipeRefs.current = new Map()
}
}, [isSmall])
useEffect(() => {
// Nicht während eines Seitenwechsels / Refills zurückspringen.
if (isLoading) return
if (emptyFolder && page !== 1) onPageChange(1)
}, [emptyFolder, isLoading, page, onPageChange])
const selectedCount = selectedFiles.length
const allPageSelected =
pageRows.length > 0 &&
pageRows.every((j) => selectedKeys.has(keyFor(j)))
useEffect(() => {
if (selectedCount === 0 && selectionActionsOpen) {
setSelectionActionsOpen(false)
}
}, [selectedCount, selectionActionsOpen])
return (
<>
{/* Toolbar (sticky) */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
{/* Desktop */}
<div className="hidden sm:block px-3 py-2.5">
{/* Zeile 1: Titel */}
<div className="flex items-center gap-2 pb-2">
<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',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')}
srLabel="Lade Downloads…"
/>
</div>
</div>
{/* Zeile 2: Controls */}
<div className="flex items-center gap-3">
<div className="min-w-0 flex flex-1 items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Leeren
</Button>
) : null}
</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 font-semibold',
selectionActionsOpen
? 'ring-2 ring-indigo-500/60'
: '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex h-9 items-center gap-2 rounded-lg border border-gray-200/70 bg-white/60 px-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
label="Behaltene Downloads anzeigen"
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
}}
/>
</div>
{view !== 'table' ? (
<div>
<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 rounded-md border border-gray-200 bg-white px-2 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}
<Button
size="md"
variant="soft"
disabled={!lastAction || undoing}
onClick={undoLastAction}
title={
!lastAction
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
>
Undo
</Button>
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="md"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: 'Tabelle',
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: 'Cards',
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: 'Galerie',
srLabel: 'Galerie',
},
]}
/>
</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',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
].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="soft"
className="flex-1"
disabled={!lastAction || undoing}
onClick={undoLastAction}
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>
{/* Auswahl-Aktionen */}
{selectedCount > 0 ? (
<div
className={[
'overflow-hidden border-t border-gray-200/60 dark:border-white/10 transition-all duration-200 ease-in-out',
selectionActionsOpen
? 'max-h-72 opacity-100'
: 'max-h-0 opacity-0',
].join(' ')}
aria-hidden={!selectionActionsOpen}
>
<div
className={[
'px-3 py-3 transition-transform duration-200 ease-in-out',
selectionActionsOpen ? 'translate-y-0' : '-translate-y-1 pointer-events-none',
].join(' ')}
>
{/* Mobile */}
<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'}
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
onClick={() => {
if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows)
}}
className="w-full justify-center"
>
{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>
{/* Desktop */}
<div className="hidden sm:flex sm:flex-wrap sm:items-center 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="h-5 w-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>
) : null}
{/* Desktop: aktive Tag-Filter anzeigen */}
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
) : null}
{/* Mobile Optionen */}
<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 p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(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)
setIncludeKeep(checked)
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
{view !== 'table' ? (
<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>
) : null}
</div>
</div>
{tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
{showLoadingCard ? (
<Card grayBody>
<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-white">
Lade Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte warte einen Moment.
</div>
</div>
<LoadingSpinner
size="lg"
className="text-indigo-500"
srLabel="Lade Downloads…"
/>
</div>
</Card>
) : (emptyFolder || emptyAll) ? (
<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() !== '' ? (
<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}
</Card>
) : (
<div className="pb-24 sm:pb-20">
{view === 'cards' && (
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={isLoading}
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={lower}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt}
handleScrubberClickIndex={handleScrubberClickIndex}
startInlineAt={startInlineAt}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
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={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
</div>
)}
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={isLoading}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
jobForDetails={jobForDetails}
keyFor={keyFor}
baseName={baseName}
lower={lower}
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={onOpenPlayer}
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={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
)}
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
isLoading={isLoading}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lower}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
postworkByFile={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
)}
</div>
)}
<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={pageSize}
totalItems={totalItemsForPagination}
onPageChange={(p) => {
flushSync(() => {
setInlinePlay(null)
setTeaserKey(null)
setHoverTeaserKey(null)
})
window.scrollTo({ top: 0, behavior: 'auto' })
onPageChange(p)
}}
showSummary={false}
prevLabel="Zurück"
nextLabel="Weiter"
className="mt-0"
/>
</div>
</div>
</div>
</>
)
}