diff --git a/backend/analyze.go b/backend/analyze.go index 7325a85..086d872 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -715,12 +715,10 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme for i := 1; i < len(out); i++ { n := out[i] - const mergeGapSeconds = 15.0 - - sameLabel := strings.EqualFold(cur.Label, n.Label) - nearEnough := n.StartSeconds <= cur.EndSeconds+mergeGapSeconds - - if sameLabel && nearEnough { + // Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen, + // unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt, + // wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten. + if strings.EqualFold(cur.Label, n.Label) { if n.StartSeconds < cur.StartSeconds { cur.StartSeconds = n.StartSeconds } diff --git a/backend/assets_generate.go b/backend/assets_generate.go index e7dd04d..6f38e2f 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -473,7 +473,13 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU cellW, cellH, ); err != nil { - fmt.Println("⚠️ preview sprite:", err) + if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { + // Sprite existiert am Ende trotzdem -> Warnung unterdrücken + return + } + + // Optional: nur kurze Meldung statt voller ffmpeg-Ausgabe + //fmt.Printf("⚠️ preview sprite failed for %s\n", videoPath) return } diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index f4d0b6b..6e791d9 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -78,14 +78,29 @@ func generatePreviewSpriteWebP( base := strings.TrimSuffix(outPath, ext) tmpPath := base + ".tmp" + ext + // alte temp-Datei vorsichtshalber vorher entfernen + _ = os.Remove(tmpPath) + + // bei JEDEM Fehler temp wieder aufräumen + renameOK := false + defer func() { + if !renameOK { + _ = os.Remove(tmpPath) + } + }() + ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath) if ffmpegPath == "" { ffmpegPath = "ffmpeg" } + // robustere Filterkette vf := fmt.Sprintf( - "fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+ - "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,tile=%dx%d:margin=0:padding=0", + "fps=1/%f,"+ + "scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ + "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ + "setsar=1,"+ + "tile=%dx%d:margin=0:padding=0", stepSec, cellW, cellH, cellW, cellH, @@ -96,18 +111,17 @@ func generatePreviewSpriteWebP( ctx, ffmpegPath, "-hide_banner", - "-loglevel", "error", "-y", "-i", videoPath, "-an", "-sn", + "-threads", "1", "-vf", vf, - "-vsync", "vfr", "-frames:v", "1", "-c:v", "libwebp", "-lossless", "0", - "-compression_level", "6", - "-q:v", "80", + "-compression_level", "3", + "-q:v", "65", "-f", "webp", tmpPath, ) @@ -115,10 +129,17 @@ func generatePreviewSpriteWebP( out, err := cmd.CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) - if msg != "" { - return fmt.Errorf("ffmpeg sprite failed: %w: %s", err, msg) + if msg == "" { + msg = "(keine ffmpeg-ausgabe)" } - return fmt.Errorf("ffmpeg sprite failed: %w", err) + return fmt.Errorf( + "ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s", + videoPath, + tmpPath, + err, + strings.Join(cmd.Args, " "), + msg, + ) } fi, err := os.Stat(tmpPath) @@ -126,15 +147,14 @@ func generatePreviewSpriteWebP( return fmt.Errorf("sprite temp stat failed: %w", err) } if fi.IsDir() || fi.Size() <= 0 { - _ = os.Remove(tmpPath) return fmt.Errorf("sprite temp file invalid/empty") } _ = os.Remove(outPath) if err := os.Rename(tmpPath, outPath); err != nil { - _ = os.Remove(tmpPath) return fmt.Errorf("sprite rename failed: %w", err) } + renameOK = true return nil } diff --git a/backend/live.go b/backend/live.go index add3410..589a3da 100644 --- a/backend/live.go +++ b/backend/live.go @@ -430,7 +430,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h publishJobUpsert(job) } - fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) + //fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) } jobsMu.Lock() diff --git a/backend/preview.go b/backend/preview.go index 29bad97..bd1bf5b 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -17,7 +17,6 @@ import ( "image/jpeg" "image/png" "io" - "log" "math" "math/rand" "net/http" @@ -621,7 +620,6 @@ func coverBatchEnter(force bool) { coverBatchErrors = 0 coverBatchNoThumb = 0 coverBatchDecodeErr = 0 - log.Printf("[cover] BATCH START") } coverBatchInflight++ @@ -648,20 +646,6 @@ func coverBatchLeave(outcome string, status int) { } coverBatchInflight-- - if coverBatchInflight <= 0 { - dur := time.Since(coverBatchStarted).Round(time.Millisecond) - log.Printf( - "[cover] BATCH END total=%d miss=%d forced=%d errors=%d noThumb=%d decodeFail=%d took=%s", - coverBatchTotal, - coverBatchMiss, - coverBatchForced, - coverBatchErrors, - coverBatchNoThumb, - coverBatchDecodeErr, - dur, - ) - coverBatchInflight = 0 - } } var reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`) diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 118926d..97405df 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -8,12 +8,12 @@ "autoStartAddedDownloads": false, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": false, - "autoDeleteSmallDownloadsBelowMB": 50, + "autoDeleteSmallDownloads": true, + "autoDeleteSmallDownloadsBelowMB": 200, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "hover", "teaserAudio": false, "enableNotifications": true, - "encryptedCookies": "cjdwemq1R/rQnAhNIj9/UR8Fhm5jdcsx1Dwg+kU4J2jDOB57PrIuGJjoAJGA5GZOHqvFSQYXkvksqi2MbfCInyHcO67Hdk5p4VipPnirCaDEOn+sK0eYtT9MzoRiDF0i2InEdl3JotpHscuJEjibV1kZ5Z8DrakwI7XbcNrEa5KE0opmhLAKX38CYSuWA4laBHMcXkUdP46CiTJzrtRVjVXLkuIiesffRF47qzmWyqUVjc4mkUPv4C2X6MK0KjEZKHAMR+bKd+aWZNoh04EK0ACVf1UTfbk14MivD0x5DKuw4ERVttkNZ5646W1jqx+2xCPr1IYXjci7mEqWwkhctJdjI907vyR2Zwy5KovOiaQ8FLuJBUe51nq6zDKqOZBwSc5wHo/UCSLqAjfIGrcEcuu+1r/xNQCKw/gnkCpcM5ysd6S0ruREYYrdVn4wcA4+5TsU1bJxwESnCBZNjE/IFbc0ElBmKobFrS11dDCuCpycDxQ35np03jXAm/ysvrMqcsaWldZfqGs=" + "encryptedCookies": "3EPvjFs7b4JIdKUT3G2fOZKc26YmYL283VVHmG+dCLAUe+xURUkM0rZMCrf8Ug7eyXZOreLItE09FSCZrA3afNgmHg5c648hhvYhkv/mW7J8ap4tMz1m8ahcvcfoLhrx5AqU4MWXnqz+VHHglqkfPn9aFcrgFnWbOPHJ1A3S77cs2gWR0/shqn3l8nk6HmIWqJ1TnAA6z2CYDngB27sv/NflLKoujezlWitEa8wEpEW8GDSEtPjpT7X9L24wP4TK/TnxZUovaRXDDbboebk2KeKP04C5tWhhpIfKl3/ipf9dPgHdV4jLheFyczMRZN5Z6yF5WRn3NgDbdCcoldRwqgTwv1NgLri8nJKp4SGmRpGFrbq6m7/26muyGbTzsU3tniae6iYHbYrPz0pMOBLcFPxnil4yT0Xgnph+P9EYYWJxtjUXi7nsiREjHBxqU/OSogavsOjlFqJgWBBCL705R2Fap0VjlgWtJEXKu+vAlexX873uoeFzFw9niwJlNRFKJtGMjJGYE5c=" } diff --git a/backend/routes.go b/backend/routes.go index adb9e63..9588a4a 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -72,6 +72,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/generated/coverinfo/list", generatedCoverInfoList) // Tasks + api.HandleFunc("/api/tasks/status", tasksStatusHandler) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) // -------------------------- diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index d57665b..64d9105 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -53,6 +53,10 @@ func snapshotAssetsState() AssetsTaskState { return st } +func getGenerateAssetsTaskStatus() AssetsTaskState { + return snapshotAssetsState() +} + func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -305,7 +309,7 @@ func runGenerateMissingAssets(ctx context.Context) { updateAssetsState(func(st *AssetsTaskState) { st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)" }) - fmt.Println("⚠️ tasks generate assets analyze:", aerr) + //fmt.Println("⚠️ tasks generate assets analyze:", aerr) } updateAssetsState(func(st *AssetsTaskState) { diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index d8a9c1f..74e201e 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -48,6 +48,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { // doneDir auflösen doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { + setCleanupTaskError("doneDir auflösung fehlgeschlagen") http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) return } @@ -68,6 +69,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { mb = 0 } + setCleanupTaskRunning("Räume auf…") + resp := cleanupResp{} // 1) Kleine Downloads löschen (wenn mb > 0) @@ -83,6 +86,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { resp.GeneratedOrphansRemoved = gcStats.Removed resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) + + orphansTotalRemoved := resp.GeneratedOrphansRemoved + setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved)) + writeJSON(w, http.StatusOK, resp) } @@ -311,7 +318,7 @@ func runGeneratedGarbageCollector() generatedGCStats { } } - fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta) + //fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta) stats.Checked += checkedMeta stats.Removed += removedMeta diff --git a/backend/tasks_status.go b/backend/tasks_status.go new file mode 100644 index 0000000..e4aefd6 --- /dev/null +++ b/backend/tasks_status.go @@ -0,0 +1,84 @@ +// backend\tasks_status.go + +package main + +import ( + "encoding/json" + "net/http" + "strings" + "sync" +) + +type CleanupTaskState struct { + Running bool `json:"running"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` +} + +type TasksStatusResponse struct { + GenerateAssets AssetsTaskState `json:"generateAssets"` + Cleanup CleanupTaskState `json:"cleanup"` + AnyRunning bool `json:"anyRunning"` +} + +var ( + cleanupTaskMu sync.Mutex + cleanupTaskState CleanupTaskState +) + +func snapshotCleanupTaskState() CleanupTaskState { + cleanupTaskMu.Lock() + st := cleanupTaskState + cleanupTaskMu.Unlock() + return st +} + +func setCleanupTaskRunning(text string) { + cleanupTaskMu.Lock() + cleanupTaskState = CleanupTaskState{ + Running: true, + Text: strings.TrimSpace(text), + Error: "", + } + cleanupTaskMu.Unlock() +} + +func setCleanupTaskDone(text string) { + cleanupTaskMu.Lock() + cleanupTaskState = CleanupTaskState{ + Running: false, + Text: strings.TrimSpace(text), + Error: "", + } + cleanupTaskMu.Unlock() +} + +func setCleanupTaskError(errText string) { + cleanupTaskMu.Lock() + cleanupTaskState = CleanupTaskState{ + Running: false, + Text: "", + Error: strings.TrimSpace(errText), + } + cleanupTaskMu.Unlock() +} + +func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + assets := getGenerateAssetsTaskStatus() + cleanup := snapshotCleanupTaskState() + + resp := TasksStatusResponse{ + GenerateAssets: assets, + Cleanup: cleanup, + AnyRunning: assets.Running || cleanup.Running, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3484b5c..7a63c57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,17 @@ import Downloads from './components/ui/Downloads' import ModelsTab from './components/ui/ModelsTab' import ProgressBar from './components/ui/ProgressBar' import ModelDetails from './components/ui/ModelDetails' -import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/react/24/solid' +import { + SignalIcon, + HeartIcon, + HandThumbUpIcon, + EyeIcon, + ArrowDownTrayIcon, + ArchiveBoxIcon, + UserGroupIcon, + Squares2X2Icon, + Cog6ToothIcon, +} from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' //import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' @@ -448,6 +458,7 @@ export default function App() { const [cookies, setCookies] = useState>({}) const [cookiesLoaded, setCookiesLoaded] = useState(false) const [selectedTab, setSelectedTab] = useState('running') + const [settingsTaskRunning, setSettingsTaskRunning] = useState(false) const [playerJob, setPlayerJob] = useState(null) const [playerExpanded, setPlayerExpanded] = useState(false) const [playerStartAtSec, setPlayerStartAtSec] = useState(null) @@ -543,6 +554,56 @@ export default function App() { } }, []) + useEffect(() => { + if (!authed) return + + let cancelled = false + let timer: number | null = null + + const loadSettingsTaskState = async () => { + try { + const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }) + if (!res.ok) { + if (!cancelled) setSettingsTaskRunning(false) + return + } + + const data = await res.json().catch(() => null) + if (cancelled) return + + const running = Boolean(data?.running) + setSettingsTaskRunning(running) + } catch { + if (!cancelled) setSettingsTaskRunning(false) + } + } + + const schedule = (ms: number) => { + if (cancelled) return + timer = window.setTimeout(async () => { + await loadSettingsTaskState() + schedule(document.hidden ? 8000 : 3000) + }, ms) + } + + void loadSettingsTaskState() + schedule(3000) + + const onVis = () => { + if (!document.hidden) { + void loadSettingsTaskState() + } + } + + document.addEventListener('visibilitychange', onVis) + + return () => { + cancelled = true + if (timer != null) window.clearTimeout(timer) + document.removeEventListener('visibilitychange', onVis) + } + }, [authed]) + useEffect(() => { void checkAuth() }, [checkAuth]) @@ -2051,11 +2112,35 @@ export default function App() { }, [jobs, pendingWatchedRooms]) const tabs: TabItem[] = [ - { id: 'running', label: 'Laufende Downloads', count: runningTabCount }, - { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount }, - { id: 'models', label: 'Models', count: modelsCount }, - { id: 'categories', label: 'Kategorien' }, - { id: 'settings', label: 'Einstellungen' }, + { + id: 'running', + label: 'Laufende Downloads', + count: runningTabCount, + icon: ArrowDownTrayIcon, + }, + { + id: 'finished', + label: 'Abgeschlossene Downloads', + count: doneCount, + icon: ArchiveBoxIcon, + }, + { + id: 'models', + label: 'Models', + count: modelsCount, + icon: UserGroupIcon, + }, + { + id: 'categories', + label: 'Kategorien', + icon: Squares2X2Icon, + }, + { + id: 'settings', + label: 'Einstellungen', + icon: Cog6ToothIcon, + spinIcon: settingsTaskRunning, + }, ] const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy]) @@ -3256,7 +3341,12 @@ export default function App() { {selectedTab === 'models' ? : null} {selectedTab === 'categories' ? : null} - {selectedTab === 'settings' ? : null} + {selectedTab === 'settings' ? ( + + ) : null} /preview.webp -function thumbUrlFromOutput(output: string): string | null { - const id = assetIdFromOutput(output) - if (!id) return null - return `/generated/meta/${encodeURIComponent(id)}/preview.webp` -} - async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) { const m = (modelName || '').trim() const url = @@ -363,12 +356,27 @@ export default function CategoriesTab() { // ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert) const pick = list[Math.floor(Math.random() * list.length)] - const thumb = thumbUrlFromOutput(pick) - - if (!thumb) { - return { tag: r.tag, ok: true, status: 0, text: 'skipped (no thumb url)' } + const id = assetIdFromOutput(pick) + if (!id) { + return { tag: r.tag, ok: true, status: 0, text: 'skipped (no asset id)' } } + // Preview aktiv generieren / aufwecken + const previewRes = await fetch(`/api/preview?id=${encodeURIComponent(id)}`, { + method: 'GET', + cache: 'no-store', + }) + + if (!previewRes.ok) { + return { + tag: r.tag, + ok: false, + status: previewRes.status, + text: `preview generation failed: HTTP ${previewRes.status}`, + } + } + + const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.webp` const model = modelKeyFromFilename(pick) await ensureCover(r.tag, thumb, model, true) diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 8c2ac2b..ba9de13 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -1166,15 +1166,18 @@ export default function FinishedDownloadsCardsView({ } const mobileStackDepth = 3 + const mobilePreloadDepth = mobileStackDepth + 3 - // Sichtbarer Stack bleibt bei 3 Karten - const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : [] + // ✅ 4 Karten mounten, aber nur 3 sichtbar stacken + const mobileLoadedStackRows = isSmall ? rows.slice(0, mobilePreloadDepth) : [] + const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth) + const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null // größerer Peek-Offset für stärkeren Stack-Effekt const stackPeekOffsetPx = 15 // weil wir nach OBEN stacken, brauchen wir oben Platz - const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) + const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) return (
@@ -1215,48 +1218,76 @@ export default function FinishedDownloadsCardsView({ const visible = mobileVisibleStackRows const topRow = visible[0] const backRows = visible.slice(1) + const preloadRow = mobilePreloadedRow return ( <> - {/* Hintere Karten zuerst (absolut, dekorativ) */} - {backRows - .map((j, backIdx) => { - const idx = backIdx + 1 // 1,2... - const { k, cardInner } = renderCardItem(j, { - forceStill: true, - disableInline: true, - disablePreviewHover: true, - isDecorative: true, - forceLoadStill: true, - blur: true, - preloadTeaserWhenStill: true, - }) - - const depth = idx - const y = -(depth * stackPeekOffsetPx) - const scale = 1 - depth * 0.03 - const opacity = 1 - depth * 0.14 - - return ( - diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 8328cd3..f721aca 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -60,6 +60,7 @@ const DEFAULTS: RecorderSettings = { type Props = { onAssetsGenerated?: () => void + onTaskRunningChange?: (running: boolean) => void } function shortTaskFilename(name?: string, max = 52) { @@ -69,7 +70,7 @@ function shortTaskFilename(name?: string, max = 52) { return '…' + s.slice(-(max - 1)) } -export default function RecorderSettings({ onAssetsGenerated }: Props) { +export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChange }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) @@ -141,6 +142,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { fading: false, }) + useEffect(() => { + const running = + assetsTask.status === 'running' || + cleanupTask.status === 'running' || + cleaning + + onTaskRunningChange?.(running) + }, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange]) + const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3) @@ -216,6 +226,57 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } }, []) + useEffect(() => { + let alive = true + + const loadTaskStatus = async () => { + try { + const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' }) + if (!res.ok) return + + const data = await res.json().catch(() => null) + if (!alive || !data) return + + const running = Boolean(data.running) + const done = Number(data.done ?? 0) + const total = Number(data.total ?? 0) + const currentFile = shortTaskFilename(data.currentFile) + + if (running) { + setAssetsTask((t) => ({ + ...t, + status: 'running', + title: 'Assets generieren', + text: currentFile || '', + done, + total, + err: undefined, + fading: false, + })) + } else { + setAssetsTask((t) => ({ + ...t, + status: 'idle', + title: 'Assets generieren', + text: '', + done: 0, + total: 0, + err: undefined, + fading: false, + })) + } + } catch { + // ignorieren + } + } + + void loadTaskStatus() + + return () => { + alive = false + } + }, []) + async function browse(target: 'record' | 'done' | 'ffmpeg') { setErr(null) setMsg(null) diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index d3fc2db..15e28f6 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -14,6 +14,7 @@ export type TabItem = { count?: number | string icon?: TabIcon disabled?: boolean + spinIcon?: boolean } export type TabsVariant = @@ -221,6 +222,7 @@ export default function Tabs({ {tabs.map((tab, idx) => { const selected = tab.id === current.id const disabled = !!tab.disabled + const Icon = tab.icon return (
+ {aiSegments.length === 0 && (
{splits.length === 0 ? (
@@ -1236,6 +1233,7 @@ export default function VideoSplitModal({ )) )}
+ )}