diff --git a/backend/main.go b/backend/main.go index 3e3dec8..edb555d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -206,7 +206,7 @@ func (d *dummyResponseWriter) WriteHeader(statusCode int) {} var ( jobs = map[string]*RecordJob{} - jobsMu = sync.Mutex{} + jobsMu = sync.RWMutex{} ) func startPreviewIdleKiller() { @@ -214,21 +214,21 @@ func startPreviewIdleKiller() { go func() { defer t.Stop() for range t.C { - jobsMu.Lock() + jobsMu.RLock() list := make([]*RecordJob, 0, len(jobs)) for _, j := range jobs { if j != nil { list = append(list, j) } } - jobsMu.Unlock() + jobsMu.RUnlock() for _, j := range list { - jobsMu.Lock() + jobsMu.RLock() cmdRunning := j.previewCmd != nil last := j.previewLastHit st := j.Status - jobsMu.Unlock() + jobsMu.RUnlock() if !cmdRunning { continue diff --git a/backend/record.go b/backend/record.go index c5b4c7c..81b3931 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1068,20 +1068,70 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) { // ---------------- Handlers ---------------- +type recordListItem struct { + ID string `json:"id"` + SourceURL string `json:"sourceUrl"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + StartedAtMs int64 `json:"startedAtMs"` + EndedAt *time.Time `json:"endedAt,omitempty"` + EndedAtMs int64 `json:"endedAtMs,omitempty"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + Phase string `json:"phase,omitempty"` + Progress int `json:"progress,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + DurationSeconds float64 `json:"durationSeconds,omitempty"` + PreviewState string `json:"previewState,omitempty"` + RoomStatus string `json:"roomStatus,omitempty"` + IsOnline bool `json:"isOnline,omitempty"` + ModelImageURL string `json:"modelImageUrl,omitempty"` + ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"` + PostWorkKey string `json:"postWorkKey,omitempty"` + PostWork *PostWorkKeyStatus `json:"postWork,omitempty"` +} + +func snapshotRecordJobForList(j *RecordJob) recordListItem { + var postWorkCopy *PostWorkKeyStatus + if j.PostWork != nil { + pw := *j.PostWork + postWorkCopy = &pw + } + + return recordListItem{ + ID: j.ID, + SourceURL: j.SourceURL, + Status: string(j.Status), + StartedAt: j.StartedAt, + StartedAtMs: j.StartedAtMs, + EndedAt: j.EndedAt, + EndedAtMs: j.EndedAtMs, + Output: j.Output, + Error: j.Error, + Phase: j.Phase, + Progress: j.Progress, + SizeBytes: j.SizeBytes, + DurationSeconds: j.DurationSeconds, + PreviewState: j.PreviewState, + PostWorkKey: j.PostWorkKey, + PostWork: postWorkCopy, + } +} + func recordList(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodGet) { return } - jobsMu.Lock() - list := make([]*RecordJob, 0, len(jobs)) + jobsMu.RLock() + list := make([]recordListItem, 0, len(jobs)) for _, j := range jobs { if j == nil || j.Hidden { continue } - list = append(list, j) + list = append(list, snapshotRecordJobForList(j)) } - jobsMu.Unlock() + jobsMu.RUnlock() sort.Slice(list, func(i, j int) bool { return list[i].StartedAt.After(list[j].StartedAt) @@ -1267,9 +1317,9 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { return } - jobsMu.Lock() + jobsMu.RLock() job, ok := jobs[id] - jobsMu.Unlock() + jobsMu.RUnlock() if !ok || job == nil { http.Error(w, "job nicht gefunden", http.StatusNotFound) return @@ -1411,9 +1461,9 @@ func recordStatus(w http.ResponseWriter, r *http.Request) { return } - jobsMu.Lock() + jobsMu.RLock() job, ok := jobs[id] - jobsMu.Unlock() + jobsMu.RUnlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) @@ -1431,9 +1481,9 @@ func recordStop(w http.ResponseWriter, r *http.Request) { id := q(r, "id") - jobsMu.Lock() + jobsMu.RLock() job, ok := jobs[id] - jobsMu.Unlock() + jobsMu.RUnlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return @@ -1754,6 +1804,33 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { // ---------------- Done meta + list ---------------- +func activePostworkBasenameSet() map[string]struct{} { + out := make(map[string]struct{}) + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + if !isPostworkJob(j) { + continue + } + if isTerminalJobStatus(j.Status) { + continue + } + + base := strings.TrimSpace(filepath.Base(j.Output)) + if base == "" { + continue + } + out[strings.ToLower(base)] = struct{}{} + } + + return out +} + func recordDoneMeta(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodGet) { return @@ -1884,34 +1961,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { sortedAll := doneCache.sortedIdx doneCache.mu.Unlock() - isActivePostworkOutput := func(fullPath string) bool { - base := strings.TrimSpace(filepath.Base(fullPath)) - if base == "" { - return false - } - - jobsMu.Lock() - defer jobsMu.Unlock() - - for _, j := range jobs { - if j == nil { - continue - } - - if !isPostworkJob(j) { - continue - } - if isTerminalJobStatus(j.Status) { - continue - } - - if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) { - return true - } - } - - return false - } + activePostwork := activePostworkBasenameSet() count := 0 if qModel == "" { @@ -1922,7 +1972,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { for _, idx := range sortedAll[incKey+"|completed_desc"] { it := items[idx] - if isActivePostworkOutput(it.job.Output) { + if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists { continue } count++ @@ -1935,7 +1985,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { if it.modelKey != qModel { continue } - if isActivePostworkOutput(it.job.Output) { + if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists { continue } count++ @@ -2157,34 +2207,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { }) } - isActivePostworkOutput := func(fullPath string) bool { - base := strings.TrimSpace(filepath.Base(fullPath)) - if base == "" { - return false - } - - jobsMu.Lock() - defer jobsMu.Unlock() - - for _, j := range jobs { - if j == nil { - continue - } - - if !isPostworkJob(j) { - continue - } - if isTerminalJobStatus(j.Status) { - continue - } - - if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) { - return true - } - } - - return false - } + activePostwork := activePostworkBasenameSet() filteredIdx := make([]int, 0, len(idx)) for _, ii := range idx { @@ -2192,7 +2215,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { if it.job == nil { continue } - if isActivePostworkOutput(it.job.Output) { + if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists { continue } filteredIdx = append(filteredIdx, ii) diff --git a/backend/recorder.go b/backend/recorder.go index 0aa2466..d4006a7 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -54,6 +54,9 @@ func setJobProgress(job *RecordJob, phase string, pct int) { jobsMu.Lock() defer jobsMu.Unlock() + oldPhase := job.Phase + oldProgress := job.Progress + inPostwork := job.EndedAt != nil || (strings.TrimSpace(job.Phase) != "" && strings.ToLower(strings.TrimSpace(job.Phase)) != "recording") if inPostwork { if phaseLower == "" || phaseLower == "recording" { @@ -61,40 +64,45 @@ func setJobProgress(job *RecordJob, phase string, pct int) { } } + newPhase := job.Phase if phase != "" { - job.Phase = phase + newPhase = phase } - // recording = direkter Prozentwert + newProgress := job.Progress + if !inPostwork { - if pct < job.Progress { - pct = job.Progress + if pct < newProgress { + pct = newProgress } - job.Progress = pct + newProgress = pct + } else { + r := rangeFor(phaseLower) + width := float64(r.end - r.start) + + mapped := r.start + if width > 0 { + mapped = r.start + int(math.Round((float64(pct)/100.0)*width)) + } + + if mapped < r.start { + mapped = r.start + } + if mapped > r.end { + mapped = r.end + } + if mapped < newProgress { + mapped = newProgress + } + newProgress = mapped + } + + if oldPhase == newPhase && oldProgress == newProgress { return } - // postwork-Phasen: pct ist IMMER lokal 0..100 innerhalb der Phase - r := rangeFor(phaseLower) - width := float64(r.end - r.start) - - mapped := r.start - if width > 0 { - mapped = r.start + int(math.Round((float64(pct)/100.0)*width)) - } - - if mapped < r.start { - mapped = r.start - } - if mapped > r.end { - mapped = r.end - } - - if mapped < job.Progress { - mapped = job.Progress - } - - job.Progress = mapped + job.Phase = newPhase + job.Progress = newProgress } // ---------------- Preview scrubber ---------------- diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 8daa9d6..64c896a 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,13 +1,13 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", - "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", + "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", + "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, "autoStartAddedDownloads": true, "enableConcurrentDownloadsLimit": true, - "maxConcurrentDownloads": 40, + "maxConcurrentDownloads": 80, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, "autoDeleteSmallDownloads": true, @@ -17,5 +17,5 @@ "teaserPlayback": "all", "teaserAudio": true, "enableNotifications": true, - "encryptedCookies": "tZzqhwM+6rrdV36edfxpkhfikSfzfs4VZESLJIpYw1U4Bwn7vo+P8Sq86M7nvXpvbQVg00QLl5Wo2dD2agojU58GYY5P5P6Wpc1ZPr2T409BaQXRA/WhHiZv7z/b4JoVYX7fScZ4FDtscQYuTdMpnU6Wr725j66Qg2x8VgXqUHb1qceUes9P3L2kc8MAvZHWnZHIEjbgDW9NAZOADlbBxOonmc5hMGpz5xzmpWWV7Gqg6n1i75U/XBDV6Ks1Jn5Ic9dwi0f1BH6zo56v5oDdPAxKPlw01TUsV9ZAA+u+TjWUiq52AJP/C20bujUEsmQupqKxMs3E4rs6DEb7SYfbQ6SSryKyYYLCv0T3+CbeUgDmgYd2n+yjatE8udHvmkCtZCWvBIK4lSglsVeFq1prgpiGBL2ItyLcR13musHGbZ7Whq/p1btJsFDTTyfGnILW+sRUcd1Y/17YM5zKAWYyi81qKop6DYqh9kBoM2y3DYVxsLkb9+lUmwN6N7oopqh/bJ8vxJKyC74=" + "encryptedCookies": "yDldmKNsPH20hdVRPLdr8nVj4CoIC/NHiXESzcqLLSsEcgi4P7V7+xcKw2d3I/GnGR6uVNQYBrc4c+YeUs9J7wbn9Ir+BdaFDAWZVWBoaHgdcgYLaKUxIMO273zfi+jwkPF4DHuSBw9gkAHgKeF15H+azEQEDjJc8VH1C+7/F6qhkHBtFKKDiAkj8a4+cqmzr6Cr+tdraCaTxu4YZmqA/3erMRl/HA6egQFAcfGB2KAcfBqQagvXZ+R8SgfTwJiLG7RkH5Xdh3fT34Cc03sbyXCJxMzFUd4RKgCRyM2oUfqXq/oOL8AbZxm0oi2Mh+8TaqbAfdu1CstnPlAFAk7s3gMvp4Ni5QVwtfKQYzSYThKyaZSV2wnWZIWOXh9Zb3SvmAQBANJUHG9WUr/nyxqDPj03bIKNWA8/ZflAyVtCQtlpQU4Nrqe3Fhl/ALBuhgTcwx8X77rs1Py7sR9SJ8Ks+bKf8ziE04oQem6HQsIf1TAZTpYbpd7mD7NrbUuDtbJ3RabZMppygQc=" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e8d6f7a..4aa7ddc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2124,7 +2124,7 @@ export default function App() { } }, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow]) - // ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility + // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible useEffect(() => { let cancelled = false @@ -2138,19 +2138,23 @@ export default function App() { } const onUpdated = () => void load() - const onFocus = () => void load() + + let wasHidden = document.hidden + const onVisible = () => { + const becameVisible = wasHidden && !document.hidden + wasHidden = document.hidden + if (becameVisible) void load() + } window.addEventListener('recorder-settings-updated', onUpdated as EventListener) - window.addEventListener('focus', onFocus) - document.addEventListener('visibilitychange', onFocus) + document.addEventListener('visibilitychange', onVisible) - load() + void load() return () => { cancelled = true window.removeEventListener('recorder-settings-updated', onUpdated as EventListener) - window.removeEventListener('focus', onFocus) - document.removeEventListener('visibilitychange', onFocus) + document.removeEventListener('visibilitychange', onVisible) } }, []) @@ -2313,37 +2317,6 @@ export default function App() { localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) }, [cookies, cookiesLoaded]) - useEffect(() => { - // 1x initial / bei sort-wechsel (für Badge) - void loadDoneCount() - - const onVis = () => { - if (!document.hidden) void loadDoneCount() - } - document.addEventListener('visibilitychange', onVis) - - return () => { - document.removeEventListener('visibilitychange', onVis) - } - }, [loadDoneCount]) - - useEffect(() => { - if (selectedTab !== 'finished') return - - void loadDoneCount() - - const onVis = () => { - if (!document.hidden) { - void loadDoneCount() - } - } - document.addEventListener('visibilitychange', onVis) - - return () => { - document.removeEventListener('visibilitychange', onVis) - } - }, [selectedTab, loadDoneCount]) - useEffect(() => { if (!authed) return diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 294d3f5..4dada61 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -18,6 +18,7 @@ import DownloadsCardRow, { } from './DownloadsCardRow' import SwipeActionRow from './SwipeActionRow' import RecordJobActions from './RecordJobActions' +import LazyMountWhenVisible from './LazyMountWhenVisible' type AutostartState = { paused?: boolean @@ -564,46 +565,6 @@ function DiskEmergencyBadge() { ) } -function LazyMountWhenVisible({ - children, - placeholder, - rootMargin = '300px', -}: { - children: ReactNode - placeholder: ReactNode - rootMargin?: string -}) { - const hostRef = useRef(null) - const [visible, setVisible] = useState(false) - - useEffect(() => { - if (visible) return - - const el = hostRef.current - if (!el) return - - const io = new IntersectionObserver( - (entries) => { - const entry = entries[0] - if (entry?.isIntersecting) { - setVisible(true) - io.disconnect() - } - }, - { rootMargin, threshold: 0.01 } - ) - - io.observe(el) - return () => io.disconnect() - }, [visible, rootMargin]) - - return ( -
- {visible ? children : placeholder} -
- ) -} - export default function Downloads({ jobs, pending = [], @@ -994,9 +955,8 @@ export default function Downloads({ return (
- } + className="w-full h-full" + placeholder={
} > - + placeholder={
} + > + +
diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index c336ac3..1cd56ff 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -814,7 +814,13 @@ export default function FinishedVideoPreview({ teaserOk && Boolean(teaserSrc) && !showingInlineVideo && - shouldPreloadAnimatedAssets + shouldPreloadAnimatedAssets && + animatedTrigger !== 'hover' + + const showStillImage = + shouldLoadStill && + !showingInlineVideo && + !(animatedMode === 'teaser' && teaserActive) // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig) const progressVideoRef = @@ -1298,7 +1304,7 @@ export default function FinishedVideoPreview({ data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined} > {/* ✅ Thumb IMMER als Fallback/Background */} - {shouldLoadStill && thumbSrc && thumbOk ? ( + {showStillImage && thumbSrc && thumbOk ? ( (null) + const [visible, setVisible] = useState(false) + + useEffect(() => { + const el = hostRef.current + if (!el) return + if (visible && once) return + + const io = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (!entry) return + + if (entry.isIntersecting) { + setVisible(true) + if (once) io.disconnect() + } else if (!once) { + setVisible(false) + } + }, + { rootMargin, threshold } + ) + + io.observe(el) + return () => io.disconnect() + }, [visible, once, rootMargin, threshold]) + + return ( +
+ {visible ? children : placeholder} +
+ ) +} \ No newline at end of file