nsfwapp/frontend/src/components/ui/RecorderSettings.tsx
2026-02-25 15:00:33 +01:00

761 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// frontend\src\components\ui\RecorderSettings.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
import Task from './Task'
import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
type RecorderSettings = {
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
}
type DiskStatus = {
emergency: boolean
pauseGB: number
resumeGB: number
freeBytes: number
freeBytesHuman: string
recordPath?: string
}
const DEFAULTS: RecorderSettings = {
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: true,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
}
type Props = {
onAssetsGenerated?: () => void
}
function shortTaskFilename(name?: string, max = 52) {
const s = String(name ?? '').trim()
if (!s) return ''
if (s.length <= max) return s
return '…' + s.slice(-(max - 1))
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
// ✅ Tasklist (Assets generieren)
const assetsAbortRef = useRef<AbortController | null>(null)
const [assetsTask, setAssetsTask] = useState<TaskItem>({
id: 'generate-assets',
status: 'idle',
title: 'Assets generieren', // oder '' wenn du es komplett weg willst
text: '',
cancellable: true,
fading: false,
})
const [cleanupTask, setCleanupTask] = useState<TaskItem>({
id: 'cleanup',
status: 'idle',
title: 'Aufräumen',
text: '',
cancellable: false,
fading: false,
})
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
useEffect(() => {
let alive = true
fetch('/api/settings', { cache: 'no-store' })
.then(async (r) => {
if (!r.ok) throw new Error(await r.text())
return r.json()
})
.then((data: RecorderSettings) => {
if (!alive) return
setValue({
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
})
})
.catch(() => {
// backend evtl. noch alt -> defaults lassen
})
return () => {
alive = false
}
}, [])
useEffect(() => {
let alive = true
const load = async () => {
try {
const r = await fetch('/api/status/disk', { cache: 'no-store' })
if (!r.ok) return
const data = (await r.json()) as DiskStatus
if (alive) setDiskStatus(data)
} catch {
// ignorieren
}
}
load()
const t = window.setInterval(load, 5000) // alle 5s aktualisieren
return () => {
alive = false
window.clearInterval(t)
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)
setBrowsing(target)
try {
window.focus()
const res = await fetch(`/api/settings/browse?target=${target}`, { cache: 'no-store' })
if (res.status === 204) return // user cancelled
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = (await res.json()) as { path?: string }
const p = (data.path ?? '').trim()
if (!p) return
setValue((v) => {
if (target === 'record') return { ...v, recordDir: p }
if (target === 'done') return { ...v, doneDir: p }
return { ...v, ffmpegPath: p }
})
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setBrowsing(null)
}
}
async function save() {
setErr(null)
setMsg(null)
const recordDir = value.recordDir.trim()
const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim()
if (!recordDir || !doneDir) {
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
return
}
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
const autoDeleteSmallDownloadsBelowMB = Math.max(
0,
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
)
const blurPreviews = !!value.blurPreviews
const teaserPlayback =
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
? value.teaserPlayback
: DEFAULTS.teaserPlayback
const teaserAudio = !!value.teaserAudio
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
const enableNotifications = !!value.enableNotifications
setSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recordDir,
doneDir,
ffmpegPath,
autoAddToDownloadList,
autoStartAddedDownloads,
useChaturbateApi,
useMyFreeCamsWatcher,
autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB,
blurPreviews,
teaserPlayback,
teaserAudio,
lowDiskPauseBelowGB,
enableNotifications,
}),
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
setMsg('✅ Gespeichert.')
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setSaving(false)
}
}
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) {
window.setTimeout(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
setter((t) => ({ ...t, status: 'idle', text: '', err: undefined, done: 0, total: 0, fading: false }))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
if (!doneDir) {
setErr('doneDir ist leer.')
return
}
if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return
}
const ok = window.confirm(
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)
if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList)
setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen', // ✅ HINZUFÜGEN (reset)
text: 'Räume auf…',
err: undefined,
done: 0,
total: 1,
fading: false,
}))
try {
const res = await fetch('/api/settings/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = await res.json()
const scannedFiles = Number(data.scannedFiles ?? 0)
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
const orphansTotalRemoved = orphanRemoved + genRemoved
setCleanupTask((t) => ({
...t,
status: 'done',
done: 1,
total: 1,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
}))
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
} catch (e: any) {
const msg = e?.message ?? String(e)
setErr(msg)
setCleanupTask((t) => ({
...t,
status: 'error',
text: 'Fehler beim Aufräumen.',
err: msg,
}))
fadeOutTask(setCleanupTask)
} finally {
setCleaning(false)
}
}
async function cancelAssetsTask() {
const ac = assetsAbortRef.current
assetsAbortRef.current = null
// UI sofort
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
if (ac) {
ac.abort()
return
}
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
}
}
return (
<Card
header={
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks.
</div>
</div>
<Button variant="primary" onClick={save} disabled={saving}>
Speichern
</Button>
</div>
}
grayBody
>
<div className="space-y-4">
<TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask()
}}
/>
{/* Alerts */}
{err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{err}
</div>
)}
{msg && (
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
{msg}
</div>
)}
{/* Aufgaben */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
</div>
</div>
</div>
<div className="mt-3 space-y-3">
<Task
title="Assets-Generator"
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
startLabel="Start"
startingLabel="Starte…"
startUrl="/api/tasks/generate-assets"
stopUrl="/api/tasks/generate-assets"
sseUrl="/api/tasks/assets/stream"
onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: fn || '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Assets generieren',
text: 'Abgebrochen.',
}))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
title: 'Assets generieren',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
<Task
title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
startLabel="Aufräumen"
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving || !value.autoDeleteSmallDownloads}
onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
setErr(message)
}}
/>
</div>
</div>
{/* Paths */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
</div>
</div>
<div className="space-y-3">
{/* Aufnahme-Ordner */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Aufnahme-Ordner
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.recordDir}
onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))}
placeholder="records (oder absolut: C:\records / /mnt/data/records)"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 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"
/>
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* Fertige Downloads */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Fertige Downloads nach
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.doneDir}
onChange={(e) => setValue((v) => ({ ...v, doneDir: e.target.value }))}
placeholder="records/done"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 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"
/>
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* ffmpeg.exe */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
ffmpeg.exe
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.ffmpegPath ?? ''}
onChange={(e) => setValue((v) => ({ ...v, ffmpegPath: e.target.value }))}
placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 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"
/>
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
</div>
</div>
{/* Automatisierung */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
</div>
</div>
<div className="space-y-3">
<LabeledSwitch
checked={!!value.autoAddToDownloadList}
onChange={(checked) =>
setValue((v) => ({
...v,
autoAddToDownloadList: checked,
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
}))
}
label="Automatisch zur Downloadliste hinzufügen"
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
/>
<LabeledSwitch
checked={!!value.autoStartAddedDownloads}
onChange={(checked) => setValue((v) => ({ ...v, autoStartAddedDownloads: checked }))}
disabled={!value.autoAddToDownloadList}
label="Hinzugefügte Downloads automatisch starten"
description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)."
/>
<LabeledSwitch
checked={!!value.useChaturbateApi}
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/>
<LabeledSwitch
checked={!!value.useMyFreeCamsWatcher}
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))}
label="MyFreeCams Auto-Check (watched)"
description="Geht watched MyFreeCams-Models einzeln durch und startet einen Download. Wenn keine Output-Datei entsteht, ist der Stream nicht öffentlich (offline/away/private) und der Job wird wieder entfernt."
/>
{/* ✅ NEU: Auto-Delete kleine Downloads */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.autoDeleteSmallDownloads}
onChange={(checked) =>
setValue((v) => ({
...v,
autoDeleteSmallDownloads: checked,
autoDeleteSmallDownloadsBelowMB:
v.autoDeleteSmallDownloadsBelowMB ?? 50,
}))
}
label="Kleine Downloads automatisch löschen"
description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Mindestgröße</div>
<div className="text-xs text-gray-600 dark:text-gray-300">Alles darunter wird gelöscht.</div>
</div>
<div className="sm:col-span-8">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={0}
step={1}
value={value.autoDeleteSmallDownloadsBelowMB ?? 50}
onChange={(e) =>
setValue((v) => ({
...v,
autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0),
}))
}
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
</div>
</div>
</div>
</div>
<LabeledSwitch
checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
/>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Teaser abspielen</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Standbild spart Leistung. Bei Hover (Standard): Desktop spielt bei Hover ab, Mobile im Viewport. Alle kann viel CPU ziehen.
</div>
</div>
<div className="sm:col-span-8">
<label className="sr-only" htmlFor="teaserPlayback">Teaser abspielen</label>
<select
id="teaserPlayback"
value={value.teaserPlayback ?? 'hover'}
onChange={(e) => setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]"
>
<option value="still">Standbild</option>
<option value="hover">Bei Hover (Standard)</option>
<option value="all">Alle</option>
</select>
</div>
</div>
<LabeledSwitch
checked={!!value.teaserAudio}
onChange={(checked) => setValue((v) => ({ ...v, teaserAudio: checked }))}
label="Teaser mit Ton"
description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet."
/>
<LabeledSwitch
checked={!!value.enableNotifications}
onChange={(checked) => setValue((v) => ({ ...v, enableNotifications: checked }))}
label="Benachrichtigungen"
description="Wenn aktiv, zeigt das Frontend Toasts (z.B. wenn watched Models online/live gehen oder wenn ein queued Model wieder public wird)."
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
</div>
</div>
<span
className={
'inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ' +
(diskStatus?.emergency
? 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200'
: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-200')
}
title={diskStatus?.emergency ? 'Notfallbremse greift gerade' : 'OK'}
>
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
</span>
</div>
<div className="mt-3 text-sm text-gray-900 dark:text-gray-200">
<div>
<span className="font-medium">Schwelle:</span>{' '}
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
{' · '}Resume ab{' '}
<span className="tabular-nums">
{uiResumeGB}
</span>{' '}
GB
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{diskStatus
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
: 'Status wird geladen…'}
</div>
{diskStatus?.emergency && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Card>
)
}