updated
This commit is contained in:
parent
e4a139d45f
commit
32bd7c1e6e
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
@ -544,6 +575,9 @@ export default function App() {
|
||||
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)
|
||||
const [sourceUrl, setSourceUrl] = useState('')
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
30
frontend/src/components/ui/LastUpdatedText.tsx
Normal file
30
frontend/src/components/ui/LastUpdatedText.tsx
Normal 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}</>
|
||||
}
|
||||
@ -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 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 CONTROLBAR_H = 0
|
||||
const objectFitCls = fit === 'contain' ? 'object-contain' : 'object-cover'
|
||||
|
||||
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
|
||||
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 pageVisibleRef = useRef(true)
|
||||
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 [pageVisible, setPageVisible] = useState(true)
|
||||
const [popupMuted, setPopupMuted] = useState(true)
|
||||
const [popupVolume, setPopupVolume] = useState(1)
|
||||
const fallbackImgSrc = useMemo(() => {
|
||||
const s = String(fallbackSrc ?? '').trim()
|
||||
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 [inView, setInView] = useState(false)
|
||||
const inViewRef = useRef(false)
|
||||
const shouldRenderPreview = inView && pageVisible
|
||||
const hq = useMemo(
|
||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
||||
[jobId]
|
||||
)
|
||||
|
||||
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(() => {
|
||||
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,71 +195,33 @@ 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user