bugfixes and optimizations

This commit is contained in:
Linrador 2026-06-01 09:16:21 +02:00
parent bd34f95ec2
commit ae957af7b2
19 changed files with 626 additions and 163 deletions

View File

@ -992,7 +992,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
sortBucket, sortName := postWorkSortForVideoPath(outPath)
ok := enrichQ.Enqueue(PostWorkTask{
ok := enqueueEnrichPostwork(PostWorkTask{
Key: "enrich:" + assetID,
Added: time.Now(),
SortBucket: sortBucket,

View File

@ -252,6 +252,45 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
return true
}
func (pq *PostWorkQueue) CancelAll() (queuedRemoved int, runningCancelled int) {
pq.mu.Lock()
waiting := append([]string(nil), pq.waitingKeys...)
runningCancels := make([]context.CancelFunc, 0, len(pq.cancelByKey))
for key, cancel := range pq.cancelByKey {
if _, running := pq.runningKeys[key]; running && cancel != nil {
runningCancels = append(runningCancels, cancel)
}
}
for _, key := range waiting {
delete(pq.inflight, key)
pq.unlockFileLocked(key)
if pq.queued > 0 {
pq.queued--
}
queuedRemoved++
}
pq.waitingKeys = nil
pq.queue = nil
runningCancelled = len(runningCancels)
pq.mu.Unlock()
for _, cancel := range runningCancels {
cancel()
}
publishTaskState()
return queuedRemoved, runningCancelled
}
func (pq *PostWorkQueue) workerLoop(id int) {
for {
pq.mu.Lock()
@ -393,6 +432,19 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
var postWorkQ = NewPostWorkQueue(1024, 1) // schneller kritischer Pfad
var enrichQ = NewPostWorkQueue(128, 1) // langsame Hintergrundaufgaben
func enrichPostworkEnabled() bool {
return getSettings().EnrichPostworkEnabled
}
func enqueueEnrichPostwork(task PostWorkTask) bool {
if !enrichPostworkEnabled() {
appLogln("⏭️ enrich postwork deaktiviert, überspringe:", task.Key)
return false
}
return enrichQ.Enqueue(task)
}
// --- Status Refresher (ehemals postwork_refresh.go) ---
type enrichStatusSnapshot struct {

View File

@ -1274,9 +1274,6 @@ func extractLastFrameJPG(path string) ([]byte, error) {
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg last-frame jpg: empty output")
}
return b, nil
}
@ -1314,9 +1311,6 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg frame-at-time jpg: empty output")
}
return b, nil
}

View File

@ -22,6 +22,7 @@
"generateAssetsTeaser": true,
"generateAssetsSprites": false,
"generateAssetsAnalyze": false,
"enrichPostworkEnabled": false,
"trainingRecognitionEnabled": true,
"trainingDetectorEpochs": 60,
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="

View File

@ -44,6 +44,7 @@ type RecorderSettings struct {
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
@ -84,6 +85,7 @@ var (
GenerateAssetsTeaser: true,
GenerateAssetsSprites: false,
GenerateAssetsAnalyze: false,
EnrichPostworkEnabled: true,
TrainingRecognitionEnabled: true,
TrainingDetectorEpochs: 60,
@ -256,6 +258,7 @@ type RecorderSettingsPublic struct {
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
@ -293,6 +296,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
GenerateAssetsSprites: s.GenerateAssetsSprites,
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
EnrichPostworkEnabled: s.EnrichPostworkEnabled,
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
TrainingDetectorEpochs: s.TrainingDetectorEpochs,
@ -455,11 +459,14 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.EnableNotifications = in.EnableNotifications
oldEnrichPostworkEnabled := current.EnrichPostworkEnabled
next.GenerateAssetsMeta = in.GenerateAssetsMeta
next.GenerateAssetsThumb = in.GenerateAssetsThumb
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
next.GenerateAssetsSprites = in.GenerateAssetsSprites
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
next.EnrichPostworkEnabled = in.EnrichPostworkEnabled
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
@ -492,6 +499,18 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
saveSettingsToDisk()
if oldEnrichPostworkEnabled && !next.EnrichPostworkEnabled {
cancelledQueued, cancelledRunning := enrichQ.CancelAll()
if cancelledQueued > 0 || cancelledRunning > 0 {
appLogln(
"🛑 enrich postwork deaktiviert:",
"queuedRemoved=", cancelledQueued,
"runningCancelled=", cancelledRunning,
)
}
}
if newStore != nil {
setModelStore(newStore)
setChaturbateOnlineModelStore(newStore)

View File

@ -11,24 +11,185 @@ import (
"github.com/google/uuid"
)
const startupLeftoverMinAge = 60 * time.Second
const (
startupLeftoverMinAge = 60 * time.Second
// .muxing.ts sind temporäre Zwischenstände.
// Nur alte Dateien löschen, damit ein sehr frischer/noch schreibender Prozess
// nicht versehentlich getroffen wird.
startupMuxingTempMinAge = 10 * time.Minute
postworkLeftoverInitialDelay = 8 * time.Second
postworkLeftoverScanInterval = 1 * time.Hour
)
func startPostworkLeftoverScanOnStartup() {
go func() {
// Kurz warten, bis Settings, Queues und UI/SSE bereit sind.
time.Sleep(8 * time.Second)
time.Sleep(postworkLeftoverInitialDelay)
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
if queued > 0 || skipped > 0 {
appLogln(
"🧩 [startup-postwork] leftover scan fertig:",
"queued=", queued,
"skipped=", skipped,
)
runPostworkLeftoverScan := func(reason string) {
removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir()
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 {
appLogln(
"🧩 [startup-postwork] leftover scan fertig:",
"reason=", reason,
"queued=", queued,
"skipped=", skipped,
"removedMuxing=", removedMuxing,
"skippedMuxing=", skippedMuxing,
)
}
}
runPostworkLeftoverScan("startup")
ticker := time.NewTicker(postworkLeftoverScanInterval)
defer ticker.Stop()
for range ticker.C {
runPostworkLeftoverScan("interval")
}
}()
}
func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
s := getSettings()
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordAbs) == "" {
recordAbs = strings.TrimSpace(s.RecordDir)
}
recordAbs = strings.TrimSpace(recordAbs)
if recordAbs == "" {
appLogln("⚠️ [startup-postwork] recordDir ist leer")
return 0, 0
}
entries, err := os.ReadDir(recordAbs)
if err != nil {
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
return 0, 0
}
now := time.Now()
for _, e := range entries {
if e == nil || e.IsDir() {
continue
}
name := strings.TrimSpace(e.Name())
if name == "" {
continue
}
if !isMuxingTempTSName(name) {
continue
}
path := filepath.Join(recordAbs, name)
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() {
skipped++
continue
}
if fi.Size() <= 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
skipped++
appLogln("⚠️ [startup-postwork] .muxing.ts leer, aber löschen fehlgeschlagen:", name, err)
continue
}
removed++
appLogln("🗑️ [startup-postwork] leere .muxing.ts gelöscht:", name)
continue
}
age := now.Sub(fi.ModTime())
if age < startupMuxingTempMinAge {
skipped++
appLogln(
"⏭️ [startup-postwork] .muxing.ts zu frisch:",
name,
"age=", age.Round(time.Second),
"min=", startupMuxingTempMinAge,
)
continue
}
if !singleFileStable(path) {
skipped++
appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name)
continue
}
// Falls für diesen temporären Namen ein Audio-Sidecar existiert, direkt mit entfernen.
removedAudioSidecars := removePostworkAudioSidecarsForOutput(path)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
skipped++
appLogln("⚠️ [startup-postwork] .muxing.ts löschen fehlgeschlagen:", name, err)
continue
}
removed++
appLogln(
"🗑️ [startup-postwork] alte .muxing.ts gelöscht:",
name,
"audioSidecars=", len(removedAudioSidecars),
)
}
return removed, skipped
}
func isMuxingTempTSName(name string) bool {
name = strings.TrimSpace(filepath.Base(name))
if name == "" {
return false
}
lowerName := strings.ToLower(name)
return strings.HasSuffix(lowerName, ".muxing.ts")
}
func singleFileStable(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
sizeOf := func(p string) (int64, bool) {
fi, err := os.Stat(p)
if err != nil || fi == nil || fi.IsDir() {
return 0, false
}
return fi.Size(), true
}
size1, ok1 := sizeOf(path)
if !ok1 {
return false
}
time.Sleep(1500 * time.Millisecond)
size2, ok2 := sizeOf(path)
if !ok2 {
return false
}
return size1 == size2
}
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
s := getSettings()

View File

@ -33,7 +33,12 @@ type cleanupResp struct {
DeletedBytesHuman string `json:"deletedBytesHuman"`
DeletedRecordOrphans int `json:"deletedRecordOrphans"`
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
ErrorCount int `json:"errorCount"`
DeletedMuxingTemps int `json:"deletedMuxingTemps"`
DeletedAudioOrphans int `json:"deletedAudioOrphans"`
DeletedTSOrphans int `json:"deletedTSOrphans"`
ErrorCount int `json:"errorCount"`
// Generated-GC
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
@ -246,7 +251,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
finishCleanupJob(
jobID,
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
cleanupProgressText(resp),
nil,
)
}(jobID, ctx, doneAbs, recordAbs, mb)
@ -278,23 +283,42 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
}
}
func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string {
if broken > 0 {
return fmt.Sprintf(
"gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s",
deleted,
total,
broken,
formatBytesSI(freedBytes),
)
func cleanupProgressText(resp cleanupResp) string {
parts := []string{
fmt.Sprintf("gelöscht: %d/%d", resp.DeletedFiles, resp.ScannedFiles),
}
return fmt.Sprintf(
"gelöscht: %d/%d · freigegeben: %s",
deleted,
total,
formatBytesSI(freedBytes),
)
if resp.DeletedBrokenFiles > 0 {
parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles))
}
if resp.DeletedRecordOrphans > 0 {
parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans))
}
if resp.DeletedTSOrphans > 0 {
parts = append(parts, fmt.Sprintf("TS ohne Audio: %d", resp.DeletedTSOrphans))
}
if resp.DeletedAudioOrphans > 0 {
parts = append(parts, fmt.Sprintf("Audio ohne TS: %d", resp.DeletedAudioOrphans))
}
if resp.DeletedMuxingTemps > 0 {
parts = append(parts, fmt.Sprintf("Muxing-Temp: %d", resp.DeletedMuxingTemps))
}
if resp.GeneratedOrphansRemoved > 0 {
parts = append(parts, fmt.Sprintf("Generated: %d", resp.GeneratedOrphansRemoved))
}
if resp.ErrorCount > 0 {
parts = append(parts, fmt.Sprintf("Fehler: %d", resp.ErrorCount))
}
parts = append(parts, fmt.Sprintf("freigegeben: %s", formatBytesSI(resp.DeletedBytes)))
return strings.Join(parts, " · ")
}
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
@ -444,10 +468,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
}
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
if err != nil || fi == nil || fi.IsDir() {
return
}
size := fi.Size()
name := filepath.Base(path)
lowName := strings.ToLower(name)
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
resp.SkippedFiles++
return
@ -458,38 +486,49 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
return
}
if !cleanupFileStable(path) {
// 0-KB-Dateien sind stabil genug zum Löschen.
// Für >0 Bytes weiter die Stabilitätsprüfung behalten.
if size > 0 && !cleanupFileStable(path) {
resp.SkippedFiles++
return
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.CurrentFile = filepath.Base(path)
st.Text = "Räume verwaiste Record-Dateien auf…"
st.CurrentFile = name
st.Text = "Räume Record-Reste auf…"
})
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
resp.ErrorCount++
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", filepath.Base(path), reason, err)
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", name, reason, err)
return
}
resp.DeletedFiles++
resp.DeletedRecordOrphans++
resp.DeletedBytes += fi.Size()
resp.DeletedRecordOrphanBytes += fi.Size()
resp.DeletedBytes += size
resp.DeletedRecordOrphanBytes += size
if strings.HasSuffix(lowName, ".muxing.ts") {
resp.DeletedMuxingTemps++
} else if strings.HasPrefix(name, ".") && strings.HasSuffix(lowName, ".audio.m4s") {
resp.DeletedAudioOrphans++
} else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
resp.DeletedTSOrphans++
}
purgeDurationCacheForPath(path)
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
base = strings.TrimPrefix(base, ".")
base = strings.TrimSuffix(base, ".audio")
base = strings.TrimSuffix(base, ".muxing")
if strings.TrimSpace(base) != "" {
removeGeneratedForID(stripHotPrefix(base))
}
appLogln("🧹 cleanup deleted record orphan:", filepath.Base(path), "reason:", reason)
appLogln("🧹 cleanup deleted record orphan:", name, "reason:", reason, "size=", size)
}
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
@ -522,6 +561,14 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
full := filepath.Join(recordAbs, name)
low := strings.ToLower(name)
// Fall 0:
// alte .muxing.ts sind temporäre Remux-/Muxing-Reste.
if strings.HasSuffix(low, ".muxing.ts") {
resp.ScannedFiles++
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "stale muxing temp")
continue
}
// Fall 1:
// foo.ts existiert, aber .foo.audio.m4s fehlt.
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
@ -658,7 +705,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
st.Done = 0
st.Total = resp.ScannedFiles
st.CurrentFile = ""
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Text = cleanupProgressText(*resp)
st.Error = ""
st.FinishedAt = nil
})
@ -716,7 +763,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
st.Done = i + 1
st.Total = resp.ScannedFiles
st.CurrentFile = c.name
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Text = cleanupProgressText(*resp)
st.Error = ""
st.FinishedAt = nil
})

