This commit is contained in:
Linrador 2026-03-10 18:27:17 +01:00
parent d5c5a8488c
commit 9e21121f8b
21 changed files with 1569 additions and 1485 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ records
.DS_Store .DS_Store
backend/generated backend/generated
backend/nsfwapp.exe backend/nsfwapp.exe
backend/web/dist

View File

@ -301,9 +301,7 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
fetchedAt, fetchedAt,
) )
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok { publishJobUpsertsForModelKey(modelKey)
publishRoomStateForModel(sm)
}
} }
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen // bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
@ -333,9 +331,32 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
fetchedAt, fetchedAt,
) )
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok { publishJobUpsertsForModelKey(modelKey)
publishRoomStateForModel(sm) }
}
func publishJobUpsertsForModelKey(modelKey string) {
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if modelKey == "" {
return
}
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil || j.Hidden {
continue
} }
if sseModelEventNameForJob(j) != modelKey {
continue
}
c := *j
list = append(list, &c)
}
jobsMu.Unlock()
for _, j := range list {
publishJobUpsert(j)
} }
} }
@ -466,6 +487,37 @@ func cbApplySnapshot(rooms []ChaturbateRoom) time.Time {
return fetchedAtNow return fetchedAtNow
} }
func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
rooms, err := fetchChaturbateOnlineRooms(ctx)
if err != nil {
cbMu.Lock()
cb.LastErr = err.Error()
cb.Rooms = nil
cb.RoomsByUser = nil
cb.LiteByUser = nil
cbMu.Unlock()
return time.Time{}, err
}
fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil {
// ✅ bekannten Store sofort auf aktuellen Snapshot ziehen
syncChaturbateRoomStateIntoModelStore(
cbModelStore,
append([]ChaturbateRoom(nil), rooms...),
fetchedAtNow,
)
// optional / best effort
if len(rooms) > 0 {
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
}
return fetchedAtNow, nil
}
// startChaturbateOnlinePoller pollt die API alle paar Sekunden, // startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller(store *ModelStore) { func startChaturbateOnlinePoller(store *ModelStore) {
@ -925,75 +977,48 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// ---------------------------
// Snapshot Cache lesen (nur Lite)
// ---------------------------
cbMu.RLock()
fetchedAt := cb.FetchedAt
lastErr := cb.LastErr
lastAttempt := cb.LastAttempt
liteByUser := cb.LiteByUser
cbMu.RUnlock()
// ---------------------------
// ✅ HLS URL Refresh für laufende Jobs (best effort)
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
// und nur wenn User gerade online ist.
// ---------------------------
if onlySpecificUsers && liteByUser != nil {
const hlsMinInterval = 12 * time.Second // throttle pro user
for _, u := range users {
rm, ok := liteByUser[u]
if !ok {
continue // offline -> nichts
}
// Optional: nur wenn wirklich "public" (reduziert unnötige fetches)
// Wenn du auch in "private" previewen willst, entferne diesen Block.
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if show == "offline" || show == "" {
continue
}
// throttle
if !shouldRefreshHLS(u, hlsMinInterval) {
continue
}
// HLS holen (kurzer Timeout soll /online nicht blockieren)
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
}
// Jobs aktualisieren + ggf. Preview stoppen
refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA)
}
}
// ---------------------------
// Persist "last seen online/offline" für explizit angefragte User
// ---------------------------
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
for _, u := range users {
_, isOnline := liteByUser[u]
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
}
}
// --------------------------- // ---------------------------
// Refresh/Bootstrap-Strategie // Refresh/Bootstrap-Strategie
// --------------------------- // ---------------------------
const bootstrapCooldown = 8 * time.Second const bootstrapCooldown = 8 * time.Second
needBootstrap := fetchedAt.IsZero() // ersten Snapshot lesen
shouldTriggerFetch := wantRefresh || (needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown) cbMu.RLock()
fetchedAt := cb.FetchedAt
lastErr := cb.LastErr
lastAttempt := cb.LastAttempt
cbMu.RUnlock()
if shouldTriggerFetch { needBootstrap := fetchedAt.IsZero()
// ✅ Bei explizitem refresh synchron aktualisieren,
// damit die Response garantiert den neuesten Stand enthält.
if wantRefresh {
cbRefreshMu.Lock()
if cbRefreshInFlight {
cbRefreshMu.Unlock()
} else {
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()
cbRefreshMu.Lock()
cbRefreshInFlight = false
cbRefreshMu.Unlock()
if err != nil {
// Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut
}
}
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
// ✅ Bootstrap darf weiterhin asynchron bleiben
cbRefreshMu.Lock() cbRefreshMu.Lock()
if cbRefreshInFlight { if cbRefreshInFlight {
cbRefreshMu.Unlock() cbRefreshMu.Unlock()
@ -1013,33 +1038,68 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
}() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
rooms, err := fetchChaturbateOnlineRooms(ctx) defer cancel()
cancel()
if err != nil { _, _ = refreshChaturbateSnapshotNow(ctx)
cbMu.Lock()
cb.LastErr = err.Error()
cb.Rooms = nil
cb.RoomsByUser = nil
cb.LiteByUser = nil
// fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit)
cbMu.Unlock()
return
}
fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil {
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(rooms, fetchedAtNow)
if len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
}
}() }()
} }
} }
// ✅ JETZT erst den finalen Snapshot lesen
cbMu.RLock()
fetchedAt = cb.FetchedAt
lastErr = cb.LastErr
lastAttempt = cb.LastAttempt
liteByUser := cb.LiteByUser
cbMu.RUnlock()
needBootstrap = fetchedAt.IsZero()
// ---------------------------
// ✅ HLS URL Refresh für laufende Jobs (best effort)
// 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)
}
}
// ---------------------------
// Persist "last seen online/offline" für explizit angefragte User
// ---------------------------
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
for _, u := range users {
_, isOnline := liteByUser[u]
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
}
}
// --------------------------- // ---------------------------
// Rooms bauen (LITE, O(Anzahl requested Users)) // Rooms bauen (LITE, O(Anzahl requested Users))
// --------------------------- // ---------------------------

View File

@ -629,6 +629,35 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
return return
} }
username := extractUsername(job.SourceURL)
if strings.TrimSpace(username) != "" {
cookie := strings.TrimSpace(job.PreviewCookie)
ua := strings.TrimSpace(job.PreviewUA)
if ua == "" {
ua = "Mozilla/5.0"
}
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
cancelRefresh()
if err == nil && strings.TrimSpace(newHls) != "" {
jobsMu.Lock()
oldHls := strings.TrimSpace(job.PreviewM3U8)
job.PreviewM3U8 = strings.TrimSpace(newHls)
job.PreviewCookie = cookie
job.PreviewUA = ua
job.PreviewState = ""
job.PreviewStateAt = ""
job.PreviewStateMsg = ""
jobsMu.Unlock()
if oldHls != "" && oldHls != strings.TrimSpace(newHls) {
stopPreview(job)
}
}
}
// ensure ffmpeg preview input data exists // ensure ffmpeg preview input data exists
// (PreviewM3U8 + Cookie/UA werden beim Job gesetzt) // (PreviewM3U8 + Cookie/UA werden beim Job gesetzt)
m3u8 := strings.TrimSpace(job.PreviewM3U8) m3u8 := strings.TrimSpace(job.PreviewM3U8)

View File

