This commit is contained in:
Linrador 2026-02-25 15:00:33 +01:00
parent 160544a65d
commit ae67c817ac
26 changed files with 3070 additions and 1629 deletions

View File

@ -16,16 +16,25 @@ var autostartPaused int32 // 0=false, 1=true
// --- SSE subscribers für Autostart-State ---
var autostartSubsMu sync.Mutex
var autostartSubs = map[chan bool]struct{}{}
func broadcastAutostartPaused(paused bool) {
type autostartStatePayload struct {
Paused bool `json:"paused"`
PausedByUser bool `json:"pausedByUser"`
PausedByDisk bool `json:"pausedByDisk"`
}
var autostartSubs = map[chan autostartStatePayload]struct{}{}
func broadcastAutostartPaused() {
state := getAutostartStatePayload()
autostartSubsMu.Lock()
defer autostartSubsMu.Unlock()
for ch := range autostartSubs {
// non-blocking: wenn Client langsam ist, droppen wir Updates (neuester Zustand kommt eh wieder)
// non-blocking: wenn Client langsam ist, droppen wir Updates
select {
case ch <- paused:
case ch <- state:
default:
}
}
@ -40,8 +49,19 @@ func isAutostartPaused() bool {
return atomic.LoadInt32(&diskEmergency) == 1
}
func getAutostartStatePayload() autostartStatePayload {
userPaused := atomic.LoadInt32(&autostartPaused) == 1
diskPaused := atomic.LoadInt32(&diskEmergency) == 1
return autostartStatePayload{
Paused: userPaused || diskPaused,
PausedByUser: userPaused,
PausedByDisk: diskPaused,
}
}
func setAutostartPaused(v bool) {
old := isAutostartPaused()
old := getAutostartStatePayload()
if v {
atomic.StoreInt32(&autostartPaused, 1)
@ -49,9 +69,11 @@ func setAutostartPaused(v bool) {
atomic.StoreInt32(&autostartPaused, 0)
}
// nur wenn sich der Zustand wirklich geändert hat -> pushen
if old != v {
broadcastAutostartPaused(v)
newState := getAutostartStatePayload()
// nur wenn sich der relevante sichtbare Zustand geändert hat -> pushen
if old != newState {
broadcastAutostartPaused()
}
}
@ -62,11 +84,7 @@ type autostartPauseReq struct {
func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
writeAutostartState(w)
return
case http.MethodPost:
@ -96,11 +114,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
setAutostartPaused(*val)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
writeAutostartState(w)
return
default:
@ -112,9 +126,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
func writeAutostartState(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
_ = json.NewEncoder(w).Encode(getAutostartStatePayload())
}
// GET /api/autostart/state
@ -149,6 +161,13 @@ func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
writeAutostartState(w)
return
case http.MethodPost:
// ✅ Resume blocken, solange Disk-Notbremse aktiv ist
if atomic.LoadInt32(&diskEmergency) == 1 {
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "Autostart durch Speicherplatz-Notbremse gesperrt", http.StatusConflict)
return
}
setAutostartPaused(false)
writeAutostartState(w)
return
@ -176,7 +195,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
ch := make(chan bool, 1)
ch := make(chan autostartStatePayload, 1)
// subscribe
autostartSubsMu.Lock()
@ -191,10 +210,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
close(ch)
}()
send := func(paused bool) {
send := func(state autostartStatePayload) {
payload := map[string]any{
"paused": paused,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"paused": state.Paused,
"pausedByUser": state.PausedByUser,
"pausedByDisk": state.PausedByDisk,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
}
b, _ := json.Marshal(payload)
@ -206,7 +227,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
}
// initial state sofort senden
send(isAutostartPaused())
send(getAutostartStatePayload())
ctx := r.Context()
hb := time.NewTicker(15 * time.Second)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -173,6 +173,23 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
return sizeOfPathBestEffort(j.Output)
}
func minRelevantInFlightBytes() uint64 {
s := getSettings()
// Nur wenn Auto-Delete kleine Downloads aktiv ist und eine sinnvolle Schwelle gesetzt ist
if !s.AutoDeleteSmallDownloads {
return 0
}
mb := s.AutoDeleteSmallDownloadsBelowMB
if mb <= 0 {
return 0
}
// MB -> Bytes (MiB passend zum restlichen Code mit GiB)
return uint64(mb) * 1024 * 1024
}
const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds:
@ -207,6 +224,7 @@ func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseN
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 {
var sum uint64
minKeepBytes := minRelevantInFlightBytes()
jobsMu.Lock()
defer jobsMu.Unlock()
@ -219,10 +237,19 @@ func sumInFlightBytes() uint64 {
continue
}
// Nimm die Datei, die gerade wächst.
// In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel).
// Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen.
sum += inFlightBytesForJob(j)
b := inFlightBytesForJob(j)
// ✅ Nur "relevante" Dateien berücksichtigen:
// Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs,
// deren aktuelle Dateigröße bereits über der Schwelle liegt.
//
// Hinweis: Ein Job kann später noch über die Schwelle wachsen.
// Diese Logik ist bewusst "weniger konservativ", so wie gewünscht.
if minKeepBytes > 0 && b > 0 && b < minKeepBytes {
continue
}
sum += b
}
return sum
@ -230,8 +257,11 @@ func sumInFlightBytes() uint64 {
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz:
// - Autostart pausieren
// - laufende Jobs stoppen (nur Status=running und Phase leer)
// - diskEmergency aktivieren (Autostart blockieren)
// - laufende Jobs stoppen
//
// Bei Erholung (Resume-Schwelle):
// - diskEmergency automatisch wieder freigeben
func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval)
defer t.Stop()
@ -259,41 +289,73 @@ func startDiskSpaceGuard() {
// Pause = ceil((2 * inFlight) / GiB)
// Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes
pauseGB, resumeGB, inFlight, pauseNeed, _ := computeDiskThresholds()
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
// Wenn nichts läuft, gibt es nichts zu reservieren.
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.)
// ✅ diskEmergency NICHT sticky behalten.
// Stattdessen dynamisch mit Hysterese setzen/löschen:
//
// - triggern bei free < pauseNeed
// - freigeben erst bei free >= resumeNeed
//
// So kann die Notbremse später erneut greifen.
wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
// Wenn aktuell nichts läuft, brauchen wir keine Reservierung.
// Dann diskEmergency freigeben (falls gesetzt), damit Autostart wieder möglich ist.
// (User-Pause bleibt davon unberührt.)
if inFlight == 0 {
// Kein Auto-Recovery:
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird.
if wasEmergency {
atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused()
fmt.Printf("✅ [disk] Emergency cleared (no in-flight jobs). free=%s (%dB) path=%s\n",
formatBytesSI(u64ToI64(free)), free, dir,
)
}
continue
}
// Wenn Emergency aktiv ist, niemals automatisch freigeben.
// (Manueller Reset erforderlich)
if atomic.LoadInt32(&diskEmergency) == 1 {
isLowForPause := free < pauseNeed
isHighEnoughForResume := free >= resumeNeed
if !wasEmergency {
// Normalzustand: nur triggern, wenn unter Pause-Schwelle
if !isLowForPause {
continue
}
atomic.StoreInt32(&diskEmergency, 1)
broadcastAutostartPaused()
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
}
continue
}
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
if free >= pauseNeed {
continue
}
// ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist
if isHighEnoughForResume {
atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused()
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
atomic.StoreInt32(&diskEmergency, 1)
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
fmt.Printf(
"✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
}
}
}

Binary file not shown.

View File

@ -1802,6 +1802,38 @@ func max(a, b int) int {
return b
}
func renameWithRetryAggressive(src, dst string) error {
// Mehrere kurze Versuche + leichtes Backoff
var lastErr error
delays := []time.Duration{
80 * time.Millisecond,
140 * time.Millisecond,
220 * time.Millisecond,
320 * time.Millisecond,
450 * time.Millisecond,
650 * time.Millisecond,
}
for i, d := range delays {
if err := os.Rename(src, dst); err == nil {
return nil
} else {
lastErr = err
// nur bei Windows SharingViolation lohnt Retry wirklich
if runtime.GOOS != "windows" || !isSharingViolation(err) {
return err
}
}
// Vor letztem Sleep nicht mehr warten
if i < len(delays)-1 {
time.Sleep(d)
}
}
return lastErr
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
@ -1938,7 +1970,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json)
// move mit retry (Windows file-lock robust)
if err := renameWithRetry(target, dst); err != nil {
if err := renameWithRetryAggressive(target, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return
@ -2097,7 +2129,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
return
}
if err := renameWithRetry(src, dst); err != nil {
if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
@ -2199,7 +2231,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
if err := renameWithRetry(src, dst); err != nil {
if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
@ -2337,7 +2369,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
}
// rename mit retry (Windows file-lock)
if err := renameWithRetry(src, dst); err != nil {
if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
@ -2436,7 +2468,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return
}
if err := renameWithRetry(src, dst); err != nil {
if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
return

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css">
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
</head>
<body>
<div id="root"></div>

View File

@ -410,11 +410,6 @@ export default function App() {
const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false)
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
const doneFetchAbortRef = useRef<AbortController | null>(null)
const doneFetchInFlightRef = useRef(false)
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
@ -450,8 +445,16 @@ export default function App() {
}, [doneSort])
const loadDoneCount = useCallback(async () => {
const now = Date.now()
// ✅ harte Dedupe-Schranke gegen Bursts
if (doneCountInFlightRef.current) return
if (now - doneCountLastAtRef.current < 500) return
doneCountInFlightRef.current = true
doneCountLastAtRef.current = now
try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
if (!res.ok) return
@ -463,9 +466,14 @@ export default function App() {
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
} finally {
doneCountInFlightRef.current = false
}
}, [])
const doneCountInFlightRef = useRef(false)
const doneCountLastAtRef = useRef(0)
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null)
@ -1236,80 +1244,6 @@ export default function App() {
}
}, [loadDoneCount])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
if (doneFetchInFlightRef.current) {
doneFetchAbortRef.current?.abort()
}
const ac = new AbortController()
doneFetchAbortRef.current = ac
doneFetchInFlightRef.current = true
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const res = await fetch(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
if (target !== wanted) {
const res2 = await fetch(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
const data2 = await res2.json().catch(() => null)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
} else {
setDoneJobs(items)
}
setLastHeaderUpdateAtMs(Date.now())
} catch (e: any) {
// Abort ist ok
if (String(e?.name) !== 'AbortError') {
// optional: console.debug('[DONE] refresh failed', e)
}
} finally {
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
const isCurrent = doneFetchAbortRef.current === ac
if (isCurrent) {
doneFetchAbortRef.current = null
doneFetchInFlightRef.current = false
}
}
},
[donePage, doneSort]
)
useEffect(() => {
if (selectedTab !== 'finished') return
@ -1498,18 +1432,17 @@ export default function App() {
const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) {
void loadDoneCount()
requestFinishedReload()
return
if (Number.isFinite(delta) && delta !== 0) {
setDoneCount((c) => Math.max(0, c + delta))
}
// ✅ Tabs sofort updaten (optimistisch)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach server-truth holen + ALL reload
// Count darf immer aktualisiert werden (Badge/Header)
void loadDoneCount()
requestFinishedReload()
// ✅ Nur Finished-Tab wirklich neu laden
if (selectedTabRef.current === 'finished') {
requestFinishedReload()
}
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
@ -1570,124 +1503,160 @@ export default function App() {
[startUrl, notify]
)
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
async function runFinishedFileAction<T>(opts: {
kind: FinishedFileActionKind
file: string
run: () => Promise<T>
onSuccess?: (result: T) => void | Promise<void>
onError?: (err: unknown) => void | Promise<void>
}) {
const { kind, file, run, onSuccess, onError } = opts
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
}
try {
const result = await run()
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
}
await onSuccess?.(result)
return result
} catch (e) {
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
}
await onError?.(e)
throw e
}
}
const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
try {
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
const data = await apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
const data = await runFinishedFileAction<{ undoToken?: string }>({
kind: 'delete',
file,
run: () =>
apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
),
onSuccess: async () => {
window.setTimeout(() => {
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
window.setTimeout(() => {
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
// ✅ sofort auffüllen, wenn wir Platz haben
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1))
// buffer zurückschreiben (mit verkürzter items-Liste)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
// ✅ Running-/Player-State bereinigen (falls offen)
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
return next
})
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1))
// ✅ Player / jobs cleanup wie bei dir
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background)
void prefetchDonePage(donePage + 1)
}, 320)
// ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1)
}, 320)
},
onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
},
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {} // ✅ kein null mehr
} catch (e: any) {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
notify.error('Löschen fehlgeschlagen: ', file)
return // ✅ void statt null
}
},
[notify, refreshDoneNow]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
const handleKeepJob = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }))
try {
await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', file)
return undoToken ? { undoToken } : {}
} catch {
return
}
},
[notify]
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
)
const handleKeepJob = useCallback(
async (job: RecordJob): Promise<void> => {
const file = baseName(job.output || '')
if (!file) return
try {
await runFinishedFileAction({
kind: 'keep',
file,
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => {
window.setTimeout(() => {
// ✅ Entfernt aus Finished + Running + Player
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished")
setDoneCount((c) => Math.max(0, c - 1))
// Optional: nächste Seite vorladen (schadet nicht)
void prefetchDonePage(donePage + 1)
}, 320)
},
onError: async () => {
notify.error('Keep fehlgeschlagen', file)
},
})
} catch {
return
}
},
[runFinishedFileAction, notify, donePage, prefetchDonePage]
)
const handleToggleHot = useCallback(
async (job: RecordJob) => {
async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
const file = baseName(job.output || '')
if (!file) return
try {
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
await new Promise((r) => window.setTimeout(r, 60))
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
@ -1695,30 +1664,38 @@ export default function App() {
{ method: 'POST' }
)
// ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player)
const oldFile = baseName(res.oldFile || file) || file
const newFile = baseName(res.newFile || '') || ''
if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück')
// ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details)
window.dispatchEvent(
new CustomEvent('finished-downloads:rename', {
detail: { oldFile: res.oldFile, newFile: res.newFile },
detail: { oldFile, newFile },
})
)
const apply = (out: string) => replaceBasename(out || '', res.newFile)
const apply = (out: string) => replaceBasename(out || '', newFile)
// ✅ 1) Player immer updaten
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
// ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
setPlayerJob((prev) => {
if (!prev) return prev
const match = prev.id === job.id || baseName(prev.output || '') === oldFile
return match ? { ...prev, output: apply(prev.output || '') } : prev
})
// ✅ 2) doneJobs über ID (Fallback: basename)
// ✅ doneJobs über ID (Fallback basename)
setDoneJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
const match = j.id === job.id || baseName(j.output || '') === oldFile
return match ? { ...j, output: apply(j.output || '') } : j
})
)
// ✅ 3) jobs (/record/list) über ID (Fallback: basename)
// ✅ jobs (/record/list) über ID (Fallback basename)
setJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
const match = j.id === job.id || baseName(j.output || '') === oldFile
return match ? { ...j, output: apply(j.output || '') } : j
})
)
@ -1732,6 +1709,13 @@ export default function App() {
[notify]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
// --- flags patch (wie bei dir) ---
async function patchModelFlags(patch: any): Promise<any | null> {
const res = await fetch('/api/models/flags', {
@ -2471,7 +2455,7 @@ export default function App() {
// ✅ war irgendwann schon mal online (vor diesem Poll)?
const hadEverBeenOnline = Boolean(everOnline[keyLower])
const name = String((room as any)?.username ?? keyLower).trim() || keyLower
const modelName = String((room as any)?.username ?? keyLower).trim() || keyLower
const imageUrl = String((room as any)?.image_url ?? '').trim()
// immer merken: jetzt ist es online
@ -2481,10 +2465,17 @@ export default function App() {
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
if (becamePublicFromWaiting) {
if (notificationsOn) {
notify.info(name, 'ist wieder online.', {
notify.info(modelName, 'ist wieder online.', {
imageUrl,
imageAlt: `${name} Vorschau`,
imageAlt: `${modelName} Vorschau`,
durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
})
}
@ -2500,12 +2491,19 @@ export default function App() {
// Startup-Spam vermeiden
if (notificationsOn && !isInitial) {
notify.info(
name,
modelName,
cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
{
imageUrl,
imageAlt: `${name} Vorschau`,
imageAlt: `${modelName} Vorschau`,
durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
}
)
}
@ -2673,7 +2671,7 @@ export default function App() {
<div className="relative">
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 sm:py-4 space-y-2 sm:space-y-3">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="min-w-0">
@ -2813,7 +2811,7 @@ export default function App() {
</header>
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 py-2">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
@ -2849,6 +2847,7 @@ export default function App() {
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}

View File

@ -43,7 +43,14 @@ export default function ButtonGroup({
const s = sizeMap[size]
return (
<span className={cn('isolate inline-flex rounded-md shadow-xs dark:shadow-none', className)} role="group" aria-label={ariaLabel}>
<span
className={cn(
'isolate inline-flex rounded-md shadow-xs dark:shadow-none ring-1 ring-gray-300 dark:ring-gray-700 overflow-hidden',
className
)}
role="group"
aria-label={ariaLabel}
>
{items.map((it, idx) => {
const active = it.id === value
const isFirst = idx === 0
@ -59,16 +66,14 @@ export default function ButtonGroup({
aria-pressed={active}
className={cn(
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
!isFirst && '-ml-px',
!isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md',
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
active
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' +
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:inset-ring-indigo-400/50 dark:hover:bg-indigo-500/50'
: 'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50'
: 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
// Disabled
'disabled:opacity-50 disabled:cursor-not-allowed',

View File

@ -26,7 +26,11 @@ type WaitingModelRow = {
currentShow?: string // public / private / hidden / away / unknown
}
type AutostartState = { paused?: boolean }
type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
type Props = {
jobs: RecordJob[]
@ -704,6 +708,20 @@ const isTerminalStatus = (status?: unknown) => {
)
}
function DiskEmergencyBadge() {
return (
<span
className="
inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold
bg-red-100 text-red-800 ring-1 ring-red-200
dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/25
"
title="Speicherplatz-Notbremse aktiv: Autostart gesperrt und Downloads wurden gestoppt"
>
Speicherplatz-Notbremse aktiv
</span>
)
}
export default function Downloads({
jobs,
@ -725,15 +743,30 @@ export default function Downloads({
const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false)
const [, setWatchedPausedByUser] = useState(false)
const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false)
const watchedPausedRef = useRef<boolean | null>(null)
const watchedPausedByUserRef = useRef<boolean | null>(null)
const watchedPausedByDiskRef = useRef<boolean | null>(null)
const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => {
try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
const next = Boolean(s?.paused)
watchedPausedRef.current = next
setWatchedPaused(next)
const nextPaused = Boolean(s?.paused)
const nextPausedByUser = Boolean(s?.pausedByUser)
const nextPausedByDisk = Boolean(s?.pausedByDisk)
watchedPausedRef.current = nextPaused
watchedPausedByUserRef.current = nextPausedByUser
watchedPausedByDiskRef.current = nextPausedByDisk
setWatchedPaused(nextPaused)
setWatchedPausedByUser(nextPausedByUser)
setWatchedPausedByDisk(nextPausedByDisk)
} catch {
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
}
@ -748,10 +781,24 @@ export default function Downloads({
'/api/autostart/state/stream',
'autostart',
(data) => {
const next = Boolean((data as any)?.paused)
if (watchedPausedRef.current === next) return
watchedPausedRef.current = next
setWatchedPaused(next)
const nextPaused = Boolean((data as any)?.paused)
const nextPausedByUser = Boolean((data as any)?.pausedByUser)
const nextPausedByDisk = Boolean((data as any)?.pausedByDisk)
const unchanged =
watchedPausedRef.current === nextPaused &&
watchedPausedByUserRef.current === nextPausedByUser &&
watchedPausedByDiskRef.current === nextPausedByDisk
if (unchanged) return
watchedPausedRef.current = nextPaused
watchedPausedByUserRef.current = nextPausedByUser
watchedPausedByDiskRef.current = nextPausedByDisk
setWatchedPaused(nextPaused)
setWatchedPausedByUser(nextPausedByUser)
setWatchedPausedByDisk(nextPausedByDisk)
}
)
@ -762,29 +809,38 @@ export default function Downloads({
const pauseWatched = useCallback(async () => {
if (watchedBusy || watchedPaused) return
setWatchedBusy(true)
try {
await fetch('/api/autostart/pause', { method: 'POST' })
setWatchedPaused(true)
const res = await fetch('/api/autostart/pause', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// State kommt normalerweise per SSE; fallback refresh:
await refreshWatchedState()
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused])
}, [watchedBusy, watchedPaused, refreshWatchedState])
const resumeWatched = useCallback(async () => {
if (watchedBusy || !watchedPaused) return
// ✅ Bei Disk-Notbremse kein Resume erlauben
if (watchedBusy || !watchedPaused || watchedPausedByDisk) return
setWatchedBusy(true)
try {
await fetch('/api/autostart/resume', { method: 'POST' })
setWatchedPaused(false)
const res = await fetch('/api/autostart/resume', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// State kommt normalerweise per SSE; fallback refresh:
await refreshWatchedState()
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused])
}, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState])
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
@ -1333,14 +1389,21 @@ export default function Downloads({
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
@ -1377,8 +1440,9 @@ export default function Downloads({
<div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? (
<>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
Downloads ({downloadJobRows.length})
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
{downloadJobRows.map((r) => (
<DownloadsCardRow
@ -1455,8 +1519,9 @@ export default function Downloads({
<div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Downloads ({downloadJobRows.length})
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
<Table
rows={downloadJobRows}

View File

@ -57,6 +57,7 @@ type Props = {
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
@ -195,6 +196,112 @@ const sizeBytesOf = (job: RecordJob): number | 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,
@ -207,6 +314,7 @@ export default function FinishedDownloads({
onToggleFavorite,
onToggleLike,
onToggleWatch,
onKeepJob,
doneTotal,
page,
pageSize,
@ -226,6 +334,8 @@ export default function FinishedDownloads({
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)
@ -240,6 +350,7 @@ export default function FinishedDownloads({
const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
@ -382,12 +493,19 @@ export default function FinishedDownloads({
})
}, [])
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
// ✅ 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) => {
setIsLoading(true)
// ✅ 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' : ''}`,
@ -405,7 +523,7 @@ export default function FinishedDownloads({
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally {
setIsLoading(false)
if (shouldShowLoading) setIsLoading(false)
}
},
[sortMode, includeKeep]
@ -458,6 +576,12 @@ export default function FinishedDownloads({
const finishRefill = () => {
refillInFlightRef.current = false
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
}
// ✅ Refill läuft
@ -966,149 +1090,296 @@ export default function FinishedDownloads({
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 } }))
}
await new Promise((r) => window.setTimeout(r, 250))
// 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): Promise<boolean> => {
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden kann nicht löschen.')
return false
}
if (deletingKeys.has(key)) return false
markDeleting(key, true)
try {
await releasePlayingFile(file, { close: true })
// ✅ Wenn App-Handler vorhanden: den benutzen
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
if (onDeleteJob) {
const r = await onDeleteJob(job)
const undoToken = (r as any)?.undoToken
if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
// optional: nicht als "error" melden, eher info/warn
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
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)
}
// ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key)
queueRefill()
emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill()
},
})
return true
}
// Fallback: Backend direkt
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ Backend liefert undoToken (Trash)
const data = (await res.json().catch(() => null)) as any
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
else setLastAction(null)
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(-1)
return true
} catch (e: any) {
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
restoreRow(key)
notify.error('Löschen fehlgeschlagen: ', file)
return false
} finally {
markDeleting(key, false)
}
return res.ok
},
[
baseName,
keyFor,
deletingKeys,
markDeleting,
releasePlayingFile,
onDeleteJob,
animateRemove,
notify,
restoreRow,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
]
)
const keepVideo = useCallback(
async (job: RecordJob) => {
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden kann nicht behalten.')
return false
}
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
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 }
)
}
markKeeping(key, true)
try {
await releasePlayingFile(file, { close: true })
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
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
// ✅ Backend liefert ggf. newFile (uniqueDestPath)
const data = (await res.json().catch(() => null)) as any
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
queueRefill()
emitCountHint(includeKeep ? 0 : -1)
},
})
// ✅ Undo-Info merken
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1)
return true
} catch (e: any) {
notify.error('Keep fehlgeschlagen', file)
return false
} finally {
markKeeping(key, false)
}
return res.ok
},
[
baseName,
keyFor,
markKeeping,
keepingKeys,
deletingKeys,
markKeeping,
releasePlayingFile,
animateRemove,
notify,
withFileReleaseRetry,
runFileMutation,
queueRefill,
emitCountHint,
includeKeep,
@ -1250,86 +1521,185 @@ export default function FinishedDownloads({
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) => {
async (job: RecordJob): Promise<void> => {
const currentFile = baseName(job.output || '')
if (!currentFile) {
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
const key = keyFor(job)
// genau "HOT " Prefix
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
// Server-Truth anwenden (inkl. duration-key move via applyRename)
const applyServerTruth = (apiOld: string, apiNew: string) => {
if (!apiOld || !apiNew || apiOld === apiNew) return
applyRename(apiOld, apiNew)
}
const oldFile = currentFile
const optimisticNew = toggledName(oldFile)
// Optimistik sofort anwenden (UI snappy)
applyRename(oldFile, optimisticNew)
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)
try {
await releasePlayingFile(oldFile, { close: true })
if (onToggleHot) {
const r = await onToggleHot(job)
return r as any
}
// ✅ 1) Wenn du einen externen Handler hast:
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
if (onToggleHot) {
const r = (await onToggleHot(job)) 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 }
)
// Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)
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
// ✅ Undo erst jetzt setzen (nach Erfolg)
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
if (apiOld && apiNew && apiOld !== apiNew) {
applyRename(apiOld, apiNew)
}
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
setLastAction({ kind: 'hot', currentFile: apiNew })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
return
}
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ 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 apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)
// ✅ Undo nach Erfolg
setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill()
} catch (e: any) {
// ❌ Rollback, weil Optimistik schon angewendet wurde
clearRenamePair(oldFile, optimisticNew)
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
setLastAction(null)
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
}
},
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
},
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 || '')
@ -1415,9 +1785,7 @@ export default function FinishedDownloads({
}
if (detail.phase === 'success') {
// delete final bestätigt
markDeleting(key, false)
queueRefill()
return
}
}
@ -1428,13 +1796,18 @@ export default function FinishedDownloads({
useEffect(() => {
const onReload = () => {
if (refillInFlightRef.current) return
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
if (refillInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
return
}
queueRefill()
}
window.addEventListener('finished-downloads:reload', onReload as any)
return () => window.removeEventListener('finished-downloads:reload', onReload as any)
}, [queueRefill /* oder fetchAllDoneJobs */])
window.addEventListener('finished-downloads:reload', onReload as EventListener)
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
}, [queueRefill])
useEffect(() => {
const onExternalRename = (ev: Event) => {
@ -1634,9 +2007,12 @@ export default function FinishedDownloads({
// ✅ 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) return
if (view !== 'cards') return
if (!isSmall || view !== 'cards') return
const top = pageRows[0]
if (!top) {
@ -1645,7 +2021,15 @@ export default function FinishedDownloads({
}
const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
// ✅ 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(() => {
@ -1763,9 +2147,8 @@ export default function FinishedDownloads({
{/* Views */}
<Button
size={isSmall ? 'sm' : 'md'}
size='md'
variant="soft"
className={isSmall ? 'h-9' : 'h-10'}
disabled={!lastAction || undoing}
onClick={undoLastAction}
title={
@ -2023,7 +2406,7 @@ export default function FinishedDownloads({
) : (
<>
{view === 'cards' && (
<div className={isSmall ? 'mt-8' : ''}>
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
@ -2067,6 +2450,9 @@ export default function FinishedDownloads({
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
</div>
)}
@ -2112,6 +2498,9 @@ export default function FinishedDownloads({
onToggleWatch={onToggleWatch}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
)}
@ -2150,6 +2539,9 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/>
)}

View File

@ -84,6 +84,10 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
}
const parseTags = (raw?: string): string[] => {
@ -157,6 +161,72 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows]
}
function CardBlurWrapper({
blurred,
animateUnblurOnMount,
children,
}: {
blurred?: boolean
animateUnblurOnMount?: boolean
children: React.ReactNode
}) {
const [entered, setEntered] = React.useState(!animateUnblurOnMount)
React.useEffect(() => {
if (!animateUnblurOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateUnblurOnMount])
const className = [
'relative will-change-[filter,transform] transition-[filter,transform,opacity] duration-180 ease-out',
blurred
? 'blur-[1.5px] saturate-90 scale-[0.995]'
: entered
? 'blur-0 saturate-100 scale-100'
: 'blur-[1.5px] saturate-90 scale-[0.995]',
]
.filter(Boolean)
.join(' ')
return <div className={className}>{children}</div>
}
function PromoteToFrontWrapper({
animateOnMount,
children,
}: {
animateOnMount?: boolean
children: React.ReactNode
}) {
const [entered, setEntered] = React.useState(!animateOnMount)
React.useEffect(() => {
if (!animateOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateOnMount])
return (
<div
className="will-change-[transform,opacity] transition-[transform,opacity] duration-160 ease-out motion-reduce:transition-none"
style={{
transform: entered
? 'translateY(0px) scale(1) translateZ(0)'
: 'translateY(-15px) scale(0.97) translateZ(0)',
opacity: entered ? 1 : 0.92,
transformOrigin: 'top center',
}}
>
{children}
</div>
)
}
export default function FinishedDownloadsCardsView({
rows,
isSmall,
@ -204,6 +274,10 @@ export default function FinishedDownloadsCardsView({
onToggleFavorite,
onToggleLike,
onToggleWatch,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) {
const parseMeta = React.useCallback((j: RecordJob): any | null => {
@ -324,6 +398,10 @@ export default function FinishedDownloadsCardsView({
isDecorative?: boolean
forceLoadStill?: boolean
mobileStackTopOnlyVideo?: boolean
disableScrubber?: boolean
blur?: boolean
animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean
}
) => {
const k = keyFor(j)
@ -346,7 +424,7 @@ export default function FinishedDownloadsCardsView({
opts?.forceStill
? false
: opts?.mobileStackTopOnlyVideo
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false))
? (teaserPlayback === 'still' ? false : true)
: (teaserPlayback === 'all'
? true
: teaserPlayback === 'hover'
@ -525,145 +603,169 @@ export default function FinishedDownloadsCardsView({
}
}}
>
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
id={inlineDomId}
ref={
opts?.disableInline || opts?.isDecorative
? undefined
: registerTeaserHost(k)
}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}
onMouseLeave={() => {
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
clearScrubActiveIndex(k)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall || opts?.disableInline) return
startInline(k)
}}
>
{/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
{!inlineActive ? (
<div
className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
(inlineActive ? 'opacity-0' : 'opacity-100')
}
>
<CardBlurWrapper
blurred={opts?.blur}
animateUnblurOnMount={opts?.animateUnblurOnMount}
>
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
id={inlineDomId}
ref={
opts?.disableInline || opts?.isDecorative
? undefined
: registerTeaserHost(k)
}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}
onMouseLeave={() => {
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
clearScrubActiveIndex(k)
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall || opts?.disableInline) return
startInline(k)
}}
>
{/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
{!inlineActive ? (
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[
dur,
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
size,
]
.filter(Boolean)
.join(' • ')}
className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
(inlineActive ? 'opacity-0' : 'opacity-100')
}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[
dur,
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
size,
]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
</div>
) : null}
) : null}
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
/>
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={
opts?.mobileStackTopOnlyVideo
? true
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
}
teaserPreloadRootMargin={
opts?.preloadTeaserWhenStill
? '1200px 0px'
: (isSmall ? '900px 0px' : '700px 0px')
}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
/>
) : null}
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} />
</div>
) : null}
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} />
</div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => setScrubHovering(k, true)}
onMouseLeave={() => {
setScrubHovering(k, false)
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
setScrubActiveIndex(k, undefined)
}}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
onIndexClick={(index) => {
// wie Preview-Klick: inline starten
if (isSmall || opts?.disableInline) {
// Mobile/Decorative/Fallback: bestehendes Verhalten
handleScrubberClickIndex(j, index, scrubberCount)
return
}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative && !opts?.disableScrubber && !inlineActive && scrubberCount > 1 ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => setScrubHovering(k, true)}
onMouseLeave={() => {
setScrubHovering(k, false)
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
setScrubActiveIndex(k, undefined)
}}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
onIndexClick={(index) => {
// wie Preview-Klick: inline starten
if (isSmall || opts?.disableInline) {
// Mobile/Decorative/Fallback: bestehendes Verhalten
handleScrubberClickIndex(j, index, scrubberCount)
return
}
// Zielsekunde aus Scrubber ableiten
const seconds =
scrubberStepSeconds > 0
? index * scrubberStepSeconds
: 0
// Zielsekunde aus Scrubber ableiten
const seconds =
scrubberStepSeconds > 0
? index * scrubberStepSeconds
: 0
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
if (startInlineAt) {
startInlineAt(k, seconds, inlineDomId)
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
if (startInlineAt) {
startInlineAt(k, seconds, inlineDomId)
// wie bei Tap im Mobile-Stack: Autoplay nochmal anschubsen
// wie bei Tap im Mobile-Stack: Autoplay nochmal anschubsen
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => {
tryAutoplayInline(inlineDomId)
})
}
})
return
}
// 2) Fallback: inline normal starten (ohne exakten Seek)
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => {
@ -671,99 +773,88 @@ export default function FinishedDownloadsCardsView({
})
}
})
return
}
// 2) Fallback: inline normal starten (ohne exakten Seek)
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => {
tryAutoplayInline(inlineDomId)
})
}
})
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
// handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
// handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
</div>
</div>
</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
) : null}
</div>
</div>
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Meta + Actions (nicht im Video) */}
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Actions: volle Breite */}
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>
{/* Meta + Actions (nicht im Video) */}
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Actions: volle Breite */}
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
</Card>
</Card>
</CardBlurWrapper>
</div>
)
@ -782,17 +873,17 @@ export default function FinishedDownloadsCardsView({
// Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile
const mobileAllRows = isSmall ? rows : []
// ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
const MOBILE_STILL_PRELOAD_LIMIT = 4 // 0..6 je nach Gerätetest
const mobileStillPreloadRows = isSmall
? rows.slice(mobileStackDepth, mobileStackDepth + MOBILE_STILL_PRELOAD_LIMIT)
: []
// größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
const stackTopGapPx = 24
// weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) * stackPeekOffsetPx
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
return (
<div className="relative">
@ -809,109 +900,135 @@ export default function FinishedDownloadsCardsView({
})}
</div>
) : (
<div className="relative">
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? null : (
<div className="relative mx-auto w-full max-w-[560px] overflow-visible">
<div
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
style={{ overflowX: 'clip' }}
>
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div
className="relative overflow-visible"
style={{
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
>
{mobileVisibleStackRows
.map((j, idx) => {
const isTop = idx === 0
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(
j,
isTop
? {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
}
: {
{(() => {
const visible = mobileVisibleStackRows
const topRow = visible[0]
const backRows = visible.slice(1)
return (
<>
{/* Hintere Karten zuerst (absolut, dekorativ) */}
{backRows
.map((j, backIdx) => {
const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
}
)
const depth = idx // 0,1,2
const y = -(depth * stackPeekOffsetPx) // nach OBEN staffeln
const scale = 1 - depth * 0.03 // etwas stärkerer Tiefen-Effekt
const opacity = 1 - depth * 0.14
blur: true,
preloadTeaserWhenStill: true,
})
// untere Karten nur Deko (keine Interaktion)
if (!isTop) {
return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
{/* leichtes Frosting, damit klar ist: nur Vorschau */}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
const depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})
.reverse()}
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => {
const j = topRow
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
})
return (
<div
key={k}
className="relative touch-pan-y"
style={{ zIndex: 30 }}
>
<PromoteToFrontWrapper animateOnMount>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
onSwipeLeft={() => {
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
>
{cardInner}
</SwipeCard>
</PromoteToFrontWrapper>
</div>
</div>
)
}
// oberste Karte: echte SwipeCard (wie bisher)
return (
<div
key={k}
className="absolute inset-x-0 top-0"
style={{
zIndex: 30,
transform: `translateY(${y}px) scale(${scale})`,
transformOrigin: 'top center',
}}
>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
</div>
)
})
.reverse() /* zuerst hinten rendern, oben zuletzt */}
)
})() : null}
</>
)
})()}
</div>
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */}
{mobileAllRows.length > mobileStackDepth ? (
{/* ✅ Hidden preloader (mobile): nur wenige nächste Cards, sonst UI träge */}
{mobileStillPreloadRows.length > 0 ? (
<div className="sr-only" aria-hidden="true">
{mobileAllRows.slice(mobileStackDepth).map((j) => {
{mobileStillPreloadRows.map((j) => {
const k = keyFor(j)
return (
@ -922,9 +1039,9 @@ export default function FinishedDownloadsCardsView({
className="h-full w-full"
showPopover={false}
blur={Boolean(blurPreviews)}
// ✅ Nur Previewbild laden kein Teaser-Video
animated={false}
teaserPreloadEnabled={true}
teaserPreloadRootMargin="1200px 0px"
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={false}
@ -932,27 +1049,14 @@ export default function FinishedDownloadsCardsView({
inlineLoop={false}
muted={true}
popoverMuted={true}
assetNonce={assetNonce ?? 0}
// ✅ Still immer laden
alwaysLoadStill
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
teaserPreloadEnabled={false}
/>
</div>
)
})}
</div>
) : null}
{/* optionaler Hinweis */}
{rows.length > 1 ? (
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
Wische nach links zum Löschen nach rechts zum Behalten
</div>
) : null}
</div>
)}
</div>

View File

@ -72,6 +72,11 @@ type Props = {
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleHot: (job: RecordJob) => void | Promise<void>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
@ -177,6 +182,9 @@ export default function FinishedDownloadsGalleryView({
onToggleFavorite,
onToggleLike,
onToggleWatch,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
@ -649,9 +657,27 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onToggleHot={async (job) => {
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>

View File

@ -73,6 +73,11 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
}
export default function FinishedDownloadsTableView({
@ -120,6 +125,9 @@ export default function FinishedDownloadsTableView({
deleteVideo,
keepVideo,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) {
const [sort, setSort] = React.useState<SortState>(null)
@ -427,9 +435,31 @@ export default function FinishedDownloadsTableView({
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onToggleHot={
onToggleHot
? async (job) => {
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}
: undefined
}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1"
/>

View File

@ -636,10 +636,8 @@ export default function FinishedVideoPreview({
}
;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile
if (!aborted && j) setFetchedMeta(j)
if (!aborted && byFile) setFetchedMeta(byFile)
})()
return () => {

View File

@ -25,6 +25,16 @@ type ParsedModel = {
type ImportKind = 'liked' | 'favorite'
type ModelsViewMode = 'table' | 'gallery'
type GallerySortMode =
| 'created_desc'
| 'created_asc'
| 'model_asc'
| 'model_desc'
| 'videos_desc'
| 'videos_asc'
| 'tags_desc'
| 'tags_asc'
export type StoredModel = {
id: string
input: string
@ -208,6 +218,46 @@ function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
}
function gallerySortModeFromSort(
sort: { key: string; direction: 'asc' | 'desc' } | null
): GallerySortMode {
if (!sort) return 'created_desc'
if (sort.key === 'createdAt') return sort.direction === 'asc' ? 'created_asc' : 'created_desc'
if (sort.key === 'model') return sort.direction === 'asc' ? 'model_asc' : 'model_desc'
if (sort.key === 'videos') return sort.direction === 'asc' ? 'videos_asc' : 'videos_desc'
if (sort.key === 'tags') return sort.direction === 'asc' ? 'tags_asc' : 'tags_desc'
return 'created_desc'
}
function sortFromGallerySortMode(mode: GallerySortMode): { key: string; direction: 'asc' | 'desc' } {
switch (mode) {
case 'created_asc':
return { key: 'createdAt', direction: 'asc' }
case 'created_desc':
return { key: 'createdAt', direction: 'desc' }
case 'model_asc':
return { key: 'model', direction: 'asc' }
case 'model_desc':
return { key: 'model', direction: 'desc' }
case 'videos_asc':
return { key: 'videos', direction: 'asc' }
case 'videos_desc':
return { key: 'videos', direction: 'desc' }
case 'tags_asc':
return { key: 'tags', direction: 'asc' }
case 'tags_desc':
return { key: 'tags', direction: 'desc' }
default:
return { key: 'createdAt', direction: 'desc' }
}
}
function GridIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
@ -249,8 +299,8 @@ export default function ModelsTab() {
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
key: 'model',
direction: 'asc',
key: 'createdAt',
direction: 'desc',
})
const refreshVideoCounts = React.useCallback(async () => {
@ -749,6 +799,34 @@ export default function ModelsTab() {
)
},
},
{
key: 'createdAt',
header: 'Hinzugefügt',
sortable: true,
sortValue: (m) => {
const t = Date.parse(String(m.createdAt ?? ''))
return Number.isFinite(t) ? t : 0
},
widthClassName: 'w-[160px]',
cell: (m) => {
const ts = Date.parse(String(m.createdAt ?? ''))
if (!Number.isFinite(ts) || ts <= 0) {
return <span className="text-xs text-gray-400 dark:text-gray-500"></span>
}
const d = new Date(ts)
return (
<div className="text-xs text-gray-700 dark:text-gray-300 leading-tight">
<div className="tabular-nums">
{d.toLocaleDateString('de-DE')}
</div>
<div className="tabular-nums text-gray-500 dark:text-gray-400">
{d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
)
},
},
{
key: 'actions',
header: '',
@ -823,7 +901,7 @@ export default function ModelsTab() {
React.useEffect(() => {
setPage(1)
}, [q, tagFilter, viewMode])
}, [q, tagFilter, viewMode, sort])
const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
@ -910,60 +988,190 @@ export default function ModelsTab() {
header={
<div className="space-y-2">
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
</div>
<div className="sm:hidden">
{/* Mobile */}
<div className="sm:hidden shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Import
</Button>
</div>
{/* Desktop */}
<div className="hidden sm:block shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<div className="flex items-center gap-2">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
<div className="w-full sm:w-auto min-w-0">
{/* Mobile Layout: 2 Zeilen */}
<div className="sm:hidden grid gap-2">
{/* Zeile 1: View + Sort */}
<div className="grid grid-cols-[auto,minmax(0,1fr)] gap-2 items-center min-w-0">
<div className="shrink-0">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
className="flex w-full [&>button]:flex-1 [&>button]:min-w-0"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<div className="hidden sm:block">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
<div className="min-w-0">
{viewMode === 'gallery' ? (
<>
<label className="sr-only" htmlFor="models-gallery-sort-mobile">
Sortierung
</label>
<select
id="models-gallery-sort-mobile"
value={gallerySortModeFromSort(sort)}
onChange={(e) => {
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-full min-w-0
rounded-md px-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</>
) : (
<div
aria-hidden="true"
className="
h-9 w-full min-w-0
rounded-md px-3 text-sm
inline-flex items-center
bg-gray-50 text-gray-400 ring-1 ring-gray-200
dark:bg-white/5 dark:text-white/30 dark:ring-white/10
select-none
"
title="Sortierung in der Tabellenansicht über Spaltenkopf"
>
Sortierung
</div>
)}
</div>
</div>
{/* Zeile 2: Suche volle Breite */}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="
w-full min-w-0
rounded-md px-3 py-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10
"
/>
</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="
w-full sm:w-[260px]
rounded-md px-3 py-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10
"
/>
{/* Desktop Layout: wie bisher */}
<div className="hidden sm:flex items-center gap-2 min-w-0 sm:justify-end">
{viewMode === 'gallery' ? (
<div className="shrink-0">
<label className="sr-only" htmlFor="models-gallery-sort">
Sortierung
</label>
<select
id="models-gallery-sort"
value={gallerySortModeFromSort(sort)}
onChange={(e) => {
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-auto
rounded-md px-3 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</div>
) : null}
<div className="shrink-0">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="
w-full sm:w-[260px]
rounded-md px-3 py-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10
"
/>
</div>
</div>
</div>
@ -1042,7 +1250,7 @@ export default function ModelsTab() {
return (
<div
key={m.id}
className="group overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10"
className="group h-full overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10 flex flex-col"
>
<div
className="relative cursor-pointer bg-slate-950"
@ -1092,19 +1300,46 @@ export default function ModelsTab() {
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{/* Modelname im Bild unten links (statt nur im Footer dominant) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5">
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
<div
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]"
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title={m.modelKey}
>
{m.modelKey}
</div>
{m.host ? (
<div className="truncate text-[11px] text-white/70">{m.host}</div>
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{m.host}
</div>
) : null}
</div>
{/* Stats im Bild (unten rechts, untereinander, mit Icons) */}
<div className="pointer-events-none absolute bottom-2 right-2 z-10 flex flex-col items-end gap-1">
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Videos"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🎬</span>
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{videoCountsLoading ? '…' : videoCount}
</span>
</span>
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Tags"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🏷</span>
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{tags.length}
</span>
</span>
</div>
{/* oben links: Record Actions Overlay */}
<div
className={clsx(
@ -1123,15 +1358,16 @@ export default function ModelsTab() {
[&_button]:bg-transparent [&_button]:shadow-none
[&_button:hover]:bg-white/10
[&_button]:border-0
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
"
/>
</div>
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.75)]">
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
{watch ? (
<span
className="inline-flex items-center justify-center text-[18px] leading-none text-indigo-300"
className="inline-flex items-center justify-center text-xl leading-none text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Beobachtet"
aria-hidden="true"
>
@ -1141,7 +1377,7 @@ export default function ModelsTab() {
{fav ? (
<span
className="inline-flex items-center justify-center text-[18px] leading-none text-amber-300"
className="inline-flex items-center justify-center text-xl leading-none text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Favorit"
aria-hidden="true"
>
@ -1151,7 +1387,7 @@ export default function ModelsTab() {
{liked ? (
<span
className="inline-flex items-center justify-center text-[18px] leading-none text-rose-300"
className="inline-flex items-center justify-center text-xl leading-none text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Gefällt mir"
aria-hidden="true"
>
@ -1164,12 +1400,12 @@ export default function ModelsTab() {
{(m.hot || m.keep) && (
<div className="absolute left-2 top-2 flex flex-col gap-1">
{m.hot ? (
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow">
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
HOT
</span>
) : null}
{m.keep ? (
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow">
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
KEEP
</span>
) : null}
@ -1179,69 +1415,44 @@ export default function ModelsTab() {
</div>
{/* Footer */}
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2">
{/* Zeile 1: Stats links, Actions rechts */}
<div className="flex items-center justify-between gap-2 text-[11px]">
<div className="flex items-center gap-1.5 min-w-0 text-slate-300">
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
{videoCountsLoading ? '…' : videoCount} Videos
</span>
{tags.length > 0 ? (
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
{tags.length} Tags
</span>
) : null}
</div>
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
{/* Mobile: kompakter, aber nicht gequetscht */}
<div className="sm:hidden">
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
<div
className="flex items-center gap-1 shrink-0"
className="grid grid-cols-3 gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<span
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
'h-8 min-w-0 px-0 shadow-none',
watch
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
rounded="full"
className={clsx(
'h-7 w-7 p-0 min-w-0',
watch
? 'bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 shadow-none'
: 'bg-white/5 text-indigo-200/80 shadow-none hover:bg-white/10 hover:text-indigo-200'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span
className={clsx(
'text-sm leading-none',
watch ? 'text-indigo-300' : 'text-slate-300 group-hover:text-indigo-200'
)}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
👁
</span>
</Button>
</span>
</span>
</Button>
<Button
variant={fav ? 'soft' : 'secondary'}
size="xs"
rounded="full"
className={clsx(
'h-7 w-7 p-0 min-w-0',
hideUntilHover && !fav ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
'h-8 min-w-0 px-0 shadow-none',
fav
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 shadow-none'
: 'bg-white/5 text-amber-200/80 shadow-none hover:bg-white/10 hover:text-amber-200'
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
@ -1250,26 +1461,21 @@ export default function ModelsTab() {
else patch(m.id, { favorite: true, liked: false })
}}
>
<span
className={clsx(
'text-sm leading-none',
fav ? 'text-amber-300' : 'text-slate-300 group-hover:text-amber-200'
)}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
</span>
</span>
</Button>
<Button
variant={liked ? 'soft' : 'secondary'}
size="xs"
rounded="full"
className={clsx(
'h-7 w-7 p-0 min-w-0',
hideUntilHover && !liked ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
'h-8 min-w-0 px-0 shadow-none',
liked
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 shadow-none'
: 'bg-white/5 text-rose-200/80 shadow-none hover:bg-white/10 hover:text-rose-200'
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => {
@ -1278,33 +1484,132 @@ export default function ModelsTab() {
else patch(m.id, { liked: true, favorite: false })
}}
>
<span
className={clsx(
'text-sm leading-none',
liked ? 'text-rose-300' : 'text-slate-300 group-hover:text-rose-200'
)}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
</span>
</span>
</Button>
</div>
{/* Zeile 2: Tags */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
{/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
<div className="hidden sm:block">
{/* Zeile 1: Actions rechts, Style wie mobile */}
<div onClick={(e) => e.stopPropagation()}>
<div className="grid grid-cols-3 gap-1.5 w-full">
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
watch
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
👁
</span>
</span>
</Button>
<Button
variant={fav ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !fav
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
fav
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
e.stopPropagation()
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
</span>
</span>
</Button>
<Button
variant={liked ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !liked
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
liked
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => {
e.stopPropagation()
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
</span>
</span>
</Button>
</div>
</div>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div>
</div>
</div>

View File

@ -702,6 +702,7 @@ export default function Player({
const isDesktop = useMediaQuery('(min-width: 640px)')
const miniDesktop = mini && isDesktop
const usePortal = expanded || miniDesktop
const WIN_KEY = 'player_window_v1'
@ -745,14 +746,18 @@ export default function Player({
React.useEffect(() => setMounted(true), [])
React.useEffect(() => {
if (!usePortal) {
setPortalTarget(null)
return
}
let el = document.getElementById('player-root') as HTMLElement | null
if (!el) {
el = document.createElement('div')
el.id = 'player-root'
}
// ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt
// ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
// Desktop / Expanded: im Top-Layer (Dialog) oder body
let host: HTMLElement | null = null
if (isDesktop) {
@ -767,7 +772,7 @@ export default function Player({
el.style.zIndex = '2147483647'
setPortalTarget(el)
}, [isDesktop])
}, [isDesktop, usePortal])
React.useEffect(() => {
const p: any = playerRef.current
@ -1612,7 +1617,8 @@ export default function Player({
if (job.status !== 'running') setStopPending(false)
}, [job.id, job.status])
if (!mounted || !portalTarget) return null
if (!mounted) return null
if (usePortal && !portalTarget) return null
const overlayBtn =
'inline-flex items-center justify-center rounded-md p-2 transition ' +
@ -2110,7 +2116,7 @@ export default function Player({
? { left: win.x, top: win.y, width: win.w, height: win.h }
: undefined
return createPortal(
const content = (
<>
<style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */
@ -2192,34 +2198,34 @@ export default function Player({
`}</style>
{expanded || miniDesktop ? (
<div
className={cn(
'fixed z-[2147483647]',
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
)}
style={{
...(wrapStyle as any),
willChange: isResizing ? 'left, top, width, height' : undefined,
}}
>
{snapGhostEl}
<div
className={cn(
'fixed z-[2147483647]',
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
)}
style={{
...(wrapStyle as any),
willChange: isResizing ? 'left, top, width, height' : undefined,
}}
>
{snapGhostEl}
{cardEl}
{cardEl}
{miniDesktop ? (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
{miniDesktop ? (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
</div>
) : null}
</div>
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
</div>
) : null}
</div>
) : (
<div
className="
@ -2234,7 +2240,12 @@ export default function Player({
{cardEl}
</div>
)}
</>,
portalTarget
</>
)
if (usePortal) {
return createPortal(content, portalTarget!)
}
return content
}

View File

@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask'
import Task from './Task'
import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
@ -416,76 +416,81 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
<div className="mt-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<GenerateAssetsTask
onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
<Task
title="Assets-Generator"
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
startLabel="Start"
startingLabel="Starte…"
startUrl="/api/tasks/generate-assets"
stopUrl="/api/tasks/generate-assets"
sseUrl="/api/tasks/assets/stream"
onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: fn || '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Assets generieren',
text: 'Abgebrochen.',
}))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
title: 'Assets generieren',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
</div>
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: fn || '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Assets generieren',
text: 'Abgebrochen.',
}))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
title: 'Assets generieren',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
<div className="shrink-0 flex items-center gap-2">
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div>
</div>
<Task
title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
startLabel="Aufräumen"
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving || !value.autoDeleteSmallDownloads}
onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
setErr(message)
}}
/>
</div>
</div>

View File

@ -196,16 +196,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
let ok: boolean | void = true
// ✅ Action sofort starten, damit Parent die nächste Karte direkt zeigen kann.
// Animation läuft parallel weiter.
let actionPromise: Promise<boolean | void>
if (runAction) {
try {
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
} catch {
ok = false
}
actionPromise = Promise.resolve(
dir === 'right' ? onSwipeRight() : onSwipeLeft()
).catch(() => false)
} else {
actionPromise = Promise.resolve(true)
}
// Mindestens die Commit-Animation abwarten (für sauberes Gefühl),
// aber Action bereits parallel laufen lassen.
const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs)
})
const [, ok] = await Promise.all([animPromise, actionPromise])
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
setAnimMs(snapMs)

View File

@ -1,4 +1,4 @@
// frontend\src\components\ui\GenerateAssetsTask.tsx
// frontend\src\components\ui\Task.tsx
'use client'
@ -22,6 +22,23 @@ type TaskState = {
type Progress = { done: number; total: number; currentFile?: string }
type Props = {
/** API-Endpunkte (optional, wenn onTrigger verwendet wird) */
startUrl?: string
stopUrl?: string
sseUrl?: string
/** Optional: lokaler Trigger statt API/SSE */
onTrigger?: () => Promise<void> | void
/** UI-Texte */
title?: string
description?: string
startLabel?: string
startingLabel?: string
disabled?: boolean
busy?: boolean
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
onFinished?: () => void
onStart?: (ac: AbortController) => void
onProgress?: (p: Progress) => void
@ -55,7 +72,17 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return data as T
}
export default function GenerateAssetsTask({
export default function Task({
startUrl,
stopUrl,
sseUrl,
onTrigger,
title = 'Task',
description = 'Startet eine Hintergrundaufgabe. Fortschritt & Abbrechen oben in der Taskliste.',
startLabel = 'Start',
startingLabel = 'Starte…',
disabled = false,
busy = false,
onFinished,
onStart,
onProgress,
@ -87,10 +114,11 @@ export default function GenerateAssetsTask({
}, [onError])
async function stopInternal() {
if (!stopUrl) return
if (stopInFlightRef.current) return
stopInFlightRef.current = true
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
await fetch(stopUrl, { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
} finally {
@ -147,7 +175,9 @@ export default function GenerateAssetsTask({
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => {
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
if (!sseUrl) return
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
setState(st)
if (st?.running) {
@ -162,7 +192,7 @@ export default function GenerateAssetsTask({
const errText = String(st?.error ?? '').trim()
// Abbruch ist kein "Fehler"-Event für die UI
// Abbruch ist kein "Fehler"-Event für die UI
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)
@ -170,9 +200,10 @@ export default function GenerateAssetsTask({
})
return () => unsub()
}, [])
}, [sseUrl])
async function start() {
if (busy) return
if (state?.running) return
setStartError(null)
@ -180,11 +211,34 @@ export default function GenerateAssetsTask({
cancelledRef.current = false
lastErrorRef.current = ''
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren
// 1) Lokaler Task-Modus (z.B. Cleanup)
if (onTrigger) {
try {
await onTrigger()
} catch (e: any) {
const msg = e?.message ?? String(e)
setStartError(msg)
onError?.(msg)
} finally {
setStarting(false)
}
return
}
// 2) API/SSE-Task-Modus (wie Assets)
if (!startUrl) {
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
setStartError(msg)
onError?.(msg)
setStarting(false)
return
}
// Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren
const ac = ensureControllerCreated()
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
setState(st)
// TaskList jetzt aktivieren
@ -215,10 +269,8 @@ export default function GenerateAssetsTask({
return (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{description}</div>
{startError ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
@ -228,10 +280,10 @@ export default function GenerateAssetsTask({
</div>
<div className="shrink-0">
<Button variant="primary" onClick={start} disabled={starting || running}>
{starting ? 'Starte…' : 'Start'}
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
{(starting || busy) ? startingLabel : startLabel}
</Button>
</div>
</div>
)
}
}

View File

@ -1,5 +1,5 @@
// frontend\src\components\ui\ToastProvider.tsx
'use client'
import * as React from 'react'
@ -22,6 +22,12 @@ export type Toast = {
imageUrl?: string
imageAlt?: string
durationMs?: number // auto close
onClick?: () => void
closeOnClick?: boolean // default true (bei klickbaren Toasts)
}
type ToastInternal = Toast & {
open: boolean
}
type ToastContextValue = {
@ -32,29 +38,64 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220
function iconFor(type: ToastType) {
switch (type) {
case 'success':
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' }
return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' }
case 'error':
return { Icon: XCircleIcon, cls: 'text-rose-500' }
return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' }
case 'warning':
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' }
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' }
default:
return { Icon: InformationCircleIcon, cls: 'text-sky-500' }
return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' }
}
}
function borderFor(type: ToastType) {
switch (type) {
case 'success':
return 'border-emerald-200/70 dark:border-emerald-400/20'
return 'border-emerald-200/80 dark:border-emerald-400/20'
case 'error':
return 'border-rose-200/70 dark:border-rose-400/20'
return 'border-rose-200/80 dark:border-rose-400/20'
case 'warning':
return 'border-amber-200/70 dark:border-amber-400/20'
return 'border-amber-200/80 dark:border-amber-400/20'
default:
return 'border-sky-200/70 dark:border-sky-400/20'
return 'border-sky-200/80 dark:border-sky-400/20'
}
}
function accentFor(type: ToastType) {
switch (type) {
case 'success':
return {
line: 'bg-emerald-500 dark:bg-emerald-400',
progressTrack: 'bg-emerald-500/10 dark:bg-emerald-400/10',
progressFill: 'bg-emerald-500/70 dark:bg-emerald-400/70',
iconBg: 'bg-emerald-50 dark:bg-emerald-400/10',
}
case 'error':
return {
line: 'bg-rose-500 dark:bg-rose-400',
progressTrack: 'bg-rose-500/10 dark:bg-rose-400/10',
progressFill: 'bg-rose-500/70 dark:bg-rose-400/70',
iconBg: 'bg-rose-50 dark:bg-rose-400/10',
}
case 'warning':
return {
line: 'bg-amber-500 dark:bg-amber-400',
progressTrack: 'bg-amber-500/10 dark:bg-amber-400/10',
progressFill: 'bg-amber-500/70 dark:bg-amber-400/70',
iconBg: 'bg-amber-50 dark:bg-amber-400/10',
}
default:
return {
line: 'bg-sky-500 dark:bg-sky-400',
progressTrack: 'bg-sky-500/10 dark:bg-sky-400/10',
progressFill: 'bg-sky-500/70 dark:bg-sky-400/70',
iconBg: 'bg-sky-50 dark:bg-sky-400/10',
}
}
}
@ -86,180 +127,420 @@ export function ToastProvider({
defaultDurationMs?: number
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
}) {
const [toasts, setToasts] = React.useState<Toast[]>([])
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
const [toasts, setToasts] = React.useState<ToastInternal[]>([])
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
const loadNotificationSetting = React.useCallback(async () => {
try {
const r = await fetch('/api/settings', { cache: 'no-store' })
if (!r.ok) return
const data = await r.json()
setNotificationsEnabled(!!(data?.enableNotifications ?? true))
} catch {
// ignorieren -> default true
const autoCloseTimersRef = React.useRef<Record<string, number>>({})
const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({})
const toastStartedAtRef = React.useRef<Record<string, number>>({})
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
const toastPausedRef = React.useRef<Record<string, boolean>>({})
const clearTimersFor = React.useCallback((id: string) => {
const autoId = autoCloseTimersRef.current[id]
if (autoId) {
window.clearTimeout(autoId)
delete autoCloseTimersRef.current[id]
}
const finId = finalizeRemoveTimersRef.current[id]
if (finId) {
window.clearTimeout(finId)
delete finalizeRemoveTimersRef.current[id]
}
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, [])
const loadNotificationSetting = React.useCallback(async () => {
try {
const r = await fetch('/api/settings', { cache: 'no-store' })
if (!r.ok) return
const data = await r.json()
setNotificationsEnabled(!!(data?.enableNotifications ?? true))
} catch {
// ignorieren -> default true
}
}, [])
React.useEffect(() => {
loadNotificationSetting()
const onUpdated = () => loadNotificationSetting()
window.addEventListener('recorder-settings-updated', onUpdated)
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
}, [loadNotificationSetting])
React.useEffect(() => {
if (!notificationsEnabled) {
// Nur Fehler sichtbar lassen (animiert schließen)
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
const ids = toasts.filter((t) => t.type !== 'error').map((t) => t.id)
ids.forEach((id) => {
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
}, TOAST_LEAVE_MS)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notificationsEnabled])
const remove = React.useCallback(
(id: string) => {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
}, TOAST_LEAVE_MS)
},
[clearTimersFor]
)
const clear = React.useCallback(() => {
setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
Object.keys(autoCloseTimersRef.current).forEach((id) => {
window.clearTimeout(autoCloseTimersRef.current[id])
delete autoCloseTimersRef.current[id]
})
Object.keys(finalizeRemoveTimersRef.current).forEach((id) => {
window.clearTimeout(finalizeRemoveTimersRef.current[id])
delete finalizeRemoveTimersRef.current[id]
})
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
}, [])
const startAutoCloseTimer = React.useCallback(
(id: string, ms: number) => {
if (!ms || ms <= 0) return
// alten Timer sicher entfernen
const old = autoCloseTimersRef.current[id]
if (old) {
window.clearTimeout(old)
delete autoCloseTimersRef.current[id]
}
}, [])
React.useEffect(() => {
// initial laden
loadNotificationSetting()
toastStartedAtRef.current[id] = Date.now()
toastRemainingMsRef.current[id] = ms
toastPausedRef.current[id] = false
// nach "Speichern" in Settings neu laden
const onUpdated = () => loadNotificationSetting()
window.addEventListener('recorder-settings-updated', onUpdated)
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
}, [loadNotificationSetting])
autoCloseTimersRef.current[id] = window.setTimeout(() => {
remove(id)
delete autoCloseTimersRef.current[id]
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, ms)
},
[remove]
)
// optional: wenn deaktiviert, alle aktuellen Toasts ausblenden
React.useEffect(() => {
if (!notificationsEnabled) {
// ✅ Nur nicht-Fehler ausblenden, Fehler dürfen bleiben
setToasts((prev) => prev.filter((t) => t.type === 'error'))
const push = React.useCallback(
(t: Omit<Toast, 'id'>) => {
if (!notificationsEnabled && t.type !== 'error') return ''
const id = uid()
const durationMs = t.durationMs ?? defaultDurationMs
setToasts((prev) => {
const next: ToastInternal[] = [{ ...t, id, durationMs, open: true }, ...prev]
const limit = Math.max(1, maxToasts)
const kept = next.slice(0, limit)
const dropped = next.slice(limit)
dropped.forEach((d) => clearTimersFor(d.id))
return kept
})
if (durationMs && durationMs > 0) {
startAutoCloseTimer(id, durationMs)
}
}, [notificationsEnabled])
const remove = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return id
},
[defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
)
const clear = React.useCallback(() => setToasts([]), [])
const pauseAutoCloseTimer = React.useCallback((id: string) => {
if (toastPausedRef.current[id]) return
const push = React.useCallback(
(t: Omit<Toast, 'id'>) => {
// ✅ Errors IMMER zeigen, alles andere abhängig vom Toggle
if (!notificationsEnabled && t.type !== 'error') return ''
const timerId = autoCloseTimersRef.current[id]
if (!timerId) return
const id = uid()
const durationMs = t.durationMs ?? defaultDurationMs
window.clearTimeout(timerId)
delete autoCloseTimersRef.current[id]
setToasts((prev) => {
const next = [{ ...t, id, durationMs }, ...prev]
return next.slice(0, Math.max(1, maxToasts))
})
const startedAt = toastStartedAtRef.current[id] ?? Date.now()
const remaining = toastRemainingMsRef.current[id] ?? 0
const elapsed = Date.now() - startedAt
const nextRemaining = Math.max(0, remaining - elapsed)
if (durationMs && durationMs > 0) {
window.setTimeout(() => remove(id), durationMs)
toastRemainingMsRef.current[id] = nextRemaining
toastPausedRef.current[id] = true
}, [])
const resumeAutoCloseTimer = React.useCallback(
(id: string) => {
if (!toastPausedRef.current[id]) return
const remaining = toastRemainingMsRef.current[id] ?? 0
if (remaining <= 0) {
remove(id)
return
}
toastPausedRef.current[id] = false
toastStartedAtRef.current[id] = Date.now()
autoCloseTimersRef.current[id] = window.setTimeout(() => {
remove(id)
delete autoCloseTimersRef.current[id]
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, remaining)
},
[remove]
)
React.useEffect(() => {
return () => {
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
}
}, [])
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls =
position === 'top-right'
? 'items-start sm:items-start sm:justify-start'
: position === 'top-left'
? 'items-start sm:items-start sm:justify-start'
: position === 'bottom-left'
? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end'
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
return (
<ToastContext.Provider value={ctx}>
{children}
<style>{`
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
return id
},
[defaultDurationMs, maxToasts, remove, notificationsEnabled]
)
.toast-progress-bar {
animation-name: toast-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
transform-origin: left center;
will-change: transform;
}
`}</style>
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
{/* Live region */}
<div
aria-live="assertive"
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
>
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
{toasts.map((t) => {
const { Icon, cls } = iconFor(t.type)
const accents = accentFor(t.type)
const title = (t.title || '').trim() || titleDefault(t.type)
const msg = (t.message || '').trim()
const img = (t.imageUrl || '').trim()
const imgAlt = (t.imageAlt || title).trim()
const isClickable = typeof t.onClick === 'function'
const posCls =
position === 'top-right'
? 'items-start sm:items-start sm:justify-start'
: position === 'top-left'
? 'items-start sm:items-start sm:justify-start'
: position === 'bottom-left'
? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end'
const alignCls =
position.endsWith('left')
? 'sm:items-start'
: 'sm:items-end'
const insetCls =
position.startsWith('top')
? 'top-0 bottom-auto'
: 'bottom-0 top-auto'
return (
<ToastContext.Provider value={ctx}>
{children}
{/* Live region */}
<div
aria-live="assertive"
className={[
'pointer-events-none fixed z-[80] inset-x-0',
insetCls,
].join(' ')}
>
<div className={['flex w-full px-4 py-6 sm:p-6', posCls].join(' ')}>
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}>
{toasts.map((t) => {
const { Icon, cls } = iconFor(t.type)
const title = (t.title || '').trim() || titleDefault(t.type)
const msg = (t.message || '').trim()
const img = (t.imageUrl || '').trim()
const imgAlt = (t.imageAlt || title).trim()
return (
<Transition key={t.id} appear show={true}>
<div
className={[
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl',
'border bg-white/90 shadow-lg backdrop-blur',
'outline-1 outline-black/5',
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10',
borderFor(t.type),
// animation classes (headlessui v2 data-*)
'transition data-closed:opacity-0 data-enter:transform data-enter:duration-200 data-enter:ease-out',
'data-closed:data-enter:translate-y-2 sm:data-closed:data-enter:translate-y-0',
position.endsWith('right')
? 'sm:data-closed:data-enter:translate-x-2'
: 'sm:data-closed:data-enter:-translate-x-2',
].join(' ')}
>
<div className="p-4">
<div className="flex items-start gap-3">
{img ? (
<div className="shrink-0">
<img
src={img}
alt={imgAlt}
loading="lazy"
referrerPolicy="no-referrer"
className={[
'h-12 w-12 rounded-lg object-cover',
'ring-1 ring-black/10 dark:ring-white/10',
].join(' ')}
/>
</div>
) : (
return (
<Transition
key={t.id}
appear
show={t.open}
enter="transform transition ease-out duration-250"
enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transform transition ease-in duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
>
<div
role={isClickable ? 'button' : 'status'}
className={[
'pointer-events-auto relative w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl',
'border bg-white dark:bg-slate-900',
'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5',
isClickable
? 'cursor-pointer transition-[box-shadow,transform,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10 active:scale-[0.998]'
: '',
borderFor(t.type),
].join(' ')}
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
onFocus={() => pauseAutoCloseTimer(t.id)}
onBlur={() => resumeAutoCloseTimer(t.id)}
onClick={() => {
if (!t.onClick) return
t.onClick()
if (t.closeOnClick !== false) remove(t.id)
}}
onKeyDown={(e) => {
if (!t.onClick) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
t.onClick()
if (t.closeOnClick !== false) remove(t.id)
}
}}
tabIndex={isClickable ? 0 : undefined}
aria-label={isClickable ? `${title} öffnen` : undefined}
>
{img ? (
// ===== Layout MIT Bild: Bild links edge-to-edge (oben/unten ohne Padding) =====
<div className="relative pr-12 sm:pr-14">
<div className="flex items-stretch">
{/* Bild links: edge-to-edge */}
<div className="shrink-0">
<Icon className={['size-6', cls].join(' ')} aria-hidden="true" />
<img
src={img}
alt={imgAlt}
loading="lazy"
referrerPolicy="no-referrer"
className={[
// größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
'h-full min-h-[72px] w-16 sm:w-[72px]',
'object-cover object-center',
// nur links runden, damit es in den Toast passt
'rounded-l-xl',
// leichte Trennlinie rechts
'border-r border-black/5 dark:border-white/10',
'bg-gray-100 dark:bg-white/5',
].join(' ')}
/>
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{/* Textbereich mit Padding */}
<div className="min-w-0 flex-1 py-3 pl-3 pr-2 sm:py-3.5 sm:pl-3.5 sm:pr-3">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 break-words">
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
{msg}
</p>
) : null}
</div>
<button
type="button"
onClick={() => remove(t.id)}
className="shrink-0 rounded-md text-gray-400 hover:text-gray-600 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:hover:text-white dark:focus:outline-indigo-500"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</button>
</div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div>
</div>
</Transition>
)
})}
</div>
) : (
// ===== Layout OHNE Bild: Icon mittig wie vorher =====
<div className="relative pr-12 py-2.5 sm:pr-14">
<div className="flex items-center gap-3 px-3 sm:px-3.5">
<div className="shrink-0">
<div
className={[
'inline-flex h-12 w-12 sm:h-14 sm:w-14 items-center justify-center rounded-full',
accents.iconBg,
'ring-1 ring-black/5 dark:ring-white/10',
].join(' ')}
>
<Icon className={['size-5.5 sm:size-6', cls].join(' ')} aria-hidden="true" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
{msg}
</p>
) : null}
</div>
</div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div>
)}
</div>
</Transition>
)
})}
</div>
</div>
</ToastContext.Provider>
)
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = React.useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}
}