nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2026-02-25 17:46:15 +01:00

2591 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// frontend\src\components\ui\FinishedDownloads.tsx
'use client'
import * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type { 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<string, StoredModelFlags>
blurPreviews?: boolean
teaserPlayback?: TeaserPlaybackMode
teaserAudio?: boolean
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
onDeleteJob?: (
job: RecordJob
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
onToggleHot?: (
job: RecordJob
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
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<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
// wie in ModelsTab: alphabetisch (case-insensitive)
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v =
anyJob.sizeBytes ??
anyJob.fileSizeBytes ??
anyJob.bytes ??
anyJob.size ??
null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
function sleep(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}
function errorTextOf(err: unknown): string {
if (err instanceof Error) return err.message || String(err)
return String(err ?? '')
}
function looksLikeFileInUseError(err: unknown): boolean {
const s = errorTextOf(err).toLowerCase()
return (
s.includes('wird gerade verwendet') ||
s.includes('wird gerade abgespielt') ||
s.includes('sharing violation') ||
s.includes('used by another process') ||
s.includes('file in use') ||
s.includes('409')
)
}
async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
const res = await fetch(input, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
const msg = (text || `HTTP ${res.status}`).trim()
throw new Error(msg)
}
return res
}
type QueuedMutationTask = {
id: string
run: () => Promise<void>
}
function useMutationQueue() {
const queueRef = React.useRef<QueuedMutationTask[]>([])
const runningRef = React.useRef(false)
const scheduledRef = React.useRef(false)
// verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion
const pendingIdsRef = React.useRef<Set<string>>(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<void>((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<void>) => {
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<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
const teaserIORef = React.useRef<IntersectionObserver | null>(null)
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [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<UndoAction | null>(() => {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(LAST_UNDO_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedUndoState
if (!parsed || parsed.v !== 1 || !parsed.action) return null
// optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird
const ageMs = Date.now() - Number(parsed.ts || 0)
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
localStorage.removeItem(LAST_UNDO_KEY)
return null
}
return parsed.action
} catch {
return null
}
})
const [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<Record<string, string>>({})
// 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken"
const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
const [refillTick, setRefillTick] = React.useState(0)
const refillTimerRef = React.useRef<number | null>(null)
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const 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<ViewMode>('table')
const [includeKeep, setIncludeKeep] = React.useState(false)
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
// 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
const modelTags = useMemo(() => {
const tagsByModelKey: Record<string, string[]> = {}
const tagSetByModelKey: Record<string, Set<string>> = {}
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
const key = lower(k)
const arr = parseTags(flags?.tags)
tagsByModelKey[key] = arr
tagSetByModelKey[key] = new Set(arr.map(lower))
}
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const deferredSearchQuery = React.useDeferredValue(searchQuery)
const searchTokens = useMemo(
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
[deferredSearchQuery]
)
const clearSearch = useCallback(() => setSearchQuery(''), [])
const toggleTagFilter = useCallback((tag: string) => {
const k = lower(tag)
setTagFilter((prev) => {
const has = prev.some((t) => lower(t) === k)
return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
})
}, [])
// ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
const searchActiveForGlobalFetch =
activeTagSet.size > 0 ||
searchTokens.some((t) => t.length >= 2)
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => {
// ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
const shouldShowLoading = overrideDoneJobs == null
if (shouldShowLoading) setIsLoading(true)
try {
const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
{
cache: 'no-store' as any,
signal,
}
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally {
if (shouldShowLoading) setIsLoading(false)
}
},
[sortMode, includeKeep]
)
const clearTagFilter = useCallback(() => setTagFilter([]), [])
useEffect(() => {
// ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
try {
const raw = localStorage.getItem('finishedDownloads_pendingTags')
if (!raw) return
const arr = JSON.parse(raw)
const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
if (tags.length === 0) return
flushSync(() => setTagFilter(tags))
if (page !== 1) onPageChange(1)
} catch {
// ignore
} finally {
try {
localStorage.removeItem('finishedDownloads_pendingTags')
} catch {}
}
}, []) // nur einmal beim Mount
const queueRefill = useCallback(() => {
// ✅ Schon geplant? Dann nicht nochmal planen.
if (refillTimerRef.current != null) return
refillTimerRef.current = window.setTimeout(() => {
refillTimerRef.current = null
setRefillTick((n) => n + 1)
}, 80)
}, [page, pageSize, sortMode, includeKeep])
useEffect(() => {
if (!effectiveAllMode) return
const ac = new AbortController()
const t = window.setTimeout(() => {
fetchAllDoneJobs(ac.signal).catch(() => {})
}, 250)
return () => {
window.clearTimeout(t)
ac.abort()
}
}, [effectiveAllMode, fetchAllDoneJobs])
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
useEffect(() => {
if (refillTick === 0) return
const ac = new AbortController()
let alive = true
let finished = false // ✅ pro Effect-Instanz idempotent
// ✅ eigene Session-ID für diese Effect-Instanz
const mySession = ++refillSessionRef.current
const finishRefill = () => {
if (finished) return
finished = true
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
if (refillSessionRef.current !== mySession) {
return
}
refillInFlightRef.current = false
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
}
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
if (refillSessionRef.current === mySession) {
refillInFlightRef.current = true
}
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) {
;(async () => {
try {
// fetchAllDoneJobs setzt isLoading selbst
await fetchAllDoneJobs(ac.signal)
if (alive) {
refillRetryRef.current = 0
}
} catch {
// ignore (Abort/Netzwerk)
} finally {
if (alive) finishRefill()
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}
// ✅ paged refill → hier Loading setzen
setIsLoading(true)
;(async () => {
try {
// 1) Liste + optional count in EINEM Request holen
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!alive || ac.signal.aborted) return
const okAll = listRes.ok
if (listRes.ok) {
const data = await listRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? data
: []
setOverrideDoneJobs(items)
// ✅ Count direkt aus /done lesen (withCount=1)
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneTotal(count)
const totalPages = Math.max(1, Math.ceil(count / pageSize))
if (page > totalPages) {
onPageChange(totalPages)
setOverrideDoneJobs(null)
return
}
}
if (okAll) {
refillRetryRef.current = 0
} else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
refillRetryRef.current += 1
const retryNo = refillRetryRef.current
window.setTimeout(() => {
if (!ac.signal.aborted) {
setRefillTick((n) => n + 1)
}
}, 400 * retryNo)
}
} catch {
// Abort / Fehler ignorieren
} finally {
if (alive) {
setIsLoading(false)
finishRefill()
}
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}, [
refillTick,
effectiveAllMode,
fetchAllDoneJobs,
page,
pageSize,
sortMode,
includeKeep,
onPageChange,
])
useEffect(() => {
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<Record<string, number>>({})
// ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount)
const durationsRef = React.useRef<Record<string, number>>({})
const durationsFlushTimerRef = React.useRef<number | null>(null)
// 🔹 hier sammeln wir die Videoauflösung pro Job/Datei
const [resolutions, setResolutions] = React.useState<Record<string, { w: number; h: number }>>({})
// ✅ Perf: resolutions gesammelt flushen (wie durations)
const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
const resolutionsFlushTimerRef = React.useRef<number | null>(null)
const refillRetryRef = React.useRef(0)
React.useEffect(() => {
resolutionsRef.current = resolutions
}, [resolutions])
const flushResolutionsSoon = React.useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => {
resolutionsFlushTimerRef.current = null
setResolutions({ ...resolutionsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (resolutionsFlushTimerRef.current != null) {
window.clearTimeout(resolutionsFlushTimerRef.current)
resolutionsFlushTimerRef.current = null
}
}
}, [])
React.useEffect(() => {
durationsRef.current = durations
}, [durations])
const flushDurationsSoon = React.useCallback(() => {
if (durationsFlushTimerRef.current != null) return
durationsFlushTimerRef.current = window.setTimeout(() => {
durationsFlushTimerRef.current = null
// neue Objekt-Referenz, damit React aktualisiert
setDurations({ ...durationsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (durationsFlushTimerRef.current != null) {
window.clearTimeout(durationsFlushTimerRef.current)
durationsFlushTimerRef.current = null
}
}
}, [])
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
const previewMuted = !Boolean(teaserAudio)
const tryAutoplayInline = useCallback((domId: string) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false
applyInlineVideoPolicy(v, { muted: previewMuted })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
return true
}, [previewMuted])
const startInline = useCallback((key: string) => {
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
}, [])
const startInlineAt = useCallback((key: string, seconds: number, domId: string) => {
const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
// Inline-Preview aktivieren / remount erzwingen
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
// Nach dem Rendern das Video suchen, seeken und autoplay versuchen
const trySeekAndPlay = (retriesLeft: number) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) {
if (retriesLeft > 0) {
requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1))
}
return
}
applyInlineVideoPolicy(v, { muted: previewMuted })
const applySeek = () => {
try {
const dur = Number(v.duration)
const maxSeek =
Number.isFinite(dur) && dur > 0
? Math.max(0, dur - 0.05)
: safeSeconds
v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek))
} catch {
// ignore
}
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
// Wenn Metadaten schon da sind -> direkt seeken
if (v.readyState >= 1) {
applySeek()
return
}
// Sonst warten bis metadata da sind
const onLoadedMetadata = () => {
v.removeEventListener('loadedmetadata', onLoadedMetadata)
applySeek()
}
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
// zusätzlich sofort play versuchen (hilft manchmal)
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
requestAnimationFrame(() => trySeekAndPlay(8))
}, [previewMuted])
const openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
setInlinePlay(null)
onOpenPlayer(job, s)
}, [onOpenPlayer])
const handleScrubberClickIndex = useCallback(
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0
const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0
if (count <= 0) {
// Fallback: Player normal öffnen
openPlayer(job)
return
}
// Dauer bevorzugt aus Preview-Metadaten, sonst aus Job
const k = keyFor(job)
const durationSec =
durations[k] ??
((job as any)?.durationSeconds as number | undefined) ??
0
if (!Number.isFinite(durationSec) || durationSec <= 0) {
// Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp)
openPlayer(job)
return
}
// Segment-Index -> Startsekunde
// Beispiel: 10 Segmente, Klick auf Index 0..9
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
const secPerSegment = durationSec / count
const startAtSec = clampedIdx * secPerSegment
openPlayerAt(job, startAtSec)
},
[durations, keyFor, openPlayer, openPlayerAt]
)
const markDeleting = useCallback((key: string, value: boolean) => {
setDeletingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const markDeleted = useCallback((key: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
}, [])
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
const markKeeping = useCallback((key: string, value: boolean) => {
setKeepingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
// neben deletedKeys / deletingKeys
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
// ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
const removeTimersRef = React.useRef<Map<string, number>>(new Map())
useEffect(() => {
return () => {
for (const t of removeTimersRef.current.values()) {
window.clearTimeout(t)
}
removeTimersRef.current.clear()
}
}, [])
const markRemoving = useCallback((key: string, value: boolean) => {
setRemovingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const cancelRemoveTimer = useCallback((key: string) => {
const t = removeTimersRef.current.get(key)
if (t != null) {
window.clearTimeout(t)
removeTimersRef.current.delete(key)
}
}, [])
const restoreRow = useCallback(
(key: string) => {
// 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<void>((r) => requestAnimationFrame(() => r()))
// 3) Nochmals release senden (hilft bei race zwischen close/unmount)
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
await new Promise((r) => window.setTimeout(r, 260))
},
[]
)
const withFileReleaseRetry = useCallback(
async <T,>(
file: string,
run: () => Promise<T>,
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
): Promise<T> => {
const attempts = Math.max(1, opts?.attempts ?? 4)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
// vor JEDEM Versuch freigeben (nicht nur einmal)
await releasePlayingFile(file, { close: opts?.close ?? true })
// kurzer Tick extra (DOM/video cleanup, OS handle release)
if (attempt > 1) {
await sleep(baseDelayMs * attempt)
} else {
await sleep(80)
}
return await run()
} catch (e) {
lastErr = e
// nur bei "Datei in Verwendung" retryen
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
throw e
}
// nächster Versuch
continue
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile]
)
type FileMutationKind = 'delete' | 'keep' | 'rename'
type RunFileMutationOptions<T> = {
kind: FileMutationKind
job: RecordJob
file: string
rowKey: string
// UI / State
setBusy?: (v: boolean) => void
isBusyNow?: () => boolean
optimisticRemove?: boolean
alreadyRemoved?: boolean
// Ausführung
run: () => Promise<T>
// Hooks
onSuccess?: (result: T) => Promise<void> | void
onError?: (err: unknown) => Promise<void> | void
// Messages
labels: {
invalidTitle: string
invalidBody: string
inUseTitle: string
failTitle: string
failPrefix?: string
}
}
const runFileMutation = useCallback(
async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
const {
file,
rowKey,
setBusy,
isBusyNow,
optimisticRemove,
alreadyRemoved,
run,
onSuccess,
onError,
labels,
} = opts
if (!file) {
notify.error(labels.invalidTitle, labels.invalidBody)
return { ok: false }
}
if (isBusyNow?.()) return { ok: false }
setBusy?.(true)
try {
if (optimisticRemove && !alreadyRemoved) {
animateRemove(rowKey)
}
const result = await run()
await onSuccess?.(result)
return { ok: true, result }
} catch (e: any) {
// Optimistik zurückrollen
if (optimisticRemove) {
restoreRow(rowKey)
}
await onError?.(e)
if (looksLikeFileInUseError(e)) {
notify.error(labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`)
} else {
const suffix = e?.message ? `${String(e.message)}` : ''
notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
}
return { ok: false }
} finally {
setBusy?.(false)
}
},
[notify, animateRemove, restoreRow]
)
const deleteVideo = useCallback(
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
const file = baseName(job.output || '')
const key = keyFor(job)
const res = await runFileMutation({
kind: 'delete',
job,
file,
rowKey: key,
setBusy: (v) => markDeleting(key, v),
isBusyNow: () => deletingKeys.has(key),
optimisticRemove: true,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Löschen nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht löschen.',
inUseTitle: 'Löschen fehlgeschlagen',
failTitle: 'Löschen fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onDeleteJob) {
return await withFileReleaseRetry(
file,
async () => await onDeleteJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
const r = await withFileReleaseRetry(
file,
async () =>
await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (result: any) => {
// Fall 1: externer Handler (App) liefert { undoToken }
if (onDeleteJob) {
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
}
return
}
// Fall 2: lokaler API-Call (liefert from + undoToken)
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
} else {
setLastAction(null)
}
queueRefill()
emitCountHint(-1)
},
})
return res.ok
},
[
baseName,
keyFor,
deletingKeys,
markDeleting,
onDeleteJob,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
]
)
const keepVideo = useCallback(
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
const file = baseName(job.output || '')
const key = keyFor(job)
const res = await runFileMutation({
kind: 'keep',
job,
file,
rowKey: key,
setBusy: (v) => markKeeping(key, v),
isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
optimisticRemove: true,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Keep nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht behalten.',
inUseTitle: 'Keep fehlgeschlagen',
failTitle: 'Keep fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onKeepJob) {
return await withFileReleaseRetry(
file,
async () => await onKeepJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
const r = await withFileReleaseRetry(
file,
async () =>
await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
queueRefill()
emitCountHint(includeKeep ? 0 : -1)
},
})
return res.ok
},
[
baseName,
keyFor,
markKeeping,
keepingKeys,
deletingKeys,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
includeKeep,
]
)
const applyRename = useCallback((oldFile: string, newFile: string) => {
if (!oldFile || !newFile || oldFile === newFile) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
}
next[oldFile] = newFile
return next
})
}, [])
const clearRenamePair = useCallback((a: string, b: string) => {
if (!a && !b) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === a || k === b || v === a || v === b) delete next[k]
}
return next
})
}, [])
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
const 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<Set<string>>(() => new Set())
const markHotBusy = useCallback((key: string, value: boolean) => {
setHotBusyKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const toggleHotVideo = useCallback(
async (job: RecordJob): Promise<void> => {
const currentFile = baseName(job.output || '')
const key = keyFor(job)
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
const oldFile = currentFile
const optimisticNew = toggledName(oldFile)
await runFileMutation({
kind: 'rename',
job,
file: currentFile,
rowKey: key,
setBusy: (v) => markHotBusy(key, v),
isBusyNow: () => hotBusyKeys.has(key),
optimisticRemove: false,
labels: {
invalidTitle: 'HOT nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht HOT togglen.',
inUseTitle: 'HOT umbenennen fehlgeschlagen',
failTitle: 'HOT umbenennen fehlgeschlagen',
failPrefix: oldFile,
},
run: async () => {
// Optimistik sofort anwenden
applyRename(oldFile, optimisticNew)
if (onToggleHot) {
const r = await onToggleHot(job)
return r as any
}
const r = await withFileReleaseRetry(
oldFile,
async () =>
await fetchWithTextError(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ method: 'POST' }
),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
if (apiOld && apiNew && apiOld !== apiNew) {
applyRename(apiOld, apiNew)
}
setLastAction({ kind: 'hot', currentFile: apiNew })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
},
onError: async () => {
// Rename-Optimistik rollback
clearRenamePair(oldFile, optimisticNew)
setLastAction(null)
},
})
},
[
baseName,
keyFor,
hotBusyKeys,
markHotBusy,
runFileMutation,
applyRename,
clearRenamePair,
onToggleHot,
withFileReleaseRetry,
queueRefill,
sortMode,
]
)
const 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<string, RecordJob>()
// 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<Map<string, string>>(new Map())
useEffect(() => {
const m = new Map<string, string>()
for (const j of viewRows) {
const f = baseName(j.output || '')
if (!f) continue
m.set(f, keyFor(j))
}
fileToKeyRef.current = m
}, [viewRows])
useEffect(() => {
const 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) // 100180ms 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) */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
{/* Header row */}
<div className="flex items-center gap-3 p-3">
{/* Left: Title + Count */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalItemsForPagination}
</span>
</div>
{/* Mobile title (bleibt wie gehabt, aber kompakter) */}
<div className="sm:hidden flex items-center gap-2 min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalItemsForPagination}
</span>
</div>
{/* Right: Controls */}
<div className="flex items-center gap-2 ml-auto shrink-0">
{isLoading ? (
<LoadingSpinner size="lg" className="text-indigo-500" srLabel="Lade Downloads…" />
) : null}
{/* Desktop: Suche soll den Platz füllen */}
<div className="hidden sm:flex items-center gap-2 min-w-0 flex-1">
<input
value={searchQuery}
onChange={(e) => 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() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Leeren
</Button>
) : null}
</div>
{/* Desktop: Keep Toggle */}
<div className="hidden sm:block">
<LabeledSwitch
label="Behaltene Downloads anzeigen"
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
}}
/>
</div>
{/* Desktop: Sort (nur wenn nicht Tabelle) */}
{view !== 'table' && (
<div className="hidden sm:block">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
<select
id="finished-sort"
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
)}
{/* Views */}
<Button
size='md'
variant="soft"
disabled={!lastAction || undoing}
onClick={undoLastAction}
title={
!lastAction
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
>
Undo
</Button>
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size={isSmall ? 'sm' : 'md'}
ariaLabel="Ansicht"
items={[
{ id: 'table', icon: <TableCellsIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' },
{ id: 'cards', icon: <RectangleStackIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Cards', srLabel: 'Cards' },
{ id: 'gallery', icon: <Squares2X2Icon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' },
]}
/>
{/* Mobile: Optionen Button (unverändert) */}
<button
type="button"
className="sm:hidden relative inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-2 shadow-sm
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
onClick={() => setMobileOptionsOpen((v) => !v)}
aria-expanded={mobileOptionsOpen}
aria-controls="finished-mobile-options"
aria-label="Filter & Optionen"
>
<AdjustmentsHorizontalIcon className="size-5" />
{/* kleiner Punkt wenn Filter aktiv */}
{globalFilterActive || includeKeep ? (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" />
) : null}
</button>
</div>
</div>
{/* Desktop: aktive Tag-Filter anzeigen */}
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
) : null}
{/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
<div
id="finished-mobile-options"
className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
>
{/* “Sheet”-Body */}
<div className="border-t border-gray-200/60 dark:border-white/10 p-3">
<div className="space-y-2">
{/* Suche */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
</div>
</div>
{/* Keep als Setting Row */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Keep anzeigen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Behaltene Downloads in der Liste
</div>
</div>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
{/* Sort */}
{view !== 'table' ? (
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Sortierung
</div>
<select
id="finished-sort-mobile"
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
) : null}
</div>
</div>
{/* Tag-Filter */}
{tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Tag-Filter</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
{showLoadingCard ? (
<Card grayBody>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Lade Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte warte einen Moment.
</div>
</div>
<LoadingSpinner
size="lg"
className="text-indigo-500"
srLabel="Lade Downloads…"
/>
</div>
</Card>
) : (emptyFolder || emptyAll) ? (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<span className="text-lg">📁</span>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine abgeschlossenen Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Im Zielordner ist aktuell nichts vorhanden.
</div>
</div>
</div>
</Card>
) : emptyByFilter ? (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Keine Treffer für die aktuellen Filter.
</div>
{tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? (
<div className="mt-2 flex flex-wrap gap-3">
{tagFilter.length > 0 ? (
<button
type="button"
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
onClick={clearTagFilter}
>
Tag-Filter zurücksetzen
</button>
) : null}
{(searchQuery || '').trim() !== '' ? (
<button
type="button"
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
onClick={clearSearch}
>
Suche zurücksetzen
</button>
) : null}
</div>
) : null}
</Card>
) : (
<>
{view === 'cards' && (
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
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}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
</div>
)}
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={isLoading}
keyFor={keyFor}
baseName={baseName}
lower={lower}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
resolutions={resolutions}
durations={durations}
canHover={canHover}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
setHoverTeaserKey={setHoverTeaserKey}
teaserPlayback={teaserPlaybackMode}
teaserKey={teaserKey}
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}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
)}
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
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}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
)}
<Pagination
page={page}
pageSize={pageSize}
totalItems={totalItemsForPagination}
onPageChange={(p) => {
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"
/>
</>
)}
</>
)
}