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 --- // --- SSE subscribers für Autostart-State ---
var autostartSubsMu sync.Mutex 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() autostartSubsMu.Lock()
defer autostartSubsMu.Unlock() defer autostartSubsMu.Unlock()
for ch := range autostartSubs { 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 { select {
case ch <- paused: case ch <- state:
default: default:
} }
} }
@ -40,8 +49,19 @@ func isAutostartPaused() bool {
return atomic.LoadInt32(&diskEmergency) == 1 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) { func setAutostartPaused(v bool) {
old := isAutostartPaused() old := getAutostartStatePayload()
if v { if v {
atomic.StoreInt32(&autostartPaused, 1) atomic.StoreInt32(&autostartPaused, 1)
@ -49,9 +69,11 @@ func setAutostartPaused(v bool) {
atomic.StoreInt32(&autostartPaused, 0) atomic.StoreInt32(&autostartPaused, 0)
} }
// nur wenn sich der Zustand wirklich geändert hat -> pushen newState := getAutostartStatePayload()
if old != v {
broadcastAutostartPaused(v) // 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) { func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
w.Header().Set("Content-Type", "application/json") writeAutostartState(w)
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return return
case http.MethodPost: case http.MethodPost:
@ -96,11 +114,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
setAutostartPaused(*val) setAutostartPaused(*val)
w.Header().Set("Content-Type", "application/json") writeAutostartState(w)
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return return
default: default:
@ -112,9 +126,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
func writeAutostartState(w http.ResponseWriter) { func writeAutostartState(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(getAutostartStatePayload())
"paused": isAutostartPaused(),
})
} }
// GET /api/autostart/state // GET /api/autostart/state
@ -149,6 +161,13 @@ func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
writeAutostartState(w) writeAutostartState(w)
return return
case http.MethodPost: 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) setAutostartPaused(false)
writeAutostartState(w) writeAutostartState(w)
return return
@ -176,7 +195,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
ch := make(chan bool, 1) ch := make(chan autostartStatePayload, 1)
// subscribe // subscribe
autostartSubsMu.Lock() autostartSubsMu.Lock()
@ -191,9 +210,11 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
close(ch) close(ch)
}() }()
send := func(paused bool) { send := func(state autostartStatePayload) {
payload := map[string]any{ payload := map[string]any{
"paused": paused, "paused": state.Paused,
"pausedByUser": state.PausedByUser,
"pausedByDisk": state.PausedByDisk,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "ts": time.Now().UTC().Format(time.RFC3339Nano),
} }
@ -206,7 +227,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
} }
// initial state sofort senden // initial state sofort senden
send(isAutostartPaused()) send(getAutostartStatePayload())
ctx := r.Context() ctx := r.Context()
hb := time.NewTicker(15 * time.Second) 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) 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) const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds: // 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. // Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 { func sumInFlightBytes() uint64 {
var sum uint64 var sum uint64
minKeepBytes := minRelevantInFlightBytes()
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock() defer jobsMu.Unlock()
@ -219,10 +237,19 @@ func sumInFlightBytes() uint64 {
continue continue
} }
// Nimm die Datei, die gerade wächst. b := inFlightBytesForJob(j)
// 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. // ✅ Nur "relevante" Dateien berücksichtigen:
sum += inFlightBytesForJob(j) // 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 return sum
@ -230,8 +257,11 @@ func sumInFlightBytes() uint64 {
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser. // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz: // Bei wenig freiem Platz:
// - Autostart pausieren // - diskEmergency aktivieren (Autostart blockieren)
// - laufende Jobs stoppen (nur Status=running und Phase leer) // - laufende Jobs stoppen
//
// Bei Erholung (Resume-Schwelle):
// - diskEmergency automatisch wieder freigeben
func startDiskSpaceGuard() { func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval) t := time.NewTicker(diskGuardInterval)
defer t.Stop() defer t.Stop()
@ -259,29 +289,44 @@ func startDiskSpaceGuard() {
// Pause = ceil((2 * inFlight) / GiB) // Pause = ceil((2 * inFlight) / GiB)
// Resume = Pause + 3 GB // Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes // 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. // ✅ diskEmergency NICHT sticky behalten.
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.) // 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 { if inFlight == 0 {
// Kein Auto-Recovery: if wasEmergency {
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird. 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 continue
} }
// Wenn Emergency aktiv ist, niemals automatisch freigeben. isLowForPause := free < pauseNeed
// (Manueller Reset erforderlich) isHighEnoughForResume := free >= resumeNeed
if atomic.LoadInt32(&diskEmergency) == 1 {
if !wasEmergency {
// Normalzustand: nur triggern, wenn unter Pause-Schwelle
if !isLowForPause {
continue continue
} }
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
if free >= pauseNeed {
continue
}
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
atomic.StoreInt32(&diskEmergency, 1) atomic.StoreInt32(&diskEmergency, 1)
broadcastAutostartPaused()
fmt.Printf( 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", "🛑 [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(free)), free,
@ -295,5 +340,22 @@ func startDiskSpaceGuard() {
if stopped > 0 { if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped) fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
} }
continue
}
// ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist
if isHighEnoughForResume {
atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused()
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 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) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE // Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete { 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) // (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json)
// move mit retry (Windows file-lock robust) // 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) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict) http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return return
@ -2097,7 +2129,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2199,7 +2231,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2337,7 +2369,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
} }
// rename mit retry (Windows file-lock) // 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) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2436,7 +2468,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict) http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
return 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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script> <script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css"> <link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -410,11 +410,6 @@ export default function App() {
const donePrefetchRef = useRef<DonePrefetch | null>(null) const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false) 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 makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const prefetchDonePage = useCallback(async (pageToFetch: number) => { const prefetchDonePage = useCallback(async (pageToFetch: number) => {
@ -450,8 +445,16 @@ export default function App() {
}, [doneSort]) }, [doneSort])
const loadDoneCount = useCallback(async () => { 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 { try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any }) const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
if (!res.ok) return if (!res.ok) return
@ -463,9 +466,14 @@ export default function App() {
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(Date.now())
} catch { } catch {
// ignore // ignore
} finally {
doneCountInFlightRef.current = false
} }
}, []) }, [])
const doneCountInFlightRef = useRef(false)
const doneCountLastAtRef = useRef(0)
// ✅ sagt FinishedDownloads: "bitte ALL neu laden" // ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null) const finishedReloadTimerRef = useRef<number | null>(null)
@ -1236,80 +1244,6 @@ export default function App() {
} }
}, [loadDoneCount]) }, [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(() => { useEffect(() => {
if (selectedTab !== 'finished') return if (selectedTab !== 'finished') return
@ -1498,19 +1432,18 @@ export default function App() {
const e = ev as CustomEvent<{ delta?: number }> const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0) const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) { if (Number.isFinite(delta) && delta !== 0) {
void loadDoneCount() setDoneCount((c) => Math.max(0, c + delta))
requestFinishedReload()
return
} }
// ✅ Tabs sofort updaten (optimistisch) // Count darf immer aktualisiert werden (Badge/Header)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach server-truth holen + ALL reload
void loadDoneCount() void loadDoneCount()
// ✅ Nur Finished-Tab wirklich neu laden
if (selectedTabRef.current === 'finished') {
requestFinishedReload() requestFinishedReload()
} }
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener) window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
@ -1570,31 +1503,67 @@ export default function App() {
[startUrl, notify] [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( const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => { async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
try { try {
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt const data = await runFinishedFileAction<{ undoToken?: string }>({
const data = await apiJSON<{ undoToken?: string }>( kind: 'delete',
file,
run: () =>
apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`, `/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' } { method: 'POST' }
) ),
onSuccess: async () => {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
window.setTimeout(() => { window.setTimeout(() => {
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
setDoneJobs((prev) => { setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file) const filtered = prev.filter((j) => baseName(j.output || '') !== file)
// ✅ sofort auffüllen, wenn wir Platz haben
const need = DONE_PAGE_SIZE - filtered.length const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered if (need <= 0) return filtered
@ -1616,78 +1585,78 @@ export default function App() {
next.push(cand) next.push(cand)
} }
// buffer zurückschreiben (mit verkürzter items-Liste)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts } donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next return next
}) })
// ✅ Count sofort optimistisch runter // ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1)) setDoneCount((c) => Math.max(0, c - 1))
// ✅ Player / jobs cleanup wie bei dir // ✅ Running-/Player-State bereinigen (falls offen)
setJobs((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)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background) // ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
}, 320) }, 320)
},
onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
},
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {} // ✅ kein null mehr return undoToken ? { undoToken } : {}
} catch (e: any) { } catch {
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 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( const handleToggleHot = useCallback(
async (job: RecordJob) => { async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
try { try {
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird // ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) 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)) await new Promise((r) => window.setTimeout(r, 60))
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
@ -1695,30 +1664,38 @@ export default function App() {
{ method: 'POST' } { 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( window.dispatchEvent(
new CustomEvent('finished-downloads:rename', { 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 // ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev)) 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) => setDoneJobs((prev) =>
prev.map((j) => { 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 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) => setJobs((prev) =>
prev.map((j) => { 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 return match ? { ...j, output: apply(j.output || '') } : j
}) })
) )
@ -1732,6 +1709,13 @@ export default function App() {
[notify] [notify]
) )
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
// --- flags patch (wie bei dir) --- // --- flags patch (wie bei dir) ---
async function patchModelFlags(patch: any): Promise<any | null> { async function patchModelFlags(patch: any): Promise<any | null> {
const res = await fetch('/api/models/flags', { const res = await fetch('/api/models/flags', {
@ -2471,7 +2455,7 @@ export default function App() {
// ✅ war irgendwann schon mal online (vor diesem Poll)? // ✅ war irgendwann schon mal online (vor diesem Poll)?
const hadEverBeenOnline = Boolean(everOnline[keyLower]) 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() const imageUrl = String((room as any)?.image_url ?? '').trim()
// immer merken: jetzt ist es online // immer merken: jetzt ist es online
@ -2481,10 +2465,17 @@ export default function App() {
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow) const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
if (becamePublicFromWaiting) { if (becamePublicFromWaiting) {
if (notificationsOn) { if (notificationsOn) {
notify.info(name, 'ist wieder online.', { notify.info(modelName, 'ist wieder online.', {
imageUrl, imageUrl,
imageAlt: `${name} Vorschau`, imageAlt: `${modelName} Vorschau`,
durationMs: 5500, durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
}) })
} }
@ -2500,12 +2491,19 @@ export default function App() {
// Startup-Spam vermeiden // Startup-Spam vermeiden
if (notificationsOn && !isInitial) { if (notificationsOn && !isInitial) {
notify.info( notify.info(
name, modelName,
cameBackFromOffline ? 'ist wieder online.' : 'ist online.', cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
{ {
imageUrl, imageUrl,
imageAlt: `${name} Vorschau`, imageAlt: `${modelName} Vorschau`,
durationMs: 5500, durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
} }
) )
} }
@ -2673,7 +2671,7 @@ export default function App() {
<div className="relative"> <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"> <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="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="min-w-0"> <div className="min-w-0">
@ -2813,7 +2811,7 @@ export default function App() {
</header> </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="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" /> <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div> </div>
</div> </div>
@ -2849,6 +2847,7 @@ export default function App() {
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch} onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)} teaserAudio={Boolean(recSettings.teaserAudio)}

View File

@ -43,7 +43,14 @@ export default function ButtonGroup({
const s = sizeMap[size] const s = sizeMap[size]
return ( 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) => { {items.map((it, idx) => {
const active = it.id === value const active = it.id === value
const isFirst = idx === 0 const isFirst = idx === 0
@ -59,16 +66,14 @@ export default function ButtonGroup({
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors', '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', isFirst && 'rounded-l-md',
isLast && 'rounded-r-md', isLast && 'rounded-r-md',
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white) // Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
active active
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' + ? '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'
'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 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
: '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',
// Disabled // Disabled
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',

View File

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

View File

@ -57,6 +57,7 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
doneTotal: number doneTotal: number
page: number page: number
pageSize: number pageSize: number
@ -195,6 +196,112 @@ const sizeBytesOf = (job: RecordJob): number | null => {
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 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({ export default function FinishedDownloads({
jobs, jobs,
doneJobs, doneJobs,
@ -207,6 +314,7 @@ export default function FinishedDownloads({
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
onKeepJob,
doneTotal, doneTotal,
page, page,
pageSize, pageSize,
@ -226,6 +334,8 @@ export default function FinishedDownloads({
const notify = useNotify() const notify = useNotify()
const mutationQueue = useMutationQueue()
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map()) const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null) const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = 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 [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false) const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { 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 effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback( const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => { 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 { try {
const res = await fetch( const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`, `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
@ -405,7 +523,7 @@ export default function FinishedDownloads({
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length) setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally { } finally {
setIsLoading(false) if (shouldShowLoading) setIsLoading(false)
} }
}, },
[sortMode, includeKeep] [sortMode, includeKeep]
@ -458,6 +576,12 @@ export default function FinishedDownloads({
const finishRefill = () => { const finishRefill = () => {
refillInFlightRef.current = false 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 // ✅ Refill läuft
@ -966,149 +1090,296 @@ export default function FinishedDownloads({
const releasePlayingFile = useCallback( const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => { async (file: string, opts?: { close?: boolean }) => {
// 1) App-/Overlay-Player freigeben
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) { if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } })) 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( const deleteVideo = useCallback(
async (job: RecordJob): Promise<boolean> => { async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
const key = keyFor(job) const key = keyFor(job)
if (!file) { const res = await runFileMutation({
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden kann nicht löschen.') kind: 'delete',
return false job,
} file,
if (deletingKeys.has(key)) return false rowKey: key,
setBusy: (v) => markDeleting(key, v),
markDeleting(key, true) isBusyNow: () => deletingKeys.has(key),
try { optimisticRemove: true,
await releasePlayingFile(file, { close: true }) alreadyRemoved: opts?.alreadyRemoved,
labels: {
// ✅ Wenn App-Handler vorhanden: den benutzen invalidTitle: 'Löschen nicht möglich',
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben) invalidBody: 'Kein Dateiname gefunden kann nicht löschen.',
inUseTitle: 'Löschen fehlgeschlagen',
failTitle: 'Löschen fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onDeleteJob) { if (onDeleteJob) {
const r = await onDeleteJob(job) return await withFileReleaseRetry(
const undoToken = (r as any)?.undoToken file,
async () => await onDeleteJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
if (typeof undoToken === 'string' && undoToken) { 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 }) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else { } else {
setLastAction(null) setLastAction(null)
// optional: nicht als "error" melden, eher info/warn }
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.') 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() queueRefill()
emitCountHint(-1) emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok: },
// queueRefill() })
return true return res.ok
}
// 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)
}
}, },
[ [
baseName,
keyFor,
deletingKeys, deletingKeys,
markDeleting, markDeleting,
releasePlayingFile,
onDeleteJob, onDeleteJob,
animateRemove, withFileReleaseRetry,
notify, runFileMutation,
restoreRow,
queueRefill, queueRefill,
emitCountHint, emitCountHint,
] ]
) )
const keepVideo = useCallback( const keepVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
const key = keyFor(job) const key = keyFor(job)
if (!file) { const res = await runFileMutation({
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden kann nicht behalten.') kind: 'keep',
return false job,
} file,
if (keepingKeys.has(key) || deletingKeys.has(key)) return false rowKey: key,
setBusy: (v) => markKeeping(key, v),
markKeeping(key, true) isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
try { optimisticRemove: true,
await releasePlayingFile(file, { close: true }) alreadyRemoved: opts?.alreadyRemoved,
labels: {
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }) invalidTitle: 'Keep nicht möglich',
if (!res.ok) { invalidBody: 'Kein Dateiname gefunden kann nicht behalten.',
const text = await res.text().catch(() => '') inUseTitle: 'Keep fehlgeschlagen',
throw new Error(text || `HTTP ${res.status}`) failTitle: 'Keep fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onKeepJob) {
return await withFileReleaseRetry(
file,
async () => await onKeepJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
} }
// ✅ Backend liefert ggf. newFile (uniqueDestPath) const r = await withFileReleaseRetry(
const data = (await res.json().catch(() => null)) as any 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 keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
// ✅ Undo-Info merken
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key }) 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() queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1) emitCountHint(includeKeep ? 0 : -1)
},
})
return true return res.ok
} catch (e: any) {
notify.error('Keep fehlgeschlagen', file)
return false
} finally {
markKeeping(key, false)
}
}, },
[ [
baseName,
keyFor,
markKeeping,
keepingKeys, keepingKeys,
deletingKeys, deletingKeys,
markKeeping, withFileReleaseRetry,
releasePlayingFile, runFileMutation,
animateRemove,
notify,
queueRefill, queueRefill,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
@ -1250,86 +1521,185 @@ export default function FinishedDownloads({
applyRename, 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( const toggleHotVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob): Promise<void> => {
const currentFile = baseName(job.output || '') const currentFile = baseName(job.output || '')
if (!currentFile) { const key = keyFor(job)
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
// genau "HOT " Prefix
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`) 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 oldFile = currentFile
const optimisticNew = toggledName(oldFile) const optimisticNew = toggledName(oldFile)
// Optimistik sofort anwenden (UI snappy) 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) applyRename(oldFile, optimisticNew)
try {
await releasePlayingFile(oldFile, { close: true })
// ✅ 1) Wenn du einen externen Handler hast:
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
if (onToggleHot) { if (onToggleHot) {
const r = (await onToggleHot(job)) as any const r = await onToggleHot(job)
return r as any
// 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)
// ✅ Undo erst jetzt setzen (nach Erfolg)
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
return
} }
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!) const r = await withFileReleaseRetry(
const res = await fetch( oldFile,
async () =>
await fetchWithTextError(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`, `/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ method: 'POST' } { method: 'POST' }
),
{ close: true, attempts: 4, baseDelayMs: 220 }
) )
if (!res.ok) { return (await r.json().catch(() => null)) as any
const text = await res.text().catch(() => '') },
throw new Error(text || `HTTP ${res.status}`) onSuccess: async (data: any) => {
}
const data = (await res.json().catch(() => null)) as any
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew 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 && apiOld !== apiNew) {
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew) applyRename(apiOld, apiNew)
}
// ✅ Undo nach Erfolg
setLastAction({ kind: 'hot', currentFile: apiNew }) setLastAction({ kind: 'hot', currentFile: apiNew })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill() 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( const applyRenamedOutput = useCallback(
(job: RecordJob): RecordJob => { (job: RecordJob): RecordJob => {
const out = norm(job.output || '') const out = norm(job.output || '')
@ -1415,9 +1785,7 @@ export default function FinishedDownloads({
} }
if (detail.phase === 'success') { if (detail.phase === 'success') {
// delete final bestätigt
markDeleting(key, false) markDeleting(key, false)
queueRefill()
return return
} }
} }
@ -1428,13 +1796,18 @@ export default function FinishedDownloads({
useEffect(() => { useEffect(() => {
const onReload = () => { 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() queueRefill()
} }
window.addEventListener('finished-downloads:reload', onReload as any) window.addEventListener('finished-downloads:reload', onReload as EventListener)
return () => window.removeEventListener('finished-downloads:reload', onReload as any) return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
}, [queueRefill /* oder fetchAllDoneJobs */]) }, [queueRefill])
useEffect(() => { useEffect(() => {
const onExternalRename = (ev: Event) => { const onExternalRename = (ev: Event) => {
@ -1634,9 +2007,12 @@ export default function FinishedDownloads({
// ✅ Hooks immer zuerst unabhängig von rows // ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)') 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(() => { useEffect(() => {
if (!isSmall) return if (!isSmall || view !== 'cards') return
if (view !== 'cards') return
const top = pageRows[0] const top = pageRows[0]
if (!top) { if (!top) {
@ -1645,7 +2021,15 @@ export default function FinishedDownloads({
} }
const topKey = keyFor(top) const topKey = keyFor(top)
// ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card
setTeaserKey((prev) => (prev === topKey ? prev : null))
const t = window.setTimeout(() => {
setTeaserKey((prev) => (prev === topKey ? prev : topKey)) setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, 140) // 100180ms testen
return () => window.clearTimeout(t)
}, [isSmall, view, pageRows, keyFor]) }, [isSmall, view, pageRows, keyFor])
useEffect(() => { useEffect(() => {
@ -1763,9 +2147,8 @@ export default function FinishedDownloads({
{/* Views */} {/* Views */}
<Button <Button
size={isSmall ? 'sm' : 'md'} size='md'
variant="soft" variant="soft"
className={isSmall ? 'h-9' : 'h-10'}
disabled={!lastAction || undoing} disabled={!lastAction || undoing}
onClick={undoLastAction} onClick={undoLastAction}
title={ title={
@ -2023,7 +2406,7 @@ export default function FinishedDownloads({
) : ( ) : (
<> <>
{view === 'cards' && ( {view === 'cards' && (
<div className={isSmall ? 'mt-8' : ''}> <div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView <FinishedDownloadsCardsView
rows={pageRows} rows={pageRows}
isSmall={isSmall} isSmall={isSmall}
@ -2067,6 +2450,9 @@ export default function FinishedDownloads({
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
</div> </div>
)} )}
@ -2112,6 +2498,9 @@ export default function FinishedDownloads({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
)} )}
@ -2150,6 +2539,9 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
)} )}

View File

@ -84,6 +84,10 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (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[] => { const parseTags = (raw?: string): string[] => {
@ -157,6 +161,72 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows] 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({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
@ -204,6 +274,10 @@ export default function FinishedDownloadsCardsView({
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
const parseMeta = React.useCallback((j: RecordJob): any | null => { const parseMeta = React.useCallback((j: RecordJob): any | null => {
@ -324,6 +398,10 @@ export default function FinishedDownloadsCardsView({
isDecorative?: boolean isDecorative?: boolean
forceLoadStill?: boolean forceLoadStill?: boolean
mobileStackTopOnlyVideo?: boolean mobileStackTopOnlyVideo?: boolean
disableScrubber?: boolean
blur?: boolean
animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean
} }
) => { ) => {
const k = keyFor(j) const k = keyFor(j)
@ -346,7 +424,7 @@ export default function FinishedDownloadsCardsView({
opts?.forceStill opts?.forceStill
? false ? false
: opts?.mobileStackTopOnlyVideo : opts?.mobileStackTopOnlyVideo
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false)) ? (teaserPlayback === 'still' ? false : true)
: (teaserPlayback === 'all' : (teaserPlayback === 'all'
? true ? true
: teaserPlayback === 'hover' : teaserPlayback === 'hover'
@ -524,6 +602,10 @@ export default function FinishedDownloadsCardsView({
onOpenPlayer(j) onOpenPlayer(j)
} }
}} }}
>
<CardBlurWrapper
blurred={opts?.blur}
animateUnblurOnMount={opts?.animateUnblurOnMount}
> >
{/* Card shell keeps backgrounds consistent */} {/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent"> <Card noBodyPadding className="overflow-hidden bg-transparent">
@ -602,8 +684,16 @@ export default function FinishedDownloadsCardsView({
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill} alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall} teaserPreloadEnabled={
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'} opts?.mobileStackTopOnlyVideo
? true
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
}
teaserPreloadRootMargin={
opts?.preloadTeaserWhenStill
? '1200px 0px'
: (isSmall ? '900px 0px' : '700px 0px')
}
scrubProgressRatio={scrubProgressRatio} scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'} preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
/> />
@ -628,7 +718,7 @@ export default function FinishedDownloadsCardsView({
) : null} ) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */} {/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? ( {!opts?.isDecorative && !opts?.disableScrubber && !inlineActive && scrubberCount > 1 ? (
<div <div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150" className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -764,6 +854,7 @@ export default function FinishedDownloadsCardsView({
</div> </div>
</div> </div>
</Card> </Card>
</CardBlurWrapper>
</div> </div>
) )
@ -782,17 +873,17 @@ export default function FinishedDownloadsCardsView({
// Sichtbarer Stack bleibt bei 3 Karten // Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : [] const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile // ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
const mobileAllRows = isSmall ? rows : [] 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 // größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15 const stackPeekOffsetPx = 15
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
const stackTopGapPx = 24
// weil wir nach OBEN stacken, brauchen wir oben Platz // 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 ( return (
<div className="relative"> <div className="relative">
@ -809,46 +900,49 @@ export default function FinishedDownloadsCardsView({
})} })}
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? null : ( {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”) */} {/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div <div
className="relative overflow-visible" className="relative overflow-visible"
style={{ style={{
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined, paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
}} }}
> >
{mobileVisibleStackRows {(() => {
.map((j, idx) => { const visible = mobileVisibleStackRows
const isTop = idx === 0 const topRow = visible[0]
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem( const backRows = visible.slice(1)
j,
isTop return (
? { <>
forceLoadStill: true, {/* Hintere Karten zuerst (absolut, dekorativ) */}
mobileStackTopOnlyVideo: true, {backRows
} .map((j, backIdx) => {
: { const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true, forceStill: true,
disableInline: true, disableInline: true,
disablePreviewHover: true, disablePreviewHover: true,
isDecorative: true, isDecorative: true,
forceLoadStill: true, forceLoadStill: true,
} blur: true,
) preloadTeaserWhenStill: 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 depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14 const opacity = 1 - depth * 0.14
// untere Karten nur Deko (keine Interaktion)
if (!isTop) {
return ( return (
<div <div
key={k} key={k}
className="absolute inset-x-0 top-0 pointer-events-none" 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={{ style={{
zIndex: 20 - depth, zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`, transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
@ -859,24 +953,30 @@ export default function FinishedDownloadsCardsView({
> >
<div className="relative"> <div className="relative">
{cardInner} {cardInner}
{/* leichtes Frosting, damit klar ist: nur Vorschau */}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" /> <div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div> </div>
</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,
})
// oberste Karte: echte SwipeCard (wie bisher)
return ( return (
<div <div
key={k} key={k}
className="absolute inset-x-0 top-0" className="relative touch-pan-y"
style={{ style={{ zIndex: 30 }}
zIndex: 30,
transform: `translateY(${y}px) scale(${scale})`,
transformOrigin: 'top center',
}}
> >
<PromoteToFrontWrapper animateOnMount>
<SwipeCard <SwipeCard
ref={(h) => { ref={(h) => {
if (h) swipeRefs.current.set(k, h) if (h) swipeRefs.current.set(k, h)
@ -889,29 +989,46 @@ export default function FinishedDownloadsCardsView({
doubleTapMaxMovePx={48} doubleTapMaxMovePx={48}
onDoubleTap={async () => { onDoubleTap={async () => {
if (isHot) return if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j) await onToggleHot?.(j)
}} }}
onTap={() => { onTap={() => {
startInline(k) startInline(k)
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId)) if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
}) })
}} }}
onSwipeLeft={() => deleteVideo(j)} onSwipeLeft={() => {
onSwipeRight={() => keepVideo(j)} if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
> >
{cardInner} {cardInner}
</SwipeCard> </SwipeCard>
</PromoteToFrontWrapper>
</div> </div>
) )
}) })() : null}
.reverse() /* zuerst hinten rendern, oben zuletzt */} </>
)
})()}
</div> </div>
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */} {/* ✅ Hidden preloader (mobile): nur wenige nächste Cards, sonst UI träge */}
{mobileAllRows.length > mobileStackDepth ? ( {mobileStillPreloadRows.length > 0 ? (
<div className="sr-only" aria-hidden="true"> <div className="sr-only" aria-hidden="true">
{mobileAllRows.slice(mobileStackDepth).map((j) => { {mobileStillPreloadRows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
return ( return (
@ -922,9 +1039,9 @@ export default function FinishedDownloadsCardsView({
className="h-full w-full" className="h-full w-full"
showPopover={false} showPopover={false}
blur={Boolean(blurPreviews)} blur={Boolean(blurPreviews)}
// ✅ Nur Previewbild laden kein Teaser-Video
animated={false} animated={false}
teaserPreloadEnabled={true}
teaserPreloadRootMargin="1200px 0px"
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
inlineVideo={false} inlineVideo={false}
@ -932,27 +1049,14 @@ export default function FinishedDownloadsCardsView({
inlineLoop={false} inlineLoop={false}
muted={true} muted={true}
popoverMuted={true} popoverMuted={true}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
// ✅ Still immer laden
alwaysLoadStill alwaysLoadStill
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
teaserPreloadEnabled={false}
/> />
</div> </div>
) )
})} })}
</div> </div>
) : null} ) : 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>
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

@ -25,6 +25,16 @@ type ParsedModel = {
type ImportKind = 'liked' | 'favorite' type ImportKind = 'liked' | 'favorite'
type ModelsViewMode = 'table' | 'gallery' 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 = { export type StoredModel = {
id: string id: string
input: 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() } 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() { function GridIcon() {
return ( return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <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 [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({ const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
key: 'model', key: 'createdAt',
direction: 'asc', direction: 'desc',
}) })
const refreshVideoCounts = React.useCallback(async () => { 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', key: 'actions',
header: '', header: '',
@ -823,7 +901,7 @@ export default function ModelsTab() {
React.useEffect(() => { React.useEffect(() => {
setPage(1) setPage(1)
}, [q, tagFilter, viewMode]) }, [q, tagFilter, viewMode, sort])
const totalItems = sortedAll.length const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
@ -910,20 +988,155 @@ export default function ModelsTab() {
header={ header={
<div className="space-y-2"> <div className="space-y-2">
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between"> <div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white"> <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> Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
</div> </div>
<div className="sm:hidden"> {/* Mobile */}
<div className="sm:hidden shrink-0">
<Button variant="secondary" size="md" onClick={openImport}> <Button variant="secondary" size="md" onClick={openImport}>
Import Import
</Button> </Button>
</div> </div>
{/* Desktop */}
<div className="hidden sm:block shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</div>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="w-full sm:w-auto min-w-0">
<div className="flex items-center gap-2"> {/* 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="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>
{/* 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 <ButtonGroup
ariaLabel="Ansicht umschalten" ariaLabel="Ansicht umschalten"
size="lg" size="lg"
@ -944,12 +1157,6 @@ export default function ModelsTab() {
}, },
]} ]}
/> />
<div className="hidden sm:block">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</div>
</div> </div>
<input <input
@ -966,6 +1173,7 @@ export default function ModelsTab() {
/> />
</div> </div>
</div> </div>
</div>
{tagFilter.length > 0 ? ( {tagFilter.length > 0 ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -1042,7 +1250,7 @@ export default function ModelsTab() {
return ( return (
<div <div
key={m.id} 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 <div
className="relative cursor-pointer bg-slate-950" className="relative cursor-pointer bg-slate-950"
@ -1092,19 +1300,46 @@ export default function ModelsTab() {
{/* dunkler Verlauf unten für bessere Lesbarkeit */} {/* 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" /> <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) */} {/* 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"> <div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
<div <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} title={m.modelKey}
> >
{m.modelKey} {m.modelKey}
</div> </div>
{m.host ? ( {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} ) : null}
</div> </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 */} {/* oben links: Record Actions Overlay */}
<div <div
className={clsx( className={clsx(
@ -1123,15 +1358,16 @@ export default function ModelsTab() {
[&_button]:bg-transparent [&_button]:shadow-none [&_button]:bg-transparent [&_button]:shadow-none
[&_button:hover]:bg-white/10 [&_button:hover]:bg-white/10
[&_button]:border-0 [&_button]:border-0
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
" "
/> />
</div> </div>
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */} {/* 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 ? ( {watch ? (
<span <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" title="Beobachtet"
aria-hidden="true" aria-hidden="true"
> >
@ -1141,7 +1377,7 @@ export default function ModelsTab() {
{fav ? ( {fav ? (
<span <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" title="Favorit"
aria-hidden="true" aria-hidden="true"
> >
@ -1151,7 +1387,7 @@ export default function ModelsTab() {
{liked ? ( {liked ? (
<span <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" title="Gefällt mir"
aria-hidden="true" aria-hidden="true"
> >
@ -1164,12 +1400,12 @@ export default function ModelsTab() {
{(m.hot || m.keep) && ( {(m.hot || m.keep) && (
<div className="absolute left-2 top-2 flex flex-col gap-1"> <div className="absolute left-2 top-2 flex flex-col gap-1">
{m.hot ? ( {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 HOT
</span> </span>
) : null} ) : null}
{m.keep ? ( {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 KEEP
</span> </span>
) : null} ) : null}
@ -1179,41 +1415,22 @@ export default function ModelsTab() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2"> <div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
{/* Zeile 1: Stats links, Actions rechts */} {/* Mobile: kompakter, aber nicht gequetscht */}
<div className="flex items-center justify-between gap-2 text-[11px]"> <div className="sm:hidden">
<div className="flex items-center gap-1.5 min-w-0 text-slate-300"> {/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
<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 <div
className="flex items-center gap-1 shrink-0" className="grid grid-cols-3 gap-1.5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
>
<span
className={clsx(
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
)}
> >
<Button <Button
variant={watch ? 'soft' : 'secondary'} variant={watch ? 'soft' : 'secondary'}
size="xs" size="xs"
rounded="full"
className={clsx( className={clsx(
'h-7 w-7 p-0 min-w-0', 'h-8 min-w-0 px-0 shadow-none',
watch watch
? 'bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 shadow-none' ? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
: 'bg-white/5 text-indigo-200/80 shadow-none hover:bg-white/10 hover:text-indigo-200' : 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
@ -1221,27 +1438,21 @@ export default function ModelsTab() {
patch(m.id, { watched: !watch }) patch(m.id, { watched: !watch })
}} }}
> >
<span <span className="inline-flex items-center justify-center gap-1">
className={clsx( <span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
'text-sm leading-none',
watch ? 'text-indigo-300' : 'text-slate-300 group-hover:text-indigo-200'
)}
>
👁 👁
</span> </span>
</Button>
</span> </span>
</Button>
<Button <Button
variant={fav ? 'soft' : 'secondary'} variant={fav ? 'soft' : 'secondary'}
size="xs" size="xs"
rounded="full"
className={clsx( className={clsx(
'h-7 w-7 p-0 min-w-0', 'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !fav ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
fav fav
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 shadow-none' ? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
: 'bg-white/5 text-amber-200/80 shadow-none hover:bg-white/10 hover:text-amber-200' : 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1250,26 +1461,21 @@ export default function ModelsTab() {
else patch(m.id, { favorite: true, liked: false }) else patch(m.id, { favorite: true, liked: false })
}} }}
> >
<span <span className="inline-flex items-center justify-center gap-1">
className={clsx( <span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
'text-sm leading-none',
fav ? 'text-amber-300' : 'text-slate-300 group-hover:text-amber-200'
)}
>
</span> </span>
</span>
</Button> </Button>
<Button <Button
variant={liked ? 'soft' : 'secondary'} variant={liked ? 'soft' : 'secondary'}
size="xs" size="xs"
rounded="full"
className={clsx( className={clsx(
'h-7 w-7 p-0 min-w-0', 'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !liked ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
liked liked
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 shadow-none' ? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
: 'bg-white/5 text-rose-200/80 shadow-none hover:bg-white/10 hover:text-rose-200' : 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {
@ -1278,14 +1484,112 @@ export default function ModelsTab() {
else patch(m.id, { liked: true, favorite: false }) else patch(m.id, { liked: true, favorite: false })
}} }}
> >
<span <span className="inline-flex items-center justify-center gap-1">
className={clsx( <span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
'text-sm leading-none',
liked ? 'text-rose-300' : 'text-slate-300 group-hover:text-rose-200'
)}
>
</span> </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>
{/* 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> </Button>
</div> </div>
</div> </div>
@ -1308,6 +1612,7 @@ export default function ModelsTab() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
})} })}
</div> </div>

View File

@ -702,6 +702,7 @@ export default function Player({
const isDesktop = useMediaQuery('(min-width: 640px)') const isDesktop = useMediaQuery('(min-width: 640px)')
const miniDesktop = mini && isDesktop const miniDesktop = mini && isDesktop
const usePortal = expanded || miniDesktop
const WIN_KEY = 'player_window_v1' const WIN_KEY = 'player_window_v1'
@ -745,14 +746,18 @@ export default function Player({
React.useEffect(() => setMounted(true), []) React.useEffect(() => setMounted(true), [])
React.useEffect(() => { React.useEffect(() => {
if (!usePortal) {
setPortalTarget(null)
return
}
let el = document.getElementById('player-root') as HTMLElement | null let el = document.getElementById('player-root') as HTMLElement | null
if (!el) { if (!el) {
el = document.createElement('div') el = document.createElement('div')
el.id = 'player-root' el.id = 'player-root'
} }
// ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt // Desktop / Expanded: im Top-Layer (Dialog) oder body
// ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
let host: HTMLElement | null = null let host: HTMLElement | null = null
if (isDesktop) { if (isDesktop) {
@ -767,7 +772,7 @@ export default function Player({
el.style.zIndex = '2147483647' el.style.zIndex = '2147483647'
setPortalTarget(el) setPortalTarget(el)
}, [isDesktop]) }, [isDesktop, usePortal])
React.useEffect(() => { React.useEffect(() => {
const p: any = playerRef.current const p: any = playerRef.current
@ -1612,7 +1617,8 @@ export default function Player({
if (job.status !== 'running') setStopPending(false) if (job.status !== 'running') setStopPending(false)
}, [job.id, job.status]) }, [job.id, job.status])
if (!mounted || !portalTarget) return null if (!mounted) return null
if (usePortal && !portalTarget) return null
const overlayBtn = const overlayBtn =
'inline-flex items-center justify-center rounded-md p-2 transition ' + '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 } ? { left: win.x, top: win.y, width: win.w, height: win.h }
: undefined : undefined
return createPortal( const content = (
<> <>
<style>{` <style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */ /* Live-Download: Progress/Seek-Bar ausblenden */
@ -2234,7 +2240,12 @@ export default function Player({
{cardEl} {cardEl}
</div> </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 Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask' import Task from './Task'
import TaskList from './TaskList' import TaskList from './TaskList'
import type { TaskItem } from './TaskList' import type { TaskItem } from './TaskList'
@ -416,9 +416,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<div className="flex items-center justify-between gap-3"> <Task
<div className="min-w-0 flex-1"> title="Assets-Generator"
<GenerateAssetsTask 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} onFinished={onAssetsGenerated}
onStart={(ac) => { onStart={(ac) => {
assetsAbortRef.current = ac assetsAbortRef.current = ac
@ -472,20 +477,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
/> />
</div>
<div className="shrink-0 flex items-center gap-2"> <Task
<Button title="Aufräumen"
variant="secondary" description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
onClick={cleanupSmallDone} startLabel="Aufräumen"
disabled={saving || cleaning || !value.autoDeleteSmallDownloads} startingLabel="Läuft…"
className="h-9 px-3" onTrigger={cleanupSmallDone}
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)" busy={cleaning}
> disabled={saving || !value.autoDeleteSmallDownloads}
{cleaning ? '…' : 'Aufräumen'} onError={(message) => {
</Button> // Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
</div> setErr(message)
</div> }}
/>
</div> </div>
</div> </div>

View File

@ -197,15 +197,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dxRef.current = outDx dxRef.current = outDx
setDx(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) { if (runAction) {
try { actionPromise = Promise.resolve(
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft() dir === 'right' ? onSwipeRight() : onSwipeLeft()
} catch { ).catch(() => false)
ok = 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 // wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)

View File

@ -1,4 +1,4 @@
// frontend\src\components\ui\GenerateAssetsTask.tsx // frontend\src\components\ui\Task.tsx
'use client' 'use client'
@ -22,6 +22,23 @@ type TaskState = {
type Progress = { done: number; total: number; currentFile?: string } type Progress = { done: number; total: number; currentFile?: string }
type Props = { 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 onFinished?: () => void
onStart?: (ac: AbortController) => void onStart?: (ac: AbortController) => void
onProgress?: (p: Progress) => void onProgress?: (p: Progress) => void
@ -55,7 +72,17 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return data as 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, onFinished,
onStart, onStart,
onProgress, onProgress,
@ -87,10 +114,11 @@ export default function GenerateAssetsTask({
}, [onError]) }, [onError])
async function stopInternal() { async function stopInternal() {
if (!stopUrl) return
if (stopInFlightRef.current) return if (stopInFlightRef.current) return
stopInFlightRef.current = true stopInFlightRef.current = true
try { 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 { } catch {
// ignore // ignore
} finally { } finally {
@ -147,7 +175,9 @@ export default function GenerateAssetsTask({
// SSE: State + Progress nur nach oben (TaskList), kein UI hier // SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => { useEffect(() => {
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => { if (!sseUrl) return
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
setState(st) setState(st)
if (st?.running) { if (st?.running) {
@ -162,7 +192,7 @@ export default function GenerateAssetsTask({
const errText = String(st?.error ?? '').trim() 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) { if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText lastErrorRef.current = errText
onErrorRef.current?.(errText) onErrorRef.current?.(errText)
@ -170,9 +200,10 @@ export default function GenerateAssetsTask({
}) })
return () => unsub() return () => unsub()
}, []) }, [sseUrl])
async function start() { async function start() {
if (busy) return
if (state?.running) return if (state?.running) return
setStartError(null) setStartError(null)
@ -180,11 +211,34 @@ export default function GenerateAssetsTask({
cancelledRef.current = false cancelledRef.current = false
lastErrorRef.current = '' 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() const ac = ensureControllerCreated()
try { try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' }) const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
setState(st) setState(st)
// TaskList jetzt aktivieren // TaskList jetzt aktivieren
@ -215,10 +269,8 @@ export default function GenerateAssetsTask({
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</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"> <div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{description}</div>
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
</div>
{startError ? ( {startError ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200"> <div className="mt-2 text-xs text-red-700 dark:text-red-200">
@ -228,8 +280,8 @@ export default function GenerateAssetsTask({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Button variant="primary" onClick={start} disabled={starting || running}> <Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
{starting ? 'Starte…' : 'Start'} {(starting || busy) ? startingLabel : startLabel}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -22,6 +22,12 @@ export type Toast = {
imageUrl?: string imageUrl?: string
imageAlt?: string imageAlt?: string
durationMs?: number // auto close durationMs?: number // auto close
onClick?: () => void
closeOnClick?: boolean // default true (bei klickbaren Toasts)
}
type ToastInternal = Toast & {
open: boolean
} }
type ToastContextValue = { type ToastContextValue = {
@ -32,29 +38,64 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null) const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220
function iconFor(type: ToastType) { function iconFor(type: ToastType) {
switch (type) { switch (type) {
case 'success': case 'success':
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' } return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' }
case 'error': case 'error':
return { Icon: XCircleIcon, cls: 'text-rose-500' } return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' }
case 'warning': case 'warning':
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' } return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' }
default: default:
return { Icon: InformationCircleIcon, cls: 'text-sky-500' } return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' }
} }
} }
function borderFor(type: ToastType) { function borderFor(type: ToastType) {
switch (type) { switch (type) {
case 'success': case 'success':
return 'border-emerald-200/70 dark:border-emerald-400/20' return 'border-emerald-200/80 dark:border-emerald-400/20'
case 'error': case 'error':
return 'border-rose-200/70 dark:border-rose-400/20' return 'border-rose-200/80 dark:border-rose-400/20'
case 'warning': case 'warning':
return 'border-amber-200/70 dark:border-amber-400/20' return 'border-amber-200/80 dark:border-amber-400/20'
default: 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,9 +127,34 @@ export function ToastProvider({
defaultDurationMs?: number defaultDurationMs?: number
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left' position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
}) { }) {
const [toasts, setToasts] = React.useState<Toast[]>([]) const [toasts, setToasts] = React.useState<ToastInternal[]>([])
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true) const [notificationsEnabled, setNotificationsEnabled] = React.useState(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 () => { const loadNotificationSetting = React.useCallback(async () => {
try { try {
const r = await fetch('/api/settings', { cache: 'no-store' }) const r = await fetch('/api/settings', { cache: 'no-store' })
@ -101,51 +167,160 @@ export function ToastProvider({
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
// initial laden
loadNotificationSetting() loadNotificationSetting()
// nach "Speichern" in Settings neu laden
const onUpdated = () => loadNotificationSetting() const onUpdated = () => loadNotificationSetting()
window.addEventListener('recorder-settings-updated', onUpdated) window.addEventListener('recorder-settings-updated', onUpdated)
return () => window.removeEventListener('recorder-settings-updated', onUpdated) return () => window.removeEventListener('recorder-settings-updated', onUpdated)
}, [loadNotificationSetting]) }, [loadNotificationSetting])
// optional: wenn deaktiviert, alle aktuellen Toasts ausblenden
React.useEffect(() => { React.useEffect(() => {
if (!notificationsEnabled) { if (!notificationsEnabled) {
// ✅ Nur nicht-Fehler ausblenden, Fehler dürfen bleiben // Nur Fehler sichtbar lassen (animiert schließen)
setToasts((prev) => prev.filter((t) => t.type === 'error')) 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]) }, [notificationsEnabled])
const remove = React.useCallback((id: string) => { 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)) 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 clear = React.useCallback(() => setToasts([]), []) 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]
}
toastStartedAtRef.current[id] = Date.now()
toastRemainingMsRef.current[id] = ms
toastPausedRef.current[id] = false
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]
)
const push = React.useCallback( const push = React.useCallback(
(t: Omit<Toast, 'id'>) => { (t: Omit<Toast, 'id'>) => {
// ✅ Errors IMMER zeigen, alles andere abhängig vom Toggle
if (!notificationsEnabled && t.type !== 'error') return '' if (!notificationsEnabled && t.type !== 'error') return ''
const id = uid() const id = uid()
const durationMs = t.durationMs ?? defaultDurationMs const durationMs = t.durationMs ?? defaultDurationMs
setToasts((prev) => { setToasts((prev) => {
const next = [{ ...t, id, durationMs }, ...prev] const next: ToastInternal[] = [{ ...t, id, durationMs, open: true }, ...prev]
return next.slice(0, Math.max(1, maxToasts))
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) { if (durationMs && durationMs > 0) {
window.setTimeout(() => remove(id), durationMs) startAutoCloseTimer(id, durationMs)
} }
return id return id
}, },
[defaultDurationMs, maxToasts, remove, notificationsEnabled] [defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
) )
const pauseAutoCloseTimer = React.useCallback((id: string) => {
if (toastPausedRef.current[id]) return
const timerId = autoCloseTimersRef.current[id]
if (!timerId) return
window.clearTimeout(timerId)
delete autoCloseTimersRef.current[id]
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)
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 ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls = const posCls =
@ -157,57 +332,93 @@ export function ToastProvider({
? 'items-end sm:items-end sm:justify-end' ? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end' : 'items-end sm:items-end sm:justify-end'
const alignCls = const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
position.endsWith('left') const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
? 'sm:items-start'
: 'sm:items-end'
const insetCls =
position.startsWith('top')
? 'top-0 bottom-auto'
: 'bottom-0 top-auto'
return ( return (
<ToastContext.Provider value={ctx}> <ToastContext.Provider value={ctx}>
{children} {children}
<style>{`
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.toast-progress-bar {
animation-name: toast-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
transform-origin: left center;
will-change: transform;
}
`}</style>
{/* Live region */} {/* Live region */}
<div <div
aria-live="assertive" aria-live="assertive"
className={[ className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
'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 px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}> <div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
{toasts.map((t) => { {toasts.map((t) => {
const { Icon, cls } = iconFor(t.type) const { Icon, cls } = iconFor(t.type)
const accents = accentFor(t.type)
const title = (t.title || '').trim() || titleDefault(t.type) const title = (t.title || '').trim() || titleDefault(t.type)
const msg = (t.message || '').trim() const msg = (t.message || '').trim()
const img = (t.imageUrl || '').trim() const img = (t.imageUrl || '').trim()
const imgAlt = (t.imageAlt || title).trim() const imgAlt = (t.imageAlt || title).trim()
const isClickable = typeof t.onClick === 'function'
return ( return (
<Transition key={t.id} appear show={true}> <Transition
<div key={t.id}
className={[ appear
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl', show={t.open}
'border bg-white/90 shadow-lg backdrop-blur', enter="transform transition ease-out duration-250"
'outline-1 outline-black/5', enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10', enterTo="opacity-100 translate-y-0"
borderFor(t.type), leave="transform transition ease-in duration-200"
// animation classes (headlessui v2 data-*) leaveFrom="opacity-100 translate-y-0"
'transition data-closed:opacity-0 data-enter:transform data-enter:duration-200 data-enter:ease-out', leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
'data-closed:data-enter:translate-y-2 sm:data-closed:data-enter:translate-y-0', >
position.endsWith('right') <div
? 'sm:data-closed:data-enter:translate-x-2' role={isClickable ? 'button' : 'status'}
: 'sm:data-closed:data-enter:-translate-x-2', className={[
].join(' ')} '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}
> >
<div className="p-4">
<div className="flex items-start gap-3">
{img ? ( {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"> <div className="shrink-0">
<img <img
src={img} src={img}
@ -215,39 +426,109 @@ export function ToastProvider({
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className={[ className={[
'h-12 w-12 rounded-lg object-cover', // größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
'ring-1 ring-black/10 dark:ring-white/10', '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(' ')} ].join(' ')}
/> />
</div> </div>
) : (
<div className="shrink-0">
<Icon className={['size-6', cls].join(' ')} aria-hidden="true" />
</div>
)}
<div className="min-w-0 flex-1"> {/* Textbereich mit Padding */}
<p className="text-sm font-semibold text-gray-900 dark:text-white"> <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} {title}
</p> </p>
{msg ? ( {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} {msg}
</p> </p>
) : null} ) : null}
</div> </div>
</div>
{/* Close-Button vertikal mittig rechts */}
<button <button
type="button" type="button"
onClick={() => remove(t.id)} onClick={(e) => {
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" 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">Close</span> <span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" /> <XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button> </button>
</div> </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> </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> </Transition>
) )
})} })}