@ -519,28 +519,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return return
} }
var req ModelFlagsPatch var req ModelFlagsPatch
if err := modelsReadJSON(r, &req); err != nil { if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
// ✅ id optional: wenn fehlt -> per (host, modelKey) sicherstellen + id setzen
if strings.TrimSpace(req.ID) == "" {
key := strings.TrimSpace(req.ModelKey)
host := strings.TrimSpace(req.Host)
if key == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
return
}
ensured, err := store.EnsureByHostModelKey(host, key) // host darf leer sein
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
req.ID = ensured.ID
}
store := getModelStore() store := getModelStore()
if store == nil { if store == nil {
@ -548,16 +532,35 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
req.ID = strings.TrimSpace(req.ID)
req.ModelKey = strings.TrimSpace(req.ModelKey)
req.Host = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(req.Host)), "www.")
// Nur wenn wirklich keine ID mitkommt -> Ensure
if req.ID == "" {
if req.ModelKey == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
return
}
ensured, err := store.EnsureByHostModelKey(req.Host, req.ModelKey)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
req.ID = ensured.ID
}
m, err := store.PatchFlags(req) m, err := store.PatchFlags(req)
if err != nil { if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
// ✅ Cleanup wenn kein relevanter Flag mehr gesetzt ist
likedOn := (m.Liked != nil && *m.Liked) likedOn := (m.Liked != nil && *m.Liked)
if !m.Watching && !m.Favorite && !likedOn { if !m.Watching && !m.Favorite && !likedOn {
_ = store.Delete(m.ID) _ = store.Delete(m.ID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }

View File

@ -222,9 +222,6 @@ func (s *ModelStore) SetChaturbateRoomState(
seenAt = seenAt.UTC() seenAt = seenAt.UTC()
now := time.Now().UTC() now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE models UPDATE models
SET SET
@ -444,9 +441,6 @@ func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom)
now := time.Now().UTC() now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return return
@ -719,9 +713,6 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA
onlineArg = false onlineArg = false
} }
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(` res, err := s.db.Exec(`
UPDATE models UPDATE models
SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3 SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3
@ -1194,9 +1185,6 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom,
roomsByUser := indexRoomsByUser(rooms) roomsByUser := indexRoomsByUser(rooms)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
@ -1569,13 +1557,11 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
return StoredModel{}, errors.New("id fehlt") return StoredModel{}, errors.New("id fehlt")
} }
s.mu.Lock()
defer s.mu.Unlock()
var ( var (
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
) )
err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID). err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID).
Scan(&watching, &favorite, &hot, &keep, &liked) Scan(&watching, &favorite, &hot, &keep, &liked)
if err != nil { if err != nil {
@ -1634,9 +1620,6 @@ func (s *ModelStore) Delete(id string) error {
return errors.New("id fehlt") return errors.New("id fehlt")
} }
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id) _, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id)
return err return err
} }

View File

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"reflect"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -90,10 +89,63 @@ func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
} }
} }
func (pq *PostWorkQueue) RemoveQueued(key string) bool {
if strings.TrimSpace(key) == "" {
return false
}
pq.mu.Lock()
defer pq.mu.Unlock()
// running darf hier NICHT entfernt werden
if _, running := pq.runningKeys[key]; running {
return false
}
found := false
for i, k := range pq.waitingKeys {
if k == key {
pq.waitingKeys = append(pq.waitingKeys[:i], pq.waitingKeys[i+1:]...)
found = true
break
}
}
if !found {
return false
}
delete(pq.inflight, key)
if pq.queued > 0 {
pq.queued--
}
// Das Task-Element bleibt evtl. noch im Channel liegen.
// Beim Worker-Start muss es dann erkannt und übersprungen werden.
return true
}
func (pq *PostWorkQueue) workerLoop(id int) { func (pq *PostWorkQueue) workerLoop(id int) {
for task := range pq.q { for task := range pq.q {
pq.mu.Lock()
_, stillInflight := pq.inflight[task.Key]
_, alreadyRunning := pq.runningKeys[task.Key]
waitingFound := false
for _, k := range pq.waitingKeys {
if k == task.Key {
waitingFound = true
break
}
}
pq.mu.Unlock()
// queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen
if !stillInflight || (!waitingFound && !alreadyRunning) {
continue
}
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running" // 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
pq.ffmpegSem <- struct{}{} // kann blocken pq.ffmpegSem <- struct{}{}
// 2) Ab hier startet er wirklich → waiting -> running // 2) Ab hier startet er wirklich → waiting -> running
pq.mu.Lock() pq.mu.Lock()
@ -201,7 +253,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
} }
// global (oder in deinem app struct halten) // global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 4 var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 4
// --- Status Refresher (ehemals postwork_refresh.go) --- // --- Status Refresher (ehemals postwork_refresh.go) ---
@ -222,9 +274,61 @@ func startPostWorkStatusRefresher() {
st := postWorkQ.StatusForKey(key) st := postWorkQ.StatusForKey(key)
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) { changed := false
// PostWork-Daten aktualisieren
if job.PostWork == nil ||
job.PostWork.State != st.State ||
job.PostWork.Position != st.Position ||
job.PostWork.Waiting != st.Waiting ||
job.PostWork.Running != st.Running ||
job.PostWork.MaxParallel != st.MaxParallel {
tmp := st tmp := st
job.PostWork = &tmp job.PostWork = &tmp
changed = true
}
// Status / Phase für UI vereinheitlichen
switch st.State {
case "queued":
if job.Status != JobPostwork {
job.Status = JobPostwork
changed = true
}
phaseLower := strings.TrimSpace(strings.ToLower(job.Phase))
if phaseLower == "" || phaseLower == "recording" {
job.Phase = "postwork"
changed = true
}
if job.Progress < 0 || job.Progress > 100 {
job.Progress = 0
changed = true
}
case "running":
if job.Status != JobPostwork {
job.Status = JobPostwork
changed = true
}
phaseLower := strings.TrimSpace(strings.ToLower(job.Phase))
// Konkrete Unterphasen NICHT überschreiben
switch phaseLower {
case "probe", "remuxing", "moving", "assets":
// ok, so lassen
default:
if phaseLower == "" || phaseLower == "recording" || phaseLower == "postwork" {
if phaseLower != "postwork" {
job.Phase = "postwork"
changed = true
}
}
}
}
if changed {
changedJobs = append(changedJobs, job) changedJobs = append(changedJobs, job)
} }
} }

View File

@ -1188,29 +1188,61 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
// --- WebP extraction helpers --- // --- WebP extraction helpers ---
func extractLastFrameWebP(path string) ([]byte, error) { func extractLastFrameWebP(path string) ([]byte, error) {
cmd := exec.Command( ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
ffmpegPath, ffmpegPath,
"-hide_banner", "-loglevel", "error", "-hide_banner",
"-sseof", "-0.1", "-loglevel", "error",
// relativ zum Dateiende suchen
"-sseof", "-0.25",
"-i", path, "-i", path,
// nur den ersten Video-Stream verwenden
"-map", "0:v:0",
// alles andere hart abschalten
"-an",
"-sn",
"-dn",
// genau 1 Frame
"-frames:v", "1", "-frames:v", "1",
"-vf", "scale=720:-2",
"-quality", "75", // schneller skalieren
"-f", "image2pipe", "-vf", "scale=720:-2:flags=fast_bilinear",
// WebP: Qualität + schnellerer Encode
"-vcodec", "libwebp", "-vcodec", "libwebp",
"-quality", "75",
"-compression_level", "2",
"-preset", "photo",
"-f", "image2pipe",
"pipe:1", "pipe:1",
) )
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ffmpeg last-frame webp: timeout")
}
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output") return nil, fmt.Errorf("ffmpeg last-frame webp: empty output")
} }
return b, nil return b, nil
} }

