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 { if cbModelStore != nil {
copiedRooms := append([]ChaturbateRoom(nil), rooms...) copiedRooms := append([]ChaturbateRoom(nil), rooms...)
// Statusfelder pflegen (online/offline, room_status, last_online_at, ...)
syncChaturbateRoomStateIntoModelStore( syncChaturbateRoomStateIntoModelStore(
cbModelStore, cbModelStore,
copiedRooms, copiedRooms,
fetchedAtNow, fetchedAtNow,
) )
// Vollständigen Snapshot pflegen, aber offline NICHT überschreiben
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow) _ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
if len(copiedRooms) > 0 {
go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
}
} }
return fetchedAtNow, nil return fetchedAtNow, nil
@ -555,13 +549,8 @@ func startChaturbateOnlinePoller(store *ModelStore) {
go func(roomsCopy []ChaturbateRoom, ts time.Time) { go func(roomsCopy []ChaturbateRoom, ts time.Time) {
syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts) syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts)
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts) _ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts)
}(copiedRooms, fetchedAtNow) }(copiedRooms, fetchedAtNow)
if len(copiedRooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
}
} }
// Tags übernehmen ist teuer -> nur selten + im Hintergrund // Tags übernehmen ist teuer -> nur selten + im Hintergrund
@ -605,7 +594,7 @@ func cachedOnline(key string) ([]byte, bool) {
if !ok { if !ok {
return nil, false return nil, false
} }
if time.Since(e.at) > 2*time.Second { // TTL if time.Since(e.at) > 5*time.Second { // TTL
delete(onlineCache, key) delete(onlineCache, key)
return nil, false return nil, false
} }
@ -635,6 +624,22 @@ type cbOnlineReq struct {
Refresh bool `json:"refresh"` 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 { func hashKey(parts ...string) string {
h := sha1.New() h := sha1.New()
for _, p := range parts { 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) { func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost { if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) 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("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
out := map[string]any{ out := cbOnlineResponse{
"enabled": false, Enabled: false,
"fetchedAt": time.Time{}, FetchedAt: time.Time{},
"count": 0, Count: 0,
"total": 0, Total: 0,
"lastError": "", LastError: "",
"rooms": []any{}, Rooms: []cbOnlineOutRoom{},
} }
body, _ := json.Marshal(out) body, _ := json.Marshal(out)
setCachedOnline(cacheKey, body) setCachedOnline(cacheKey, body)
@ -954,32 +999,31 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
needBootstrap := fetchedAt.IsZero() needBootstrap := fetchedAt.IsZero()
// ✅ Bei explizitem refresh synchron aktualisieren, // Bei explizitem refresh Snapshot-Aktualisierung asynchron anstoßen,
// damit die Response garantiert den neuesten Stand enthält. // Response bleibt dabei schnell und liefert den letzten bekannten Stand.
if wantRefresh { if wantRefresh {
cbRefreshMu.Lock() cbRefreshMu.Lock()
if cbRefreshInFlight { if !cbRefreshInFlight {
cbRefreshMu.Unlock()
} else {
cbRefreshInFlight = true cbRefreshInFlight = true
cbRefreshMu.Unlock()
cbMu.Lock() cbMu.Lock()
cb.LastAttempt = time.Now() cb.LastAttempt = time.Now()
cbMu.Unlock() cbMu.Unlock()
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) go func() {
_, err := refreshChaturbateSnapshotNow(ctx) defer func() {
cancel() cbRefreshMu.Lock()
cbRefreshInFlight = false
cbRefreshMu.Unlock()
}()
cbRefreshMu.Lock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
cbRefreshInFlight = false defer cancel()
cbRefreshMu.Unlock()
if err != nil { _, _ = refreshChaturbateSnapshotNow(ctx)
// Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut }()
}
} }
cbRefreshMu.Unlock()
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown { } else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
// ✅ Bootstrap darf weiterhin asynchron bleiben // ✅ Bootstrap darf weiterhin asynchron bleiben
cbRefreshMu.Lock() 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) // Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
// und nur wenn User gerade online ist. // und nur wenn User gerade online ist.
// --------------------------- // ---------------------------
const hlsMinInterval = 12 * time.Second
if onlySpecificUsers && liteByUser != nil { if onlySpecificUsers && liteByUser != nil {
for _, u := range users { refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA)
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)
}
} }
// --------------------------- // ---------------------------
@ -1066,12 +1085,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
// --------------------------- // ---------------------------
// Rooms bauen (LITE, O(Anzahl requested Users)) // 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 { matches := func(rm ChaturbateOnlineRoomLite) bool {
if len(allowedShow) > 0 { 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 { if onlySpecificUsers && liteByUser != nil {
for _, u := range users { for _, u := range users {
@ -1142,7 +1155,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if !matches(rm) { if !matches(rm) {
continue continue
} }
outRooms = append(outRooms, outRoom{ outRooms = append(outRooms, cbOnlineOutRoom{
Username: rm.Username, Username: rm.Username,
CurrentShow: rm.CurrentShow, CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL, 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("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
out := map[string]any{ out := cbOnlineResponse{
"enabled": true, Enabled: true,
"fetchedAt": fetchedAt, FetchedAt: fetchedAt,
"count": len(outRooms), Count: len(outRooms),
"total": total, Total: total,
"lastError": lastErr, LastError: lastErr,
"rooms": outRooms, Rooms: outRooms,
} }
body, _ := json.Marshal(out) body, _ := json.Marshal(out)

View File

@ -1326,14 +1326,18 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
return b, nil return b, nil
} }
func latestPreviewSegment(previewDir string) (string, error) { func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) {
segDir := previewSegmentsDir(previewDir) segDir := previewSegmentsDir(previewDir)
entries, err := os.ReadDir(segDir) entries, err := os.ReadDir(segDir)
if err != nil { 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 { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
continue continue
@ -1344,15 +1348,25 @@ func latestPreviewSegment(previewDir string) (string, error) {
continue continue
} }
if best == "" || name > best { names = append(names, e.Name())
best = e.Name()
}
} }
if best == "" { if len(names) == 0 {
return "", fmt.Errorf("kein Preview-Segment in %s", segDir) 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) { 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) return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
} }
segPath, err := latestPreviewSegment(previewDir) segPaths, err := latestPreviewSegments(previewDir, 4)
if err != nil { if err != nil {
return "", nil, err 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" { 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") initPath := filepath.Join(segDir, "init.mp4")
if _, err := os.Stat(initPath); err != nil { if _, err := os.Stat(initPath); err != nil {
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir) 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) 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") tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
if err != nil { if err != nil {
return "", nil, err return "", nil, err
@ -1404,10 +1445,22 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
cleanup() cleanup()
return "", nil, err return "", nil, err
} }
if _, err := tmp.Write(segBytes); err != nil {
cleanup() for _, segPath := range segPaths {
return "", nil, err 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 { if err := tmp.Close(); err != nil {
cleanup() cleanup()
return "", nil, err return "", nil, err
@ -1666,25 +1719,52 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
return 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 // file serving
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(strings.TrimSpace(file)) low := strings.ToLower(strings.TrimSpace(file))
// ✅ preview.jpg weiterhin hier behandeln
if low == "preview.jpg" { if low == "preview.jpg" {
servePreviewJPGAlias(w, r, id) servePreviewJPGAlias(w, r, id)
return return
} }
// ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go
recordPreviewFile(w, r) recordPreviewFile(w, r)
return return
} }
// JPG preview (running jobs have live thumb behavior) // JPG preview (running jobs have live thumb behavior)
jobsMu.Lock() jobsMu.RLock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.RUnlock()
if ok { if ok {
if job.Status == JobRunning { if job.Status == JobRunning {
@ -1741,9 +1821,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
return return
} }
jobsMu.Lock() jobsMu.RLock()
state := strings.TrimSpace(job.PreviewState) state := strings.TrimSpace(job.PreviewState)
jobsMu.Unlock() jobsMu.RUnlock()
if state == "private" { if state == "private" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK) 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) { func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.Lock() jobsMu.RLock()
status := job.Status status := job.Status
previewDir := job.PreviewDir previewDir := job.PreviewDir
out := job.Output out := job.Output
jobsMu.Unlock() jobsMu.RUnlock()
if status != JobRunning { if status != JobRunning {
return return
@ -1780,7 +1860,8 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
} }
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 { 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 return
} }
} }
@ -1827,9 +1908,9 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
jobsMu.Lock() jobsMu.RLock()
st := job.Status st := job.Status
jobsMu.Unlock() jobsMu.RUnlock()
if st != JobRunning { if st != JobRunning {
return return
} }

View File

@ -1,8 +1,8 @@
{ {
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "", "ffmpegPath": "",
"autoAddToDownloadList": true, "autoAddToDownloadList": true,
"autoStartAddedDownloads": true, "autoStartAddedDownloads": true,

View File

@ -31,6 +31,7 @@ import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage' import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal' import VideoSplitModal from './components/ui/VideoSplitModal'
import TextInput from './components/ui/TextInput' import TextInput from './components/ui/TextInput'
import LastUpdatedText from './components/ui/LastUpdatedText'
const COOKIE_STORAGE_KEY = 'record_cookies' const COOKIE_STORAGE_KEY = 'record_cookies'
@ -477,6 +478,36 @@ const isPostworkJobForCount = (job: RecordJob): boolean => {
return false 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() { export default function App() {
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
@ -544,6 +575,9 @@ export default function App() {
const modelEventNamesRef = useRef<Set<string>>(new Set()) const modelEventNamesRef = useRef<Set<string>>(new Set())
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) 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) const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('') const [sourceUrl, setSourceUrl] = useState('')
@ -556,7 +590,6 @@ export default function App() {
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({}) const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now()) const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({}) const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
@ -852,15 +885,69 @@ export default function App() {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
// akzeptiere: Array oder { items: [] } const serverItems = Array.isArray(data)
const items = Array.isArray(data)
? (data as RecordJob[]) ? (data as RecordJob[])
: Array.isArray(data?.items) : Array.isArray(data?.items)
? (data.items as RecordJob[]) ? (data.items as RecordJob[])
: [] : []
setJobs(items) const now = Date.now()
jobsRef.current = items
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()) setLastHeaderUpdateAtMs(Date.now())
} catch { } catch {
// ignore // ignore
@ -909,28 +996,10 @@ export default function App() {
const jobId = String(msg?.jobId ?? '').trim() const jobId = String(msg?.jobId ?? '').trim()
if (!jobId) return if (!jobId) return
if (msg.type === 'job_remove') { const now = Date.now()
setJobs((prev) => {
const next = prev.filter((j) => String((j as any).id ?? '') !== jobId)
jobsRef.current = next
return next
})
setPlayerJob((prev) => const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => {
prev && String((prev as any).id ?? '') === jobId ? null : prev return {
)
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 = {
...(prevJob ?? ({} as RecordJob)), ...(prevJob ?? ({} as RecordJob)),
id: jobId, id: jobId,
status: (msg.status as any) ?? (prevJob?.status ?? 'running'), status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
@ -959,14 +1028,48 @@ export default function App() {
} }
: (prevJob as any)?.postWork, : (prevJob as any)?.postWork,
} as any } as any
}
let next: RecordJob[] if (msg.type === 'job_remove') {
if (idx >= 0) { delete lastSeenAtByJobIdRef.current[jobId]
next = [...prev]
next[idx] = patch setJobs((prev) => {
} else { const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId)
next = [patch, ...prev] 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 jobsRef.current = next
return next return next
@ -974,41 +1077,11 @@ export default function App() {
setPlayerJob((prev) => { setPlayerJob((prev) => {
if (!prev) return prev if (!prev) return prev
return String((prev as any).id ?? '') === jobId if (String((prev as any).id ?? '').trim() !== jobId) return prev
? ({ return mergeJob(prev)
...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
}) })
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(now)
}, []) }, [])
const ensureModelEventListener = useCallback((name: string) => { const ensureModelEventListener = useCallback((name: string) => {
@ -1050,25 +1123,6 @@ export default function App() {
return true 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 buildModelsByKey = useCallback((list: StoredModel[]) => {
const map: Record<string, StoredModel> = {} const map: Record<string, StoredModel> = {}
@ -1285,11 +1339,6 @@ export default function App() {
} }
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange]) }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(t)
}, [])
useEffect(() => { useEffect(() => {
const onOpen = (ev: Event) => { const onOpen = (ev: Event) => {
const e = ev as CustomEvent<{ modelKey?: string }> const e = ev as CustomEvent<{ modelKey?: string }>
@ -1516,8 +1565,11 @@ export default function App() {
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
// UI sofort aktualisieren (optional) // UI sofort aktualisieren (optional)
setJobs((prev) => [created, ...prev]) setJobs((prev) => {
jobsRef.current = [created, ...jobsRef.current] const next = upsertJobById(prev, created)
jobsRef.current = next
return next
})
return true return true
} catch (e: any) { } catch (e: any) {
@ -2104,8 +2156,11 @@ export default function App() {
// und kurz danach kommt der Job nochmal über SSE/polling rein. // und kurz danach kommt der Job nochmal über SSE/polling rein.
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
setJobs((prev) => [created, ...prev]) setJobs((prev) => {
jobsRef.current = [created, ...jobsRef.current] const next = upsertJobById(prev, created)
jobsRef.current = next
return next
})
return true return true
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? String(e) const msg = e?.message ?? String(e)
@ -3358,14 +3413,14 @@ export default function App() {
</span> </span>
</div> </div>
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400"> <div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
{headerUpdatedText} <LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
</div> </div>
</div> </div>
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */} {/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
<div className="sm:hidden mt-1 w-full"> <div className="sm:hidden mt-1 w-full">
<div className="text-[11px] text-gray-500 dark:text-gray-400"> <div className="text-[11px] text-gray-500 dark:text-gray-400">
{headerUpdatedText} <LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
</div> </div>
<div className="mt-2 flex items-stretch gap-2"> <div className="mt-2 flex items-stretch gap-2">

View File

@ -179,17 +179,6 @@ function effectiveRoomStatusOfJob(
return raw 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 => { const roomStatusTone = (status: string): string => {
switch (status) { switch (status) {
case 'Public': case 'Public':
@ -220,35 +209,6 @@ const toMs = (v: unknown): number => {
return 0 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 => { const addedAtMsOf = (r: DownloadRow): number => {
if (r.kind === 'job') { if (r.kind === 'job') {
const j = r.job as any const j = r.job as any
@ -959,6 +919,7 @@ export default function Downloads({
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
live={true}
blur={blurPreviews} blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob( roomStatus={effectiveRoomStatusOfJob(
j, j,
@ -966,13 +927,7 @@ export default function Downloads({
modelsByKey, modelsByKey,
growingByJobId growingByJobId
)} )}
alignStartAt={j.startedAt} fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</LazyMountWhenVisible> </LazyMountWhenVisible>
@ -1278,11 +1233,19 @@ export default function Downloads({
]) ])
const downloadJobRows = useMemo<DownloadRow[]>(() => { const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs const seen = new Set<string>()
const list = jobs
.filter((j) => { .filter((j) => {
if (isPostworkJob(j)) return false if (isPostworkJob(j)) return false
if (isTerminalStatus((j as any)?.status)) return false if (isTerminalStatus((j as any)?.status)) return false
if (Boolean((j as any)?.endedAt)) 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 return true
}) })
.map((job) => ({ kind: 'job', job }) as const) .map((job) => ({ kind: 'job', job }) as const)

View File

@ -200,16 +200,6 @@ function previewRoomStatusOfJob(
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId) 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 => { const roomStatusTone = (status: string): string => {
switch (status) { switch (status) {
case 'Public': case 'Public':
@ -240,35 +230,6 @@ const toMs = (v: unknown): number => {
return 0 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 => { const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—' if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000) const total = Math.floor(ms / 1000)
@ -711,6 +672,7 @@ export default function DownloadsCardRow({
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
live={true}
blur={blurPreviews} blur={blurPreviews}
roomStatus={previewRoomStatusOfJob( roomStatus={previewRoomStatusOfJob(
j, j,
@ -718,13 +680,7 @@ export default function DownloadsCardRow({
modelsByKey, modelsByKey,
growingByJobId growingByJobId
)} )}
alignStartAt={j.startedAt} fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</LazyMountWhenVisible> </LazyMountWhenVisible>

View File

@ -67,8 +67,6 @@ export type FinishedVideoPreviewProps = {
/** Popover-Video muted? (Default: true) */ /** Popover-Video muted? (Default: true) */
popoverMuted?: boolean popoverMuted?: boolean
noGenerateTeaser?: boolean
/** Still-Preview (Bild) unabhängig vom inView-Gating laden */ /** Still-Preview (Bild) unabhängig vom inView-Gating laden */
alwaysLoadStill?: boolean alwaysLoadStill?: boolean
@ -124,7 +122,6 @@ export default function FinishedVideoPreview({
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
alwaysLoadStill = false, alwaysLoadStill = false,
teaserPreloadEnabled = false, teaserPreloadEnabled = false,
teaserPreloadRootMargin = '700px 0px', teaserPreloadRootMargin = '700px 0px',
@ -557,7 +554,7 @@ export default function FinishedVideoPreview({
emittedResolutionRef.current = '' emittedResolutionRef.current = ''
teaserSoundForcedRef.current = false teaserSoundForcedRef.current = false
setMetaLoaded(false) setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser]) }, [previewId, assetNonce])
useEffect(() => { useEffect(() => {
const onRelease = (ev: any) => { const onRelease = (ev: any) => {
@ -673,9 +670,8 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
const noGen = noGenerateTeaser ? '&noGenerate=1' : '' return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` }, [previewId, v])
}, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
@ -811,7 +807,6 @@ export default function FinishedVideoPreview({
const teaserCanPrewarm = const teaserCanPrewarm =
animated && animated &&
animatedMode === 'teaser' && animatedMode === 'teaser' &&
teaserOk &&
Boolean(teaserSrc) && Boolean(teaserSrc) &&
!showingInlineVideo && !showingInlineVideo &&
shouldPreloadAnimatedAssets && shouldPreloadAnimatedAssets &&
@ -1361,9 +1356,15 @@ export default function FinishedVideoPreview({
className="hidden" className="hidden"
muted muted
playsInline playsInline
preload="auto" preload="metadata"
onLoadedData={() => setTeaserReady(true)} onLoadedData={() => {
onCanPlay={() => setTeaserReady(true)} setTeaserOk(true)
setTeaserReady(true)
}}
onCanPlay={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onError={() => { onError={() => {
setTeaserOk(false) setTeaserOk(false)
setTeaserReady(false) setTeaserReady(false)
@ -1394,6 +1395,14 @@ export default function FinishedVideoPreview({
loop loop
preload={teaserReady ? 'auto' : 'metadata'} preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onCanPlay={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onError={() => { onError={() => {
setTeaserOk(false) setTeaserOk(false)
setTeaserReady(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 = { type Props = {
jobId: string jobId: string
live?: boolean
thumbTick?: number thumbTick?: number
autoTickMs?: number autoTickMs?: number
blur?: boolean blur?: boolean
className?: string className?: string
fit?: 'cover' | 'contain' fit?: 'cover' | 'contain'
roomStatus?: string roomStatus?: string
fallbackSrc?: 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>
} }
export default function ModelPreview({ export default function ModelPreview({
jobId, jobId,
live = false,
thumbTick, thumbTick,
autoTickMs = 10_000, autoTickMs = 10_000,
blur = false, blur = false,
className, className,
fit = 'cover',
roomStatus, roomStatus,
alignStartAt, fallbackSrc,
alignEndAt = null,
alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
thumbsJPGUrl,
thumbsCandidates,
}: Props) { }: Props) {
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)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
const CONTROLBAR_H = 0 const objectFitCls = fit === 'contain' ? 'object-contain' : 'object-cover'
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase() const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const rootRef = useRef<HTMLDivElement | null>(null) const tick = live
? (typeof thumbTick === 'number' ? thumbTick : localTick)
: 0
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange) const previewSrc = useMemo(() => {
const pageVisibleRef = useRef(true) // 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 [pageVisible, setPageVisible] = useState(true) const fallbackImgSrc = useMemo(() => {
const [popupMuted, setPopupMuted] = useState(true) const s = String(fallbackSrc ?? '').trim()
const [popupVolume, setPopupVolume] = useState(1) if (s) return s
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
}, [fallbackSrc, jobId])
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI) const hq = useMemo(
const [inView, setInView] = useState(false) () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
const inViewRef = useRef(false) [jobId]
const shouldRenderPreview = inView && pageVisible )
const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false)
const [apiImgError, setApiImgError] = useState(false)
const retryT = useRef<number | null>(null)
const fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
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 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 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(() => { useEffect(() => {
const onVis = () => { const onVis = () => setPageVisible(!document.hidden)
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)
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis)
}, []) }, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
if (!el) return if (!el) return
@ -132,15 +81,11 @@ export default function ModelPreview({
const obs = new IntersectionObserver( const obs = new IntersectionObserver(
(entries) => { (entries) => {
const entry = entries[0] const entry = entries[0]
const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)) setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
if (next === inViewRef.current) return
inViewRef.current = next
setInView(next)
}, },
{ {
root: null, root: null,
threshold: 0, threshold: 0.01,
rootMargin: '300px 0px',
} }
) )
@ -148,125 +93,27 @@ export default function ModelPreview({
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
useEffect(() => { useEffect(() => {
setImgError(false)
}, [jobId])
useEffect(() => {
setImgError(false)
}, [previewSrc])
useEffect(() => {
if (!live) return
if (typeof thumbTick === 'number') return if (typeof thumbTick === 'number') return
if (!inView) return if (!inView) return
if (!pageVisibleRef.current) return if (!pageVisible) return
if (enteredViewOnce.current) return
enteredViewOnce.current = true
setLocalTick((x) => x + 1)
}, [inView, thumbTick])
// ✅ 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(() => { const id = window.setInterval(() => {
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
}, period) setImgError(false)
}, autoTickMs)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs]) }, [live, thumbTick, inView, pageVisible, autoTickMs])
// ✅ 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])
return ( return (
<HoverPopover <HoverPopover
@ -275,10 +122,7 @@ export default function ModelPreview({
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div <div
className="relative rounded-lg overflow-hidden bg-black" className="relative rounded-lg overflow-hidden bg-black"
style={{ style={{ paddingBottom: '56.25%' }}
// 16:9 Videofläche + feste 30px Controlbar
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
}}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<LiveVideo <LiveVideo
@ -318,13 +162,11 @@ export default function ModelPreview({
} }
}} }}
> >
<span className="text-[13px] leading-none"> {popupMuted ? (
{popupMuted ? ( <SpeakerXMarkIcon className="h-4 w-4" />
<SpeakerXMarkIcon className="h-4 w-4" /> ) : (
) : ( <SpeakerWaveIcon className="h-4 w-4" />
<SpeakerWaveIcon className="h-4 w-4" /> )}
)}
</span>
</button> </button>
</div> </div>
@ -353,71 +195,33 @@ export default function ModelPreview({
'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden', 'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden',
className || 'w-full h-full', className || 'w-full h-full',
].join(' ')} ].join(' ')}
onClick={(e) => { onClick={(e) => e.stopPropagation()}
e.stopPropagation() onMouseDown={(e) => e.stopPropagation()}
}} onTouchStart={(e) => e.stopPropagation()}
onMouseDown={(e) => { onPointerDown={(e) => e.stopPropagation()}
e.stopPropagation()
}}
onTouchStart={(e) => {
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
> >
{shouldRenderPreview ? ( {inView ? (
!apiImgError ? ( !imgError ? (
<img <img
src={currentImgSrc} src={previewSrc}
loading="eager" loading="lazy"
fetchPriority="high"
decoding="async" decoding="async"
alt="" alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => { onLoad={() => setImgError(false)}
hadSuccess.current = true onError={() => setImgError(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)
}}
/> />
) : ( ) : (
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400"> <img
keine Vorschau src={fallbackImgSrc}
</div> 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> </div>
</HoverPopover> </HoverPopover>