606 lines
24 KiB
TypeScript
606 lines
24 KiB
TypeScript
// frontend\src\components\ui\RecorderSettings.tsx
|
||
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import Button from './Button'
|
||
import Card from './Card'
|
||
import LabeledSwitch from './LabeledSwitch'
|
||
import GenerateAssetsTask from './GenerateAssetsTask'
|
||
|
||
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
|
||
}
|
||
|
||
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)
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
setCleaning(true)
|
||
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()
|
||
|
||
setMsg(
|
||
`🧹 Aufräumen fertig:\n` +
|
||
`• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` +
|
||
`• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` +
|
||
`• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)`
|
||
)
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setCleaning(false)
|
||
}
|
||
}
|
||
|
||
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">
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* ✅ Tasks (als erstes) */}
|
||
<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">Tasks</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0">
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 dark:bg-white/10 dark:text-gray-200">
|
||
Utilities
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<GenerateAssetsTask onFinished={onAssetsGenerated} />
|
||
</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 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-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"
|
||
/>
|
||
|
||
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
|
||
|
||
<Button
|
||
variant="secondary"
|
||
onClick={cleanupSmallDone}
|
||
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
|
||
className="h-9 shrink-0 px-3"
|
||
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
|
||
>
|
||
{cleaning ? '…' : 'Aufräumen'}
|
||
</Button>
|
||
</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>
|
||
)
|
||
}
|