View File

@ -567,6 +567,176 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "") _, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
} }
func isTerminalJobStatus(status any) bool {
s := strings.TrimSpace(strings.ToLower(fmt.Sprint(status)))
switch s {
case "stopped", "finished", "failed", "done", "completed", "canceled", "cancelled":
return true
default:
return false
}
}
func isPostworkJob(job *RecordJob) bool {
if job == nil {
return false
}
phase := strings.TrimSpace(strings.ToLower(job.Phase))
pwKey := strings.TrimSpace(job.PostWorkKey)
// 1) expliziter Queue-Key vorhanden
if pwKey != "" {
return true
}
// 2) postWork-Status vom Refresher/Queue vorhanden
if job.PostWork != nil {
state := strings.TrimSpace(strings.ToLower(job.PostWork.State))
if state == "queued" || state == "running" {
return true
}
}
// 3) Aufnahme ist beendet und es gibt noch eine Nachbearbeitungs-Phase
if job.EndedAt != nil && phase != "" {
return true
}
// 4) explizite postwork-Phase
if phase == "postwork" {
return true
}
return false
}
func getEffectivePostworkState(job *RecordJob) string {
if job == nil {
return "none"
}
phase := strings.TrimSpace(strings.ToLower(job.Phase))
pwState := ""
hasPwKey := strings.TrimSpace(job.PostWorkKey) != ""
if job.PostWork != nil {
pwState = strings.TrimSpace(strings.ToLower(job.PostWork.State))
}
if !isPostworkJob(job) {
return "none"
}
if isTerminalJobStatus(job.Status) {
return "none"
}
// 1) Harte Wahrheit aus postWork
if pwState == "queued" {
return "queued"
}
if pwState == "running" {
return "running"
}
if job.PostWork != nil {
if job.PostWork.Position > 0 {
return "queued"
}
if job.PostWork.Running > 0 && job.PostWork.Position <= 0 {
return "running"
}
}
// 2) Sobald ein PostWorkKey existiert und nichts explizit "running" sagt:
// lieber queued statt alte phase dominieren lassen
if hasPwKey {
return "queued"
}
// 3) Nur noch Heuristik für Alt-Fälle ohne PostWorkKey
switch phase {
case "postwork":
if job.Progress > 0 {
return "running"
}
return "queued"
case "remuxing", "moving", "assets", "probe":
return "running"
}
if job.EndedAt != nil {
return "queued"
}
return "none"
}
func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) {
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if !ok || job == nil {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
if !isPostworkJob(job) {
http.Error(w, "job ist kein postwork-job", http.StatusConflict)
return
}
state := getEffectivePostworkState(job)
if state != "queued" {
http.Error(w, "nur queued postwork-jobs können entfernt werden", http.StatusConflict)
return
}
postKey := strings.TrimSpace(job.PostWorkKey)
if postKey == "" {
http.Error(w, "postwork key fehlt", http.StatusConflict)
return
}
if ok := postWorkQ.RemoveQueued(postKey); !ok {
http.Error(w, "postwork job konnte nicht aus der warteschlange entfernt werden", http.StatusConflict)
return
}
out := strings.TrimSpace(job.Output)
if out != "" {
_ = removeWithRetry(out)
purgeDurationCacheForPath(out)
base := filepath.Base(out)
id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
if strings.TrimSpace(id1) != "" {
removeGeneratedForID(id1)
}
}
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job)
notifyDoneChanged()
respondJSON(w, map[string]any{
"ok": true,
"id": id,
})
}
func recordVideo(w http.ResponseWriter, r *http.Request) { func recordVideo(w http.ResponseWriter, r *http.Request) {
tw := &rwTrack{ResponseWriter: w} tw := &rwTrack{ResponseWriter: w}
w = tw w = tw

View File

@ -46,6 +46,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record", startRecordingFromRequest) api.HandleFunc("/api/record", startRecordingFromRequest)
api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/status", recordStatus)
api.HandleFunc("/api/record/stop", recordStop) api.HandleFunc("/api/record/stop", recordStop)
api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork)
api.HandleFunc("/api/preview", recordPreview) api.HandleFunc("/api/preview", recordPreview)
api.HandleFunc("/api/preview/live", recordPreviewLive) api.HandleFunc("/api/preview/live", recordPreviewLive)
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame) api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)

View File