View File

@ -69,11 +69,23 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
generateAssetsMeta: true,
generateAssetsThumb: true,
generateAssetsTeaser: true,
generateAssetsSprites: true,
generateAssetsAnalyze: true,
enrichPostworkEnabled: true,
trainingRecognitionEnabled: true,
trainingDetectorEpochs: 60,
}
type StoredModel = {
@ -3812,14 +3824,30 @@ export default function App() {
return <LoginPage onLoggedIn={checkAuth} />
}
const formatHeaderStatValue = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
if (n >= 10000) return `${Math.round(n / 1000)}k`
if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
return String(Math.max(0, Math.floor(n)))
}
const headerShellClass =
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06]'
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] max-lg:[@media_(orientation:landscape)]:rounded-xl max-lg:[@media_(orientation:landscape)]:p-2'
const headerStatClass =
'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
const mobileHeaderStatClass =
'flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-gray-200/70 bg-white/75 px-1.5 text-[10px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
const headerStatValueClass =
'inline-block min-w-[3ch] text-right tabular-nums text-gray-950 dark:text-white'
const headerActionClass =
'h-9 shrink-0 rounded-xl px-3 text-sm'
'h-9 shrink-0 rounded-xl px-3 text-sm max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:rounded-lg max-lg:[@media_(orientation:landscape)]:px-2.5 max-lg:[@media_(orientation:landscape)]:text-xs'
const headerStatItems = [
{
@ -3858,60 +3886,79 @@ export default function App() {
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
<div className="mx-auto max-w-7xl p-3">
<div className="mx-auto max-w-7xl p-3 max-lg:[@media_(orientation:landscape)]:p-1.5">
<div className={headerShellClass}>
<div className="grid gap-2.5">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
<div className="grid gap-2.5 max-lg:[@media_(orientation:landscape)]:gap-1.5">
<div className="flex flex-col gap-2.5 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-start max-lg:[@media_(orientation:landscape)]:justify-between max-lg:[@media_(orientation:landscape)]:gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20">
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:w-8 max-lg:[@media_(orientation:landscape)]:rounded-lg">
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
</div>
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg">
Recorder
</h1>
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg max-lg:[@media_(orientation:landscape)]:text-base">
Recorder
</h1>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{headerStatItems.map((item) => {
const Icon = item.icon
<div className="hidden min-w-0 flex-wrap items-center gap-1.5 lg:flex">
{headerStatItems.map((item) => {
const Icon = item.icon
return (
<span
key={item.label}
className={headerStatClass}
>
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
return (
<span
key={item.label}
className={headerStatClass}
title={item.label}
>
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
<span className="tabular-nums text-gray-950 dark:text-white">
{item.value}
</span>
<span className={headerStatValueClass}>
{formatHeaderStatValue(item.value)}
</span>
)
})}
</div>
</span>
)
})}
</div>
</div>
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px]">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:hidden max-lg:[@media_(orientation:landscape)]:hidden">
{headerStatItems.map((item) => {
const Icon = item.icon
return (
<span
key={item.label}
className={mobileHeaderStatClass}
title={item.label}
>
<Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden="true" />
<span className={headerStatValueClass}>
{formatHeaderStatValue(item.value)}
</span>
</span>
)
})}
</div>
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px] max-lg:[@media_(orientation:landscape)]:hidden">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
<div className="flex flex-col gap-2 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:justify-end sm:flex-row sm:items-center lg:justify-end">
{showPerfMon ? (
<PerformanceMonitor
mode="inline"
compact
className="w-full sm:w-[300px] lg:w-[360px]"
className="w-full max-lg:[@media_(orientation:landscape)]:w-[300px] sm:w-[300px] lg:w-[360px]"
/>
) : null}
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:items-center sm:gap-2">
<div className="grid grid-cols-2 gap-1.5 max-lg:[@media_(orientation:landscape)]:flex max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:gap-2 sm:flex sm:items-center sm:gap-2">
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
@ -3938,8 +3985,8 @@ export default function App() {
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative">
<div className="grid min-w-0 gap-2 max-lg:[@media_(orientation:landscape)]:grid-cols-[minmax(0,1fr)_7.5rem] max-lg:[@media_(orientation:landscape)]:items-center lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="relative min-w-0">
<label className="sr-only">Source URL</label>
<TextInput
ref={sourceUrlInputRef}
@ -4002,6 +4049,7 @@ export default function App() {
onChange={setSelectedTab}
ariaLabel="Tabs"
variant="barUnderline"
desktopBreakpoint="lg"
/>
</div>
</div>

View File

@ -640,7 +640,7 @@ export default function Downloads({
enableConcurrentDownloadsLimit = false,
maxConcurrentDownloads = 20,
}: Props) {
const isDesktop = useMediaQuery('(min-width: 640px)', true)
const isDesktop = useMediaQuery('(min-width: 1024px)', true)
const [stopAllBusy, setStopAllBusy] = useState(false)

View File

@ -4725,7 +4725,7 @@ export default function FinishedDownloads({
<>
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
<aside className="hidden min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:block lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
<div
className="
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
@ -4735,7 +4735,7 @@ export default function FinishedDownloads({
>
{/* Desktop */}
{/* Sidebar */}
<div className="hidden sm:block px-3 py-2.5">
<div className="hidden lg:block px-3 py-2.5">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
@ -4786,7 +4786,7 @@ export default function FinishedDownloads({
: '-translate-y-1 pointer-events-none',
].join(' ')}
>
<div className="sm:hidden">
<div className="lg:hidden">
{bulkBusy ? (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
@ -4851,7 +4851,7 @@ export default function FinishedDownloads({
</div>
</div>
<div className="hidden sm:grid sm:grid-cols-2 sm:gap-2">
<div className="hidden lg:grid lg:grid-cols-2 lg:gap-2">
{bulkBusy ? (
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
@ -5079,7 +5079,7 @@ export default function FinishedDownloads({
</div>
{/* Mobile */}
<div className="sm:hidden">
<div className="lg:hidden">
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
@ -5185,7 +5185,7 @@ export default function FinishedDownloads({
</div>
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
<div className="hidden lg:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
@ -5201,7 +5201,7 @@ export default function FinishedDownloads({
<div
id="finished-mobile-options"
className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
'lg:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
>

View File

@ -43,9 +43,7 @@ import {
type FinishedSelectionStore,
} from './finishedSelectionStore'
import SpritePreloader from './SpritePreloader'
import FinishedDownloadsDesktopCard, {
SelectionCheckboxOverlay,
} from './FinishedDownloadsDesktopCard'
import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay'
@ -641,15 +639,6 @@ function FinishedDownloadsCardsView({
blurred={opts?.blur}
animateUnblurOnMount={opts?.animateUnblurOnMount}
>
{!isSmall ? (
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
) : null}
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}

View File

@ -2,7 +2,7 @@
'use client'
import { memo, useCallback, type ReactNode } from 'react'
import { memo, useCallback, useRef, type ReactNode } from 'react'
import Card from './Card'
import Checkbox from './Checkbox'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
@ -116,37 +116,6 @@ function inlineStateForKey(
}
}
export function SelectionCheckboxOverlay({
selectionStore,
rowKey,
label,
onToggle,
}: {
selectionStore: FinishedSelectionStore
rowKey: string
label: string
onToggle: () => void
}) {
const checked = useSelectionChecked(selectionStore, rowKey)
return (
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={label}
onChange={onToggle}
/>
</div>
)
}
const FinishedDownloadsDesktopCard = memo(
function FinishedDownloadsDesktopCard({
job: j,
@ -195,6 +164,9 @@ const FinishedDownloadsDesktopCard = memo(
}: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving
const checked = useSelectionChecked(selectionStore, k)
const longPressTimerRef = useRef<number | null>(null)
const longPressTriggeredRef = useRef(false)
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
@ -213,6 +185,25 @@ const FinishedDownloadsDesktopCard = memo(
return false
}, [selectionStore, onToggleSelected, j])
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
const startLongPressSelect = useCallback(() => {
clearLongPressTimer()
if (selectionStore.hasAnySelection()) return
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null
longPressTriggeredRef.current = true
onToggleSelected(j)
}, 450)
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
@ -263,6 +254,7 @@ const FinishedDownloadsDesktopCard = memo(
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
@ -272,11 +264,19 @@ const FinishedDownloadsDesktopCard = memo(
e.preventDefault()
e.stopPropagation()
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false
return
}
if (handleSelectOrOpen()) return
openPlayer(j)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
onMouseLeave={() => {
clearLongPressTimer()
onHoverPreviewKeyChange?.(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
@ -285,14 +285,34 @@ const FinishedDownloadsDesktopCard = memo(
onOpenPlayer(j)
}
}}
onPointerDown={(e) => {
if (e.pointerType === 'mouse') return
startLongPressSelect()
}}
onPointerUp={clearLongPressTimer}
onPointerCancel={clearLongPressTimer}
onPointerLeave={clearLongPressTimer}
onContextMenu={(e) => {
if (longPressTriggeredRef.current) {
e.preventDefault()
}
}}
>
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
<div
className={[
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div
id={inlineDomId}
@ -348,7 +368,18 @@ const FinishedDownloadsDesktopCard = memo(
</div>
<div
className="relative min-h-[112px] overflow-visible border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900"
className={[
'relative min-h-[112px] overflow-visible border-t px-4 py-3 transition-colors',
checked
? [
'border-indigo-200 bg-indigo-50/90',
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
].join(' ')
: [
'border-black/5 bg-white',
'dark:border-white/10 dark:bg-gray-900',
].join(' '),
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()

View File

@ -13,12 +13,12 @@ import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import PreviewScrubber from './PreviewScrubber'
import Checkbox from './Checkbox'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
import Checkbox from './Checkbox'
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
import {
buildSpriteFrameStyle,
@ -158,6 +158,8 @@ function FinishedDownloadsGalleryCardInner({
}: Props) {
const k = rowKey
const checked = selectedKeys.has(rowKey)
const longPressTimerRef = useRef<number | null>(null)
const longPressTriggeredRef = useRef(false)
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
@ -180,6 +182,11 @@ function FinishedDownloadsGalleryCardInner({
e?.preventDefault()
e?.stopPropagation()
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false
return
}
if (selectionModeActive) {
onToggleSelected(j)
return
@ -190,6 +197,25 @@ function FinishedDownloadsGalleryCardInner({
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
)
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
const startLongPressSelect = useCallback(() => {
clearLongPressTimer()
if (selectionModeActive) return
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null
longPressTriggeredRef.current = true
onToggleSelected(j)
}, 450)
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => {
if (index === undefined) {
@ -336,8 +362,21 @@ function FinishedDownloadsGalleryCardInner({
onClick={(e) => {
handlePrimaryClick(e)
}}
onPointerDown={(e) => {
if (e.pointerType === 'mouse') return
startLongPressSelect()
}}
onPointerUp={clearLongPressTimer}
onPointerCancel={clearLongPressTimer}
onPointerLeave={clearLongPressTimer}
onContextMenu={(e) => {
if (longPressTriggeredRef.current) {
e.preventDefault()
}
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
clearLongPressTimer()
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
@ -354,6 +393,7 @@ function FinishedDownloadsGalleryCardInner({
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
@ -362,10 +402,11 @@ function FinishedDownloadsGalleryCardInner({
>
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
@ -374,7 +415,6 @@ function FinishedDownloadsGalleryCardInner({
onChange={() => onToggleSelected(j)}
/>
</div>
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHost(k)}
@ -513,7 +553,16 @@ function FinishedDownloadsGalleryCardInner({
<div
className={[
'relative flex flex-col rounded-b-lg border-t border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
'relative flex flex-col rounded-b-lg border-t transition-colors',
checked
? [
'border-indigo-200 bg-indigo-50/90',
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
].join(' ')
: [
'border-black/5 bg-white',
'dark:border-white/10 dark:bg-gray-900',
].join(' '),
footerMinHeightClass,
contentPaddingClass,
].join(' ')}

View File

@ -280,18 +280,18 @@ export default function PerformanceMonitor({
return (
<div className={`${wrapperClass} ${className ?? ''}`}>
<div className={panelClass}>
<div className="grid grid-cols-2 gap-1 md:grid-cols-4 sm:h-full sm:w-full sm:items-center">
<div className="grid w-full grid-cols-4 gap-1">
{metrics.map((item) => (
<div
key={item.key}
title={item.title}
className="min-w-0 rounded-md px-1.5 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
className="min-w-0 rounded-md px-1 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
>
<div className="flex items-center justify-between gap-1.5 whitespace-nowrap">
<span className="min-w-0 truncate text-[9px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
<div className="flex items-center justify-between gap-1 whitespace-nowrap">
<span className="min-w-0 truncate text-[8px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
{item.label}
</span>
<span className="shrink-0 whitespace-nowrap text-[10px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
<span className="shrink-0 whitespace-nowrap text-[9px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
{item.value}
</span>
</div>

View File

@ -38,6 +38,7 @@ type RecorderSettings = {
generateAssetsTeaser?: boolean
generateAssetsSprites?: boolean
generateAssetsAnalyze?: boolean
enrichPostworkEnabled?: boolean
trainingRecognitionEnabled?: boolean
trainingDetectorEpochs?: number
@ -203,6 +204,7 @@ const DEFAULTS: RecorderSettings = {
generateAssetsTeaser: true,
generateAssetsSprites: true,
generateAssetsAnalyze: true,
enrichPostworkEnabled: true,
trainingRecognitionEnabled: true,
trainingDetectorEpochs: 60,
@ -592,6 +594,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
enrichPostworkEnabled: (data as any).enrichPostworkEnabled ?? DEFAULTS.enrichPostworkEnabled,
trainingRecognitionEnabled:
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
@ -989,6 +992,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const generateAssetsTeaser = !!value.generateAssetsTeaser
const generateAssetsSprites = !!value.generateAssetsSprites
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
const enrichPostworkEnabled = !!value.enrichPostworkEnabled
const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled
const trainingDetectorEpochs = Math.max(
@ -1026,6 +1030,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
generateAssetsTeaser,
generateAssetsSprites,
generateAssetsAnalyze,
enrichPostworkEnabled,
trainingRecognitionEnabled,
trainingDetectorEpochs,
}),
@ -1104,6 +1109,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
`• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)
@ -1871,6 +1877,18 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
<div className="space-y-3">
<LabeledSwitch
checked={!!value.enrichPostworkEnabled}
onChange={(checked) =>
setValue((v) => ({
...v,
enrichPostworkEnabled: checked,
}))
}
label="EnrichQ-Postwork im Hintergrund"
description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich."
/>
<LabeledSwitch
checked={!!value.autoAddToDownloadList}
onChange={(checked) =>

View File

@ -33,15 +33,10 @@ type TabsProps = {
onChange: (id: string) => void
className?: string
ariaLabel?: string
/** Siehe Variants aus pasted.txt */
variant?: TabsVariant
/**
* Optional: In der pasted.txt sind Badges teils erst ab md sichtbar.
* Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop).
*/
hideCountUntilMd?: boolean
desktopBreakpoint?: 'sm' | 'md' | 'lg'
}
export default function Tabs({
@ -52,11 +47,27 @@ export default function Tabs({
ariaLabel = 'Ansicht auswählen',
variant = 'underline',
hideCountUntilMd = false,
desktopBreakpoint = 'sm',
}: TabsProps) {
if (!tabs?.length) return null
const current = tabs.find((t) => t.id === value) ?? tabs[0]
const mobileClassByBreakpoint = {
sm: 'sm:hidden',
md: 'md:hidden',
lg: 'lg:hidden',
} as const
const desktopClassByBreakpoint = {
sm: 'hidden sm:block',
md: 'hidden md:block',
lg: 'hidden lg:block',
} as const
const mobileWrapperClass = mobileClassByBreakpoint[desktopBreakpoint]
const desktopWrapperClass = desktopClassByBreakpoint[desktopBreakpoint]
const mobileSelectClass = clsx(
// entspricht den Beispielen (outline-1 + -outline-offset-1)
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
@ -317,7 +328,7 @@ export default function Tabs({
return (
<div className={className}>
{/* Mobile: Select + Chevron (wie Beispiele) */}
<div className="grid grid-cols-1 sm:hidden">
<div className={clsx('grid grid-cols-1', mobileWrapperClass)}>
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
@ -332,7 +343,7 @@ export default function Tabs({
</div>
{/* Desktop */}
<div className="hidden sm:block">{renderDesktop()}</div>
<div className={desktopWrapperClass}>{renderDesktop()}</div>
</div>
)
}

View File

@ -4817,7 +4817,7 @@ export default function TrainingTab(props: {
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
return (
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
<div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
{loadingPreviewUrl ? (
<img
src={loadingPreviewUrl}

View File

@ -1,3 +1,5 @@
/* frontend\src\index.css */
@import "tailwindcss";
@import 'flag-icons/css/flag-icons.min.css';
@ -16,6 +18,35 @@
-moz-osx-font-smoothing: grayscale;
}
/* Mobile: Textauswahl/Callout bei Long-Press global verhindern */
html,
body,
#root,
button,
a,
[role="button"],
.select-none-mobile {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Eingaben und explizit markierbarer Text bleiben auswählbar */
input,
textarea,
select,
[contenteditable="true"],
.user-select-text {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
html,
body {
-webkit-tap-highlight-color: transparent;
}
/* Video.js Popups (Playback-Rate Menü, Volume-Slider) über eigene Overlays legen */
.video-js {
position: relative; /* schafft eine stabile Basis */

View File

@ -257,11 +257,23 @@ export type RecorderSettingsState = {
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
generateAssetsMeta?: boolean
generateAssetsThumb?: boolean
generateAssetsTeaser?: boolean
generateAssetsSprites?: boolean
generateAssetsAnalyze?: boolean
enrichPostworkEnabled?: boolean
trainingRecognitionEnabled?: boolean
trainingDetectorEpochs?: number
}
export type JobEvent = {