// 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 { RecordJob } from '../../types' import ButtonGroup from './ButtonGroup' import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon, AdjustmentsHorizontalIcon, } 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' type SortMode = | 'completed_desc' | 'completed_asc' | 'file_asc' | 'file_desc' | 'duration_desc' | 'duration_asc' | 'size_desc' | 'size_asc' type TeaserPlaybackMode = 'still' | 'hover' | 'all' type Props = { jobs: RecordJob[] doneJobs: RecordJob[] modelsByKey: Record blurPreviews?: boolean teaserPlayback?: TeaserPlaybackMode teaserAudio?: 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 doneTotal: number page: number pageSize: number onPageChange: (page: number) => void assetNonce?: number sortMode: SortMode onSortModeChange: (m: SortMode) => 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 } 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 } type StoredModelFlags = { id: string modelKey: string favorite?: boolean liked?: boolean | null watching?: boolean | null tags?: string } 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 } type QueuedMutationTask = { id: string run: () => Promise } function useMutationQueue() { const queueRef = React.useRef([]) const runningRef = React.useRef(false) const scheduledRef = React.useRef(false) // verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion const pendingIdsRef = React.useRef>(new Set()) const schedulePump = React.useCallback(() => { if (scheduledRef.current) return scheduledRef.current = true const kick = () => { scheduledRef.current = false void pump() } // best-effort "im Hintergrund" if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { ;(window as any).requestIdleCallback(kick, { timeout: 250 }) } else { setTimeout(kick, 0) } }, []) const pump = React.useCallback(async () => { if (runningRef.current) return runningRef.current = true try { while (queueRef.current.length > 0) { const task = queueRef.current.shift() if (!task) continue try { await task.run() } finally { pendingIdsRef.current.delete(task.id) } // Yield zwischen Tasks -> UI bleibt responsiver await new Promise((resolve) => setTimeout(resolve, 0)) } } finally { runningRef.current = false // falls währenddessen neue Tasks reinkamen if (queueRef.current.length > 0) { schedulePump() } } }, [schedulePump]) const enqueue = React.useCallback((id: string, run: () => Promise) => { if (!id) return false if (pendingIdsRef.current.has(id)) return false pendingIdsRef.current.add(id) queueRef.current.push({ id, run }) schedulePump() return true }, [schedulePump]) const isQueued = React.useCallback((id: string) => { return pendingIdsRef.current.has(id) }, []) return { enqueue, isQueued } } export default function FinishedDownloads({ jobs, doneJobs, blurPreviews, teaserPlayback, teaserAudio, onOpenPlayer, onDeleteJob, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onKeepJob, doneTotal, page, pageSize, onPageChange, assetNonce, sortMode, onSortModeChange, modelsByKey, loadMode = 'paged', }: Props) { const allMode = loadMode === 'all' const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' // Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> Teaser nur bei Hover, Mobile -> im Viewport const canHover = useMediaQuery('(hover: hover) and (pointer: fine)') const notify = useNotify() const mutationQueue = useMutationQueue() 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 [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) type UndoAction = | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string } | { kind: 'hot'; currentFile: string } type PersistedUndoState = { v: 1 action: UndoAction ts: number } const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' 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 [undoing, setUndoing] = React.useState(false) 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]) // 🔥 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 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) }, []) type ViewMode = 'table' | 'cards' | 'gallery' const VIEW_KEY = 'finishedDownloads_view' const KEEP_KEY = 'finishedDownloads_includeKeep_v2' const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' const [view, setView] = React.useState('table') const [includeKeep, setIncludeKeep] = React.useState(false) const [mobileOptionsOpen, setMobileOptionsOpen] = 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 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]) // 🔎 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(() => { if (effectiveAllMode) return setOverrideDoneJobs(null) setOverrideDoneTotal(null) }, [page, pageSize, sortMode, includeKeep, effectiveAllMode]) useEffect(() => { if (!includeKeep) { // zurück auf "nur /done/" (Props) if (!globalFilterActive) { setOverrideDoneJobs(null) setOverrideDoneTotal(null) } return } // includeKeep = true: // - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep) if (globalFilterActive) return const ac = new AbortController() ;(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) 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) } catch {} })() return () => ac.abort() }, [includeKeep, globalFilterActive, 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)?.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) => { // Timer stoppen (falls die "commit delete"-Phase noch aussteht) cancelRemoveTimer(key) // wieder sichtbar machen 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 }) }, [cancelRemoveTimer] ) const animateRemove = useCallback( (key: string) => { markRemoving(key, true) cancelRemoveTimer(key) const t = window.setTimeout(() => { removeTimersRef.current.delete(key) markDeleted(key) markRemoving(key, false) }, 320) removeTimersRef.current.set(key, t) }, [markDeleted, markRemoving, cancelRemoveTimer] ) 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] ) type FileMutationKind = 'delete' | 'keep' | 'rename' type RunFileMutationOptions = { kind: FileMutationKind job: RecordJob file: string rowKey: string // UI / State setBusy?: (v: boolean) => void isBusyNow?: () => boolean optimisticRemove?: boolean alreadyRemoved?: boolean // Ausführung run: () => Promise // Hooks onSuccess?: (result: T) => Promise | void onError?: (err: unknown) => Promise | void // Messages labels: { invalidTitle: string invalidBody: string inUseTitle: string failTitle: string failPrefix?: string } } 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) } 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 }) }, []) 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 unhideByToken = (token: string) => { setDeletedKeys((prev) => { const next = new Set(prev) next.delete(token) return next }) setDeletingKeys((prev) => { const next = new Set(prev) next.delete(token) return next }) setKeepingKeys((prev) => { const next = new Set(prev) next.delete(token) return next }) setRemovingKeys((prev) => { const next = new Set(prev) next.delete(token) 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) unhideByToken(lastAction.rowKey) // Fallbacks (falls alte lastAction ohne rowKey existiert) unhideByToken(lastAction.originalFile) unhideByToken(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) unhideByToken(lastAction.rowKey) // Fallbacks (für ältere Actions / Sonderfälle) unhideByToken(lastAction.originalFile) unhideByToken(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 enqueueDeleteVideo = useCallback((job: RecordJob): boolean => { const key = keyFor(job) const file = baseName(job.output || '') if (!key || !file) return false // bereits aktiv? dann nicht nochmal if (deletingKeys.has(key) || keepingKeys.has(key)) return false // sofort visuelles Busy (leichtgewichtig) markDeleting(key, true) // ✅ sofort raus aus dem Stack (optimistisch) animateRemove(key) const qid = `delete:${key}` const accepted = mutationQueue.enqueue(qid, async () => { try { await deleteVideo(job, { alreadyRemoved: true }) } finally { // deleteVideo setzt markDeleting(false) selbst im finally, // daher hier nichts zusätzlich nötig. } }) if (!accepted) { restoreRow(key) // ✅ macht markDeleting false + removing/deleted rollback } return accepted }, [ mutationQueue, keyFor, baseName, deletingKeys, keepingKeys, markDeleting, deleteVideo, ]) const enqueueKeepVideo = useCallback((job: RecordJob): boolean => { const key = keyFor(job) const file = baseName(job.output || '') if (!key || !file) return false if (keepingKeys.has(key) || deletingKeys.has(key)) return false markKeeping(key, true) // ✅ sofort aus dem sichtbaren Stack raus animateRemove(key) const qid = `keep:${key}` const accepted = mutationQueue.enqueue(qid, async () => { try { await keepVideo(job, { alreadyRemoved: true }) } finally { // keepVideo macht markKeeping(false) im finally } }) if (!accepted) { restoreRow(key) } return accepted }, [ mutationQueue, keyFor, baseName, keepingKeys, deletingKeys, markKeeping, keepVideo, ]) const enqueueToggleHotVideo = useCallback((job: RecordJob): boolean => { const key = keyFor(job) if (!key) return false if (hotBusyKeys.has(key)) return false const qid = `hot:${key}` return mutationQueue.enqueue(qid, async () => { await toggleHotVideo(job) }) }, [mutationQueue, keyFor, toggleHotVideo, hotBusyKeys]) 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() // Basis: Files aus dem Done-Ordner for (const j of doneJobsPage) { const jj = applyRenamedOutput(j) map.set(keyFor(jj), jj) } // Jobs aus /list drübermergen for (const j of jobs) { const jj = applyRenamedOutput(j) const k = keyFor(jj) if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj }) } const list = Array.from(map.values()).filter((j) => { if (deletedKeys.has(keyFor(j))) return false // ✅ .trash niemals anzeigen if (isTrashOutput(j.output)) return false return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped' }) return list }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput]) 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 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) return } if (detail.phase === 'error') { // ✅ alles zurückrollen -> wieder sichtbar restoreRow(key) // ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht) swipeRefs.current.get(key)?.reset() return } if (detail.phase === 'success') { markDeleting(key, false) return } } window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) }, [animateRemove, markDeleting, queueRefill, restoreRow]) useEffect(() => { const onReload = () => { 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 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 = teaserPlaybackMode === 'hover' && !canHover && (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, teaserPlaybackMode, 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 sec = durations[k] ?? (job as any)?.durationSeconds 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 { 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]) // ✅ 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 const top = pageRows[0] if (!top) { setTeaserKey(null) return } const topKey = keyFor(top) // ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card setTeaserKey((prev) => (prev === topKey ? prev : null)) const t = window.setTimeout(() => { setTeaserKey((prev) => (prev === topKey ? prev : topKey)) }, 140) // 100–180ms testen return () => window.clearTimeout(t) }, [isSmall, view, pageRows, keyFor]) useEffect(() => { if (!isSmall) { // dein Cleanup (z.B. swipeRefs reset) wie gehabt swipeRefs.current = new Map() } }, [isSmall]) useEffect(() => { if (emptyFolder && page !== 1) onPageChange(1) }, [emptyFolder, page, onPageChange]) return ( <> {/* Toolbar (sticky) */}
{/* Header row */}
{/* Left: Title + Count */}
Abgeschlossene Downloads
{totalItemsForPagination}
{/* Mobile title (bleibt wie gehabt, aber kompakter) */}
Abgeschlossene Downloads
{totalItemsForPagination}
{/* Right: Controls */}
{isLoading ? ( ) : null} {/* Desktop: Suche soll den Platz füllen */}
setSearchQuery(e.target.value)} placeholder="Suchen…" className=" h-9 w-full max-w-[420px] 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}
{/* Desktop: Keep Toggle */}
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) }} />
{/* Desktop: Sort (nur wenn nicht Tabelle) */} {view !== 'table' && (
)} {/* Views */} setView(id as ViewMode)} size={isSmall ? 'sm' : 'md'} ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' }, { id: 'cards', icon: , label: isSmall ? undefined : 'Cards', srLabel: 'Cards' }, { id: 'gallery', icon: , label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' }, ]} /> {/* Mobile: Optionen Button (unverändert) */}
{/* Desktop: aktive Tag-Filter anzeigen */} {tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null} {/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
{/* “Sheet”-Body */}
{/* Suche */}
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 – als Setting Row */}
Keep anzeigen
Behaltene Downloads in der Liste
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) }} ariaLabel="Behaltene Downloads anzeigen" size="default" />
{/* Sort */} {view !== 'table' ? (
Sortierung
) : null}
{/* Tag-Filter */} {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' && (
)} {view === 'table' && ( )} {view === 'gallery' && ( )} { flushSync(() => { setInlinePlay(null) setTeaserKey(null) setHoverTeaserKey(null) }) for (const j of pageRows) { const f = baseName(j.output || '') if (!f) continue window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } })) window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } })) } window.scrollTo({ top: 0, behavior: 'auto' }) onPageChange(p) }} showSummary={false} prevLabel="Zurück" nextLabel="Weiter" className="mt-4" /> )} ) }