@ -41,7 +41,11 @@ type jobEvent struct {
IsOnline bool `json:"isOnline,omitempty"` IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"` ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"` ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
TS int64 `json:"ts"`
PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork any `json:"postWork,omitempty"`
TS int64 `json:"ts"`
} }
type ssePublishItem struct { type ssePublishItem struct {
@ -49,6 +53,78 @@ type ssePublishItem struct {
Data []byte Data []byte
} }
func isTerminalJobStatusForSSE(status JobStatus) bool {
s := strings.ToLower(strings.TrimSpace(string(status)))
return s == "stopped" ||
s == "finished" ||
s == "failed" ||
s == "done" ||
s == "completed" ||
s == "canceled" ||
s == "cancelled"
}
func isPostworkJobForSSE(j *RecordJob) bool {
if j == nil {
return false
}
phase := strings.ToLower(strings.TrimSpace(j.Phase))
pwKey := strings.TrimSpace(j.PostWorkKey)
if pwKey != "" {
return true
}
if j.PostWork != nil {
// falls PostWork als struct/map vorliegt, reicht für SSE der generelle Hinweis:
return true
}
if j.EndedAt != nil && phase != "" {
return true
}
if phase == "postwork" {
return true
}
return false
}
func isVisibleDownloadJobForSSE(j *RecordJob) bool {
if j == nil {
return false
}
if isPostworkJobForSSE(j) {
return false
}
if isTerminalJobStatusForSSE(j.Status) {
return false
}
if j.EndedAt != nil {
return false
}
return true
}
func isVisiblePostworkJobForSSE(j *RecordJob) bool {
if j == nil {
return false
}
if !isPostworkJobForSSE(j) {
return false
}
if isTerminalJobStatusForSSE(j.Status) {
return false
}
return true
}
func shouldPublishModelEventForJob(j *RecordJob) bool {
return isVisibleDownloadJobForSSE(j) || isVisiblePostworkJobForSSE(j)
}
func visibleJobEventsJSON() []ssePublishItem { func visibleJobEventsJSON() []ssePublishItem {
nowTs := time.Now().UnixMilli() nowTs := time.Now().UnixMilli()
out := make([]ssePublishItem, 0, 64) out := make([]ssePublishItem, 0, 64)
@ -60,6 +136,9 @@ func visibleJobEventsJSON() []ssePublishItem {
if j == nil || j.Hidden { if j == nil || j.Hidden {
continue continue
} }
if !shouldPublishModelEventForJob(j) {
continue
}
eventName := sseModelEventNameForJob(j) eventName := sseModelEventNameForJob(j)
if eventName == "" { if eventName == "" {
@ -80,6 +159,8 @@ func visibleJobEventsJSON() []ssePublishItem {
SizeBytes: j.SizeBytes, SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds, DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState, PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: nowTs, TS: nowTs,
} }
@ -109,53 +190,13 @@ func visibleJobEventsJSON() []ssePublishItem {
return out return out
} }
func visibleRoomStateEventsJSON() []ssePublishItem {
nowTs := time.Now().UnixMilli()
out := make([]ssePublishItem, 0, 128)
if cbModelStore == nil {
return out
}
models := cbModelStore.List()
for _, sm := range models {
if strings.ToLower(strings.TrimSpace(sm.Host)) != "chaturbate.com" {
continue
}
modelKey := strings.ToLower(strings.TrimSpace(sm.ModelKey))
if modelKey == "" {
continue
}
payload := jobEvent{
Type: "room_state",
Model: modelKey,
RoomStatus: strings.ToLower(strings.TrimSpace(sm.RoomStatus)),
IsOnline: sm.IsOnline,
ModelImageURL: strings.TrimSpace(sm.ImageURL),
ModelChatRoomURL: strings.TrimSpace(sm.ChatRoomURL),
TS: nowTs,
}
b, err := json.Marshal(payload)
if err != nil {
continue
}
out = append(out, ssePublishItem{
EventName: modelKey,
Data: b,
})
}
return out
}
func publishJobUpsert(j *RecordJob) { func publishJobUpsert(j *RecordJob) {
if j == nil || j.Hidden { if j == nil || j.Hidden {
return return
} }
if !shouldPublishModelEventForJob(j) {
return
}
eventName := sseModelEventNameForJob(j) eventName := sseModelEventNameForJob(j)
if eventName == "" { if eventName == "" {
@ -177,6 +218,8 @@ func publishJobUpsert(j *RecordJob) {
SizeBytes: j.SizeBytes, SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds, DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState, PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
} }
@ -215,34 +258,6 @@ func publishJobRemove(j *RecordJob) {
publishSSE(eventName, b) publishSSE(eventName, b)
} }
func publishRoomStateForModel(sm *StoredModel) {
if sm == nil {
return
}
if strings.ToLower(strings.TrimSpace(sm.Host)) != "chaturbate.com" {
return
}
modelKey := strings.ToLower(strings.TrimSpace(sm.ModelKey))
if modelKey == "" {
return
}
payload := jobEvent{
Type: "room_state",
Model: modelKey,
RoomStatus: strings.ToLower(strings.TrimSpace(sm.RoomStatus)),
IsOnline: sm.IsOnline,
ModelImageURL: strings.TrimSpace(sm.ImageURL),
ModelChatRoomURL: strings.TrimSpace(sm.ChatRoomURL),
TS: time.Now().UnixMilli(),
}
b, _ := json.Marshal(payload)
publishSSE(modelKey, b)
}
func sseModelEventNameForJob(j *RecordJob) string { func sseModelEventNameForJob(j *RecordJob) string {
if j == nil { if j == nil {
return "" return ""
@ -364,37 +379,6 @@ func initSSE() {
} }
} }
}() }()
// Room state snapshot: 1 SSE event per second for known chaturbate models
go func() {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
lastByModel := map[string]string{}
for range t.C {
events := visibleRoomStateEventsJSON()
nextByModel := make(map[string]string, len(events))
for _, ev := range events {
if len(ev.Data) == 0 || ev.EventName == "" {
continue
}
key := ev.EventName
payloadKey := string(ev.Data)
nextByModel[key] = payloadKey
if prev, ok := lastByModel[key]; ok && prev == payloadKey {
continue
}
publishSSE(ev.EventName, ev.Data)
}
lastByModel = nextByModel
}
}()
} }
func publishSSE(eventName string, data []byte) { func publishSSE(eventName string, data []byte) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-z2cKWgjr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C6R3TW-y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

View File

@ -35,10 +35,19 @@ type Props = {
pending?: PendingWatchedRoom[] pending?: PendingWatchedRoom[]
autostartState?: AutostartState autostartState?: AutostartState
onRefreshAutostartState?: () => Promise<void> | void onRefreshAutostartState?: () => Promise<void> | void
modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }> modelsByKey?: Record<
string,
{
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
}
>
roomStatusByModelKey?: Record<string, string> roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void onStopJob: (id: string) => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
blurPreviews?: boolean blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
@ -116,7 +125,8 @@ const modelKeyFromJob = (job: RecordJob): string => {
const roomStatusOfJob = ( const roomStatusOfJob = (
job: RecordJob, job: RecordJob,
roomStatusByModelKey?: Record<string, string> roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>
): string => { ): string => {
const j = job as any const j = job as any
@ -142,9 +152,38 @@ const roomStatusOfJob = (
return normalizeRoomStatus(roomStatusByModelKey[modelKey]) return normalizeRoomStatus(roomStatusByModelKey[modelKey])
} }
if (modelKey && modelsByKey?.[modelKey]?.roomStatus) {
return normalizeRoomStatus(modelsByKey[modelKey].roomStatus)
}
return 'Unknown' return 'Unknown'
} }
function effectiveRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey)
if (raw !== 'Unknown') return raw
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
if (isActiveRecording && growingByJobId?.[job.id]) {
return 'Public'
}
return raw
}
const roomStatusTone = (status: string): string => { const roomStatusTone = (status: string): string => {
switch (status) { switch (status) {
case 'Public': case 'Public':
@ -260,24 +299,24 @@ function postWorkLabel(
job: RecordJob, job: RecordJob,
override?: { pos?: number; total?: number } override?: { pos?: number; total?: number }
): string { ): string {
const pw = (job as any).postWork const anyJ = job as any
const pw = anyJ.postWork
if (!pw) return 'Warte auf Nacharbeiten…' const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'running') {
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
if (pw.state === 'running') {
const running = typeof pw.running === 'number' ? pw.running : 0
const maxP = typeof pw.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0 return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)` ? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…' : 'Nacharbeiten laufen…'
} }
if (pw.state === 'queued') { if (effectiveState === 'queued') {
// Backend-Werte (können was anderes zählen -> deshalb nur Fallback) const posServer = typeof pw?.position === 'number' ? pw.position : 0
const posServer = typeof pw.position === 'number' ? pw.position : 0 const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
const waitingServer = typeof pw.waiting === 'number' ? pw.waiting : 0 const totalServer = Math.max(waitingServer, posServer)
const runningServer = typeof (pw as any).running === 'number' ? (pw as any).running : 0
const totalServer = Math.max(waitingServer + runningServer, posServer)
const pos = const pos =
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0 typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
@ -304,26 +343,38 @@ function StatusCell({
job: RecordJob job: RecordJob
postworkInfo?: { pos?: number; total?: number } postworkInfo?: { pos?: number; total?: number }
}) { }) {
const phaseRaw = String((job as any)?.phase ?? '').trim() const anyJ = job as any
const progress = Number((job as any)?.progress ?? 0) const phaseRaw = String(anyJ?.phase ?? '').trim()
const phase = phaseRaw.toLowerCase() const phase = phaseRaw.toLowerCase()
const progress = Number(anyJ?.progress ?? 0)
const isRecording = phase === 'recording' const isRecording = phase === 'recording'
const isPwJob = isPostworkJob(job)
const pwState = getEffectivePostworkState(job)
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : '' let text = ''
// ✅ postwork genauer machen (wartend/running + Position)
if (phase === 'postwork') {
phaseText = postWorkLabel(job, postworkInfo)
}
if (isRecording) { if (isRecording) {
phaseText = 'Recording läuft…' text = 'Recording läuft…'
} else if (isPwJob) {
// 1) Konkrete Postwork-Unterphasen IMMER bevorzugen
if (phase === 'probe' || phase === 'remuxing' || phase === 'moving' || phase === 'assets') {
text = phaseLabel(phaseRaw) || phaseRaw
}
// 2) Queue-Fall
else if (pwState === 'queued') {
text = postWorkLabel(job, postworkInfo)
}
// 3) Generischer laufender Postwork-Fall
else if (pwState === 'running') {
text = phaseLabel(phaseRaw) || postWorkLabel(job, postworkInfo)
}
} }
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase() if (!text) {
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
}
// ✅ Balken NICHT während recording anzeigen
const showBar = const showBar =
!isRecording && !isRecording &&
Number.isFinite(progress) && Number.isFinite(progress) &&
@ -333,7 +384,7 @@ function StatusCell({
const showIndeterminate = const showIndeterminate =
!isRecording && !isRecording &&
!showBar && !showBar &&
Boolean(phaseRaw) && Boolean(text) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100) (!Number.isFinite(progress) || progress <= 0 || progress >= 100)
return ( return (
@ -369,10 +420,12 @@ function DownloadsCardRow({
modelsByKey, modelsByKey,
roomStatusByModelKey, roomStatusByModelKey,
stopRequestedIds, stopRequestedIds,
growingByJobId,
postworkInfoOf, postworkInfoOf,
markStopRequested, markStopRequested,
onOpenPlayer, onOpenPlayer,
onStopJob, onStopJob,
onRemoveQueuedPostworkJob,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
@ -380,13 +433,23 @@ function DownloadsCardRow({
r: DownloadRow r: DownloadRow
nowMs: number nowMs: number
blurPreviews?: boolean blurPreviews?: boolean
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }> modelsByKey: Record<
string,
{
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
}
>
roomStatusByModelKey: Record<string, string> roomStatusByModelKey: Record<string, string>
stopRequestedIds: Record<string, true> stopRequestedIds: Record<string, true>
growingByJobId: Record<string, boolean>
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
markStopRequested: (ids: string | string[]) => void markStopRequested: (ids: string | string[]) => void
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void onStopJob: (id: string) => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
@ -485,24 +548,45 @@ function DownloadsCardRow({
const file = baseName(j.output || '') const file = baseName(j.output || '')
const phase = String((j as any).phase ?? '').trim() const phase = String((j as any).phase ?? '').trim()
const phaseLower = phase.toLowerCase() const phaseLower = phase.toLowerCase()
const isRecording = phaseLower === 'recording' const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur für Stop-Button/UI const isStopRequested = Boolean(stopRequestedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase() const rawStatus = String(j.status ?? '').toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
const roomStatus = roomStatusOfJob(j, roomStatusByModelKey) const showRemoveQueuedButton = isQueuedPostwork
const disableStopButton = showRemoveQueuedButton ? false : isStopping
const actionsBusy = showRemoveQueuedButton ? false : isStopping
let phaseText = phase ? (phaseLabel(phase) || phase) : '' const roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
let phaseText = ''
if (phaseLower === 'recording') { if (phaseLower === 'recording') {
phaseText = 'Recording läuft…' phaseText = 'Recording läuft…'
} else if (phaseLower === 'postwork') { } else if (isPostworkJob(j)) {
phaseText = postWorkLabel(j, postworkInfoOf(j)) const pwState = getEffectivePostworkState(j)
if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') {
phaseText = phaseLabel(phase) || phase
} else if (pwState === 'queued') {
phaseText = postWorkLabel(j, postworkInfoOf(j))
} else if (pwState === 'running') {
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
}
} else {
phaseText = phase ? (phaseLabel(phase) || phase) : ''
} }
const progressLabel = phaseText || roomStatus const progressLabel = phaseText || roomStatus
@ -654,7 +738,7 @@ function DownloadsCardRow({
<RecordJobActions <RecordJobActions
job={j} job={j}
variant="table" variant="table"
busy={isStopping} busy={actionsBusy}
isFavorite={isFav} isFavorite={isFav}
isLiked={isLiked} isLiked={isLiked}
isWatching={isWatching} isWatching={isWatching}
@ -668,17 +752,23 @@ function DownloadsCardRow({
<Button <Button
size="sm" size="sm"
variant="primary" variant={showRemoveQueuedButton ? 'secondary' : 'primary'}
disabled={isStopping} disabled={disableStopButton}
className="shrink-0" className="shrink-0"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
if (showRemoveQueuedButton) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return if (isStopping) return
markStopRequested(j.id) markStopRequested(j.id)
onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
{isStopping ? 'Stoppe…' : 'Stop'} {showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
</Button> </Button>
</div> </div>
</div> </div>
@ -775,6 +865,61 @@ const isPostworkJob = (job: RecordJob): boolean => {
return false return false
} }
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
if (!isPostworkJob(job)) return 'none'
if (isTerminalStatus(anyJ?.status)) return 'none'
// 1) Harte Wahrheit aus postWork
if (pwState === 'queued') return 'queued'
if (pwState === 'running') return 'running'
// 2) Echte Arbeitsphasen schlagen alles andere
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
// 3) Queue-Hinweise aus postWork
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
pw.position > 0
) {
return 'queued'
}
if (
typeof pw?.running === 'number' &&
Number.isFinite(pw.running) &&
pw.running > 0 &&
(!Number.isFinite(pw?.position) || pw.position <= 0)
) {
return 'running'
}
// 4) Wenn es einen PostWorkKey gibt, aber noch keine Running-Phase sichtbar ist,
// dann eher queued
if (hasPwKey) {
return 'queued'
}
// 5) Fallback für alte Jobs
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'
}
const isTerminalStatus = (status?: unknown) => { const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase() const s = String(status ?? '').trim().toLowerCase()
return ( return (
@ -814,6 +959,7 @@ export default function Downloads({
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
onAddToDownloads, onAddToDownloads,
onRemoveQueuedPostworkJob,
modelsByKey = {}, modelsByKey = {},
roomStatusByModelKey = {}, roomStatusByModelKey = {},
blurPreviews blurPreviews
@ -841,6 +987,8 @@ export default function Downloads({
} }
}, [onRefreshAutostartState]) }, [onRefreshAutostartState])
useEffect(() => { useEffect(() => {
const nextPaused = Boolean(autostartState?.paused) const nextPaused = Boolean(autostartState?.paused)
const nextPausedByUser = Boolean(autostartState?.pausedByUser) const nextPausedByUser = Boolean(autostartState?.pausedByUser)
@ -935,25 +1083,88 @@ export default function Downloads({
for (const id of keys) { for (const id of keys) {
const j = jobs.find((x) => x.id === id) const j = jobs.find((x) => x.id === id)
if (!j) continue if (!j) continue
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
if (isQueuedPostwork) {
continue
}
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase() const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' const isStopping = isBusyPhase || j.status !== 'running'
if (!isStopping) next[id] = true if (isStopping) {
next[id] = true
}
} }
return next return next
}) })
}, [jobs]) }, [jobs])
const [nowMs, setNowMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now())
const prevSizeBytesRef = useRef<Record<string, number>>({})
const [growingByJobId, setGrowingByJobId] = useState<Record<string, boolean>>({})
const hasActive = useMemo(() => { const hasActive = useMemo(() => {
// tickt solange mind. ein Job noch nicht beendet ist // tickt solange mind. ein Job noch nicht beendet ist
return jobs.some((j) => !j.endedAt && j.status === 'running') return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs]) }, [jobs])
const postworkQueueInfoById = useMemo(() => { useEffect(() => {
setGrowingByJobId((prev) => {
const next: Record<string, boolean> = { ...prev }
const seen = new Set<string>()
let changed = false
for (const job of jobs) {
const id = String(job.id ?? '').trim()
if (!id) continue
seen.add(id)
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
const currentSize = sizeBytesOf(job) ?? 0
const prevSize = prevSizeBytesRef.current[id] ?? 0
const grewNow = currentSize > prevSize
const nextGrowing = isActiveRecording ? (prev[id] === true || grewNow) : false
if (next[id] !== nextGrowing) {
next[id] = nextGrowing
changed = true
}
prevSizeBytesRef.current[id] = currentSize
}
for (const id of Object.keys(prevSizeBytesRef.current)) {
if (!seen.has(id)) {
delete prevSizeBytesRef.current[id]
}
}
for (const id of Object.keys(next)) {
if (!seen.has(id)) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [jobs, nowMs])
const postworkQueueInfoById = useMemo(() => {
const infoById = new Map<string, { pos: number; total: number }>() const infoById = new Map<string, { pos: number; total: number }>()
const enqueueMsOf = (job: RecordJob): number => { const enqueueMsOf = (job: RecordJob): number => {
@ -965,46 +1176,64 @@ export default function Downloads({
toMs(anyJ.queuedAt) || toMs(anyJ.queuedAt) ||
toMs(anyJ.createdAt) || toMs(anyJ.createdAt) ||
toMs(anyJ.addedAt) || toMs(anyJ.addedAt) ||
toMs(job.endedAt) || // Postwork entsteht oft nach endedAt toMs(job.endedAt) ||
toMs(job.startedAt) || toMs(job.startedAt) ||
0 0
) )
} }
// 1) alle relevanten Postwork-Jobs sammeln (queued + running) const runningSortMsOf = (job: RecordJob): number => {
const anyJ = job as any
return (
toMs(anyJ.updatedAt) ||
toMs(anyJ.phaseUpdatedAt) ||
toMs(anyJ.progressUpdatedAt) ||
toMs(anyJ.postWork?.updatedAt) ||
enqueueMsOf(job)
)
}
const running: RecordJob[] = [] const running: RecordJob[] = []
const queued: RecordJob[] = [] const queued: RecordJob[] = []
for (const j of jobs) { for (const j of jobs) {
const pw = (j as any)?.postWork if (!isPostworkJob(j)) continue
if (!pw) continue if (isTerminalStatus((j as any)?.status)) continue
const state = String(pw.state ?? '').toLowerCase() const state = getEffectivePostworkState(j)
if (state === 'running') running.push(j)
else if (state === 'queued') queued.push(j) if (state === 'running') {
running.push(j)
} else if (state === 'queued') {
queued.push(j)
}
} }
// 2) Reihenfolge stabil machen (FIFO) // aktuell bearbeitete Jobs zuerst
running.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b)) running.sort((a, b) => runningSortMsOf(b) - runningSortMsOf(a))
// wartende Jobs FIFO
queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b)) queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b))
const runningCount = running.length const runningCount = running.length
const total = runningCount + queued.length const queuedCount = queued.length
const total = runningCount + queuedCount
// 3) Positionen setzen: running belegt "vorne", queued danach // running bekommt weiter Gesamtinfo
for (let i = 0; i < running.length; i++) {
const id = String((running[i] as any)?.id ?? '')
if (!id) continue
infoById.set(id, { pos: i + 1, total })
}
// queued als echte Warteschlangenposition:
// erster wartender Job = 1
for (let i = 0; i < queued.length; i++) { for (let i = 0; i < queued.length; i++) {
const id = String((queued[i] as any)?.id ?? '') const id = String((queued[i] as any)?.id ?? '')
if (!id) continue if (!id) continue
infoById.set(id, { pos: runningCount + i + 1, total }) infoById.set(id, { pos: i + 1, total: queuedCount })
} }
// optional (wenn du auch bei running "x / total" sehen willst):
// for (let i = 0; i < running.length; i++) {
// const id = String((running[i] as any)?.id ?? '')
// if (!id) continue
// infoById.set(id, { pos: i + 1, total })
// }
return infoById return infoById
}, [jobs]) }, [jobs])
@ -1052,7 +1281,12 @@ export default function Downloads({
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
blur={blurPreviews} blur={blurPreviews}
roomStatus={roomStatusOfJob(j, roomStatusByModelKey)} roomStatus={effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)}
alignStartAt={j.startedAt} alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null} alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000} alignEveryMs={10_000}
@ -1108,7 +1342,12 @@ export default function Downloads({
const f = baseName(j.output || '') const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output) const name = modelNameFromOutput(j.output)
const roomStatus = roomStatusOfJob(j, roomStatusByModelKey) const roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
return ( return (
<> <>
@ -1247,9 +1486,14 @@ export default function Downloads({
const phase = String((j as any).phase ?? '').trim() const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopRequested = Boolean(stopRequestedIds[j.id])
const phaseLower = phase.trim().toLowerCase() const phaseLower = phase.trim().toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const disableStopButton = isQueuedPostwork ? false : isStopping
const actionsBusy = isQueuedPostwork ? false : isStopping
const key = modelNameFromOutput(j.output || '') const key = modelNameFromOutput(j.output || '')
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
@ -1263,7 +1507,7 @@ export default function Downloads({
<RecordJobActions <RecordJobActions
job={j} job={j}
variant="table" variant="table"
busy={isStopping} busy={actionsBusy}
isFavorite={isFav} isFavorite={isFav}
isLiked={isLiked} isLiked={isLiked}
isWatching={isWatching} isWatching={isWatching}
@ -1286,16 +1530,23 @@ export default function Downloads({
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
disabled={isStopping} color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0" className="shrink-0"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return if (isStopping) return
markStopRequested(j.id) markStopRequested(j.id)
onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
{isStopping ? 'Stoppe…' : 'Stop'} {isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
</Button> </Button>
) )
})()} })()}
@ -1304,7 +1555,7 @@ export default function Downloads({
}, },
}, },
] ]
}, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf]) }, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, growingByJobId, nowMs, onStopJob, onRemoveQueuedPostworkJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
const downloadJobRows = useMemo<DownloadRow[]>(() => { const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs const list = jobs
@ -1335,10 +1586,8 @@ export default function Downloads({
.map((job) => ({ kind: 'job', job }) as const) .map((job) => ({ kind: 'job', job }) as const)
const stateRank = (j: RecordJob): number => { const stateRank = (j: RecordJob): number => {
const pw = (j as any)?.postWork const state = getEffectivePostworkState(j)
const state = String(pw?.state ?? '').toLowerCase()
// running zuerst anzeigen, queued danach
if (state === 'running') return 0 if (state === 'running') return 0
if (state === 'queued') return 1 if (state === 'queued') return 1
return 2 return 2
@ -1350,6 +1599,17 @@ export default function Downloads({
return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER
} }
const runningSortMsOf = (j: RecordJob): number => {
const anyJ = j as any
return (
toMs(anyJ.updatedAt) ||
toMs(anyJ.phaseUpdatedAt) ||
toMs(anyJ.progressUpdatedAt) ||
toMs(anyJ.postWork?.updatedAt) ||
addedAtMsOf({ kind: 'job', job: j })
)
}
list.sort((a, b) => { list.sort((a, b) => {
const aj = a.job const aj = a.job
const bj = b.job const bj = b.job
@ -1358,12 +1618,19 @@ export default function Downloads({
const sr = stateRank(aj) - stateRank(bj) const sr = stateRank(aj) - stateRank(bj)
if (sr !== 0) return sr if (sr !== 0) return sr
// 2) queued nach Position in der Queue sortieren (1, 2, 3, ...) // 2) innerhalb running: der aktuell bearbeitete nach oben
if (getEffectivePostworkState(aj) === 'running' && getEffectivePostworkState(bj) === 'running') {
const ra = runningSortMsOf(aj)
const rb = runningSortMsOf(bj)
if (ra !== rb) return rb - ra
}
// 3) queued nach Position in der Queue sortieren
const pa = queuePosOf(aj) const pa = queuePosOf(aj)
const pb = queuePosOf(bj) const pb = queuePosOf(bj)
if (pa !== pb) return pa - pb if (pa !== pb) return pa - pb
// 3) Fallback stabil nach enqueue/addedAt (älter zuerst) // 4) Fallback stabil nach enqueue/addedAt
return addedAtMsOf(a) - addedAtMsOf(b) return addedAtMsOf(a) - addedAtMsOf(b)
}) })
@ -1486,9 +1753,11 @@ export default function Downloads({
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob} onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
@ -1512,9 +1781,11 @@ export default function Downloads({
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob} onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
@ -1538,9 +1809,11 @@ export default function Downloads({
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob} onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}

View File

@ -227,6 +227,120 @@ function PromoteToFrontWrapper({
) )
} }
function LoadingCardSkeleton({ blurred = false }: { blurred?: boolean }) {
return (
<div
className={[
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
blurred ? 'blur-[1.5px] saturate-90 scale-[0.995]' : '',
].join(' ')}
aria-hidden="true"
>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div className="animate-pulse">
{/* Preview */}
<div className="relative aspect-video rounded-t-lg bg-gray-200/80 dark:bg-white/10">
<div className="absolute inset-0 bg-gradient-to-br from-white/20 via-transparent to-black/5 dark:from-white/5 dark:to-white/10" />
{/* Meta-Overlay unten rechts */}
<div className="absolute right-2 bottom-2 z-10">
<div className="h-4 w-28 rounded bg-black/25 dark:bg-black/35" />
</div>
</div>
{/* Footer / Meta */}
<div className="relative min-h-[112px] overflow-hidden border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-white/10" />
<div className="mt-2 h-3 w-48 rounded bg-gray-100 dark:bg-white/5" />
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
</div>
</div>
<div className="mt-3 rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center gap-1.5">
<div className="h-8 flex-1 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="h-5 w-16 rounded-full bg-gray-100 dark:bg-white/5" />
<div className="h-5 w-20 rounded-full bg-gray-100 dark:bg-white/5" />
<div className="h-5 w-14 rounded-full bg-gray-100 dark:bg-white/5" />
</div>
</div>
</div>
</Card>
</div>
)
}
function MobileLoadingStack({
stackPeekOffsetPx,
stackExtraTopPx,
}: {
stackPeekOffsetPx: number
stackExtraTopPx: number
}) {
const backDepths = [2, 1]
return (
<div className="relative mx-auto w-full max-w-[560px] overflow-y-visible" style={{ overflowX: 'clip' }}>
<div
className="relative overflow-visible"
style={{
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
>
{backDepths.map((depth) => {
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={`loading-back-${depth}`}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity]"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
<LoadingCardSkeleton blurred />
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})}
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
<LoadingCardSkeleton />
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
@ -866,12 +980,6 @@ export default function FinishedDownloadsCardsView({
// Sichtbarer Stack bleibt bei 3 Karten // Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : [] const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
const MOBILE_STILL_PRELOAD_LIMIT = 4 // 0..6 je nach Gerätetest
const mobileStillPreloadRows = isSmall
? rows.slice(mobileStackDepth, mobileStackDepth + MOBILE_STILL_PRELOAD_LIMIT)
: []
// größerer Peek-Offset für stärkeren Stack-Effekt // größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15 const stackPeekOffsetPx = 15
@ -879,12 +987,7 @@ export default function FinishedDownloadsCardsView({
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
return ( return (
<div <div className="relative">
className={[
'relative',
isLoading && rows.length === 0 ? 'min-h-[320px]' : '',
].filter(Boolean).join(' ')}
>
{!isSmall ? ( {!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => { {rows.map((j) => {
@ -899,165 +1002,142 @@ export default function FinishedDownloadsCardsView({
</div> </div>
) : ( ) : (
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}> <div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? null : ( {rows.length === 0 ? (
<div isLoading ? (
className="relative mx-auto w-full max-w-[560px] overflow-y-visible" <MobileLoadingStack
style={{ overflowX: 'clip' }} stackPeekOffsetPx={stackPeekOffsetPx}
> stackExtraTopPx={stackExtraTopPx}
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */} />
) : null
) : (
<div <div
className="relative overflow-visible" className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
style={{ style={{ overflowX: 'clip' }}
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
> >
{(() => { {/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
const visible = mobileVisibleStackRows <div
const topRow = visible[0] className="relative overflow-visible"
const backRows = visible.slice(1) style={{
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
}}
>
{(() => {
const visible = mobileVisibleStackRows
const topRow = visible[0]
const backRows = visible.slice(1)
return ( return (
<> <>
{backRows {/* Hintere Karten zuerst (absolut, dekorativ) */}
.map((j, backIdx) => { {backRows
const idx = backIdx + 1 .map((j, backIdx) => {
const { k, cardInner } = renderCardItem(j, { const idx = backIdx + 1 // 1,2...
forceStill: true, const { k, cardInner } = renderCardItem(j, {
disableInline: true, forceStill: true,
disablePreviewHover: true, disableInline: true,
isDecorative: true, disablePreviewHover: true,
forceLoadStill: true, isDecorative: true,
blur: true, forceLoadStill: true,
preloadTeaserWhenStill: true, blur: true,
preloadTeaserWhenStill: true,
})
const depth = idx
const y = -(depth * stackPeekOffsetPx)
const scale = 1 - depth * 0.03
const opacity = 1 - depth * 0.14
return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
}) })
.reverse()}
const depth = idx {/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
const y = -(depth * stackPeekOffsetPx) {topRow ? (() => {
const scale = 1 - depth * 0.03 const j = topRow
const opacity = 1 - depth * 0.14 const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
})
return ( return (
<div <div
key={k} key={k}
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none" className="relative touch-pan-y"
style={{ style={{ zIndex: 30 }}
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
> >
<div className="relative"> <PromoteToFrontWrapper animateOnMount>
{cardInner} <SwipeCard
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" /> ref={(h) => {
</div> if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
onSwipeLeft={() => {
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
>
{cardInner}
</SwipeCard>
</PromoteToFrontWrapper>
</div> </div>
) )
}) })() : null}
.reverse()} </>
{topRow ? (() => {
const j = topRow
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
})
return (
<div
key={k}
className="relative touch-pan-y"
style={{ zIndex: 30 }}
>
<PromoteToFrontWrapper animateOnMount>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
onSwipeLeft={() => {
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
>
{cardInner}
</SwipeCard>
</PromoteToFrontWrapper>
</div>
)
})() : null}
</>
)
})()}
</div>
{mobileStillPreloadRows.length > 0 ? (
<div className="sr-only" aria-hidden="true">
{mobileStillPreloadRows.map((j) => {
const k = keyFor(j)
return (
<div key={`preload-still-${k}`} className="relative aspect-video">
<FinishedVideoPreview
job={j}
getFileName={baseName}
className="h-full w-full"
showPopover={false}
blur={Boolean(blurPreviews)}
animated={false}
teaserPreloadEnabled={true}
teaserPreloadRootMargin="1200px 0px"
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={false}
inlineControls={false}
inlineLoop={false}
muted={true}
popoverMuted={true}
assetNonce={assetNonce ?? 0}
alwaysLoadStill
/>
</div>
) )
})} })()}
</div> </div>
) : null} </div>
</div> )}
)}
</div> </div>
)} )}
{isLoading && rows.length === 0 ? ( {!isSmall && isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40"> <div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70"> <div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" /> <div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />

View File

@ -18,18 +18,23 @@ export default function LiveVideo({
}) { }) {
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false) const [broken, setBroken] = useState(false)
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | null>(null) const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null)
const normalizeBrokenReason = (status?: string): 'private' | 'hidden' | 'away' | 'offline' => { const normalizeBrokenReason = (
status?: string
): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => {
const s = String(status ?? '').trim().toLowerCase() const s = String(status ?? '').trim().toLowerCase()
if (s === 'private') return 'private' if (s === 'private') return 'private'
if (s === 'hidden') return 'hidden' if (s === 'hidden') return 'hidden'
if (s === 'away') return 'away' if (s === 'away') return 'away'
return 'offline' if (s === 'offline') return 'offline'
return 'unknown'
} }
const brokenMessage = (reason: 'private' | 'hidden' | 'away' | 'offline' | null): string => { const brokenMessage = (
reason: 'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null
): string => {
switch (reason) { switch (reason) {
case 'hidden': case 'hidden':
return 'Cam is hidden' return 'Cam is hidden'
@ -39,6 +44,8 @@ export default function LiveVideo({
return 'Private show in progress.' return 'Private show in progress.'
case 'offline': case 'offline':
return 'Model is offline' return 'Model is offline'
case 'unknown':
return 'Live video unavailable'
default: default:
return 'Live video unavailable' return 'Live video unavailable'
} }
@ -94,16 +101,29 @@ export default function LiveVideo({
return return
} }
const reason = normalizeBrokenReason(roomStatus)
if (reason === 'unknown') {
return
}
setBroken(true) setBroken(true)
setBrokenReason(normalizeBrokenReason(roomStatus)) setBrokenReason(reason)
} }
}, 4_000) }, 4_000)
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich. // 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert. // Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
const onError = () => { const onError = () => {
const reason = normalizeBrokenReason(roomStatus)
if (reason === 'unknown') {
// bei unbekanntem Raumstatus nicht sofort hart fehlschlagen
return
}
setBroken(true) setBroken(true)
setBrokenReason(normalizeBrokenReason(roomStatus)) setBrokenReason(reason)
} }
video.addEventListener('error', onError) video.addEventListener('error', onError)

View File

@ -46,6 +46,9 @@ export default function ModelPreview({
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
const CONTROLBAR_H = 0 const CONTROLBAR_H = 0
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange) // ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
@ -273,15 +276,17 @@ export default function ModelPreview({
<div className="absolute inset-0"> <div className="absolute inset-0">
<LiveVideo <LiveVideo
src={hq} src={hq}
muted={true} muted={false}
roomStatus={roomStatus} roomStatus={roomStatus}
className="w-full h-full object-contain object-bottom relative z-0" className="w-full h-full object-contain object-bottom relative z-0"
/> />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm"> {showLiveBadge ? (
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" /> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
Live <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
</div> Live
</div>
) : null}
<button <button
type="button" type="button"
@ -308,6 +313,18 @@ 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) => {
e.stopPropagation()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
onTouchStart={(e) => {
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
> >
{!apiImgError ? ( {!apiImgError ? (
<img <img

View File

@ -1272,16 +1272,16 @@ export default function ModelsTab() {
return ( return (
<div <div
key={m.id} key={m.id}
className="group h-full overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10 flex flex-col" className="group flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-white/10 dark:bg-slate-900/80"
> >
<div <div
className="relative cursor-pointer bg-slate-950" className="relative cursor-pointer bg-gray-100 dark:bg-slate-950"
onClick={() => { onClick={() => {
if (href) window.open(href, '_blank', 'noreferrer') if (href) window.open(href, '_blank', 'noreferrer')
}} }}
> >
{/* Bildbereich ähnlich Screenshot */} {/* Bildbereich ähnlich Screenshot */}
<div className="relative aspect-[3/4] w-full overflow-hidden bg-[#12202c]"> <div className="relative aspect-[3/4] w-full overflow-hidden bg-gradient-to-br from-gray-100 to-slate-200 dark:from-[#12202c] dark:to-[#0b1620]">
{imgSrc ? ( {imgSrc ? (
<img <img
src={imgSrc} src={imgSrc}
@ -1299,7 +1299,7 @@ export default function ModelsTab() {
{/* Fallback-Name im Bild */} {/* Fallback-Name im Bild */}
<div <div
className={clsx( className={clsx(
'absolute inset-0 items-center justify-center px-3 text-center font-semibold text-slate-500 leading-tight', 'absolute inset-0 items-center justify-center px-3 text-center font-semibold leading-tight text-slate-600 dark:text-slate-500',
imgSrc ? 'hidden' : 'flex' imgSrc ? 'hidden' : 'flex'
)} )}
style={{ style={{
@ -1320,7 +1320,7 @@ export default function ModelsTab() {
</div> </div>
{/* dunkler Verlauf unten für bessere Lesbarkeit */} {/* dunkler Verlauf unten für bessere Lesbarkeit */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/15 to-transparent dark:from-black/70 dark:via-black/20" />
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */} {/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
@ -1378,7 +1378,7 @@ export default function ModelsTab() {
flex items-center gap-1 flex items-center gap-1
[&_button]:h-7 [&_button]:w-7 [&_button]:min-w-0 [&_button]:p-0 [&_button]:h-7 [&_button]:w-7 [&_button]:min-w-0 [&_button]:p-0
[&_button]:bg-transparent [&_button]:shadow-none [&_button]:bg-transparent [&_button]:shadow-none
[&_button:hover]:bg-white/10 [&_button:hover]:bg-white/20 dark:[&_button:hover]:bg-white/10
[&_button]:border-0 [&_button]:border-0
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)] [&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
" "
@ -1437,7 +1437,7 @@ export default function ModelsTab() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1"> <div className="flex-1 rounded-b-xl border-t border-gray-200 bg-gray-50 px-2.5 py-2 dark:border-white/5 dark:bg-slate-800/70">
{/* Mobile: kompakter, aber nicht gequetscht */} {/* Mobile: kompakter, aber nicht gequetscht */}
<div className="sm:hidden"> <div className="sm:hidden">
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */} {/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
@ -1451,8 +1451,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
watch watch
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30' ? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
@ -1461,7 +1461,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
👁 👁
</span> </span>
</span> </span>
@ -1473,8 +1473,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
fav fav
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30' ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1484,7 +1484,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
</span> </span>
</span> </span>
@ -1496,8 +1496,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
liked liked
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30' ? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {
@ -1507,7 +1507,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
</span> </span>
</span> </span>
@ -1546,8 +1546,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
watch watch
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30' ? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
@ -1556,7 +1556,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
👁 👁
</span> </span>
</span> </span>
@ -1571,8 +1571,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
fav fav
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30' ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1582,7 +1582,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
</span> </span>
</span> </span>
@ -1597,8 +1597,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
liked liked
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30' ? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {
@ -1608,7 +1608,7 @@ export default function ModelsTab() {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}> <span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
</span> </span>
</span> </span>

View File

@ -449,7 +449,7 @@ export default function Player({
}, [onClose]) }, [onClose])
const hlsIndexUrl = React.useMemo(() => { const hlsIndexUrl = React.useMemo(() => {
const u = `/api/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1` const u = `/api/preview?id=${encodeURIComponent(previewId)}&play=1`
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u) return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
}, [previewId, isRunning]) }, [previewId, isRunning])