From 32bd7c1e6ef93ffb3e1df0c1539bdaa2b632a764 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:59:42 +0200 Subject: [PATCH] updated --- backend/chaturbate_online.go | 163 +++++---- backend/preview.go | 147 ++++++-- backend/recorder_settings.json | 4 +- frontend/src/App.tsx | 247 ++++++++----- frontend/src/components/ui/Downloads.tsx | 59 +-- .../src/components/ui/DownloadsCardRow.tsx | 48 +-- .../components/ui/FinishedVideoPreview.tsx | 31 +- .../src/components/ui/LastUpdatedText.tsx | 30 ++ frontend/src/components/ui/ModelPreview.tsx | 340 ++++-------------- 9 files changed, 490 insertions(+), 579 deletions(-) create mode 100644 frontend/src/components/ui/LastUpdatedText.tsx diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index f99771c..b82c37d 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -475,19 +475,13 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) { if cbModelStore != nil { copiedRooms := append([]ChaturbateRoom(nil), rooms...) - // Statusfelder pflegen (online/offline, room_status, last_online_at, ...) syncChaturbateRoomStateIntoModelStore( cbModelStore, copiedRooms, fetchedAtNow, ) - // Vollständigen Snapshot pflegen, aber offline NICHT überschreiben _ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow) - - if len(copiedRooms) > 0 { - go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms) - } } return fetchedAtNow, nil @@ -555,13 +549,8 @@ func startChaturbateOnlinePoller(store *ModelStore) { go func(roomsCopy []ChaturbateRoom, ts time.Time) { syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts) - _ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts) }(copiedRooms, fetchedAtNow) - - if len(copiedRooms) > 0 { - cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms) - } } // Tags übernehmen ist teuer -> nur selten + im Hintergrund @@ -605,7 +594,7 @@ func cachedOnline(key string) ([]byte, bool) { if !ok { return nil, false } - if time.Since(e.at) > 2*time.Second { // TTL + if time.Since(e.at) > 5*time.Second { // TTL delete(onlineCache, key) return nil, false } @@ -635,6 +624,22 @@ type cbOnlineReq struct { Refresh bool `json:"refresh"` } +type cbOnlineOutRoom struct { + Username string `json:"username"` + CurrentShow string `json:"current_show"` + ChatRoomURL string `json:"chat_room_url"` + ImageURL string `json:"image_url"` +} + +type cbOnlineResponse struct { + Enabled bool `json:"enabled"` + FetchedAt time.Time `json:"fetchedAt"` + Count int `json:"count"` + Total int `json:"total"` + LastError string `json:"lastError"` + Rooms []cbOnlineOutRoom `json:"rooms"` +} + func hashKey(parts ...string) string { h := sha1.New() for _, p := range parts { @@ -738,6 +743,46 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st } } +func refreshRunningJobsHLSAsync(users []string, liteByUser map[string]ChaturbateOnlineRoomLite, cookieHeader string, reqUA string) { + if len(users) == 0 || liteByUser == nil { + return + } + + const hlsMinInterval = 12 * time.Second + + liteCopy := make(map[string]ChaturbateOnlineRoomLite, len(liteByUser)) + for k, v := range liteByUser { + liteCopy[k] = v + } + + go func(usersCopy []string, rooms map[string]ChaturbateOnlineRoomLite, cookie string, ua string) { + for _, u := range usersCopy { + rm, ok := rooms[u] + if !ok { + continue + } + + show := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) + if show == "" || show == "offline" { + continue + } + + if !shouldRefreshHLS(u, hlsMinInterval) { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookie, ua) + cancel() + if err != nil || strings.TrimSpace(newHls) == "" { + continue + } + + refreshRunningJobsHLS(u, newHls, cookie, ua) + } + }(append([]string(nil), users...), liteCopy, cookieHeader, reqUA) +} + func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) @@ -926,13 +971,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - out := map[string]any{ - "enabled": false, - "fetchedAt": time.Time{}, - "count": 0, - "total": 0, - "lastError": "", - "rooms": []any{}, + out := cbOnlineResponse{ + Enabled: false, + FetchedAt: time.Time{}, + Count: 0, + Total: 0, + LastError: "", + Rooms: []cbOnlineOutRoom{}, } body, _ := json.Marshal(out) setCachedOnline(cacheKey, body) @@ -954,32 +999,31 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { needBootstrap := fetchedAt.IsZero() - // ✅ Bei explizitem refresh synchron aktualisieren, - // damit die Response garantiert den neuesten Stand enthält. + // Bei explizitem refresh Snapshot-Aktualisierung asynchron anstoßen, + // Response bleibt dabei schnell und liefert den letzten bekannten Stand. if wantRefresh { cbRefreshMu.Lock() - if cbRefreshInFlight { - cbRefreshMu.Unlock() - } else { + if !cbRefreshInFlight { cbRefreshInFlight = true - cbRefreshMu.Unlock() cbMu.Lock() cb.LastAttempt = time.Now() cbMu.Unlock() - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - _, err := refreshChaturbateSnapshotNow(ctx) - cancel() + go func() { + defer func() { + cbRefreshMu.Lock() + cbRefreshInFlight = false + cbRefreshMu.Unlock() + }() - cbRefreshMu.Lock() - cbRefreshInFlight = false - cbRefreshMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() - if err != nil { - // Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut - } + _, _ = refreshChaturbateSnapshotNow(ctx) + }() } + cbRefreshMu.Unlock() } else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown { // ✅ Bootstrap darf weiterhin asynchron bleiben cbRefreshMu.Lock() @@ -1023,33 +1067,8 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { // Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so) // und nur wenn User gerade online ist. // --------------------------- - const hlsMinInterval = 12 * time.Second - if onlySpecificUsers && liteByUser != nil { - for _, u := range users { - rm, ok := liteByUser[u] - if !ok { - continue - } - - show := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) - if show == "offline" || show == "" { - continue - } - - if !shouldRefreshHLS(u, hlsMinInterval) { - continue - } - - ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) - newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookieHeader, reqUA) - cancel() - if err != nil || strings.TrimSpace(newHls) == "" { - continue - } - - refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA) - } + refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA) } // --------------------------- @@ -1066,12 +1085,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { // --------------------------- // Rooms bauen (LITE, O(Anzahl requested Users)) // --------------------------- - type outRoom struct { - Username string `json:"username"` - CurrentShow string `json:"current_show"` - ChatRoomURL string `json:"chat_room_url"` - ImageURL string `json:"image_url"` - } matches := func(rm ChaturbateOnlineRoomLite) bool { if len(allowedShow) > 0 { @@ -1131,7 +1144,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { } } - outRooms := make([]outRoom, 0, len(users)) + outRooms := make([]cbOnlineOutRoom, 0, len(users)) if onlySpecificUsers && liteByUser != nil { for _, u := range users { @@ -1142,7 +1155,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { if !matches(rm) { continue } - outRooms = append(outRooms, outRoom{ + outRooms = append(outRooms, cbOnlineOutRoom{ Username: rm.Username, CurrentShow: rm.CurrentShow, ChatRoomURL: rm.ChatRoomURL, @@ -1158,13 +1171,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - out := map[string]any{ - "enabled": true, - "fetchedAt": fetchedAt, - "count": len(outRooms), - "total": total, - "lastError": lastErr, - "rooms": outRooms, + out := cbOnlineResponse{ + Enabled: true, + FetchedAt: fetchedAt, + Count: len(outRooms), + Total: total, + LastError: lastErr, + Rooms: outRooms, } body, _ := json.Marshal(out) diff --git a/backend/preview.go b/backend/preview.go index 2a3a015..a2a5e13 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -1326,14 +1326,18 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er return b, nil } -func latestPreviewSegment(previewDir string) (string, error) { +func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) { segDir := previewSegmentsDir(previewDir) entries, err := os.ReadDir(segDir) if err != nil { - return "", err + return nil, err } - var best string + if maxCount <= 0 { + maxCount = 4 + } + + names := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue @@ -1344,15 +1348,25 @@ func latestPreviewSegment(previewDir string) (string, error) { continue } - if best == "" || name > best { - best = e.Name() - } + names = append(names, e.Name()) } - if best == "" { - return "", fmt.Errorf("kein Preview-Segment in %s", segDir) + if len(names) == 0 { + return nil, fmt.Errorf("kein Preview-Segment in %s", segDir) } - return filepath.Join(segDir, best), nil + + sort.Strings(names) + + if len(names) > maxCount { + names = names[len(names)-maxCount:] + } + + out := make([]string, 0, len(names)) + for _, name := range names { + out = append(out, filepath.Join(segDir, name)) + } + + return out, nil } func buildPreviewInputFromDir(previewDir string) (string, func(), error) { @@ -1361,19 +1375,51 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) { return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir) } - segPath, err := latestPreviewSegment(previewDir) + segPaths, err := latestPreviewSegments(previewDir, 4) if err != nil { return "", nil, err } - ext := strings.ToLower(filepath.Ext(segPath)) + ext := strings.ToLower(filepath.Ext(segPaths[len(segPaths)-1])) - // TS kann direkt gelesen werden + // Bei TS: ebenfalls lieber die letzten paar Segmente zusammenkleben, + // nicht nur das letzte einzelne Segment. if ext == ".ts" { - return segPath, func() {}, nil + tmp, err := os.CreateTemp(segDir, "preview_input_*.ts") + if err != nil { + return "", nil, err + } + tmpPath := tmp.Name() + + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + + for _, segPath := range segPaths { + b, err := os.ReadFile(segPath) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) + } + if len(b) == 0 { + continue + } + if _, err := tmp.Write(b); err != nil { + cleanup() + return "", nil, err + } + } + + if err := tmp.Close(); err != nil { + cleanup() + return "", nil, err + } + + return tmpPath, cleanup, nil } - // fMP4: init.mp4 + segment.m4s zu temp zusammensetzen + // fMP4/CMAF: init.mp4 + die letzten N Segmente initPath := filepath.Join(segDir, "init.mp4") if _, err := os.Stat(initPath); err != nil { return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir) @@ -1384,11 +1430,6 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) { return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err) } - segBytes, err := os.ReadFile(segPath) - if err != nil { - return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) - } - tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4") if err != nil { return "", nil, err @@ -1404,10 +1445,22 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) { cleanup() return "", nil, err } - if _, err := tmp.Write(segBytes); err != nil { - cleanup() - return "", nil, err + + for _, segPath := range segPaths { + segBytes, err := os.ReadFile(segPath) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) + } + if len(segBytes) == 0 { + continue + } + if _, err := tmp.Write(segBytes); err != nil { + cleanup() + return "", nil, err + } } + if err := tmp.Close(); err != nil { cleanup() return "", nil, err @@ -1666,25 +1719,52 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri return } + fallbackOnlyRaw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("fallbackOnly"))) + fallbackOnly := fallbackOnlyRaw == "1" || fallbackOnlyRaw == "true" || fallbackOnlyRaw == "yes" + + if fallbackOnly { + jobsMu.RLock() + job, ok := jobs[id] + jobsMu.RUnlock() + + if ok { + if serveFallbackPreviewImage(w, r, job, job.Output) { + return + } + servePreviewStatusSVG(w, "Preview", http.StatusOK) + return + } + + assetID := stripHotPrefix(strings.TrimSpace(id)) + if assetID != "" { + if outPath, err := findFinishedFileByID(assetID); err == nil { + if serveFallbackPreviewImage(w, r, nil, outPath) { + return + } + } + } + + servePreviewStatusSVG(w, "Preview", http.StatusOK) + return + } + // file serving if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { low := strings.ToLower(strings.TrimSpace(file)) - // ✅ preview.jpg weiterhin hier behandeln if low == "preview.jpg" { servePreviewJPGAlias(w, r, id) return } - // ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go recordPreviewFile(w, r) return } // JPG preview (running jobs have live thumb behavior) - jobsMu.Lock() + jobsMu.RLock() job, ok := jobs[id] - jobsMu.Unlock() + jobsMu.RUnlock() if ok { if job.Status == JobRunning { @@ -1741,9 +1821,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri return } - jobsMu.Lock() + jobsMu.RLock() state := strings.TrimSpace(job.PreviewState) - jobsMu.Unlock() + jobsMu.RUnlock() if state == "private" { serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK) @@ -1763,11 +1843,11 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri } func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { - jobsMu.Lock() + jobsMu.RLock() status := job.Status previewDir := job.PreviewDir out := job.Output - jobsMu.Unlock() + jobsMu.RUnlock() if status != JobRunning { return @@ -1780,7 +1860,8 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { } if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 { - if time.Since(st.ModTime()) < 10*time.Second { + // etwas Puffer, damit die 10s-Loop nicht an exakten Grenzwerten hängen bleibt + if time.Since(st.ModTime()) < 9*time.Second { return } } @@ -1827,9 +1908,9 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) { case <-ctx.Done(): return case <-time.After(10 * time.Second): - jobsMu.Lock() + jobsMu.RLock() st := job.Status - jobsMu.Unlock() + jobsMu.RUnlock() if st != JobRunning { return } diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 64c896a..b8f0572 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,8 +1,8 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", - "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", + "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", + "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, "autoStartAddedDownloads": true, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4aa7ddc..eebc480 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import CategoriesTab from './components/ui/CategoriesTab' import LoginPage from './components/ui/LoginPage' import VideoSplitModal from './components/ui/VideoSplitModal' import TextInput from './components/ui/TextInput' +import LastUpdatedText from './components/ui/LastUpdatedText' const COOKIE_STORAGE_KEY = 'record_cookies' @@ -477,6 +478,36 @@ const isPostworkJobForCount = (job: RecordJob): boolean => { return false } +function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] { + const incomingId = String((incoming as any)?.id ?? '').trim() + if (!incomingId) return list + + const byId = new Map() + + for (const j of list) { + const id = String((j as any)?.id ?? '').trim() + if (!id) continue + byId.set(id, j) + } + + byId.set(incomingId, { + ...(byId.get(incomingId) ?? ({} as RecordJob)), + ...incoming, + }) + + return Array.from(byId.values()).sort((a, b) => { + const aMs = + Number((a as any)?.startedAtMs ?? 0) || + (a?.startedAt ? Date.parse(a.startedAt) || 0 : 0) + + const bMs = + Number((b as any)?.startedAtMs ?? 0) || + (b?.startedAt ? Date.parse(b.startedAt) || 0 : 0) + + return bMs - aMs + }) +} + export default function App() { const [authChecked, setAuthChecked] = useState(false) @@ -543,6 +574,9 @@ export default function App() { const eventSourceRef = useRef(null) const modelEventNamesRef = useRef>(new Set()) const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) + + const lastSeenAtByJobIdRef = useRef>({}) + const MISSING_JOB_GRACE_MS = 20_000 const [playerModelKey, setPlayerModelKey] = useState(null) @@ -556,7 +590,6 @@ export default function App() { const [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) - const [nowMs, setNowMs] = useState(() => Date.now()) const [modelsByKey, setModelsByKey] = useState>({}) @@ -852,15 +885,69 @@ export default function App() { const data = await res.json().catch(() => null) - // akzeptiere: Array oder { items: [] } - const items = Array.isArray(data) + const serverItems = Array.isArray(data) ? (data as RecordJob[]) : Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] - setJobs(items) - jobsRef.current = items + const now = Date.now() + + for (const j of serverItems) { + if (j?.id) { + lastSeenAtByJobIdRef.current[j.id] = now + } + } + + setJobs((prev) => { + const byId = new Map() + + // 1) Server gewinnt immer + for (const j of serverItems) { + if (!j?.id) continue + byId.set(j.id, j) + } + + // 2) Lokale aktive Jobs kurz weiter behalten, wenn sie serverseitig + // vorübergehend fehlen + for (const j of prev) { + if (!j?.id) continue + if (byId.has(j.id)) continue + + const status = String(j.status ?? '').trim().toLowerCase() + const phase = String((j as any)?.phase ?? '').trim().toLowerCase() + const isTerminal = + status === 'stopped' || + status === 'finished' || + status === 'failed' || + status === 'done' || + status === 'completed' || + status === 'canceled' || + status === 'cancelled' + + const isActive = + !j.endedAt && + !isTerminal && + (status === 'running' || phase === 'recording' || phase === '') + + const lastSeen = lastSeenAtByJobIdRef.current[j.id] ?? 0 + const withinGrace = now - lastSeen < MISSING_JOB_GRACE_MS + + if (isActive || withinGrace) { + byId.set(j.id, j) + } + } + + const next = Array.from(byId.values()).sort((a, b) => { + const ta = new Date(a.startedAt || 0).getTime() + const tb = new Date(b.startedAt || 0).getTime() + return tb - ta + }) + + jobsRef.current = next + return next + }) + setLastHeaderUpdateAtMs(Date.now()) } catch { // ignore @@ -909,28 +996,10 @@ export default function App() { const jobId = String(msg?.jobId ?? '').trim() if (!jobId) return - if (msg.type === 'job_remove') { - setJobs((prev) => { - const next = prev.filter((j) => String((j as any).id ?? '') !== jobId) - jobsRef.current = next - return next - }) + const now = Date.now() - setPlayerJob((prev) => - prev && String((prev as any).id ?? '') === jobId ? null : prev - ) - - setLastHeaderUpdateAtMs(Date.now()) - return - } - - if (msg.type !== 'job_upsert') return - - setJobs((prev) => { - const idx = prev.findIndex((j) => String((j as any).id ?? '') === jobId) - const prevJob = idx >= 0 ? prev[idx] : null - - const patch: RecordJob = { + const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => { + return { ...(prevJob ?? ({} as RecordJob)), id: jobId, status: (msg.status as any) ?? (prevJob?.status ?? 'running'), @@ -959,14 +1028,48 @@ export default function App() { } : (prevJob as any)?.postWork, } as any + } - let next: RecordJob[] - if (idx >= 0) { - next = [...prev] - next[idx] = patch - } else { - next = [patch, ...prev] - } + if (msg.type === 'job_remove') { + delete lastSeenAtByJobIdRef.current[jobId] + + setJobs((prev) => { + const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId) + jobsRef.current = next + return next + }) + + setPlayerJob((prev) => + prev && String((prev as any).id ?? '').trim() === jobId ? null : prev + ) + + setLastHeaderUpdateAtMs(now) + return + } + + if (msg.type !== 'job_upsert') return + + lastSeenAtByJobIdRef.current[jobId] = now + + setJobs((prev) => { + const sameId = prev.filter((j) => String((j as any).id ?? '').trim() === jobId) + const prevJob = sameId.length > 0 ? sameId[0] : null + const merged = mergeJob(prevJob) + + const next = [ + merged, + ...prev.filter((j) => String((j as any).id ?? '').trim() !== jobId), + ].sort((a, b) => { + const aMs = + Number((a as any)?.startedAtMs ?? 0) || + (a?.startedAt ? Date.parse(a.startedAt) || 0 : 0) + + const bMs = + Number((b as any)?.startedAtMs ?? 0) || + (b?.startedAt ? Date.parse(b.startedAt) || 0 : 0) + + return bMs - aMs + }) jobsRef.current = next return next @@ -974,41 +1077,11 @@ export default function App() { setPlayerJob((prev) => { if (!prev) return prev - return String((prev as any).id ?? '') === jobId - ? ({ - ...prev, - ...{ - id: jobId, - status: (msg.status as any) ?? (prev as any).status, - sourceUrl: msg.sourceUrl ?? (prev as any).sourceUrl, - output: msg.output ?? prev.output, - startedAt: msg.startedAt ?? prev.startedAt, - endedAt: msg.endedAt ?? prev.endedAt, - phase: msg.phase ?? (prev as any).phase, - progress: msg.progress ?? (prev as any).progress, - startedAtMs: msg.startedAtMs ?? (prev as any).startedAtMs, - endedAtMs: msg.endedAtMs ?? (prev as any).endedAtMs, - sizeBytes: msg.sizeBytes ?? (prev as any).sizeBytes, - durationSeconds: msg.durationSeconds ?? (prev as any).durationSeconds, - previewState: msg.previewState ?? (prev as any).previewState, - roomStatus: msg.roomStatus ?? (prev as any).roomStatus, - isOnline: msg.isOnline ?? (prev as any).isOnline, - modelImageUrl: msg.modelImageUrl ?? (prev as any).modelImageUrl, - modelChatRoomUrl: msg.modelChatRoomUrl ?? (prev as any).modelChatRoomUrl, - - postWorkKey: msg.postWorkKey ?? (prev as any).postWorkKey, - postWork: msg.postWork - ? { - ...((prev as any).postWork ?? {}), - ...msg.postWork, - } - : (prev as any).postWork, - }, - } as any) - : prev + if (String((prev as any).id ?? '').trim() !== jobId) return prev + return mergeJob(prev) }) - setLastHeaderUpdateAtMs(Date.now()) + setLastHeaderUpdateAtMs(now) }, []) const ensureModelEventListener = useCallback((name: string) => { @@ -1050,25 +1123,6 @@ export default function App() { return true } - const formatAgoDE = (diffMs: number) => { - const s = Math.max(0, Math.floor(diffMs / 1000)) - if (s < 2) return 'gerade eben' - if (s < 60) return `vor ${s} Sekunden` - - const m = Math.floor(s / 60) - if (m === 1) return 'vor 1 Minute' - if (m < 60) return `vor ${m} Minuten` - - const h = Math.floor(m / 60) - if (h === 1) return 'vor 1 Stunde' - return `vor ${h} Stunden` - } - - const headerUpdatedText = useMemo(() => { - const diff = nowMs - lastHeaderUpdateAtMs - return `(zuletzt aktualisiert: ${formatAgoDE(diff)})` - }, [nowMs, lastHeaderUpdateAtMs]) - const buildModelsByKey = useCallback((list: StoredModel[]) => { const map: Record = {} @@ -1285,11 +1339,6 @@ export default function App() { } }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange]) - useEffect(() => { - const t = window.setInterval(() => setNowMs(Date.now()), 1000) - return () => window.clearInterval(t) - }, []) - useEffect(() => { const onOpen = (ev: Event) => { const e = ev as CustomEvent<{ modelKey?: string }> @@ -1516,8 +1565,11 @@ export default function App() { if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true // UI sofort aktualisieren (optional) - setJobs((prev) => [created, ...prev]) - jobsRef.current = [created, ...jobsRef.current] + setJobs((prev) => { + const next = upsertJobById(prev, created) + jobsRef.current = next + return next + }) return true } catch (e: any) { @@ -2104,8 +2156,11 @@ export default function App() { // und kurz danach kommt der Job nochmal über SSE/polling rein. if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true - setJobs((prev) => [created, ...prev]) - jobsRef.current = [created, ...jobsRef.current] + setJobs((prev) => { + const next = upsertJobById(prev, created) + jobsRef.current = next + return next + }) return true } catch (e: any) { const msg = e?.message ?? String(e) @@ -3358,14 +3413,14 @@ export default function App() {
- {headerUpdatedText} +
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
- {headerUpdatedText} +
diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index d1a985c..0446bbb 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -179,17 +179,6 @@ function effectiveRoomStatusOfJob( return raw } -function previewAlignEndAtOfJob(job: RecordJob): string | null { - const postworkState = getEffectivePostworkState(job) - - // Während Nachbearbeitung Preview nicht "abschalten" - if (postworkState === 'running' || postworkState === 'queued') { - return null - } - - return job.endedAt ?? null -} - const roomStatusTone = (status: string): string => { switch (status) { case 'Public': @@ -220,35 +209,6 @@ const toMs = (v: unknown): number => { return 0 } -const absUrlMaybe = (u?: string | null): string => { - const s = String(u ?? '').trim() - if (!s) return '' - if (/^https?:\/\//i.test(s)) return s - if (s.startsWith('/')) return s - return `/${s}` -} - -const jobThumbsJPGCandidates = (job: RecordJob): string[] => { - const j = job as any - - const direct = [ - j.thumbsJPGUrl, - j.thumbsUrl, - j.previewThumbsUrl, - j.thumbnailSheetUrl, - ] - - const base = [ - j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - ] - - return [...direct, ...base] - .map((x) => absUrlMaybe(String(x ?? ''))) - .filter(Boolean) -} - const addedAtMsOf = (r: DownloadRow): number => { if (r.kind === 'job') { const j = r.job as any @@ -959,6 +919,7 @@ export default function Downloads({ > @@ -1278,11 +1233,19 @@ export default function Downloads({ ]) const downloadJobRows = useMemo(() => { - const list = jobs + const seen = new Set() + + const list = jobs .filter((j) => { if (isPostworkJob(j)) return false if (isTerminalStatus((j as any)?.status)) return false if (Boolean((j as any)?.endedAt)) return false + + const id = String((j as any)?.id ?? '').trim() + if (!id) return false + if (seen.has(id)) return false + + seen.add(id) return true }) .map((job) => ({ kind: 'job', job }) as const) diff --git a/frontend/src/components/ui/DownloadsCardRow.tsx b/frontend/src/components/ui/DownloadsCardRow.tsx index 6de42b2..c52db61 100644 --- a/frontend/src/components/ui/DownloadsCardRow.tsx +++ b/frontend/src/components/ui/DownloadsCardRow.tsx @@ -200,16 +200,6 @@ function previewRoomStatusOfJob( return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId) } -function previewAlignEndAtOfJob(job: RecordJob): string | null { - const postworkState = getEffectivePostworkState(job) - - if (postworkState === 'running' || postworkState === 'queued') { - return null - } - - return job.endedAt ?? null -} - const roomStatusTone = (status: string): string => { switch (status) { case 'Public': @@ -240,35 +230,6 @@ const toMs = (v: unknown): number => { return 0 } -const absUrlMaybe = (u?: string | null): string => { - const s = String(u ?? '').trim() - if (!s) return '' - if (/^https?:\/\//i.test(s)) return s - if (s.startsWith('/')) return s - return `/${s}` -} - -const jobThumbsJPGCandidates = (job: RecordJob): string[] => { - const j = job as any - - const direct = [ - j.thumbsJPGUrl, - j.thumbsUrl, - j.previewThumbsUrl, - j.thumbnailSheetUrl, - ] - - const base = [ - j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '', - ] - - return [...direct, ...base] - .map((x) => absUrlMaybe(String(x ?? ''))) - .filter(Boolean) -} - const formatDuration = (ms: number): string => { if (!Number.isFinite(ms) || ms <= 0) return '—' const total = Math.floor(ms / 1000) @@ -711,6 +672,7 @@ export default function DownloadsCardRow({ > diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 1cd56ff..b95f274 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -67,8 +67,6 @@ export type FinishedVideoPreviewProps = { /** Popover-Video muted? (Default: true) */ popoverMuted?: boolean - noGenerateTeaser?: boolean - /** Still-Preview (Bild) unabhängig vom inView-Gating laden */ alwaysLoadStill?: boolean @@ -124,7 +122,6 @@ export default function FinishedVideoPreview({ muted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED, - noGenerateTeaser, alwaysLoadStill = false, teaserPreloadEnabled = false, teaserPreloadRootMargin = '700px 0px', @@ -557,7 +554,7 @@ export default function FinishedVideoPreview({ emittedResolutionRef.current = '' teaserSoundForcedRef.current = false setMetaLoaded(false) - }, [previewId, assetNonce, noGenerateTeaser]) + }, [previewId, assetNonce]) useEffect(() => { const onRelease = (ev: any) => { @@ -673,9 +670,8 @@ export default function FinishedVideoPreview({ const teaserSrc = useMemo(() => { if (!previewId) return '' - const noGen = noGenerateTeaser ? '&noGenerate=1' : '' - return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` - }, [previewId, v, noGenerateTeaser]) + return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}` + }, [previewId, v]) // --- Inline Video sichtbar? const showingInlineVideo = @@ -811,7 +807,6 @@ export default function FinishedVideoPreview({ const teaserCanPrewarm = animated && animatedMode === 'teaser' && - teaserOk && Boolean(teaserSrc) && !showingInlineVideo && shouldPreloadAnimatedAssets && @@ -1361,9 +1356,15 @@ export default function FinishedVideoPreview({ className="hidden" muted playsInline - preload="auto" - onLoadedData={() => setTeaserReady(true)} - onCanPlay={() => setTeaserReady(true)} + preload="metadata" + onLoadedData={() => { + setTeaserOk(true) + setTeaserReady(true) + }} + onCanPlay={() => { + setTeaserOk(true) + setTeaserReady(true) + }} onError={() => { setTeaserOk(false) setTeaserReady(false) @@ -1394,6 +1395,14 @@ export default function FinishedVideoPreview({ loop preload={teaserReady ? 'auto' : 'metadata'} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} + onLoadedData={() => { + setTeaserOk(true) + setTeaserReady(true) + }} + onCanPlay={() => { + setTeaserOk(true) + setTeaserReady(true) + }} onError={() => { setTeaserOk(false) setTeaserReady(false) diff --git a/frontend/src/components/ui/LastUpdatedText.tsx b/frontend/src/components/ui/LastUpdatedText.tsx new file mode 100644 index 0000000..caa2f86 --- /dev/null +++ b/frontend/src/components/ui/LastUpdatedText.tsx @@ -0,0 +1,30 @@ +import { useEffect, useMemo, useState } from 'react' + +function formatAgoDE(diffMs: number) { + const s = Math.max(0, Math.floor(diffMs / 1000)) + if (s < 2) return 'gerade eben' + if (s < 60) return `vor ${s} Sekunden` + + const m = Math.floor(s / 60) + if (m === 1) return 'vor 1 Minute' + if (m < 60) return `vor ${m} Minuten` + + const h = Math.floor(m / 60) + if (h === 1) return 'vor 1 Stunde' + return `vor ${h} Stunden` +} + +export default function LastUpdatedText({ lastAt }: { lastAt: number }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(t) + }, []) + + const text = useMemo(() => { + return `(zuletzt aktualisiert: ${formatAgoDE(now - lastAt)})` + }, [now, lastAt]) + + return <>{text} +} \ No newline at end of file diff --git a/frontend/src/components/ui/ModelPreview.tsx b/frontend/src/components/ui/ModelPreview.tsx index dbc55ce..4b7dacd 100644 --- a/frontend/src/components/ui/ModelPreview.tsx +++ b/frontend/src/components/ui/ModelPreview.tsx @@ -12,119 +12,68 @@ import { type Props = { jobId: string + live?: boolean thumbTick?: number autoTickMs?: number blur?: boolean className?: string fit?: 'cover' | 'contain' roomStatus?: string - - alignStartAt?: string | number | Date - alignEndAt?: string | number | Date | null - alignEveryMs?: number - - fastRetryMs?: number - fastRetryMax?: number - fastRetryWindowMs?: number - - thumbsJPGUrl?: string | null - thumbsCandidates?: Array + fallbackSrc?: string } export default function ModelPreview({ jobId, + live = false, thumbTick, autoTickMs = 10_000, blur = false, className, + fit = 'cover', roomStatus, - alignStartAt, - alignEndAt = null, - alignEveryMs, - fastRetryMs, - fastRetryMax, - fastRetryWindowMs, - thumbsJPGUrl, - thumbsCandidates, + fallbackSrc, }: Props) { - const blurCls = blur ? 'blur-md' : '' - const CONTROLBAR_H = 0 - - const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase() - const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' - const rootRef = useRef(null) + const [inView, setInView] = useState(false) + const [pageVisible, setPageVisible] = useState(!document.hidden) + const [localTick, setLocalTick] = useState(0) + const [imgError, setImgError] = useState(false) - // ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange) - const pageVisibleRef = useRef(true) - - const [pageVisible, setPageVisible] = useState(true) const [popupMuted, setPopupMuted] = useState(true) const [popupVolume, setPopupVolume] = useState(1) - // inView als State (brauchen wir für eager/lazy + fetchPriority + UI) - const [inView, setInView] = useState(false) - const inViewRef = useRef(false) - const shouldRenderPreview = inView && pageVisible + const blurCls = blur ? 'blur-md' : '' + const objectFitCls = fit === 'contain' ? 'object-contain' : 'object-cover' - const [localTick, setLocalTick] = useState(0) - const [directImgError, setDirectImgError] = useState(false) - const [apiImgError, setApiImgError] = useState(false) + const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase() + const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' + + const tick = live + ? (typeof thumbTick === 'number' ? thumbTick : localTick) + : 0 - const retryT = useRef(null) - const fastTries = useRef(0) - const hadSuccess = useRef(false) - const enteredViewOnce = useRef(false) + const previewSrc = useMemo(() => { + // Nur bereits vorhandenes preview.jpg anfordern, niemals on-the-fly generieren + return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${tick}` + }, [jobId, tick]) - const toMs = (v: any): number => { - if (typeof v === 'number' && Number.isFinite(v)) return v - if (v instanceof Date) return v.getTime() - const ms = Date.parse(String(v ?? '')) - return Number.isFinite(ms) ? ms : NaN - } + const fallbackImgSrc = useMemo(() => { + const s = String(fallbackSrc ?? '').trim() + if (s) return s + return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` + }, [fallbackSrc, jobId]) - const normalizeUrl = (u?: string | null): string => { - const s = String(u ?? '').trim() - if (!s) return '' - if (/^https?:\/\//i.test(s)) return s - if (s.startsWith('/')) return s - return `/${s}` - } + const hq = useMemo( + () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`, + [jobId] + ) - const thumbsCandidatesKey = useMemo(() => { - const list = [ - thumbsJPGUrl, - ...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []), - ] - .map(normalizeUrl) - .filter(Boolean) - - // Reihenfolge behalten, nur dedupe - return Array.from(new Set(list)).join('|') - }, [thumbsJPGUrl, thumbsCandidates]) - - // ✅ visibilitychange -> nur REF updaten useEffect(() => { - const onVis = () => { - const vis = !document.hidden - pageVisibleRef.current = vis - setPageVisible(vis) // ✅ sorgt dafür, dass Tick-Effect neu aufgebaut wird - } - const vis = !document.hidden - pageVisibleRef.current = vis - setPageVisible(vis) - + const onVis = () => setPageVisible(!document.hidden) document.addEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis) }, []) - useEffect(() => { - return () => { - if (retryT.current) window.clearTimeout(retryT.current) - } - }, []) - - // ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel useEffect(() => { const el = rootRef.current if (!el) return @@ -132,15 +81,11 @@ export default function ModelPreview({ const obs = new IntersectionObserver( (entries) => { const entry = entries[0] - const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)) - if (next === inViewRef.current) return - inViewRef.current = next - setInView(next) + setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0)) }, { root: null, - threshold: 0, - rootMargin: '300px 0px', + threshold: 0.01, } ) @@ -148,125 +93,27 @@ export default function ModelPreview({ return () => obs.disconnect() }, []) - // ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt) useEffect(() => { + setImgError(false) + }, [jobId]) + + useEffect(() => { + setImgError(false) + }, [previewSrc]) + + useEffect(() => { + if (!live) return if (typeof thumbTick === 'number') return if (!inView) return - if (!pageVisibleRef.current) return - if (enteredViewOnce.current) return - enteredViewOnce.current = true - setLocalTick((x) => x + 1) - }, [inView, thumbTick]) + if (!pageVisible) return - // ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden) - useEffect(() => { - if (typeof thumbTick === 'number') return - if (!inView) return - if (!pageVisibleRef.current) return - - const period = Number(alignEveryMs ?? autoTickMs ?? 10_000) - if (!Number.isFinite(period) || period <= 0) return - - const startMs = alignStartAt ? toMs(alignStartAt) : NaN - const endMs = alignEndAt ? toMs(alignEndAt) : NaN - - // aligned schedule - if (Number.isFinite(startMs)) { - let t: number | undefined - - const schedule = () => { - // ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen - if (!pageVisibleRef.current) return - const now = Date.now() - if (Number.isFinite(endMs) && now >= endMs) return - - const elapsed = Math.max(0, now - startMs) - const rem = elapsed % period - const wait = rem === 0 ? period : period - rem - - t = window.setTimeout(() => { - // ✅ nochmal checken, falls inzwischen offscreen/hidden - if (!inViewRef.current) return - if (!pageVisibleRef.current) return - setLocalTick((x) => x + 1) - schedule() - }, wait) - } - - schedule() - return () => { - if (t) window.clearTimeout(t) - } - } - - // fallback interval const id = window.setInterval(() => { - if (!inViewRef.current) return - if (!pageVisibleRef.current) return setLocalTick((x) => x + 1) - }, period) + setImgError(false) + }, autoTickMs) return () => window.clearInterval(id) - }, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs]) - - // ✅ tick Quelle - const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick - - // ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!) - // Wir "freezen" den Tick, solange inView=false oder tab hidden - const frozenTickRef = useRef(0) - const [frozenTick, setFrozenTick] = useState(0) - - useEffect(() => { - if (!inView) return - if (!pageVisibleRef.current) return - frozenTickRef.current = rawTick - setFrozenTick(rawTick) - }, [rawTick, inView]) - - // bei neuem *sichtbaren* Tick Error-Flag zurücksetzen - useEffect(() => { - setDirectImgError(false) - setApiImgError(false) - }, [frozenTick]) - - // bei Job-Wechsel reset - useEffect(() => { - hadSuccess.current = false - fastTries.current = 0 - enteredViewOnce.current = false - setDirectImgError(false) - setApiImgError(false) - - if (inViewRef.current && pageVisibleRef.current) { - setLocalTick((x) => x + 1) - } - }, [jobId, thumbsCandidatesKey]) - - const thumb = useMemo( - () => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`, - [jobId, frozenTick] - ) - - const hq = useMemo( - () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`, - [jobId] - ) - - const directThumbCandidates = useMemo(() => { - if (!thumbsCandidatesKey) return [] - return thumbsCandidatesKey.split('|') - }, [thumbsCandidatesKey]) - - const directThumb = directThumbCandidates[0] || '' - const useDirectThumb = Boolean(directThumb) && !directImgError - const currentImgSrc = useMemo(() => { - if (useDirectThumb) { - const sep = directThumb.includes('?') ? '&' : '?' - return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}` - } - return thumb - }, [useDirectThumb, directThumb, frozenTick, thumb]) + }, [live, thumbTick, inView, pageVisible, autoTickMs]) return (
- - {popupMuted ? ( - - ) : ( - - )} - + {popupMuted ? ( + + ) : ( + + )}
@@ -353,73 +195,35 @@ export default function ModelPreview({ 'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden', className || 'w-full h-full', ].join(' ')} - onClick={(e) => { - e.stopPropagation() - }} - onMouseDown={(e) => { - e.stopPropagation() - }} - onTouchStart={(e) => { - e.stopPropagation() - }} - onPointerDown={(e) => { - e.stopPropagation() - }} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > - {shouldRenderPreview ? ( - !apiImgError ? ( + {inView ? ( + !imgError ? ( { - hadSuccess.current = true - fastTries.current = 0 - if (retryT.current) window.clearTimeout(retryT.current) - - if (useDirectThumb) setDirectImgError(false) - else setApiImgError(false) - }} - onError={() => { - if (useDirectThumb) { - setDirectImgError(true) - return - } - - setApiImgError(true) - - if (!fastRetryMs) return - if (!inViewRef.current || !pageVisibleRef.current) return - if (hadSuccess.current) return - - const startMs = alignStartAt ? toMs(alignStartAt) : NaN - const windowMs = Number(fastRetryWindowMs ?? 60_000) - const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs - if (!withinWindow) return - - const max = Number(fastRetryMax ?? 25) - if (fastTries.current >= max) return - - if (retryT.current) window.clearTimeout(retryT.current) - retryT.current = window.setTimeout(() => { - fastTries.current += 1 - setApiImgError(false) - setLocalTick((x) => x + 1) - }, fastRetryMs) - }} + className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')} + onLoad={() => setImgError(false)} + onError={() => setImgError(true)} /> ) : ( -
- keine Vorschau -
+ ) ) : ( -
+
)}
) -} +} \ No newline at end of file