This commit is contained in:
Linrador 2026-04-03 10:59:42 +02:00
parent e4a139d45f
commit 32bd7c1e6e
9 changed files with 490 additions and 579 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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,

View File

@ -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<string, RecordJob>()
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<EventSource | null>(null)
const modelEventNamesRef = useRef<Set<string>>(new Set())
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
const lastSeenAtByJobIdRef = useRef<Record<string, number>>({})
const MISSING_JOB_GRACE_MS = 20_000
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
@ -556,7 +590,6 @@ export default function App() {
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
@ -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<string, RecordJob>()
// 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<string, StoredModel> = {}
@ -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() {
</span>
</div>
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
{headerUpdatedText}
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
</div>
</div>
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
<div className="sm:hidden mt-1 w-full">
<div className="text-[11px] text-gray-500 dark:text-gray-400">
{headerUpdatedText}
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
</div>
<div className="mt-2 flex items-stretch gap-2">

View File

@ -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({
>
<ModelPreview
jobId={j.id}
live={true}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
j,
@ -966,13 +927,7 @@ export default function Downloads({
modelsByKey,
growingByJobId
)}
alignStartAt={j.startedAt}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full"
/>
</LazyMountWhenVisible>
@ -1278,11 +1233,19 @@ export default function Downloads({
])
const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs
const seen = new Set<string>()
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)

View File

@ -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({
>
<ModelPreview
jobId={j.id}
live={true}
blur={blurPreviews}
roomStatus={previewRoomStatusOfJob(
j,
@ -718,13 +680,7 @@ export default function DownloadsCardRow({
modelsByKey,
growingByJobId
)}
alignStartAt={j.startedAt}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full"
/>
</LazyMountWhenVisible>

View File

@ -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)

View File

@ -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}</>
}

View File

@ -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<string | null | undefined>
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<HTMLDivElement | null>(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<number | null>(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 (
<HoverPopover
@ -275,10 +122,7 @@ export default function ModelPreview({
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div
className="relative rounded-lg overflow-hidden bg-black"
style={{
// 16:9 Videofläche + feste 30px Controlbar
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
}}
style={{ paddingBottom: '56.25%' }}
>
<div className="absolute inset-0">
<LiveVideo
@ -318,13 +162,11 @@ export default function ModelPreview({
}
}}
>
<span className="text-[13px] leading-none">
{popupMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</span>
{popupMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</button>
</div>
@ -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 ? (
<img
src={currentImgSrc}
loading="eager"
fetchPriority="high"
src={previewSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
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)}
/>
) : (
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
keine Vorschau
</div>
<img
src={fallbackImgSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
/>
)
) : (
<div className="absolute inset-0 bg-gray-100 dark:bg-white/5" />
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
)}
</div>
</HoverPopover>
)
}
}