// 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 blurPreviews?: boolean teaserPlayback?: TeaserPlaybackMode teaserAudio?: boolean pauseTeasers?: boolean onOpenPlayer: (job: RecordJob, startAtSec?: number) => void onDeleteJob?: ( job: RecordJob ) => void | { undoToken?: string } | Promise onToggleHot?: ( job: RecordJob ) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onKeepJob?: (job: RecordJob) => void | Promise onSplitJob?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise 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): 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() 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((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 ): 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 { 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), ...(existingMeta as Record), } 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).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() 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() 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 ? ( ) : ( {summary.state === 'running' ? ( ) : summary.state === 'queued' ? ( ) : summary.state === 'done' ? ( ) : summary.state === 'error' ? ( ) : summary.state === 'missing' ? ( ) : null} {summary.label} ) if (opts?.disablePopover) { return badgeNode } return ( open && (
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
{summary.file}
{summary.steps.map((step) => (
{step.label}
{step.state === 'running' ? ( <> Läuft ) : step.state === 'queued' ? ( <> {typeof step.position === 'number' && step.position > 0 ? `Wartet #${step.position}` : 'Wartet'} ) : step.state === 'done' ? ( <> Fertig ) : step.state === 'error' ? ( <> Fehler ) : step.state === 'missing' ? ( <> Kein Status ) : ( )}
))}
) } > {badgeNode}
) } 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>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) const teaserIORef = React.useRef(null) const elToKeyRef = React.useRef>(new WeakMap()) // 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) const [hiddenFiles, setHiddenFiles] = React.useState>(() => 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>({}) const [undoing, setUndoing] = React.useState(false) const [selectedItems, setSelectedItems] = React.useState>({}) const [bulkBusy, setBulkBusy] = React.useState(false) // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln const [renamedFiles, setRenamedFiles] = React.useState>({}) // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) const [refillTick, setRefillTick] = React.useState(0) const refillTimerRef = React.useRef(null) const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) const [view, setView] = React.useState('table') const [includeKeep, setIncludeKeep] = React.useState(false) const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) const swipeRefs = React.useRef>(new Map()) // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) const [tagFilter, setTagFilter] = React.useState([]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const teaserState = React.useMemo( () => ({ 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(() => { 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('[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 = {} const tagSetByModelKey: Record> = {} 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('') 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>({}) // ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount) const durationsRef = React.useRef>({}) const durationsFlushTimerRef = React.useRef(null) // 🔹 hier sammeln wir die Videoauflösung pro Job/Datei const [resolutions, setResolutions] = React.useState>({}) // ✅ Perf: resolutions gesammelt flushen (wie durations) const resolutionsRef = React.useRef>({}) const resolutionsFlushTimerRef = React.useRef(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).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).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).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>(() => 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>(() => new Set()) // ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können const removeTimersRef = React.useRef>(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((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 ( file: string, run: () => Promise, opts?: { close?: boolean; attempts?: number; baseDelayMs?: number } ): Promise => { 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 (opts: RunFileMutationOptions): 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 => { 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 = { ...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 = {} 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 = { ...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>(() => 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 => { 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() const doneBaseKeys = new Set() // 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>(() => { const next: Record = {} // 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>(new Map()) useEffect(() => { const m = new Map() 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).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) // ✅ Nach erfolgreichem externen Delete aktuelle Seite nachziehen if (refillInFlightRef.current) { refillQueuedWhileInFlightRef.current = true } else { queueRefill() } 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) */}
{/* Desktop */}
{/* Zeile 1: Titel */}
Abgeschlossene Downloads
{totalItemsForPagination}
{/* Zeile 2: Controls */}
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() !== '' ? ( ) : null}
{selectedCount > 0 ? ( ) : null}
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) }} />
{view !== 'table' ? (
) : null} setView(id as ViewMode)} size="md" ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: 'Tabelle', srLabel: 'Tabelle', }, { id: 'cards', icon: , label: 'Cards', srLabel: 'Cards', }, { id: 'gallery', icon: , label: 'Galerie', srLabel: 'Galerie', }, ]} />
{/* Mobile */}
Abgeschlossene Downloads
{totalItemsForPagination}
setView(id as ViewMode)} size="sm" ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: undefined, srLabel: 'Tabelle', }, { id: 'cards', icon: , label: undefined, srLabel: 'Cards', }, { id: 'gallery', icon: , label: undefined, srLabel: 'Galerie', }, ]} />
{selectedCount > 0 ? ( ) : null}
{/* Auswahl-Aktionen */} {selectedCount > 0 ? (
{/* Mobile */}
{bulkBusy ? (
Wird verarbeitet…
) : null}
{/* Desktop */}
{bulkBusy ? (
Wird verarbeitet…
) : null}
) : null} {/* Desktop: aktive Tag-Filter anzeigen */} {tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null} {/* Mobile Optionen */}
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() !== '' ? ( ) : null}
Keep anzeigen
Behaltene Downloads in der Liste
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) }} ariaLabel="Behaltene Downloads anzeigen" size="default" />
{view !== 'table' ? (
Sortierung
) : null}
{tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null}
{showLoadingCard ? (
Lade Downloads…
Bitte warte einen Moment.
) : (emptyFolder || emptyAll) ? (
📁
Keine abgeschlossenen Downloads
Im Zielordner ist aktuell nichts vorhanden.
) : emptyByFilter ? (
Keine Treffer für die aktuellen Filter.
{tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? (
{tagFilter.length > 0 ? ( ) : null} {(searchQuery || '').trim() !== '' ? ( ) : null}
) : null}
) : (
{view === 'cards' && (
{ 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) }, }) } />
)} {view === 'table' && ( { 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' && ( { 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) }, }) } /> )}
)}
{ 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" />
) }