This commit is contained in:
Linrador 2026-02-20 18:18:59 +01:00
parent 6cd9dcd41e
commit 478e2696da
52 changed files with 9171 additions and 6026 deletions

View File

@ -4,7 +4,9 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -40,94 +42,160 @@ func u64ToI64(x uint64) int64 {
return int64(x) return int64(x)
} }
// -------------------------
// Asset layout helpers
// -------------------------
// Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
func assetIDFromVideoPath(videoPath string) string {
base := filepath.Base(strings.TrimSpace(videoPath))
if base == "" {
return ""
}
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id)
return strings.TrimSpace(id)
}
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, metaPath string, err error) {
id = strings.TrimSpace(id)
if id == "" {
return "", "", "", "", fmt.Errorf("empty id")
}
assetDir, err = ensureGeneratedDir(id)
if err != nil || strings.TrimSpace(assetDir) == "" {
return "", "", "", "", fmt.Errorf("generated dir: %v", err)
}
thumbPath = filepath.Join(assetDir, "thumbs.webp")
previewPath = filepath.Join(assetDir, "preview.mp4")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json")
}
return assetDir, thumbPath, previewPath, metaPath, nil
}
type ensuredMeta struct {
durSec float64
vw, vh int
fps float64
sourceURL string
ok bool // ok = duration + props vorhanden
}
// Stellt sicher:
// - Duration ist berechnet (cache ok)
// - Props (w/h/fps) sind drin (ffprobe wenn nötig)
// - meta.json wird (best-effort) geschrieben, inkl. sourceURL
func ensureVideoMeta(ctx context.Context, videoPath, metaPath, sourceURL string, vfi os.FileInfo) (ensuredMeta, error) {
out := ensuredMeta{sourceURL: strings.TrimSpace(sourceURL)}
videoPath = strings.TrimSpace(videoPath)
metaPath = strings.TrimSpace(metaPath)
if videoPath == "" || metaPath == "" {
return out, nil
}
if vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
return out, nil
}
// 1) Try cache/meta first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
out.durSec, out.vw, out.vh, out.fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
out.durSec = d
}
}
// 3) Props ggf. nachziehen (ffprobe)
if out.durSec > 0 && (out.vw <= 0 || out.vh <= 0 || out.fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
}
} else {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) meta.json schreiben/aktualisieren (best-effort)
if out.durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, out.durSec, out.vw, out.vh, out.fps, out.sourceURL)
}
out.ok = out.durSec > 0 && out.vw > 0 && out.vh > 0
return out, nil
}
type EnsureAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
MetaOK bool
}
// Public wrappers (kompatibel zu deinem bisherigen API)
func ensureAssetsForVideo(videoPath string) error { func ensureAssetsForVideo(videoPath string) error {
// Default: keine SourceURL (für Covers egal) // Default: keine SourceURL (für Covers egal)
return ensureAssetsForVideoWithProgress(videoPath, "", nil) return ensureAssetsForVideoWithProgress(videoPath, "", nil)
} }
// Optional: für Stellen, wo du die URL hast (z.B. Postwork / Jobs)
func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error { func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error {
return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil) return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil)
} }
// onRatio: 0..1 (Assets-Gesamtfortschritt) // onRatio: 0..1 (Assets-Gesamtfortschritt)
func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRatio func(r float64)) error { func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRatio func(r float64)) error {
ctx := context.Background()
_, err := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, onRatio)
return err
}
// Task/Bulk sollte diesen Context-aware Call nutzen.
func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
res, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, onRatio)
return res, err
}
// Core: generiert thumbs/preview/meta und sagt zurück was passiert ist.
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
var out EnsureAssetsResult
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
if videoPath == "" { if videoPath == "" {
return nil return out, nil
} }
fi, statErr := os.Stat(videoPath) fi, statErr := os.Stat(videoPath)
if statErr != nil || fi.IsDir() || fi.Size() <= 0 { if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
return nil return out, nil
} }
// ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix) id := assetIDFromVideoPath(videoPath)
base := filepath.Base(videoPath) if id == "" {
id := strings.TrimSuffix(base, filepath.Ext(base)) return out, nil
id = stripHotPrefix(id)
if strings.TrimSpace(id) == "" {
return nil
} }
assetDir, gerr := ensureGeneratedDir(id) _, thumbPath, previewPath, metaPath, perr := assetPathsForID(id)
if gerr != nil || strings.TrimSpace(assetDir) == "" { if perr != nil {
return fmt.Errorf("generated dir: %v", gerr) return out, perr
} }
metaPath := filepath.Join(assetDir, "meta.json")
// ---- Meta / Duration + Props (Width/Height/FPS/Resolution) ----
durSec := 0.0
vw, vh := 0, 0
fps := 0.0
// 1) Try cache (Meta) first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, fi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
durSec = d
}
}
// 3) Wenn wir Duration haben, aber Props fehlen -> ffprobe holen und Voll-Meta schreiben
// (damit resolution wirklich in meta.json landet)
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
// optional: durSem für ffprobe begrenzen (du hast es global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
} else {
// wenn Acquire fehlschlägt, best-effort ohne props
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) Meta schreiben/aktualisieren:
// - schreibt resolution (über formatResolution) nur wenn vw/vh > 0
// - schreibt sourceURL wenn vorhanden
if durSec > 0 {
_ = writeVideoMeta(metaPath, fi, durSec, vw, vh, fps, sourceURL)
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25
previewW = 0.75
)
progress := func(r float64) { progress := func(r float64) {
if onRatio == nil { if onRatio == nil {
return return
@ -141,73 +209,108 @@ func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRati
onRatio(r) onRatio(r)
} }
// Vorher-Checks (für Result)
thumbBefore := func() bool {
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
return true
}
return false
}()
previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
}
return false
}()
// Meta sicherstellen (dedupliziert)
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
// Wenn alles da ist: skipped
if thumbBefore && previewBefore && meta.ok {
out.Skipped = true
progress(1)
return out, nil
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25
previewW = 0.75
)
progress(0) progress(0)
// ---------------- // ----------------
// Thumbs // Thumbs (WebP-only)
// ---------------- // ----------------
thumbPath := filepath.Join(assetDir, "thumbs.jpg") if thumbBefore {
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
progress(thumbsW) progress(thumbsW)
} else { } else {
progress(0.05) progress(0.05)
genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) func() {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel() defer cancel()
// Acquire; wenn Context cancelled → Fehler zurück
if err := thumbSem.Acquire(genCtx); err != nil { if err := thumbSem.Acquire(genCtx); err != nil {
// best-effort // wenn ctx cancelled -> hart zurück, sonst best-effort weiter
progress(thumbsW) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
goto PREVIEW return
}
return
} }
defer thumbSem.Release() defer thumbSem.Release()
progress(0.10) progress(0.10)
t := 0.0 t := 0.0
if durSec > 0 { if meta.durSec > 0 {
t = durSec * 0.5 t = meta.durSec * 0.5
} }
progress(0.15) progress(0.15)
img, e1 := extractFrameAtTimeJPEG(videoPath, t) img, e1 := extractFrameAtTimeWebP(videoPath, t)
if e1 != nil || len(img) == 0 { if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(videoPath) img, e1 = extractLastFrameWebP(videoPath)
if e1 != nil || len(img) == 0 { if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(videoPath) img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75)
} }
} }
progress(0.20) progress(0.20)
if e1 == nil && len(img) > 0 { if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err != nil { if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true
} else {
fmt.Println("⚠️ thumb write:", err) fmt.Println("⚠️ thumb write:", err)
} }
} }
}()
progress(thumbsW) progress(thumbsW)
} }
PREVIEW:
// ---------------- // ----------------
// Preview // Preview
// ---------------- // ----------------
previewPath := filepath.Join(assetDir, "preview.mp4") if previewBefore {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
progress(1) progress(1)
return nil return out, nil
} }
genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
progress(thumbsW + 0.02) progress(thumbsW + 0.02)
if err := genSem.Acquire(genCtx); err != nil { if err := genSem.Acquire(genCtx); err != nil {
progress(1) return
return nil
} }
defer genSem.Release() defer genSem.Release()
@ -223,8 +326,46 @@ PREVIEW:
progress(thumbsW + r*previewW) progress(thumbsW + r*previewW)
}); err != nil { }); err != nil {
fmt.Println("⚠️ preview clips:", err) fmt.Println("⚠️ preview clips:", err)
return
} }
progress(1) out.PreviewGenerated = true
return nil
// ✅ Preview-Clips (Starts + Dur) in meta.json schreiben (best-effort)
func() {
// nur wenn wir die Original-Dauer kennen
if !(meta.durSec > 0) {
return
}
// muss identisch zu generateTeaserClipsMP4WithProgress Defaults sein
opts := TeaserPreviewOptions{
Segments: 18,
SegmentDuration: 1.0,
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000, // 3 decimals wie ffmpeg arg
DurationSeconds: math.Round(segDur*1000) / 1000, // 3 decimals
})
}
// Originalvideo-fi (nicht preview-fi!), damit Validierung konsistent bleibt
_ = writeVideoMetaWithPreviewClips(metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, clips)
}()
}()
progress(1)
return out, nil
} }

View File

@ -11,6 +11,7 @@ import (
"io" "io"
"net/http" "net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -57,6 +58,13 @@ type ChaturbateOnlineRoomLite struct {
CurrentShow string `json:"current_show"` CurrentShow string `json:"current_show"`
ChatRoomURL string `json:"chat_room_url"` ChatRoomURL string `json:"chat_room_url"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
// fürs Filtern
Gender string `json:"gender"`
Country string `json:"country"`
NumUsers int `json:"num_users"`
IsHD bool `json:"is_hd"`
Tags []string `json:"tags"`
} }
type chaturbateCache struct { type chaturbateCache struct {
@ -85,6 +93,74 @@ var (
cbRefreshInFlight bool cbRefreshInFlight bool
) )
func normalizeList(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
sort.Strings(out)
return out
}
func keysOfSet(m map[string]bool) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func toSet(list []string) map[string]bool {
if len(list) == 0 {
return nil
}
m := make(map[string]bool, len(list))
for _, s := range list {
m[s] = true
}
return m
}
func tagsAnyMatch(tags []string, allowed map[string]bool) bool {
if len(allowed) == 0 {
return true
}
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if allowed[t] {
return true
}
}
return false
}
func derefInt(p *int) string {
if p == nil {
return "any"
}
return strconv.Itoa(*p)
}
func derefBool(p *bool) string {
if p == nil {
return "any"
}
if *p {
return "true"
}
return "false"
}
// setChaturbateOnlineModelStore wird einmal beim Startup aufgerufen. // setChaturbateOnlineModelStore wird einmal beim Startup aufgerufen.
func setChaturbateOnlineModelStore(store *ModelStore) { func setChaturbateOnlineModelStore(store *ModelStore) {
cbModelStore = store cbModelStore = store
@ -161,6 +237,12 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
CurrentShow: rm.CurrentShow, CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL, ChatRoomURL: rm.ChatRoomURL,
ImageURL: rm.ImageURL, ImageURL: rm.ImageURL,
Gender: rm.Gender,
Country: rm.Country,
NumUsers: rm.NumUsers,
IsHD: rm.IsHD,
Tags: rm.Tags,
} }
} }
return m return m
@ -300,6 +382,14 @@ func setCachedOnline(key string, body []byte) {
type cbOnlineReq struct { type cbOnlineReq struct {
Q []string `json:"q"` // usernames Q []string `json:"q"` // usernames
Show []string `json:"show"` // public/private/hidden/away Show []string `json:"show"` // public/private/hidden/away
// neue Filter
Gender []string `json:"gender"` // m/f/c/t/s ... (was die API liefert)
Country []string `json:"country"` // country codes/names (wie in API)
MinUsers *int `json:"minUsers"` // Mindestviewer
IsHD *bool `json:"isHD"` // true/false
TagsAny []string `json:"tagsAny"` // mind. ein Tag matcht
Refresh bool `json:"refresh"` Refresh bool `json:"refresh"`
} }
@ -327,6 +417,19 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
var users []string var users []string
var shows []string var shows []string
// ---------------------------
// Filter state (muss vor GET/POST da sein)
// ---------------------------
var (
allowedShow map[string]bool
allowedGender map[string]bool
allowedCountry map[string]bool
allowedTagsAny map[string]bool
minUsers *int
isHD *bool
)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 8<<20) r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
@ -346,6 +449,18 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
wantRefresh = req.Refresh wantRefresh = req.Refresh
// ✅ neue Filter übernehmen (POST)
genders := normalizeList(req.Gender)
countries := normalizeList(req.Country)
tagsAny := normalizeList(req.TagsAny)
minUsers = req.MinUsers
isHD = req.IsHD
allowedGender = toSet(genders)
allowedCountry = toSet(countries)
allowedTagsAny = toSet(tagsAny)
// normalize users // normalize users
seenU := map[string]bool{} seenU := map[string]bool{}
for _, u := range req.Q { for _, u := range req.Q {
@ -369,6 +484,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
shows = append(shows, s) shows = append(shows, s)
} }
sort.Strings(shows) sort.Strings(shows)
allowedShow = toSet(shows)
} else { } else {
// GET (legacy) // GET (legacy)
qRefresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) qRefresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
@ -401,6 +517,43 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
} }
sort.Strings(shows) sort.Strings(shows)
} }
// ✅ gender=...
qGender := strings.TrimSpace(r.URL.Query().Get("gender"))
if qGender != "" {
genders := normalizeList(strings.Split(qGender, ","))
allowedGender = toSet(genders)
}
// ✅ country=...
qCountry := strings.TrimSpace(r.URL.Query().Get("country"))
if qCountry != "" {
countries := normalizeList(strings.Split(qCountry, ","))
allowedCountry = toSet(countries)
}
// ✅ tagsAny=...
qTagsAny := strings.TrimSpace(r.URL.Query().Get("tagsAny"))
if qTagsAny != "" {
tagsAny := normalizeList(strings.Split(qTagsAny, ","))
allowedTagsAny = toSet(tagsAny)
}
// ✅ minUsers=123
qMinUsers := strings.TrimSpace(r.URL.Query().Get("minUsers"))
if qMinUsers != "" {
if n, err := strconv.Atoi(qMinUsers); err == nil {
minUsers = &n
}
}
// ✅ isHD=1/true/yes
qIsHD := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("isHD")))
if qIsHD != "" {
b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes")
isHD = &b
}
allowedShow = toSet(shows)
} }
// --------------------------- // ---------------------------
@ -409,19 +562,21 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
// --------------------------- // ---------------------------
onlySpecificUsers := len(users) > 0 onlySpecificUsers := len(users) > 0
// show allow-set
allowedShow := map[string]bool{}
for _, s := range shows {
allowedShow[s] = true
}
// --------------------------- // ---------------------------
// Response Cache (2s) // Response Cache (2s)
// --------------------------- // ---------------------------
cacheKey := "cb_online:" + hashKey( cacheKey := "cb_online:" + hashKey(
fmt.Sprintf("enabled=%v", enabled), fmt.Sprintf("enabled=%v", enabled),
"users="+strings.Join(users, ","), "users="+strings.Join(users, ","),
"show="+strings.Join(shows, ","), "show="+strings.Join(keysOfSet(allowedShow), ","),
// ✅ neue Filter in den Key!
"gender="+strings.Join(keysOfSet(allowedGender), ","),
"country="+strings.Join(keysOfSet(allowedCountry), ","),
"tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","),
"minUsers="+derefInt(minUsers),
"isHD="+derefBool(isHD),
fmt.Sprintf("refresh=%v", wantRefresh), fmt.Sprintf("refresh=%v", wantRefresh),
"lite=1", "lite=1",
) )
@ -463,21 +618,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock() cbMu.RUnlock()
// ✅ total = Anzahl online rooms (ggf. show-gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
if len(allowedShow) == 0 {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if allowedShow[s] {
total++
}
}
}
}
// --------------------------- // ---------------------------
// Refresh/Bootstrap-Strategie: // Refresh/Bootstrap-Strategie:
// - Handler blockiert NICHT auf Remote-Fetch (Performance!) // - Handler blockiert NICHT auf Remote-Fetch (Performance!)
@ -550,6 +690,65 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
} }
matches := func(rm ChaturbateOnlineRoomLite) bool {
if len(allowedShow) > 0 {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if !allowedShow[s] {
return false
}
}
if len(allowedGender) > 0 {
g := strings.ToLower(strings.TrimSpace(rm.Gender))
if !allowedGender[g] {
return false
}
}
if len(allowedCountry) > 0 {
c := strings.ToLower(strings.TrimSpace(rm.Country))
if !allowedCountry[c] {
return false
}
}
if minUsers != nil && rm.NumUsers < *minUsers {
return false
}
if isHD != nil && rm.IsHD != *isHD {
return false
}
if len(allowedTagsAny) > 0 && !tagsAnyMatch(rm.Tags, allowedTagsAny) {
return false
}
return true
}
// ✅ total = Anzahl online rooms (gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
noExtraFilters :=
len(allowedShow) == 0 &&
len(allowedGender) == 0 &&
len(allowedCountry) == 0 &&
len(allowedTagsAny) == 0 &&
minUsers == nil &&
isHD == nil
if noExtraFilters {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
if matches(rm) {
total++
}
}
}
}
outRooms := make([]outRoom, 0, len(users)) outRooms := make([]outRoom, 0, len(users))
if onlySpecificUsers && liteByUser != nil { if onlySpecificUsers && liteByUser != nil {
@ -558,13 +757,9 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
continue continue
} }
// show filter if !matches(rm) {
if len(allowedShow) > 0 {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if !allowedShow[s] {
continue continue
} }
}
outRooms = append(outRooms, outRoom{ outRooms = append(outRooms, outRoom{
Username: rm.Username, Username: rm.Username,
CurrentShow: rm.CurrentShow, CurrentShow: rm.CurrentShow,

View File

@ -22,6 +22,10 @@ type cleanupResp struct {
// Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei) // Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei)
OrphanIDsScanned int `json:"orphanIdsScanned"` OrphanIDsScanned int `json:"orphanIdsScanned"`
OrphanIDsRemoved int `json:"orphanIdsRemoved"` OrphanIDsRemoved int `json:"orphanIdsRemoved"`
// ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen)
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
} }
// Optional: falls du später Threshold per Body überschreiben willst. // Optional: falls du später Threshold per Body überschreiben willst.
@ -78,8 +82,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen, // ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
// damit die Zahlen in der JSON-Response landen. // damit die Zahlen in der JSON-Response landen.
gcStats := triggerGeneratedGarbageCollectorSync() gcStats := triggerGeneratedGarbageCollectorSync()
resp.OrphanIDsScanned += gcStats.Checked resp.GeneratedOrphansChecked = gcStats.Checked
resp.OrphanIDsRemoved += gcStats.Removed resp.GeneratedOrphansRemoved = gcStats.Removed
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -21,7 +21,7 @@ type generatedGCStats struct {
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response). // Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
func triggerGeneratedGarbageCollectorSync() generatedGCStats { func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// gleiches "nur 1 GC gleichzeitig" Verhalten wie async // nur 1 GC gleichzeitig
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) { if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
fmt.Println("🧹 [gc] skip: already running") fmt.Println("🧹 [gc] skip: already running")
return generatedGCStats{} return generatedGCStats{}

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,11 @@ import (
// generated/meta/<id>/meta.json // generated/meta/<id>/meta.json
// -------------------------- // --------------------------
type previewClip struct {
StartSeconds float64 `json:"startSeconds"`
DurationSeconds float64 `json:"durationSeconds"`
}
type videoMeta struct { type videoMeta struct {
Version int `json:"version"` Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"` DurationSeconds float64 `json:"durationSeconds"`
@ -26,6 +31,7 @@ type videoMeta struct {
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080" Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
SourceURL string `json:"sourceUrl,omitempty"` SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"`
} }
@ -73,19 +79,16 @@ func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int,
} }
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
d, _, _, _, ok := readVideoMeta(metaPath, fi) m, ok := readVideoMetaIfValid(metaPath, fi)
return d, ok if !ok || m == nil || m.DurationSeconds <= 0 {
return 0, false
}
return m.DurationSeconds, true
} }
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
b, err := os.ReadFile(metaPath) m, ok := readVideoMetaIfValid(metaPath, fi)
if err != nil || len(b) == 0 { if !ok || m == nil {
return "", false
}
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return "", false return "", false
} }
u := strings.TrimSpace(m.SourceURL) u := strings.TrimSpace(m.SourceURL)
@ -95,10 +98,6 @@ func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
return u, true return u, true
} }
// altes v1 ohne SourceURL -> keine URL
return "", false
}
// Voll-Write (wenn du dur + props schon hast) // Voll-Write (wenn du dur + props schon hast)
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error { func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 { if strings.TrimSpace(metaPath) == "" || dur <= 0 {
@ -124,17 +123,49 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
return atomicWriteFile(metaPath, buf) return atomicWriteFile(metaPath, buf)
} }
func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, clips []previewClip) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
PreviewClips: clips,
UpdatedAtUnix: time.Now().Unix(),
}
buf, err := json.Marshal(m)
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props) // Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
} }
func generatedMetaFile(id string) (string, error) { func generatedMetaFile(assetID string) (string, error) {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis assetID = stripHotPrefix(strings.TrimSpace(assetID))
if err != nil { if assetID == "" {
return "", err return "", fmt.Errorf("empty assetID")
} }
return filepath.Join(dir, "meta.json"), nil
// exakt wie beim Schreiben normalisieren
id, err := sanitizeID(assetID)
if err != nil || id == "" {
return "", fmt.Errorf("invalid assetID: %w", err)
}
return metaJSONPathForAssetID(id)
} }
// ✅ Neu: /generated/meta/<id>/... // ✅ Neu: /generated/meta/<id>/...
@ -176,7 +207,7 @@ func generatedThumbFile(id string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(dir, "thumbs.jpg"), nil return filepath.Join(dir, "thumbs.webp"), nil
} }
func generatedPreviewFile(id string) (string, error) { func generatedPreviewFile(id string) (string, error) {

Binary file not shown.

1177
backend/preview_covers.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// backend\preview_hls.go
package main package main
import ( import (
@ -384,8 +386,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
jobsMu.Unlock() jobsMu.Unlock()
}() }()
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.jpg regelmäßig neu) // ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.webp regelmäßig neu)
startLiveThumbLoop(ctx, job) startLiveThumbWebPLoop(ctx, job)
return nil return nil
} }

View File

@ -1,135 +0,0 @@
package main
import (
"bytes"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return
}
type frame struct {
path string
mt time.Time
}
now := time.Now()
var frames []frame
for _, e := range entries {
name := e.Name()
path := filepath.Join(previewDir, name)
// .part Dateien immer weg
if strings.HasSuffix(name, ".part") {
_ = os.Remove(path)
continue
}
// optional: preview.jpg neu erzeugen lassen, wenn uralt
if name == "preview.jpg" {
if info, err := e.Info(); err == nil {
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
}
}
continue
}
// Nur t_*.jpg verwalten
if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") {
info, err := e.Info()
if err != nil {
continue
}
// alte Frames löschen
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
continue
}
frames = append(frames, frame{path: path, mt: info.ModTime()})
}
}
// Anzahl begrenzen: älteste zuerst löschen
if maxFrames > 0 && len(frames) > maxFrames {
sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) })
toDelete := len(frames) - maxFrames
for i := 0; i < toDelete; i++ {
_ = os.Remove(frames[i].path)
}
}
}
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func servePreviewJPEGBytesNoStore(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func serveLivePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store, max-age=0, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func servePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
func extractFirstFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-q:v", "10",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}

View File

@ -5,7 +5,6 @@ import (
"bytes" "bytes"
"net/url" "net/url"
"path" "path"
"regexp"
"strings" "strings"
) )
@ -99,36 +98,3 @@ func rewriteAttrURI(line, base string) string {
return line[:start] + repl + line[end:] return line[:start] + repl + line[end:]
} }
func rewriteQuotedURI(line, id string) string {
re := regexp.MustCompile(`URI="([^"]+)"`)
return re.ReplaceAllStringFunc(line, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
u := sub[1]
uu := strings.TrimSpace(u)
if uu == "" || strings.HasPrefix(uu, "http://") || strings.HasPrefix(uu, "https://") || strings.HasPrefix(uu, "/") {
return m
}
repl := "/api/record/preview?id=" + url.QueryEscape(id) + "&file=" + url.QueryEscape(uu)
return `URI="` + repl + `"`
})
}
func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string {
lines := strings.Split(m3u8, "\n")
escapedID := url.QueryEscape(id)
for i, line := range lines {
l := strings.TrimSpace(line)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
// Segment/URI-Zeilen umschreiben
lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l)
}
return strings.Join(lines, "\n")
}

744
backend/preview_webp.go Normal file
View File

@ -0,0 +1,744 @@
// backend\preview_webp.go
package main
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// ------------------------------------------------------------
// Frame extraction helpers (WebP only)
// ------------------------------------------------------------
// extractLastFrameWebP extrahiert ein WebP aus dem letzten Frame der Datei.
func extractLastFrameWebP(path string) ([]byte, error) {
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-sseof", "-0.1",
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-quality", "75",
"-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output")
}
return b, nil
}
// extractFrameAtTimeWebP extrahiert ein WebP an einer Zeitposition (Sekunden).
func extractFrameAtTimeWebP(path string, seconds float64) ([]byte, error) {
if seconds < 0 {
seconds = 0
}
seek := fmt.Sprintf("%.3f", seconds)
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-ss", seek,
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-quality", "75",
"-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg frame-at-time webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg frame-at-time webp: empty output")
}
return b, nil
}
// extractLastFrameWebPScaled extrahiert ein WebP aus dem letzten Frame und skaliert auf width (Höhe automatisch).
// quality: 0..100 (ffmpeg -quality)
func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-sseof", "-0.25",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-quality", strconv.Itoa(quality),
"-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: empty output")
}
return b, nil
}
// extractFirstFrameWebPScaled extrahiert ein WebP aus dem ersten Frame und skaliert auf width.
func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", "0",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-quality", strconv.Itoa(quality),
"-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: empty output")
}
return b, nil
}
// sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts)
func latestPreviewSegment(previewDir string) (string, error) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return "", err
}
var best string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") {
continue
}
if best == "" || name > best {
best = name
}
}
if best == "" {
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir)
}
return filepath.Join(previewDir, best), nil
}
// extractLastFrameFromPreviewDirThumbWebP erzeugt ein kleines WebP aus dem letzten Preview-Segment.
func extractLastFrameFromPreviewDirThumbWebP(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
// low-res, notfalls fallback auf erstes Frame
img, err := extractLastFrameWebPScaled(seg, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameWebPScaled(seg, 320, 70)
}
// extractLastFrameFromPreviewDirWebP erzeugt ein WebP aus dem letzten Preview-Segment.
func extractLastFrameFromPreviewDirWebP(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameWebP(seg)
if err != nil {
// extractFirstFrameWebP muss bei dir existieren oder du implementierst es analog wie oben;
// wenn du es nicht hast, nimm scaled-first als fallback.
return extractFirstFrameWebPScaled(seg, 720, 75)
}
return img, nil
}
// ------------------------------------------------------------
// Preview serving (webp only)
// ------------------------------------------------------------
func serveLivePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "thumbs.webp", st.ModTime(), f)
}
func servePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/webp")
// finished previews dürfen cachen
w.Header().Set("Cache-Control", "public, max-age=600")
http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
}
func servePreviewWebPBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "public, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func serveLivePreviewWebPBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// ------------------------------------------------------------
// Preview alias: thumbs.webp / preview.webp (webp only)
// ------------------------------------------------------------
func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
// 1) Wenn Job bekannt (id = job.ID): assetID aus Output ableiten
jobsMu.Lock()
job := jobs[id]
jobsMu.Unlock()
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
if job.Status == JobRunning {
serveLivePreviewWebPFile(w, r, webpPath)
} else {
servePreviewWebPFile(w, r, webpPath)
}
return
}
}
}
// Optional: running in-memory fallback (nur WebP)
if job.Status == JobRunning {
job.previewMu.Lock()
cached := job.previewWebp
job.previewMu.Unlock()
if len(cached) > 0 {
serveLivePreviewWebPBytes(w, cached)
return
}
}
servePreviewStatusSVG(w, "Preview", http.StatusOK)
return
}
// 2) Kein Job im RAM: id als assetID behandeln (finished files nach Neustart)
assetID := stripHotPrefix(strings.TrimSpace(id))
if assetID == "" {
http.NotFound(w, r)
return
}
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewWebPFile(w, r, webpPath)
return
}
}
http.NotFound(w, r)
}
func isHover(r *http.Request) bool {
v := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hover")))
return v == "1" || v == "true" || v == "yes"
}
func touchPreview(job *RecordJob) {
if job == nil {
return
}
jobsMu.Lock()
job.previewLastHit = time.Now()
jobsMu.Unlock()
}
func ensurePreviewStarted(r *http.Request, job *RecordJob) {
if job == nil {
return
}
job.previewStartMu.Lock()
defer job.previewStartMu.Unlock()
jobsMu.Lock()
// läuft schon?
if job.previewCmd != nil && job.PreviewDir != "" {
job.previewLastHit = time.Now()
jobsMu.Unlock()
return
}
// brauchen M3U8 URL
m3u8 := strings.TrimSpace(job.PreviewM3U8)
cookie := strings.TrimSpace(job.PreviewCookie)
ua := strings.TrimSpace(job.PreviewUA)
jobsMu.Unlock()
if m3u8 == "" {
return
}
// eigener Context für Preview (WICHTIG: nicht der Recording ctx)
pctx, cancel := context.WithCancel(context.Background())
// PreviewDir temp
assetID := assetIDForJob(job)
pdir := filepath.Join(os.TempDir(), "rec_preview", assetID)
jobsMu.Lock()
job.PreviewDir = pdir
job.previewCancel = cancel
job.previewLastHit = time.Now()
jobsMu.Unlock()
_ = startPreviewHLS(pctx, job, m3u8, pdir, cookie, ua)
}
func recordPreview(w http.ResponseWriter, r *http.Request) {
// nur GET/HEAD erlauben
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
// Alias: Frontend schickt "name"
id = strings.TrimSpace(r.URL.Query().Get("name"))
}
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
// Image / HLS file requests abfangen
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(file)
// ✅ NUR WEBP
if low == "thumbs.webp" || low == "preview.webp" {
servePreviewWebPAlias(w, r, id)
return
}
// HLS wie gehabt
servePreviewHLSFile(w, r, id, file)
return
}
// Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig)
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if ok {
// ✅ 0) Running: wenn generated/<assetID>/thumbs.webp existiert -> sofort ausliefern
// (kein ffmpeg pro HTTP-Request)
if job.Status == JobRunning {
assetID := assetIDForJob(job)
if assetID != "" {
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
serveLivePreviewWebPFile(w, r, webpPath)
return
}
}
}
}
// ✅ Fallback: In-Memory-Cache (falls thumbs.webp noch nicht da ist)
job.previewMu.Lock()
cached := job.previewWebp
cachedAt := job.previewWebpAt
freshWindow := 8 * time.Second
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < freshWindow
// Wenn nicht frisch, ggf. im Hintergrund aktualisieren (einmal gleichzeitig)
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob, jobID string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
// 1) aus Preview-Segmenten
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirWebP(previewDir)
}
// 2) Fallback: aus der Ausgabedatei
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameWebP(outPath)
if genErr != nil {
// fallback: erster Frame skaliert
img, _ = extractFirstFrameWebPScaled(outPath, 720, 75)
}
}
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewWebp = img
j.previewWebpAt = time.Now()
j.previewMu.Unlock()
}
}(job, id)
}
// Wir liefern entweder ein frisches Bild, oder das zuletzt gecachte.
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
serveLivePreviewWebPBytes(w, out) // no-store für laufende Jobs
return
}
// Wenn Preview definitiv nicht geht -> Placeholder statt 204
jobsMu.Lock()
state := strings.TrimSpace(job.PreviewState)
jobsMu.Unlock()
if state == "private" {
servePreviewStatusSVG(w, "Private", http.StatusOK)
return
}
if state == "offline" {
servePreviewStatusSVG(w, "Offline", http.StatusOK)
return
}
// noch kein Bild verfügbar -> 204 (Frontend zeigt Placeholder und retry)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// Kein Job im RAM → id als Dateistamm für fertige Downloads behandeln
servePreviewForFinishedFile(w, r, id)
}
// ------------------------------------------------------------
// Live thumbs generator (WebP)
// ------------------------------------------------------------
func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
// Snapshot unter Lock holen
jobsMu.Lock()
status := job.Status
previewDir := job.PreviewDir
out := job.Output
jobsMu.Unlock()
if status != JobRunning {
return
}
// Zielpfad: generated/<assetID>/thumbs.webp
assetID := assetIDForJob(job)
thumbPath, err := generatedThumbWebPFile(assetID)
if err != nil {
return
}
// Wenn frisch genug: skip
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
if time.Since(st.ModTime()) < 10*time.Second {
return
}
}
// Concurrency limit über thumbSem
if thumbSem != nil {
thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := thumbSem.Acquire(thumbCtx); err != nil {
return
}
defer thumbSem.Release()
}
var img []byte
// 1) bevorzugt aus Preview-Segmenten
if previewDir != "" {
if b, err := extractLastFrameFromPreviewDirThumbWebP(previewDir); err == nil && len(b) > 0 {
img = b
}
}
// 2) fallback aus Output-Datei
if len(img) == 0 && out != "" {
if b, err := extractLastFrameWebPScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 {
return
}
_ = atomicWriteFile(thumbPath, img)
}
func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
// einmalig starten
jobsMu.Lock()
if job.LiveThumbStarted {
jobsMu.Unlock()
return
}
job.LiveThumbStarted = true
jobsMu.Unlock()
go func() {
// sofort einmal versuchen
updateLiveThumbWebPOnce(ctx, job)
for {
// dynamische Frequenz: je mehr aktive Jobs, desto langsamer (weniger Last)
jobsMu.Lock()
nRunning := 0
for _, j := range jobs {
if j != nil && j.Status == JobRunning {
nRunning++
}
}
jobsMu.Unlock()
delay := 12 * time.Second
if nRunning >= 6 {
delay = 18 * time.Second
}
if nRunning >= 12 {
delay = 25 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(delay):
// Stoppen, sobald Job nicht mehr läuft
jobsMu.Lock()
st := job.Status
jobsMu.Unlock()
if st != JobRunning {
return
}
updateLiveThumbWebPOnce(ctx, job)
}
}
}()
}
// ------------------------------------------------------------
// Finished file preview (WebP only, no legacy jpg migration)
// ------------------------------------------------------------
func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) {
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
// Assets immer auf "basename ohne HOT" ablegen
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
// Frame-Caching für t=... (WebP)
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
secI := int64(sec + 0.5)
if secI < 0 {
secI = 0
}
framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.webp", secI))
if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewWebPFile(w, r, framePath)
return
}
img, err := extractFrameAtTimeWebP(outPath, float64(secI))
if err == nil && len(img) > 0 {
_ = atomicWriteFile(framePath, img)
servePreviewWebPBytes(w, img)
return
}
}
}
thumbPath := filepath.Join(assetDir, "thumbs.webp")
// 1) Cache hit
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewWebPFile(w, r, thumbPath)
return
}
// 2) Neu erzeugen
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var t float64 = 0
if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 {
t = dur * 0.5
}
img, err := extractFrameAtTimeWebP(outPath, t)
if err != nil || len(img) == 0 {
img, err = extractLastFrameWebP(outPath)
if err != nil || len(img) == 0 {
// fallback: erster Frame skaliert
img, err = extractFirstFrameWebPScaled(outPath, 720, 75)
if err != nil || len(img) == 0 {
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
return
}
}
}
_ = atomicWriteFile(thumbPath, img)
servePreviewWebPBytes(w, img)
}

View File

@ -40,6 +40,17 @@ type doneListResponse struct {
PageSize int `json:"pageSize,omitempty"` PageSize int `json:"pageSize,omitempty"`
} }
type doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Error string `json:"error,omitempty"`
}
type doneMetaResp struct { type doneMetaResp struct {
Count int `json:"count"` Count int `json:"count"`
} }
@ -153,9 +164,11 @@ func writeSSE(w http.ResponseWriter, data []byte) {
} }
func handleDoneStream(w http.ResponseWriter, r *http.Request) { func handleDoneStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
// wichtig für nginx / reverse proxies
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
@ -163,23 +176,36 @@ func handleDoneStream(w http.ResponseWriter, r *http.Request) {
return return
} }
ch := make(chan []byte, 16) // pro client ein channel
ch := make(chan []byte, 32)
doneHub.add(ch) doneHub.add(ch)
defer doneHub.remove(ch) defer doneHub.remove(ch)
// optional: initial ping/hello, damit Client sofort "lebt" // ✅ KEIN doneChanged als hello nur Kommentar
fmt.Fprintf(w, "event: doneChanged\ndata: {\"type\":\"doneChanged\",\"seq\":%d,\"ts\":%d}\n\n", fmt.Fprintf(w, ": hello seq=%d ts=%d\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
flusher.Flush() flusher.Flush()
ctx := r.Context() ctx := r.Context()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case b := <-ch:
// wichtig: event-name setzen -> Client kann addEventListener("doneChanged", ...) case <-ping.C:
fmt.Fprintf(w, "event: doneChanged\ndata: %s\n\n", b) // ✅ Keepalive als Kommentar (triggert keine addEventListener("doneChanged"))
fmt.Fprintf(w, ": ping ts=%d\n\n", time.Now().UnixMilli())
flusher.Flush()
case b, ok := <-ch:
if !ok {
return
}
// ✅ nur echte Changes als doneChanged
fmt.Fprintf(w, "event: doneChanged\n")
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush() flusher.Flush()
} }
} }
@ -233,6 +259,100 @@ func (t *rwTrack) Write(p []byte) (int, error) {
return t.ResponseWriter.Write(p) return t.ResponseWriter.Write(p)
} }
// ensureMetaJSONForPlayback erzeugt generated/meta/<id>/meta.json falls sie fehlt.
// Best-effort: wenn es nicht geht (FFprobe fehlt, Fehler, etc.), wird Playback nicht verhindert.
func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
// nur mp4 (nach TS-remux ist es mp4)
if strings.ToLower(filepath.Ext(videoPath)) != ".mp4" {
return
}
// ID: basename ohne Ext + ohne HOT
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
id := stripHotPrefix(base)
id = strings.TrimSpace(id)
if id == "" {
return
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return
}
// existiert schon?
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return
}
// Video stat für spätere Checks / Meta-Write
vfi, err := os.Stat(videoPath)
if err != nil || vfi == nil || vfi.IsDir() || vfi.Size() == 0 {
return
}
// Versuche Meta aus dem Video zu extrahieren (FFprobe)
// -> du hast bereits ensureFFprobeAvailable(), getVideoHeightCached(), durationSecondsCached(), etc.
if err := ensureFFprobeAvailable(); err != nil {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
// Dauer
dur, derr := durationSecondsCached(pctx, videoPath)
if derr != nil || dur <= 0 {
// best-effort: nicht blockieren
dur = 0
}
// Height (und daraus evtl. Width) falls du schon Width helper hast, nimm den.
h, _ := getVideoHeightCached(pctx, videoPath)
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
fps := 0.0
// Quelle URL ist bei done-files oft nur in meta; wenn unbekannt, leer lassen.
srcURL := ""
// Sicherstellen, dass Ordner existiert
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
// Format: passe an deine readVideoMeta(...) / readVideoMetaDuration(...) Parser-Struktur an!
// Ich nehme hier eine sehr typische Struktur an:
type videoMeta struct {
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
// optional: file info / updatedAt
UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"`
FileSizeBytes int64 `json:"fileSizeBytes,omitempty"`
}
m := videoMeta{
DurationSeconds: dur,
Width: 0,
Height: h,
FPS: fps,
SourceURL: srcURL,
UpdatedAtUnix: time.Now().Unix(),
FileSizeBytes: vfi.Size(),
}
// Atomisch schreiben (damit parallele Requests kein kaputtes JSON sehen)
tmp := metaPath + ".tmp"
if b, err := json.MarshalIndent(m, "", " "); err == nil {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, metaPath)
} else {
_ = os.Remove(tmp)
}
}
func recordVideo(w http.ResponseWriter, r *http.Request) { func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- wrap writer to detect "already wrote" ---- // ---- wrap writer to detect "already wrote" ----
tw := &rwTrack{ResponseWriter: w} tw := &rwTrack{ResponseWriter: w}
@ -485,17 +605,15 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- convert progress fraction to seconds (if needed) ---- // ---- convert progress fraction to seconds (if needed) ----
if startSec == 0 && startFrac > 0 && startFrac < 1.0 { if startSec == 0 && startFrac > 0 && startFrac < 1.0 {
// ffprobe duration (cached)
if err := ensureFFprobeAvailable(); err == nil {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
dur, derr := getVideoDurationSecondsCached(pctx, outPath) defer cancel()
cancel()
// ✅ nutzt deinen zentralen Cache + (wenn du es wie empfohlen ergänzt hast) durSem-Limit
dur, derr := durationSecondsCached(pctx, outPath)
if derr == nil && dur > 0 { if derr == nil && dur > 0 {
startSec = int(startFrac * dur) startSec = int(startFrac * dur)
} }
} }
}
// sanitize + optional bucket align (wie bei GOP-ish seeking) // sanitize + optional bucket align (wie bei GOP-ish seeking)
if startSec < 0 { if startSec < 0 {
@ -540,6 +658,9 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
} }
} }
// ✅ NEU: meta.json sicherstellen (best effort), bevor wir ausliefern/transcoden
ensureMetaJSONForPlayback(r.Context(), outPath)
// ---- Quality / Transcode handling ---- // ---- Quality / Transcode handling ----
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
@ -682,7 +803,7 @@ func serveTranscodedStreamAt(ctx context.Context, w http.ResponseWriter, inPath
// ffmpeg args (mit -ss vor -i) // ffmpeg args (mit -ss vor -i)
args := buildFFmpegStreamArgsAt(inPath, prof, startSec) args := buildFFmpegStreamArgsAt(inPath, prof, startSec)
cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
@ -1085,18 +1206,7 @@ type doneIndexCache struct {
var doneCache doneIndexCache var doneCache doneIndexCache
func recordDoneList(w http.ResponseWriter, r *http.Request) { func normalizeQueryModel(raw string) string {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// ✅ optional: auch /done/keep/ einbeziehen (Standard: false)
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
// ✅ NEU: optionaler Model-Filter (Pagination dann "pro Model" sinnvoll)
normalizeQueryModel := func(raw string) string {
s := strings.TrimSpace(raw) s := strings.TrimSpace(raw)
if s == "" { if s == "" {
return "" return ""
@ -1104,7 +1214,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
s = strings.TrimPrefix(s, "http://") s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "https://") s = strings.TrimPrefix(s, "https://")
// letzter URL-Segment, falls jemand "…/modelname" übergibt // letzter URL-Segment, falls jemand ".../modelname" übergibt
if strings.Contains(s, "/") { if strings.Contains(s, "/") {
parts := strings.Split(s, "/") parts := strings.Split(s, "/")
for i := len(parts) - 1; i >= 0; i-- { for i := len(parts) - 1; i >= 0; i-- {
@ -1125,6 +1235,206 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return strings.ToLower(strings.TrimSpace(s)) return strings.ToLower(strings.TrimSpace(s))
} }
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// ✅ NEW: File-Mode: /api/record/done/meta?file=XYZ.mp4
if raw := strings.TrimSpace(r.URL.Query().Get("file")); raw != "" {
file, err := url.QueryUnescape(raw)
if err != nil {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = strings.TrimSpace(file)
// nur Basename erlauben (kein Traversal)
if !isSafeBasename(file) {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(file))
if ext != ".mp4" && ext != ".ts" {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
// Datei in done/ oder keep/ finden
full, _, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
// optional: TS -> MP4 remux (meta soll sich auf abspielbare MP4 beziehen)
outPath := filepath.Clean(strings.TrimSpace(full))
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" {
outPath = filepath.Clean(strings.TrimSpace(newOut))
if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil {
fi = nfi
}
}
}
// ✅ best-effort meta.json erzeugen
ensureMetaJSONForPlayback(r.Context(), outPath)
// Response-Shape: bewusst "fertig" fürs Frontend
type doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Error string `json:"error,omitempty"`
}
resp := doneMetaFileResp{File: filepath.Base(outPath)}
// meta lesen (wenn vorhanden)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
resp.DurationSeconds = dur
resp.Width = w2
resp.Height = h2
resp.FPS = fps2
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
resp.SourceURL = u
}
}
}
}
// fallback: wenn Meta existiert aber Duration fehlt -> zentralen Cache/ffprobe nutzen
if resp.DurationSeconds <= 0 {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
resp.DurationSeconds = d
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
return
}
// ---------------------
// ✅ ORIGINAL: Count-Mode (wie vorher)
// ---------------------
// optional: includeKeep (falls du später mal brauchst)
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
// optional: model filter (falls du später mal brauchst)
qModel := normalizeQueryModel(r.URL.Query().Get("model"))
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now()
// Cache rebuild (wie in recordDoneList; Count kommt aus Index)
doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq ||
doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild {
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
doneCache.items = nil
doneCache.sortedIdx = make(map[string][]int, 16)
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
}
items := doneCache.items
sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock()
// Count bestimmen
count := 0
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
count = len(sortedAll[incKey+"|completed_desc"])
} else {
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
count++
}
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(doneMetaResp{Count: count})
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// ✅ optional: auch /done/keep/ einbeziehen (Standard: false)
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
// ✅ NEU: optionaler Model-Filter (Pagination dann "pro Model" sinnvoll)
qModel := normalizeQueryModel(r.URL.Query().Get("model")) qModel := normalizeQueryModel(r.URL.Query().Get("model"))
// optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste // optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste
@ -1168,56 +1478,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
qWithCount := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("withCount"))) qWithCount := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("withCount")))
withCount := qWithCount == "1" || qWithCount == "true" || qWithCount == "yes" withCount := qWithCount == "1" || qWithCount == "true" || qWithCount == "yes"
// ✅ .trash niemals als "done item" zählen/listen
isTrashOutput := func(p string) bool {
pp := strings.ToLower(filepath.ToSlash(strings.TrimSpace(p)))
return strings.Contains(pp, "/.trash/") || strings.HasSuffix(pp, "/.trash")
}
isTrashPath := func(full string) bool {
p := strings.ReplaceAll(full, "\\", "/")
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
}
// --- helpers (ModelKey aus Filename/Dir ableiten) ---
modelFromStem := func(stem string) string {
// stem: lower, ohne ext, ohne HOT
if stem == "" {
return ""
}
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
return strings.ToLower(strings.TrimSpace(m[1]))
}
// fallback: alles vor letztem "_" (oder kompletter stem)
if i := strings.LastIndex(stem, "_"); i > 0 {
return strings.ToLower(strings.TrimSpace(stem[:i]))
}
return strings.ToLower(strings.TrimSpace(stem))
}
modelFromFullPath := func(full string) string {
name := strings.ToLower(filepath.Base(full))
stem := strings.TrimSuffix(name, filepath.Ext(name))
stem = strings.TrimPrefix(stem, "hot ")
mk := modelFromStem(stem)
// fallback: wenn Dateiname nichts taugt, aus Ordner nehmen (/done/<model>/file)
if mk == "" {
parent := strings.ToLower(filepath.Base(filepath.Dir(full)))
parent = strings.TrimSpace(parent)
if parent != "" && parent != "keep" {
mk = parent
}
}
return mk
}
// helpers (Sort)
fileForSortName := func(filename string) string {
f := strings.ToLower(filename)
f = strings.TrimPrefix(f, "hot ")
return f
}
durationForSort := func(j *RecordJob) (sec float64, ok bool) { durationForSort := func(j *RecordJob) (sec float64, ok bool) {
if j.DurationSeconds > 0 { if j.DurationSeconds > 0 {
return j.DurationSeconds, true return j.DurationSeconds, true
@ -1336,177 +1596,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return return
} }
// --------- Cache rebuild (nur bei doneSeq-Change oder TTL) ---------
buildDoneIndex := func(doneAbs string) ([]doneIndexItem, map[string][]int) {
items := make([]doneIndexItem, 0, 2048)
addFile := func(full string, fi os.FileInfo, fromKeep bool) {
if fi == nil || fi.IsDir() || fi.Size() == 0 {
return
}
// ✅ .trash niemals zählen / zurückgeben
if isTrashPath(full) || isTrashOutput(full) {
return
}
name := filepath.Base(full)
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return
}
base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime()
// StartedAt aus Dateiname (Fallback: ModTime)
start := t
stem := base
if strings.HasPrefix(stem, "HOT ") {
stem = strings.TrimPrefix(stem, "HOT ")
}
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
mm, _ := strconv.Atoi(m[2])
dd, _ := strconv.Atoi(m[3])
yy, _ := strconv.Atoi(m[4])
hh, _ := strconv.Atoi(m[5])
mi, _ := strconv.Atoi(m[6])
ss, _ := strconv.Atoi(m[7])
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
}
dur := 0.0
srcURL := ""
// 1) meta.json aus generated/<id>/meta.json lesen (schnell)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)))
if strings.TrimSpace(id) != "" {
if mp, err := generatedMetaFile(id); err == nil {
if d, ok := readVideoMetaDuration(mp, fi); ok {
dur = d
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
srcURL = u
}
}
}
// ✅ Kein Cache-only Fallback hier.
// Wenn meta fehlt, bleibt dur erstmal 0 und wird beim Ausliefern (Pagination) via ensureVideoMetaForFileBestEffort erzeugt.
ended := t
mk := modelFromFullPath(full)
fs := fileForSortName(name)
items = append(items, doneIndexItem{
job: &RecordJob{
ID: base,
Output: full,
SourceURL: srcURL,
Status: JobFinished,
StartedAt: start,
EndedAt: &ended,
DurationSeconds: dur,
SizeBytes: fi.Size(),
},
endedAt: ended,
fileSort: fs,
fromKeep: fromKeep,
modelKey: mk,
})
}
// scan one level: doneAbs + doneAbs/<sub>/*
scanRoot := func(root string, fromKeep bool, skipKeepDir bool) {
entries, err := os.ReadDir(root)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
// ✅ .trash Ordner niemals scannen
if strings.EqualFold(e.Name(), ".trash") {
continue
}
// ✅ keep nicht doppelt scannen (wenn root==doneAbs)
if skipKeepDir && e.Name() == "keep" {
continue
}
sub := filepath.Join(root, e.Name())
subEntries, err := os.ReadDir(sub)
if err != nil {
continue
}
for _, se := range subEntries {
if se.IsDir() {
continue
}
full := filepath.Join(sub, se.Name())
fi, err := se.Info()
if err != nil {
// fallback
fi2, err2 := os.Stat(full)
if err2 != nil {
continue
}
fi = fi2
}
addFile(full, fi, fromKeep)
}
continue
}
full := filepath.Join(root, e.Name())
fi, err := e.Info()
if err != nil {
fi2, err2 := os.Stat(full)
if err2 != nil {
continue
}
fi = fi2
}
addFile(full, fi, fromKeep)
}
}
// doneAbs ohne keep
scanRoot(doneAbs, false, true)
// keep (wenn existiert)
scanRoot(filepath.Join(doneAbs, "keep"), true, false)
// pre-sorted indices: includeKeep 0/1 und pro sortMode
sorted := make(map[string][]int)
buildSorted := func(inc bool, mode string) []int {
idx := make([]int, 0, len(items))
for i := range items {
if !inc && items[i].fromKeep {
continue
}
idx = append(idx, i)
}
sort.Slice(idx, func(a, b int) bool {
return compareIdx(items, mode, idx[a], idx[b])
})
return idx
}
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
sorted["0|"+m] = buildSorted(false, m)
sorted["1|"+m] = buildSorted(true, m)
}
return items, sorted
}
// rebuild wenn doneSeq geändert oder TTL // rebuild wenn doneSeq geändert oder TTL
curSeq := atomic.LoadUint64(&doneSeq) curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now() now := time.Now()
@ -1520,9 +1609,16 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
// Wenn doneAbs nicht existiert: leere Daten im Cache // Wenn doneAbs nicht existiert: leere Daten im Cache
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) { if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
doneCache.items = nil doneCache.items = nil
doneCache.sortedIdx = map[string][]int{ doneCache.sortedIdx = make(map[string][]int, 16)
"0|completed_desc": {}, modes := []string{
"1|completed_desc": {}, "completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
} }
doneCache.seq = curSeq doneCache.seq = curSeq
doneCache.doneAbs = doneAbs doneCache.doneAbs = doneAbs
@ -1611,30 +1707,27 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
// ✅ Kopie erzeugen (wichtig: keine Race/Mutations am Cache-Objekt) // ✅ Kopie erzeugen (wichtig: keine Race/Mutations am Cache-Objekt)
c := *base c := *base
// ✅ Meta immer aus meta.json (ggf. generieren, wenn fehlt) // Size immer korrekt setzen
// Kurzes Timeout pro Item, damit eine Seite nicht "hängen" kann.
pctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
m, ok := ensureVideoMetaForFileBestEffort(pctx, c.Output, c.SourceURL)
cancel()
// Wenn Meta ok: Felder IMMER daraus setzen
if ok && m != nil {
c.Meta = m
c.DurationSeconds = m.DurationSeconds
c.SizeBytes = m.FileSize
c.VideoWidth = m.VideoWidth
c.VideoHeight = m.VideoHeight
c.FPS = m.FPS
// SourceURL: wenn Job leer, aus Meta übernehmen
if strings.TrimSpace(c.SourceURL) == "" && strings.TrimSpace(m.SourceURL) != "" {
c.SourceURL = strings.TrimSpace(m.SourceURL)
}
} else {
// Falls wirklich gar keine Meta gebaut werden kann: wenigstens Size korrekt setzen
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
c.SizeBytes = fi.Size() c.SizeBytes = fi.Size()
} }
// Meta nur lesen, wenn es existiert (kein Generieren!)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(c.Output), filepath.Ext(c.Output)))
if id != "" {
if mp, err := generatedMetaFile(id); err == nil {
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() {
if dur, w, h, fps, ok := readVideoMeta(mp, fi); ok {
c.DurationSeconds = dur
c.VideoWidth = w
c.VideoHeight = h
c.FPS = fps
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok && strings.TrimSpace(c.SourceURL) == "" {
c.SourceURL = u
}
}
}
} }
out = append(out, &c) out = append(out, &c)
@ -2073,7 +2166,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
} }
http.Error(w, "unkeep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "unkeep fehlgeschlagen: "+file, http.StatusInternalServerError)
return return
} }
@ -2211,7 +2304,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
} }
http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "keep fehlgeschlagen: "+file, http.StatusInternalServerError)
return return
} }

View File

@ -77,8 +77,9 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
SourceURL: url, SourceURL: url,
Status: JobRunning, Status: JobRunning,
StartedAt: startedAt, StartedAt: startedAt,
Output: outPath, // ✅ sofort befüllt StartedAtMs: startedAt.UnixMilli(), // ✅ NEU
Hidden: req.Hidden, // ✅ NEU Output: outPath,
Hidden: req.Hidden,
cancel: cancel, cancel: cancel,
} }
@ -106,6 +107,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
now = time.Now() now = time.Now()
} }
// ✅ falls StartedAtMs aus irgendeinem Grund leer ist
if job.StartedAtMs == 0 {
base := job.StartedAt
if base.IsZero() {
base = time.Now()
jobsMu.Lock()
job.StartedAt = base
jobsMu.Unlock()
}
jobsMu.Lock()
job.StartedAtMs = base.UnixMilli()
jobsMu.Unlock()
}
// ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können) // ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können)
setJobProgress(job, "recording", 0) setJobProgress(job, "recording", 0)
notifyJobsChanged() notifyJobsChanged()
@ -198,6 +213,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// EndedAt + Error speichern (kurz locken) // EndedAt + Error speichern (kurz locken)
jobsMu.Lock() jobsMu.Lock()
job.EndedAt = &end job.EndedAt = &end
job.EndedAtMs = end.UnixMilli() // ✅ NEU
if errText != "" { if errText != "" {
job.Error = errText job.Error = errText
} }
@ -205,13 +221,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt” // ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt”
job.Phase = "postwork" job.Phase = "postwork"
/*
// ✅ Progress darf ab jetzt nicht mehr runtergehen (mind. Einstieg in Postwork)
if job.Progress < 70 {
job.Progress = 70
}
*/
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobsChanged()
@ -288,9 +297,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// - Zusätzlich: PostWorkKey setzen + initialen Queue-Status ins Job-JSON hängen. // - Zusätzlich: PostWorkKey setzen + initialen Queue-Status ins Job-JSON hängen.
jobsMu.Lock() jobsMu.Lock()
job.Phase = "postwork" job.Phase = "postwork"
if job.Progress < 70 {
job.Progress = 70
}
job.PostWorkKey = postKey job.PostWorkKey = postKey
// initialer Status (meist "missing", bis Enqueue done ist wir updaten direkt danach nochmal) // initialer Status (meist "missing", bis Enqueue done ist wir updaten direkt danach nochmal)
@ -401,7 +407,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
} }
// 6) Assets (thumbs.jpg + preview.mp4) // 6) Assets (thumbs.webp + preview.mp4)
const ( const (
assetsStart = 86 assetsStart = 86
assetsEnd = 99 assetsEnd = 99

View File

@ -47,6 +47,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/preview", recordPreview) api.HandleFunc("/api/record/preview", recordPreview)
api.HandleFunc("/api/record/list", recordList) api.HandleFunc("/api/record/list", recordList)
api.HandleFunc("/api/record/stream", recordStream) api.HandleFunc("/api/record/stream", recordStream)
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
api.HandleFunc("/api/record/video", recordVideo) api.HandleFunc("/api/record/video", recordVideo)
api.HandleFunc("/api/record/done", recordDoneList) api.HandleFunc("/api/record/done", recordDoneList)
api.HandleFunc("/api/record/delete", recordDeleteVideo) api.HandleFunc("/api/record/delete", recordDeleteVideo)
@ -65,6 +66,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
// Tasks // Tasks
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
// -------------------------- // --------------------------
// 3) ModelStore // 3) ModelStore
// -------------------------- // --------------------------

401
backend/sse.go Normal file
View File

@ -0,0 +1,401 @@
// backend/sse.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
// -------------------- SSE primitives --------------------
type sseHub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
}
func newSSEHub() *sseHub {
return &sseHub{clients: map[chan []byte]struct{}{}}
}
func (h *sseHub) add(ch chan []byte) {
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
}
func (h *sseHub) remove(ch chan []byte) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *sseHub) broadcast(b []byte) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
// Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein)
select {
case ch <- b:
default:
}
}
}
// -------------------- SSE channels + notify --------------------
var (
// done changed stream (Client soll nur "refetch done" machen)
doneHub = newSSEHub()
doneNotify = make(chan struct{}, 1)
doneSeq uint64
// record jobs stream
recordJobsHub = newSSEHub()
recordJobsNotify = make(chan struct{}, 1)
// assets task stream
assetsHub = newSSEHub()
assetsNotify = make(chan struct{}, 1)
)
func notifyDoneChanged() {
select {
case doneNotify <- struct{}{}:
default:
}
}
func notifyJobsChanged() {
select {
case recordJobsNotify <- struct{}{}:
default:
}
}
func notifyAssetsChanged() {
select {
case assetsNotify <- struct{}{}:
default:
}
}
// initSSE startet die Debounce-Broadcaster.
// Wichtig: wird aus main.go init() aufgerufen.
func initSSE() {
// Debounced broadcaster (jobs)
go func() {
for range recordJobsNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-recordJobsNotify:
default:
goto SEND
}
}
SEND:
recordJobsHub.broadcast(jobsSnapshotJSON())
}
}()
// Debounced broadcaster (done changed)
go func() {
for range doneNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-doneNotify:
default:
goto SEND
}
}
SEND:
seq := atomic.AddUint64(&doneSeq, 1)
b := []byte(fmt.Sprintf(`{"type":"doneChanged","seq":%d,"ts":%d}`, seq, time.Now().UnixMilli()))
doneHub.broadcast(b)
}
}()
// ✅ Debounced broadcaster (assets task)
go func() {
for range assetsNotify {
time.Sleep(80 * time.Millisecond)
for {
select {
case <-assetsNotify:
default:
goto SEND
}
}
SEND:
b := assetsSnapshotJSON()
if len(b) > 0 {
assetsHub.broadcast(b)
}
}
}()
}
// -------------------- SSE: /api/record/stream --------------------
// jobsSnapshotJSON liefert die aktuelle (gefilterte) Job-Liste als JSON.
// Greift auf jobs/jobsMu aus main.go zu (gleiches Package).
func jobsSnapshotJSON() []byte {
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
// Hidden-Jobs niemals an die UI senden
if j == nil || j.Hidden {
continue
}
c := *j
c.cancel = nil // nicht serialisieren
list = append(list, &c)
}
jobsMu.Unlock()
// neueste zuerst
sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt)
})
b, _ := json.Marshal(list)
return b
}
func recordStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// SSE-Header
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no") // hilfreich bei Reverse-Proxies
// sofort starten
w.WriteHeader(http.StatusOK)
writeEvent := func(event string, data []byte) bool {
// returns false => client weg / write error
if event != "" {
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
}
if len(data) > 0 {
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
} else {
// empty payload ok (nur terminator)
if _, err := io.WriteString(w, "\n"); err != nil {
return false
}
}
flusher.Flush()
return true
}
writeComment := func(msg string) bool {
if _, err := fmt.Fprintf(w, ": %s\n\n", msg); err != nil {
return false
}
flusher.Flush()
return true
}
// Reconnect-Hinweis
if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil {
return
}
flusher.Flush()
// Channel + Hub
ch := make(chan []byte, 32)
recordJobsHub.add(ch)
defer recordJobsHub.remove(ch)
// Initialer Snapshot sofort
if b := jobsSnapshotJSON(); len(b) > 0 {
if !writeEvent("jobs", b) {
return
}
}
ctx := r.Context()
// Ping/Keepalive
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case b, ok := <-ch:
if !ok {
return
}
if len(b) == 0 {
continue
}
// Burst-Coalescing: wenn viele Updates schnell kommen, nur das neueste senden
last := b
drain:
for i := 0; i < 64; i++ {
select {
case nb, ok := <-ch:
if !ok {
return
}
if len(nb) > 0 {
last = nb
}
default:
break drain
}
}
if !writeEvent("jobs", last) {
return
}
case <-ping.C:
// Keepalive als Kommentar (stört nicht, hält Verbindungen offen)
if !writeComment(fmt.Sprintf("ping %d", time.Now().Unix())) {
return
}
}
}
}
// -------------------- SSE: /api/tasks/assets/stream --------------------
func assetsSnapshotJSON() []byte {
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
b, _ := json.Marshal(st)
return b
}
func assetsStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Reconnect-Hinweis
fmt.Fprintf(w, "retry: 3000\n\n")
flusher.Flush()
writeEvent := func(event string, data []byte) bool {
if event != "" {
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
flusher.Flush()
return true
}
writeComment := func(msg string) bool {
if _, err := fmt.Fprintf(w, ": %s\n\n", msg); err != nil {
return false
}
flusher.Flush()
return true
}
ch := make(chan []byte, 32)
assetsHub.add(ch)
defer assetsHub.remove(ch)
// Initial Snapshot
if b := assetsSnapshotJSON(); len(b) > 0 {
if !writeEvent("state", b) {
return
}
}
ctx := r.Context()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case b, ok := <-ch:
if !ok {
return
}
if len(b) == 0 {
continue
}
// coalesce
last := b
drain:
for i := 0; i < 64; i++ {
select {
case nb, ok := <-ch:
if !ok {
return
}
if len(nb) > 0 {
last = nb
}
default:
break drain
}
}
if !writeEvent("state", last) {
return
}
case <-ping.C:
if !writeComment(fmt.Sprintf("ping %d", time.Now().Unix())) {
return
}
}
}
}

View File

@ -1,7 +1,9 @@
// backend\tasks_assets.go
package main package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -31,12 +33,30 @@ var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState var assetsTaskState AssetsTaskState
var assetsTaskCancel context.CancelFunc var assetsTaskCancel context.CancelFunc
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { // updateAssetsState mutiert den State atomar und triggert danach SSE notify.
switch r.Method { // notifyAssetsChanged() muss außerhalb des Locks passieren.
case http.MethodGet: func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
assetsTaskMu.Lock()
fn(&assetsTaskState)
st := assetsTaskState
assetsTaskMu.Unlock()
notifyAssetsChanged()
return st
}
func snapshotAssetsState() AssetsTaskState {
assetsTaskMu.Lock() assetsTaskMu.Lock()
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
return st
}
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// GET bleibt als Fallback/Debug möglich (UI nutzt SSE)
st := snapshotAssetsState()
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
@ -49,17 +69,28 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ cancelbaren Context erzeugen // cancelbarer Context (pro Run)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
assetsTaskCancel = cancel assetsTaskCancel = cancel
now := time.Now()
assetsTaskState = AssetsTaskState{ assetsTaskState = AssetsTaskState{
Running: true, Running: true,
StartedAt: time.Now(), Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
StartedAt: now,
FinishedAt: nil,
Error: "",
} }
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
// ✅ SSE: Start pushen
notifyAssetsChanged()
go runGenerateMissingAssets(ctx) go runGenerateMissingAssets(ctx)
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
@ -72,48 +103,60 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
if !running || cancel == nil { if !running || cancel == nil {
// nichts zu stoppen
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
// canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber
cancel() cancel()
// optional: sofortiges Feedback in state.error // UI sofort informieren (ohne Running künstlich auf false zu setzen —
assetsTaskMu.Lock() // das macht der Worker zuverlässig im finishWithErr(context.Canceled))
if assetsTaskState.Running { st := updateAssetsState(func(st *AssetsTaskState) {
assetsTaskState.Error = "abgebrochen" if st.Running {
st.Error = "abgebrochen"
} }
st := assetsTaskState })
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
default: default:
http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed) http.Error(w, "Nur GET/POST/DELETE", http.StatusMethodNotAllowed)
return return
} }
} }
func runGenerateMissingAssets(ctx context.Context) { func runGenerateMissingAssets(ctx context.Context) {
finishWithErr := func(err error) { // Worker-Ende: CancelFunc zurücksetzen (pro Run)
now := time.Now()
assetsTaskMu.Lock()
assetsTaskState.Running = false
assetsTaskState.FinishedAt = &now
if err != nil {
assetsTaskState.Error = err.Error()
}
assetsTaskMu.Unlock()
}
defer func() { defer func() {
assetsTaskMu.Lock() assetsTaskMu.Lock()
assetsTaskCancel = nil assetsTaskCancel = nil
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
}() }()
finishWithErr := func(err error) {
now := time.Now()
updateAssetsState(func(st *AssetsTaskState) {
st.Running = false
st.FinishedAt = &now
if err == nil {
// Erfolg: Error leeren
st.Error = ""
return
}
// stabiler Text für UI
if errors.Is(err, context.Canceled) {
st.Error = "abgebrochen"
} else {
st.Error = err.Error()
}
})
}
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
@ -150,7 +193,6 @@ func runGenerateMissingAssets(ctx context.Context) {
return return
} }
// Dedupe
if _, ok := seen[full]; ok { if _, ok := seen[full]; ok {
return return
} }
@ -164,7 +206,6 @@ func runGenerateMissingAssets(ctx context.Context) {
return return
} }
for _, e := range ents { for _, e := range ents {
// .trash-Ordner nie scannen
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") { if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
continue continue
} }
@ -187,18 +228,20 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
} }
// done + done/<model>/ + done/keep + done/keep/<model>/ // done + done/<model>/ + done/keep + done/keep/<model>/
scanOneLevel(doneAbs) scanOneLevel(doneAbs)
scanOneLevel(filepath.Join(doneAbs, "keep")) scanOneLevel(filepath.Join(doneAbs, "keep"))
assetsTaskMu.Lock() // ✅ Initialisierung: Total etc. + SSE Push
assetsTaskState.Total = len(items) updateAssetsState(func(st *AssetsTaskState) {
assetsTaskState.Done = 0 st.Total = len(items)
assetsTaskState.GeneratedThumbs = 0 st.Done = 0
assetsTaskState.GeneratedPreviews = 0 st.GeneratedThumbs = 0
assetsTaskState.Skipped = 0 st.GeneratedPreviews = 0
assetsTaskState.Error = "" st.Skipped = 0
assetsTaskMu.Unlock() // Start hat Error schon geleert — hier nur sicherheitshalber:
st.Error = ""
})
for i, it := range items { for i, it := range items {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@ -206,175 +249,63 @@ func runGenerateMissingAssets(ctx context.Context) {
return return
} }
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base) id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
assetsTaskMu.Lock() updateAssetsState(func(st *AssetsTaskState) {
assetsTaskState.Done = i + 1 st.Done = i + 1
assetsTaskMu.Unlock() })
continue continue
} }
assetDir, derr := ensureGeneratedDir(id) // Datei-Info (validieren)
if derr != nil {
assetsTaskMu.Lock()
assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
fmt.Println("⚠️ ensureGeneratedDir:", derr)
continue
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
previewPath := filepath.Join(assetDir, "preview.mp4")
metaPath := filepath.Join(assetDir, "meta.json")
thumbOK := func() bool {
fi, err := os.Stat(thumbPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
previewOK := func() bool {
fi, err := os.Stat(previewPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
// Datei-Info (für Meta-Validierung)
vfi, verr := os.Stat(it.path) vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
assetsTaskMu.Lock() updateAssetsState(func(st *AssetsTaskState) {
assetsTaskState.Done = i + 1 st.Done = i + 1
assetsTaskMu.Unlock() })
continue continue
} }
// ✅ SourceURL best-effort: aus bestehender meta.json, wenn vorhanden/valide // Pfade einmalig über zentralen Helper
_, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
updateAssetsState(func(st *AssetsTaskState) {
// UI bekommt stabilen Hinweis, aber Task läuft weiter
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done = i + 1
})
fmt.Println("⚠️ assetPathsForID:", perr)
continue
}
// SourceURL best-effort: aus bestehender meta.json
sourceURL := "" sourceURL := ""
if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok { if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok {
sourceURL = u sourceURL = u
} }
// ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet // Generate/Ensure (einheitliche Core-Funktion)
durSec := 0.0 res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
vw, vh := 0, 0 if e != nil {
fps := 0.0 finishWithErr(e)
// Wir wollen nicht nur "Duration ok", sondern auch Props ok.
// Sonst wird später fälschlich "skipped" und Resolution bleibt für immer leer.
metaOK := false
// 1) Versuch: komplette Meta lesen (Duration + w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Fallback: Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, it.path)
cancel()
if derr == nil && d > 0 {
durSec = d
}
}
// 3) Wenn wir Duration haben, aber Props fehlen: einmal ffprobe für Props
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
// optional: Semaphore verwenden (du hast durSem global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
durSem.Release()
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
}
}
// 4) Jetzt voll schreiben (inkl. Resolution via formatResolution)
if durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, durSec, vw, vh, fps, sourceURL)
}
// Meta gilt nur als "OK", wenn Duration + Auflösung vorhanden ist
metaOK = durSec > 0 && vw > 0 && vh > 0
if thumbOK && previewOK && metaOK {
assetsTaskMu.Lock()
assetsTaskState.Skipped++
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
// ----------------
// Thumbs
// ----------------
if !thumbOK {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
if err := thumbSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return
}
cancel() // Timeout-Context freigeben, Semaphore bleibt gehalten
defer thumbSem.Release()
t := 0.0
if durSec > 0 {
t = durSec * 0.5
}
img, e1 := extractFrameAtTimeJPEG(it.path, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(it.path)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(it.path)
}
}
// Release wurde defert, aber wir wollen pro Iteration releasen:
thumbSem.Release()
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedThumbs++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}
// ----------------
// Preview
// ----------------
if !previewOK {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
if err := genSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return return
} }
err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18) // ✅ Progress + Counters + SSE Push
updateAssetsState(func(st *AssetsTaskState) {
genSem.Release() if res.Skipped {
cancel() st.Skipped++
if err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedPreviews++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ preview clips:", err)
} }
if res.ThumbGenerated {
st.GeneratedThumbs++
} }
if res.PreviewGenerated {
assetsTaskMu.Lock() st.GeneratedPreviews++
assetsTaskState.Done = i + 1 }
assetsTaskMu.Unlock() st.Done = i + 1
})
} }
finishWithErr(nil) finishWithErr(nil)

View File

@ -165,6 +165,53 @@ func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur flo
return os.Rename(tmp, out) return os.Rename(tmp, out)
} }
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
// opts normalisieren wie in generateTeaserPreviewMP4WithProgress
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = 18
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
// Kurzvideo-Fallback: wenn Video kürzer als Segments*SegmentDuration -> 1 Segment über ganze Dauer
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
usedSegments = opts.Segments
// Dauer unbekannt: Start 0
if !(dur > 0) {
return []float64{0}, segDur, 1
}
stepSize, offset := opts.stepSizeAndOffset(dur)
starts = make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
maxStart := math.Max(0, dur-0.05-segDur)
if t < 0 {
t = 0
}
if t > maxStart {
t = maxStart
}
if t < 0.05 {
t = 0.05
}
starts = append(starts, t)
}
return starts, segDur, usedSegments
}
func generateTeaserPreviewMP4WithProgress( func generateTeaserPreviewMP4WithProgress(
ctx context.Context, ctx context.Context,
srcPath, outPath string, srcPath, outPath string,
@ -223,26 +270,9 @@ func generateTeaserPreviewMP4WithProgress(
return err return err
} }
// Startpunkte wie "die andere": offset + i*stepSize starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
stepSize, offset := opts.stepSizeAndOffset(dur) // segDur ist später im Code benutzt -> segDur damit überschreiben:
segDur = segDurComputed
starts := make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
// clamp: sicherstellen, dass wir nicht über Ende hinaus trimmen
maxStart := math.Max(0, dur-0.05-segDur)
if t < 0 {
t = 0
}
if t > maxStart {
t = maxStart
}
if t < 0.05 {
t = 0.05
}
starts = append(starts, t)
}
expectedOutSec := float64(len(starts)) * segDur expectedOutSec := float64(len(starts)) * segDur
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4" tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-JupgTTdL.js"></script> <script type="module" crossorigin src="/assets/index-rrLyu52u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SqYhLYXQ.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cd67oQ3U.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -401,12 +401,109 @@ export default function App() {
} }
}) })
type DonePrefetch = {
key: string
items: RecordJob[]
ts: number
}
const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false)
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
const doneFetchAbortRef = useRef<AbortController | null>(null)
const doneFetchInFlightRef = useRef(false)
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return
const key = makePrefetchKey(pageToFetch, doneSort)
const cur = donePrefetchRef.current
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
// frisch genug
return
}
donePrefetchInFlightRef.current = true
try {
const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
donePrefetchRef.current = { key, items, ts: Date.now() }
} finally {
donePrefetchInFlightRef.current = false
}
}, [doneSort])
const loadDoneCount = useCallback(async () => {
try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
const countRaw = Number(data?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const requestFinishedReload = useCallback(() => {
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
}, [])
const loadJobs = useCallback(async () => {
try {
const res = await fetch('/api/record/list', { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
// akzeptiere: Array oder { items: [] }
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setJobs(items)
jobsRef.current = items
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(DONE_SORT_KEY, doneSort) window.localStorage.setItem(DONE_SORT_KEY, doneSort)
} catch {} } catch {}
}, [doneSort]) }, [doneSort])
useEffect(() => {
if (!authed) return
void loadDoneCount()
}, [authed, loadDoneCount])
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null) const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('') const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([]) const [jobs, setJobs] = useState<RecordJob[]>([])
@ -594,13 +691,6 @@ export default function App() {
const [assetNonce, setAssetNonce] = useState(0) const [assetNonce, setAssetNonce] = useState(0)
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
const assetsBumpTimerRef = useRef<number | null>(null)
const bumpAssetsTwice = useCallback(() => {
bumpAssets()
if (assetsBumpTimerRef.current) window.clearTimeout(assetsBumpTimerRef.current)
assetsBumpTimerRef.current = window.setTimeout(() => bumpAssets(), 3500)
}, [bumpAssets])
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS) const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
const recSettingsRef = useRef(recSettings) const recSettingsRef = useRef(recSettings)
@ -1108,251 +1198,37 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded]) }, [cookies, cookiesLoaded])
// ✅ done count polling über /api/record/done (kein /done/meta mehr)
useEffect(() => { useEffect(() => {
let cancelled = false // 1x initial / bei sort-wechsel (für Badge)
let t: number | undefined void loadDoneCount()
const loadDoneCount = async () => {
try {
// pageSize=1 => minimaler Payload, zählt trotzdem korrekt
const res = await fetch(
`/api/record/done?page=1&pageSize=1&withCount=1&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const countRaw =
Number(data?.count ?? data?.totalCount ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (!cancelled) {
setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
// ignore
} finally {
if (!cancelled) {
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneCount, ms)
}
}
}
const onVis = () => { const onVis = () => {
if (!document.hidden) void loadDoneCount() if (!document.hidden) void loadDoneCount()
} }
document.addEventListener('visibilitychange', onVis)
void loadDoneCount()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [doneSort])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
useEffect(() => {
if (!authed) return // ✅ WICHTIG: bei Logout alles stoppen
let cancelled = false
let es: EventSource | null = null
let fallbackTimer: number | null = null
let inFlight = false
const stopFallbackPolling = () => {
if (fallbackTimer) {
window.clearInterval(fallbackTimer)
fallbackTimer = null
}
}
const applyList = (list: any) => {
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
if (cancelled) return
// --- vorheriger Snapshot für Status-Transitions ---
const prev = jobsRef.current
const prevStatusById = new Map<string, string>()
for (const j of Array.isArray(prev) ? prev : []) {
const id = String((j as any)?.id ?? '')
if (!id) continue
prevStatusById.set(id, String((j as any)?.status ?? ''))
}
// ✅ 0) Initial load: KEINE Toasts, aber als "gesehen" markieren (falls du später wieder Start-Toast einführen willst)
if (!jobsInitDoneRef.current) {
const seen: Record<string, true> = {}
for (const j of arr) {
const id = String((j as any)?.id ?? '')
if (id) seen[id] = true
}
startedToastByJobIdRef.current = seen
jobsInitDoneRef.current = true
}
// ✅ Finished/Stopped/Failed Transition zählen -> Count-Hint + Asset-Bump
const terminal = new Set(['finished', 'stopped', 'failed'])
let endedDelta = 0
for (const j of arr) {
const id = String((j as any)?.id ?? '')
if (!id) continue
const before = String(prevStatusById.get(id) ?? '').toLowerCase().trim()
const now = String((j as any)?.status ?? '').toLowerCase().trim()
if (!before || before === now) continue
// nur zählen, wenn wir "neu" in einen terminal state gehen
if (terminal.has(now) && !terminal.has(before)) endedDelta++
}
if (endedDelta > 0) {
window.dispatchEvent(
new CustomEvent('finished-downloads:count-hint', { detail: { delta: endedDelta } })
)
bumpAssetsTwice()
}
// ---- Queue-Info berechnen (Postwork-Warteschlange) ----
const statusLower = (j: any) => String(j?.status ?? '').toLowerCase().trim()
const isPostworkQueued = (j: any) => {
const s = statusLower(j)
return s === 'postwork' || s === 'queued_postwork' || s === 'waiting_postwork'
}
const ts = (j: any) =>
Number(
j?.endedAtMs ??
j?.endedAt ??
j?.createdAtMs ??
j?.createdAt ??
j?.startedAtMs ??
j?.startedAt ??
0
) || 0
const postworkQueue = arr
.filter(isPostworkQueued)
.slice()
.sort((a, b) => ts(a) - ts(b))
const postworkTotal = postworkQueue.length
const postworkPosById = new Map<string, number>()
for (let i = 0; i < postworkQueue.length; i++) {
const id = String((postworkQueue[i] as any)?.id ?? '')
if (id) postworkPosById.set(id, i + 1)
}
const arrWithQueue = arr.map((j: any) => {
const id = String(j?.id ?? '')
const pos = id ? postworkPosById.get(id) : undefined
if (!pos) return j
return {
...j,
postworkQueuePos: pos,
postworkQueueTotal: postworkTotal,
}
})
setJobs(arrWithQueue)
jobsRef.current = arrWithQueue
setPlayerJob((prevJob) => {
if (!prevJob) return prevJob
const updated = arrWithQueue.find((j) => j.id === prevJob.id)
if (updated) return updated
// wenn running und nicht mehr in list: player schließen, sonst stehen lassen
return prevJob.status === 'running' ? null : prevJob
})
}
const loadOnce = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/list')
applyList(list)
} catch {
// ignore
} finally {
inFlight = false
}
}
const startFallbackPolling = () => {
if (fallbackTimer) return
fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000)
}
void loadOnce()
es = new EventSource('/api/record/stream')
// ✅ wenn SSE wieder verbunden ist: Fallback-Polling stoppen
es.onopen = () => {
stopFallbackPolling()
}
const onJobs = (ev: MessageEvent) => {
stopFallbackPolling() // ✅ sobald Daten kommen, Polling aus
try {
applyList(JSON.parse(ev.data))
} catch {}
}
es.addEventListener('jobs', onJobs as any)
es.onerror = () => startFallbackPolling()
const onVis = () => {
if (!document.hidden) void loadOnce()
}
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
// ❌ das hier empfehle ich rauszuwerfen, siehe Schritt C
// window.addEventListener('hover', onVis)
return () => { return () => {
cancelled = true
stopFallbackPolling()
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
// window.removeEventListener('hover', onVis)
es?.removeEventListener('jobs', onJobs as any)
es?.close()
es = null
} }
}, [authed]) }, [loadDoneCount])
useEffect(() => { const refreshDoneNow = useCallback(
if (selectedTab !== 'finished') return async (preferPage?: number) => {
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
if (doneFetchInFlightRef.current) {
doneFetchAbortRef.current?.abort()
}
let cancelled = false
const inFlightRef = { current: false }
const ac = new AbortController() const ac = new AbortController()
doneFetchAbortRef.current = ac
const loadDone = async () => { doneFetchInFlightRef.current = true
if (cancelled || inFlightRef.current) return
inFlightRef.current = true
try { try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const res = await fetch( const res = await fetch(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` + `/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}` + `&sort=${encodeURIComponent(doneSort)}&withCount=1`,
`&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal } { cache: 'no-store' as any, signal: ac.signal }
) )
@ -1360,69 +1236,12 @@ export default function App() {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
// akzeptiere beide Formen:
// A) { count, items }
// B) doneListResponse { totalCount, items }
// C) Legacy array
const items = Array.isArray(data?.items) const items = Array.isArray(data?.items)
? (data.items as RecordJob[]) ? (data.items as RecordJob[])
: Array.isArray(data) : Array.isArray(data)
? (data as RecordJob[]) ? (data as RecordJob[])
: [] : []
const countRaw =
typeof data?.count === 'number'
? data.count
: typeof data?.totalCount === 'number'
? data.totalCount
: items.length
if (!cancelled) {
setDoneJobs(items)
setDoneCount(Number.isFinite(countRaw) ? countRaw : items.length)
}
} catch {
if (!cancelled) {
setDoneJobs([])
setDoneCount(0)
}
} finally {
inFlightRef.current = false
}
}
void loadDone()
const baseMs = 20000
const t = window.setInterval(() => {
if (!document.hidden) void loadDone()
}, baseMs)
const onVis = () => {
if (!document.hidden) void loadDone()
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
ac.abort()
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, donePage, doneSort])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const data = await apiJSON<any>(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length) const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
@ -1432,53 +1251,190 @@ export default function App() {
const target = Math.min(Math.max(1, wanted), maxPage) const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target) if (target !== donePage) setDonePage(target)
// wenn target anders ist, optional nochmal mit target laden: // Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
if (target === wanted) { if (target !== wanted) {
setDoneJobs(items) const res2 = await fetch(
} else {
const data2 = await apiJSON<any>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` + `/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`, `&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any } { cache: 'no-store' as any, signal: ac.signal }
) )
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
const data2 = await res2.json().catch(() => null)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : [] const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2) setDoneJobs(items2)
} else {
setDoneJobs(items)
}
setLastHeaderUpdateAtMs(Date.now())
} catch (e: any) {
// Abort ist ok
if (String(e?.name) !== 'AbortError') {
// optional: console.debug('[DONE] refresh failed', e)
}
} finally {
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
const isCurrent = doneFetchAbortRef.current === ac
if (isCurrent) {
doneFetchAbortRef.current = null
doneFetchInFlightRef.current = false
} }
} catch {
// ignore
} }
}, },
[donePage, doneSort] [donePage, doneSort]
) )
useEffect(() => { useEffect(() => {
if (selectedTab !== 'finished') return
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount()
requestFinishedReload()
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
requestFinishedReload()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, loadDoneCount, requestFinishedReload])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
useEffect(() => {
if (!authed) return
let es: EventSource | null = null let es: EventSource | null = null
try { let timer: number | null = null
es = new EventSource('/api/record/done/stream')
} catch { const stopPoll = () => {
if (timer != null) {
window.clearInterval(timer)
timer = null
}
}
const startPoll = () => {
if (timer != null) return
timer = window.setInterval(() => {
if (document.hidden) return
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, document.hidden ? 60000 : 15000)
}
const lastFireRef = { t: 0 }
let coalesceTimer: number | null = null
const requestRefresh = () => {
const now = Date.now()
const since = now - lastFireRef.t
// coalesce bursts
if (since < 800) {
if (coalesceTimer != null) return
coalesceTimer = window.setTimeout(() => {
coalesceTimer = null
lastFireRef.t = Date.now()
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, 900)
return return
} }
const onDone = () => { lastFireRef.t = now
// wenn finished tab offen: liste aktualisieren
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void refreshDoneNow() void loadDoneCount()
requestFinishedReload()
} else { } else {
// sonst nur count aktualisieren (leicht) void loadDoneCount()
// optional: void loadDoneCount() wenn du es aus dem Scope verfügbar machst
} }
} }
es.addEventListener('doneChanged', onDone as any) // initial
es.onerror = () => { void loadDoneCount()
// fallback: dein bestehendes polling bleibt als sicherheit
es = new EventSource('/api/record/done/stream')
es.onopen = () => {
// ✅ sobald SSE stabil da ist: Poll aus
stopPoll()
} }
es.onerror = () => {
// ✅ SSE kaputt -> Poll an
startPoll()
}
const onDone = () => requestRefresh()
es.addEventListener('doneChanged', onDone as any)
const onVis = () => {
if (!document.hidden) requestRefresh()
}
document.addEventListener('visibilitychange', onVis)
return () => { return () => {
document.removeEventListener('visibilitychange', onVis)
if (coalesceTimer != null) window.clearTimeout(coalesceTimer)
stopPoll()
es?.removeEventListener('doneChanged', onDone as any) es?.removeEventListener('doneChanged', onDone as any)
es?.close() es?.close()
es = null
} }
}, [refreshDoneNow]) }, [authed, loadDoneCount, requestFinishedReload])
useEffect(() => {
if (!authed) return
// initial
void loadJobs()
// polling: schneller wenn running-tab offen oder jobs laufen
const t = window.setInterval(() => {
if (document.hidden) return
const hasRunning = jobsRef.current.some((j) => {
const s = String((j as any)?.status ?? '').toLowerCase()
return s === 'running' || s === 'postwork'
})
// wenn Tab "running" offen ODER irgendwas läuft -> häufiger pollen
if (selectedTabRef.current === 'running' || hasRunning) {
void loadJobs()
}
}, document.hidden ? 60000 : 3000) // 3s fühlt sich "live" an
const onVis = () => {
if (!document.hidden) void loadJobs()
}
document.addEventListener('visibilitychange', onVis)
return () => {
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [authed, loadJobs])
function isChaturbate(raw: string): boolean { function isChaturbate(raw: string): boolean {
const norm = normalizeHttpUrl(raw) const norm = normalizeHttpUrl(raw)
@ -1517,21 +1473,24 @@ export default function App() {
const onHint = (ev: Event) => { const onHint = (ev: Event) => {
const e = ev as CustomEvent<{ delta?: number }> const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0) const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) { if (!Number.isFinite(delta) || delta === 0) {
void refreshDoneNow() void loadDoneCount()
requestFinishedReload()
return return
} }
// ✅ Tabs sofort updaten (optimistisch) // ✅ Tabs sofort updaten (optimistisch)
setDoneCount((c) => Math.max(0, c + delta)) setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach einmal server-truth holen (Pagination + count 100% korrekt) // ✅ danach server-truth holen + ALL reload
void refreshDoneNow() void loadDoneCount()
requestFinishedReload()
} }
window.addEventListener('finished-downloads:count-hint', onHint as EventListener) window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [refreshDoneNow]) }, [loadDoneCount, requestFinishedReload])
useEffect(() => { useEffect(() => {
const onNav = (ev: Event) => { const onNav = (ev: Event) => {
@ -1608,9 +1567,46 @@ export default function App() {
) )
window.setTimeout(() => { window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
// ✅ sofort auffüllen, wenn wir Platz haben
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
// buffer zurückschreiben (mit verkürzter items-Liste)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1))
// ✅ Player / jobs cleanup wie bei dir
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background)
void prefetchDonePage(donePage + 1)
}, 320) }, 320)
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
@ -1619,7 +1615,7 @@ export default function App() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }) new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
) )
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e)) notify.error('Löschen fehlgeschlagen: ', file)
return // ✅ void statt null return // ✅ void statt null
} }
}, },
@ -1652,7 +1648,7 @@ export default function App() {
} catch (e: any) { } catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', e?.message ?? String(e)) notify.error('Keep fehlgeschlagen', file)
return return
} }
}, },
@ -1665,6 +1661,11 @@ export default function App() {
if (!file) return if (!file) return
try { try {
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
await new Promise((r) => window.setTimeout(r, 60))
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, `/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' } { method: 'POST' }
@ -1697,8 +1698,11 @@ export default function App() {
return match ? { ...j, output: apply(j.output || '') } : j return match ? { ...j, output: apply(j.output || '') } : j
}) })
) )
return res
} catch (e: any) { } catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
return
} }
}, },
[notify] [notify]
@ -2384,7 +2388,7 @@ export default function App() {
getShow: () => ['public', 'private', 'hidden', 'away'], getShow: () => ['public', 'private', 'hidden', 'away'],
intervalMs: 12000, intervalMs: 8000,
onData: (data: ChaturbateOnlineResponse) => { onData: (data: ChaturbateOnlineResponse) => {
void (async () => { void (async () => {
@ -2830,6 +2834,7 @@ export default function App() {
setDoneSort(m) setDoneSort(m)
setDonePage(1) setDonePage(1)
}} }}
loadMode="all"
/> />
) : null} ) : null}
@ -2871,6 +2876,12 @@ export default function App() {
{playerJob ? ( {playerJob ? (
<Player <Player
key={[
String((playerJob as any)?.id ?? ''),
baseName(playerJob.output || ''),
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
String(assetNonce),
].join('::')}
job={playerJob} job={playerJob}
modelKey={playerModelKey ?? undefined} modelKey={playerModelKey ?? undefined}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}

View File

@ -26,9 +26,9 @@ function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
} }
const sizeMap: Record<Size, { btn: string; icon: string }> = { const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5' }, sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5' }, md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
} }
export default function ButtonGroup({ export default function ButtonGroup({
@ -57,7 +57,7 @@ export default function ButtonGroup({
onClick={() => onChange(it.id)} onClick={() => onChange(it.id)}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'relative inline-flex items-center justify-center font-semibold focus:z-10 transition-colors', 'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
!isFirst && '-ml-px', !isFirst && '-ml-px',
isFirst && 'rounded-l-md', isFirst && 'rounded-l-md',
isLast && 'rounded-r-md', isLast && 'rounded-r-md',
@ -73,7 +73,7 @@ export default function ButtonGroup({
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
// Padding / Größe // Padding / Größe
iconOnly ? 'px-2 py-2' : s.btn iconOnly ? `p-0 ${s.iconOnly}` : s.btn
)} )}
title={typeof it.label === 'string' ? it.label : it.srLabel} title={typeof it.label === 'string' ? it.label : it.srLabel}
> >

View File

@ -62,11 +62,11 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
return stem ? stem.trim() : null return stem ? stem.trim() : null
} }
// ✅ passt zu Backend: generated/meta/<id>/thumbs.jpg // ✅ passt zu Backend: generated/meta/<id>/thumbs.webp
function thumbUrlFromOutput(output: string): string | null { function thumbUrlFromOutput(output: string): string | null {
const id = assetIdFromOutput(output) const id = assetIdFromOutput(output)
if (!id) return null if (!id) return null
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg` return `/generated/meta/${encodeURIComponent(id)}/thumbs.webp`
} }
async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) { async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) {
@ -77,7 +77,11 @@ async function ensureCover(category: string, thumbPath: string, modelName: strin
(m ? `&model=${encodeURIComponent(m)}` : ``) + (m ? `&model=${encodeURIComponent(m)}` : ``) +
(refresh ? `&refresh=1` : ``) (refresh ? `&refresh=1` : ``)
await fetch(url, { method: 'GET', cache: 'no-store' as any }) const res = await fetch(url, { method: 'GET', cache: 'no-store' as any })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `ensureCover failed: HTTP ${res.status}`)
}
} }
type TagRow = { type TagRow = {
@ -101,6 +105,7 @@ export default function CategoriesTab() {
const [err, setErr] = React.useState<string | null>(null) const [err, setErr] = React.useState<string | null>(null)
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now()) const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({}) const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({})
const [hasCoverByTag, setHasCoverByTag] = React.useState<Record<string, boolean>>({})
const [renewing, setRenewing] = React.useState(false) const [renewing, setRenewing] = React.useState(false)
const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null) const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null)
@ -248,23 +253,31 @@ export default function CategoriesTab() {
})) }))
.sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' })) .sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' }))
let coverInfoByTag = new Map<string, CoverInfoListItem>() // coverinfo/list holen (optional)
const coverInfoByTag = new Map<string, CoverInfoListItem>()
try { try {
const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', { const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', {
cache: 'no-store' as any, cache: 'no-store' as any,
signal: ac.signal as any, signal: ac.signal as any,
}) })
for (const i of Array.isArray(infos) ? infos : []) { for (const i of Array.isArray(infos) ? infos : []) {
const k = String(i?.category ?? '').trim().toLowerCase() const k = String((i as any)?.category ?? '').trim().toLowerCase()
if (k) coverInfoByTag.set(k, i) if (k) coverInfoByTag.set(k, i)
} }
} catch { } catch {
// optional: still weiterlaufen ohne Fallback // weiter ohne coverinfo
} }
// stabiles Model pro Tag (für &model= in <img>) // ✅ 1) hasCoverByTag befüllen (Default: false)
const coverModelByTag: Record<string, string> = {} const has: Record<string, boolean> = {}
for (const r of outRows) {
const info = coverInfoByTag.get(r.tag)
has[r.tag] = Boolean(info?.hasCover)
}
setHasCoverByTag(has)
// ✅ 2) stabiles Model pro Tag (für Overlay)
const nextCoverModelByTag: Record<string, string> = {}
for (const r of outRows) { for (const r of outRows) {
// 1) bevorzugt aus candidates (doneJobs) // 1) bevorzugt aus candidates (doneJobs)
const list = candMap[r.tag] || [] const list = candMap[r.tag] || []
@ -279,15 +292,29 @@ export default function CategoriesTab() {
} }
} }
if (model) coverModelByTag[r.tag] = model if (model) nextCoverModelByTag[r.tag] = model
} }
setCoverModelByTag(nextCoverModelByTag)
setCoverModelByTag(coverModelByTag) // ✅ 3) coverState aufräumen:
// - Tags die nicht mehr existieren rauswerfen
// - Tags ohne Cover => state entfernen (damit nix "ok" bleibt)
setCoverState((prev) => {
const next: Record<string, 'ok' | 'error'> = {}
for (const r of outRows) {
const st = prev[r.tag]
if (!st) continue
if (has[r.tag] !== true) continue
next[r.tag] = st
}
return next
})
setRows(outRows) setRows(outRows)
// nur cache-bust der IMG URLs (wenn du willst) // ❗ Optional: NICHT immer busten (sonst unnötige Reloads).
setCoverBust(Date.now()) // Wenn du trotzdem jedes Mal neu laden willst, uncomment:
// setCoverBust(Date.now())
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') return if (e?.name === 'AbortError') return
@ -295,6 +322,8 @@ export default function CategoriesTab() {
setRows([]) setRows([])
candidatesRef.current = {} candidatesRef.current = {}
setCoverModelByTag({}) setCoverModelByTag({})
setHasCoverByTag({})
setCoverState({})
} finally { } finally {
// nur "aus" schalten, wenn dieser refresh noch der aktuelle ist // nur "aus" schalten, wenn dieser refresh noch der aktuelle ist
if (refreshAbortRef.current === ac) { if (refreshAbortRef.current === ac) {
@ -324,15 +353,29 @@ export default function CategoriesTab() {
rows.map(async (r) => { rows.map(async (r) => {
try { try {
const list = candMap[r.tag] || [] const list = candMap[r.tag] || []
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
const thumb = pick ? thumbUrlFromOutput(pick) : null
if (thumb) { // ✅ wenn es keine Kandidaten gibt -> NICHT versuchen, /cover zu fetchen (vermeidet 404 komplett)
const model = pick ? modelKeyFromFilename(pick) : null if (list.length === 0) {
// optional: wenn du willst, kannst du hier hasCoverByTag NICHT anfassen.
// Wir sagen nur: "konnte nicht erneuern, weil keine Quelle da"
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no candidates)' }
}
// ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert)
const pick = list[Math.floor(Math.random() * list.length)]
const thumb = thumbUrlFromOutput(pick)
if (!thumb) {
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no thumb url)' }
}
const model = modelKeyFromFilename(pick)
await ensureCover(r.tag, thumb, model, true) await ensureCover(r.tag, thumb, model, true)
// ✅ Overlay-Model = wirklich genutztes Model // ✅ WICHTIG: nach Erfolg UI-Flags setzen, sonst "sieht" die UI das neue Cover nicht
setHasCoverByTag((p) => ({ ...p, [r.tag]: true }))
setCoverModelByTag((prev) => { setCoverModelByTag((prev) => {
const next = { ...prev } const next = { ...prev }
if (model?.trim()) next[r.tag] = model.trim() if (model?.trim()) next[r.tag] = model.trim()
@ -340,18 +383,14 @@ export default function CategoriesTab() {
return next return next
}) })
return { tag: r.tag, ok: true, status: 200, text: '' } // ✅ CoverState resetten, damit <img> neu lädt und onLoad wieder "ok" setzen kann
} setCoverState((s) => {
const n = { ...s }
const model = coverModelByTag[r.tag] ?? '' delete n[r.tag]
return n
const res = await fetch(coverSrc(r.tag, Date.now(), true, model), {
method: 'GET',
cache: 'no-store',
}) })
const text = !res.ok ? await res.text().catch(() => '') : ''
const ok = res.ok || res.status === 404 return { tag: r.tag, ok: true, status: 200, text: '' }
return { tag: r.tag, ok, status: res.status, text }
} catch (e: any) { } catch (e: any) {
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) } return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
} finally { } finally {
@ -360,18 +399,18 @@ export default function CategoriesTab() {
}) })
) )
const failedNo404 = results.filter((x) => !x.ok && x.status !== 404) const failed = results.filter((x) => !x.ok)
if (failedNo404.length) { if (failed.length) {
console.warn('Cover renew failed:', failedNo404.slice(0, 20)) console.warn('Cover renew failed:', failed.slice(0, 20))
const sample = failedNo404.slice(0, 8).map((f) => `${f.tag} (${f.status || 'ERR'})`).join(', ') const sample = failed.slice(0, 8).map((f) => `${f.tag} (ERR)`).join(', ')
setErr(`Covers fehlgeschlagen: ${failedNo404.length}/${results.length} — z.B.: ${sample}`) setErr(`Covers fehlgeschlagen: ${failed.length}/${results.length} — z.B.: ${sample}`)
} else { } else {
setErr(null) setErr(null)
} }
} finally { } finally {
// ✅ nach Batch einmal busten reicht
setCoverBust(Date.now()) setCoverBust(Date.now())
setRenewing(false) setRenewing(false)
// optional: kurz stehen lassen, dann ausblenden
setTimeout(() => setRenewProgress(null), 400) setTimeout(() => setRenewProgress(null), 400)
} }
}, [rows, renewing]) }, [rows, renewing])
@ -425,7 +464,14 @@ export default function CategoriesTab() {
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{rows.map((r) => { {rows.map((r) => {
const model = coverModelByTag[r.tag] ?? null const model = coverModelByTag[r.tag] ?? null
const img = coverSrc(r.tag, coverBust, false)
// ✅ wichtig: nur laden, wenn backend sagt "Cover existiert"
const hasCover = hasCoverByTag[r.tag] === true
const hasCoverKnown = Object.prototype.hasOwnProperty.call(hasCoverByTag, r.tag)
// ✅ img URL nur bauen, wenn wir sie wirklich benutzen
const img = hasCover ? coverSrc(r.tag, coverBust, false, model) : ''
const isOk = coverState[r.tag] === 'ok' const isOk = coverState[r.tag] === 'ok'
const isErr = coverState[r.tag] === 'error' const isErr = coverState[r.tag] === 'error'
@ -451,10 +497,27 @@ export default function CategoriesTab() {
title="In FinishedDownloads öffnen (Tag-Filter setzen)" title="In FinishedDownloads öffnen (Tag-Filter setzen)"
> >
<div className="relative w-full overflow-hidden aspect-[16/9] bg-gray-100/70 dark:bg-white/5"> <div className="relative w-full overflow-hidden aspect-[16/9] bg-gray-100/70 dark:bg-white/5">
{/* Wenn Fehler: hübscher Placeholder statt broken image */} {/* ✅ 0) Noch nicht bekannt ob Cover existiert -> KEIN <img>, nur Skeleton */}
{isErr ? ( {!hasCoverKnown ? (
<div className="absolute inset-0">
<div
className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.20), transparent 55%),' +
'radial-gradient(circle at 70% 80%, rgba(14,165,233,0.14), transparent 55%)',
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
<div className="absolute left-3 bottom-3 text-[11px] font-semibold text-white/80 bg-black/30 px-2.5 py-1 rounded-full ring-1 ring-white/10">
Cover wird geprüft
</div>
</div>
) : !hasCover ? (
/* ✅ 1) Definitiv KEIN Cover -> niemals <img> rendern -> keine 404 */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="absolute inset-0 opacity-70" <div
className="absolute inset-0 opacity-70"
style={{ style={{
background: background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' + 'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' +
@ -463,13 +526,34 @@ export default function CategoriesTab() {
/> />
<div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md"> <div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md">
<div className="text-xs font-semibold text-gray-900 dark:text-white"> <div className="text-xs font-semibold text-gray-900 dark:text-white">
Cover nicht verfügbar Kein Cover vorhanden
</div> </div>
{model ? ( {model ? (
<div className="mt-0.5 text-[11px] text-gray-700 dark:text-gray-300"> <div className="mt-0.5 text-[11px] text-gray-700 dark:text-gray-300">
Model: <span className="font-semibold">{model}</span> Model: <span className="font-semibold">{model}</span>
</div> </div>
) : null} ) : null}
<div className="mt-1 text-[11px] text-gray-600 dark:text-gray-400 text-center">
(Kein Request / keine 404)
</div>
</div>
</div>
) : isErr ? (
/* ✅ 2) Cover sollte existieren, aber Laden schlug fehl -> Retry erlaubt */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div
className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' +
'radial-gradient(circle at 70% 80%, rgba(14,165,233,0.18), transparent 55%)',
}}
/>
<div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Cover konnte nicht geladen werden
</div>
<div className="mt-1 flex justify-center"> <div className="mt-1 flex justify-center">
<span <span
className="text-[11px] inline-flex items-center rounded-full bg-indigo-50 px-2 py-1 font-semibold text-indigo-700 ring-1 ring-indigo-100 hover:bg-indigo-100 className="text-[11px] inline-flex items-center rounded-full bg-indigo-50 px-2 py-1 font-semibold text-indigo-700 ring-1 ring-indigo-100 hover:bg-indigo-100
@ -477,7 +561,7 @@ export default function CategoriesTab() {
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// retry: alle IMG-URLs neu laden (einfach) // retry: state reset + bust
setCoverState((s) => { setCoverState((s) => {
const n = { ...s } const n = { ...s }
delete n[r.tag] delete n[r.tag]
@ -492,8 +576,8 @@ export default function CategoriesTab() {
</div> </div>
</div> </div>
) : ( ) : (
/* ✅ 3) Cover existiert -> <img> rendern */
<> <>
{/* subtle top sheen + bottom gradient for readability */}
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 z-[1] pointer-events-none" className="absolute inset-0 z-[1] pointer-events-none"
@ -503,6 +587,7 @@ export default function CategoriesTab() {
'linear-gradient(to top, rgba(0,0,0,0.35), rgba(0,0,0,0) 45%)', 'linear-gradient(to top, rgba(0,0,0,0.35), rgba(0,0,0,0) 45%)',
}} }}
/> />
{/* blurred fill */} {/* blurred fill */}
<img <img
src={img} src={img}
@ -519,17 +604,23 @@ export default function CategoriesTab() {
className="absolute inset-0 z-0 h-full w-full object-contain" className="absolute inset-0 z-0 h-full w-full object-contain"
loading="lazy" loading="lazy"
onLoad={() => setCoverState((s) => ({ ...s, [r.tag]: 'ok' }))} onLoad={() => setCoverState((s) => ({ ...s, [r.tag]: 'ok' }))}
onError={() => setCoverState((s) => ({ ...s, [r.tag]: 'error' }))} onError={() => {
// ✅ wenn es hier 404 wäre, stimmt hasCoverByTag nicht -> auf false schalten
setCoverState((s) => ({ ...s, [r.tag]: 'error' }))
setHasCoverByTag((p) => ({ ...p, [r.tag]: false }))
}}
/> />
{/* Model-Overlay: nur wenn Bild wirklich OK */} {/* Model-Overlay: nur wenn Bild wirklich OK */}
{isOk && model ? ( {isOk && model ? (
<div className="absolute left-3 bottom-3 z-10 max-w-[calc(100%-24px)]"> <div className="absolute left-3 bottom-3 z-10 max-w-[calc(100%-24px)]">
<div className={clsx( <div
className={clsx(
'truncate rounded-full px-2.5 py-1 text-[11px] font-semibold', 'truncate rounded-full px-2.5 py-1 text-[11px] font-semibold',
'bg-black/40 text-white backdrop-blur-md', 'bg-black/40 text-white backdrop-blur-md',
'ring-1 ring-white/15' 'ring-1 ring-white/15'
)}> )}
>
{model} {model}
</div> </div>
</div> </div>

View File

@ -11,6 +11,7 @@ import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { subscribeSSE } from '../../lib/sseSingleton' import { subscribeSSE } from '../../lib/sseSingleton'
import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE'
type PendingWatchedRoom = WaitingModelRow & { type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown currentShow: string // public / private / hidden / away / unknown
@ -601,10 +602,21 @@ const formatDuration = (ms: number): string => {
} }
const runtimeOf = (j: RecordJob, nowMs: number) => { const runtimeOf = (j: RecordJob, nowMs: number) => {
const start = Date.parse(String(j.startedAt || '')) const anyJ = j as any
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs const start =
if (!Number.isFinite(end)) return '—' toMs(anyJ.startedAtMs) || // ✅ NEU bevorzugt
toMs(j.startedAt)
if (!Number.isFinite(start) || start <= 0) return '—'
const end =
j.endedAt
? (toMs(anyJ.endedAtMs) || toMs(j.endedAt))
: nowMs
if (!Number.isFinite(end) || end <= 0) return '—'
return formatDuration(end - start) return formatDuration(end - start)
} }
@ -648,6 +660,20 @@ const isPostworkJob = (job: RecordJob): boolean => {
return false return false
} }
const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
s === 'stopped' ||
s === 'finished' ||
s === 'failed' ||
s === 'done' ||
s === 'completed' ||
s === 'canceled' ||
s === 'cancelled'
)
}
export default function Downloads({ export default function Downloads({
jobs, jobs,
pending = [], pending = [],
@ -661,6 +687,8 @@ export default function Downloads({
blurPreviews blurPreviews
}: Props) { }: Props) {
const jobsLive = useRecordJobsSSE(jobs)
const [stopAllBusy, setStopAllBusy] = useState(false) const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false)
@ -761,7 +789,7 @@ export default function Downloads({
const next: Record<string, true> = {} const next: Record<string, true> = {}
for (const id of keys) { for (const id of keys) {
const j = jobs.find((x) => x.id === id) const j = jobsLive.find((x) => x.id === id)
if (!j) continue if (!j) 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'
@ -771,15 +799,15 @@ export default function Downloads({
} }
return next return next
}) })
}, [jobs]) }, [jobsLive])
const [nowMs, setNowMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now())
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 jobsLive.some((j) => !j.endedAt && j.status === 'running')
}, [jobs]) }, [jobsLive])
const postworkQueueInfoById = useMemo(() => { const postworkQueueInfoById = useMemo(() => {
const infoById = new Map<string, { pos: number; total: number }>() const infoById = new Map<string, { pos: number; total: number }>()
@ -803,7 +831,7 @@ export default function Downloads({
const running: RecordJob[] = [] const running: RecordJob[] = []
const queued: RecordJob[] = [] const queued: RecordJob[] = []
for (const j of jobs) { for (const j of jobsLive) {
const pw = (j as any)?.postWork const pw = (j as any)?.postWork
if (!pw) continue if (!pw) continue
@ -834,7 +862,7 @@ export default function Downloads({
// } // }
return infoById return infoById
}, [jobs]) }, [jobsLive])
const postworkInfoOf = useCallback( const postworkInfoOf = useCallback(
(job: RecordJob) => { (job: RecordJob) => {
@ -846,12 +874,12 @@ export default function Downloads({
useEffect(() => { useEffect(() => {
if (!hasActive) return if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 15000) const t = window.setInterval(() => setNowMs(Date.now()), 1000) // ✅ 1s statt 15s
return () => window.clearInterval(t) return () => window.clearInterval(t)
}, [hasActive]) }, [hasActive])
const stoppableIds = useMemo(() => { const stoppableIds = useMemo(() => {
return jobs return jobsLive
.filter((j) => { .filter((j) => {
if (isPostworkJob(j)) return false if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false if ((j as any).endedAt) return false
@ -864,7 +892,7 @@ export default function Downloads({
return !isStopping return !isStopping
}) })
.map((j) => j.id) .map((j) => j.id)
}, [jobs, stopRequestedIds]) }, [jobsLive, stopRequestedIds])
const columns = useMemo<Column<DownloadRow>[]>(() => { const columns = useMemo<Column<DownloadRow>[]>(() => {
return [ return [
@ -1157,22 +1185,36 @@ export default function Downloads({
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf]) }, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
const downloadJobRows = useMemo<DownloadRow[]>(() => { const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs const list = jobsLive
.filter((j) => !isPostworkJob(j)) .filter((j) => {
if (isPostworkJob(j)) return false
// ✅ Terminale Jobs sollen nicht in "Downloads" bleiben
if (isTerminalStatus((j as any)?.status)) return false
// ✅ Jobs, die ein endedAt haben, sind nicht mehr "Downloads"
if (Boolean((j as any)?.endedAt)) return false
return true
})
.map((job) => ({ kind: 'job', job }) as const) .map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list return list
}, [jobs]) }, [jobsLive])
const postworkRows = useMemo<DownloadRow[]>(() => { const postworkRows = useMemo<DownloadRow[]>(() => {
const list = jobs const list = jobsLive
.filter((j) => isPostworkJob(j)) .filter((j) => {
if (!isPostworkJob(j)) return false
if (isTerminalStatus((j as any)?.status)) return false
return true
})
.map((job) => ({ kind: 'job', job }) as const) .map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list return list
}, [jobs]) }, [jobsLive])
const pendingRows = useMemo<DownloadRow[]>(() => { const pendingRows = useMemo<DownloadRow[]>(() => {
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const) const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)

View File

@ -4,10 +4,8 @@
import * as React from 'react' import * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react' import { useMemo, useEffect, useCallback } from 'react'
import { type Column, type SortState } from './Table'
import Card from './Card' import Card from './Card'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import ButtonGroup from './ButtonGroup' import ButtonGroup from './ButtonGroup'
import { import {
TableCellsIcon, TableCellsIcon,
@ -23,12 +21,12 @@ import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination' import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy' import { applyInlineVideoPolicy } from './videoPolicy'
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import Button from './Button' import Button from './Button'
import { useNotify } from './notify' import { useNotify } from './notify'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch' import Switch from './Switch'
import LoadingSpinner from './LoadingSpinner'
type SortMode = type SortMode =
| 'completed_desc' | 'completed_desc'
@ -53,7 +51,9 @@ type Props = {
onDeleteJob?: ( onDeleteJob?: (
job: RecordJob job: RecordJob
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }> ) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (
job: RecordJob
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
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>
@ -64,6 +64,7 @@ type Props = {
assetNonce?: number assetNonce?: number
sortMode: SortMode sortMode: SortMode
onSortModeChange: (m: SortMode) => void onSortModeChange: (m: SortMode) => void
loadMode?: 'paged' | 'all'
} }
const norm = (p: string) => (p || '').replaceAll('\\', '/') const norm = (p: string) => (p || '').replaceAll('\\', '/')
@ -73,7 +74,16 @@ const baseName = (p: string) => {
const parts = n.split('/') const parts = n.split('/')
return parts[parts.length - 1] || '' return parts[parts.length - 1] || ''
} }
const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id
const keyFor = (j: RecordJob) => {
// ✅ Primär: stabile Job-ID (bleibt gleich bei Rename/HOT)
const id = (j as any)?.id
if (id != null && String(id).trim() !== '') return String(id)
// Fallback (sollte selten sein)
const f = baseName(j.output || '')
return f || String((j as any)?.output || '')
}
const isTrashOutput = (output?: string) => { const isTrashOutput = (output?: string) => {
const p = norm(String(output ?? '')) const p = norm(String(output ?? ''))
@ -125,11 +135,6 @@ function useMediaQuery(query: string) {
return matches return matches
} }
const httpCodeFromError = (err?: string) => {
const m = (err ?? '').match(/\bHTTP\s+(\d{3})\b/i)
return m ? `HTTP ${m[1]}` : null
}
const modelNameFromOutput = (output?: string) => { const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
@ -211,7 +216,9 @@ export default function FinishedDownloads({
sortMode, sortMode,
onSortModeChange, onSortModeChange,
modelsByKey, modelsByKey,
loadMode = 'paged',
}: Props) { }: Props) {
const allMode = loadMode === 'all'
const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
@ -231,8 +238,10 @@ export default function FinishedDownloads({
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set()) const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [isLoading, setIsLoading] = React.useState(false)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string } | { kind: 'delete'; undoToken: string; originalFile: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string } | { kind: 'keep'; keptFile: string; originalFile: string }
| { kind: 'hot'; currentFile: string } | { kind: 'hot'; currentFile: string }
@ -254,7 +263,27 @@ export default function FinishedDownloads({
refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80) refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
}, []) }, [])
const [sort, setSort] = React.useState<SortState>(null) const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const emitCountHint = React.useCallback((delta: number) => {
if (!delta || !Number.isFinite(delta)) return
countHintRef.current.pending += delta
// coalesce mehrere Aktionen sehr kurz hintereinander
if (countHintRef.current.timer) return
countHintRef.current.timer = window.setTimeout(() => {
countHintRef.current.timer = 0
const d = countHintRef.current.pending
countHintRef.current.pending = 0
if (!d) return
window.dispatchEvent(
new CustomEvent('finished-downloads:count-hint', { detail: { delta: d } })
)
}, 120)
}, [])
type ViewMode = 'table' | 'cards' | 'gallery' type ViewMode = 'table' | 'cards' | 'gallery'
const VIEW_KEY = 'finishedDownloads_view' const VIEW_KEY = 'finishedDownloads_view'
@ -311,7 +340,8 @@ export default function FinishedDownloads({
unhide(lastAction.originalFile) unhide(lastAction.originalFile)
unhide(restoredFile) unhide(restoredFile)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } })) const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta)
queueRefill() queueRefill()
setLastAction(null) setLastAction(null)
return return
@ -332,7 +362,7 @@ export default function FinishedDownloads({
unhide(lastAction.originalFile) unhide(lastAction.originalFile)
unhide(restoredFile) unhide(restoredFile)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } })) emitCountHint(+1)
queueRefill() queueRefill()
setLastAction(null) setLastAction(null)
return return
@ -407,9 +437,12 @@ export default function FinishedDownloads({
}, []) }, [])
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0 const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
const effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback( const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => { async (signal?: AbortSignal) => {
setIsLoading(true)
try {
const res = await fetch( const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`, `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
{ {
@ -425,6 +458,9 @@ export default function FinishedDownloads({
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length) setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally {
setIsLoading(false)
}
}, },
[sortMode, includeKeep] [sortMode, includeKeep]
) )
@ -453,11 +489,10 @@ export default function FinishedDownloads({
}, []) // nur einmal beim Mount }, []) // nur einmal beim Mount
useEffect(() => { useEffect(() => {
if (!globalFilterActive) return if (!effectiveAllMode) return
const ac = new AbortController() const ac = new AbortController()
// debounce: erst fetchen wenn User kurz aufgehört hat zu tippen
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
fetchAllDoneJobs(ac.signal).catch(() => {}) fetchAllDoneJobs(ac.signal).catch(() => {})
}, 250) }, 250)
@ -466,40 +501,68 @@ export default function FinishedDownloads({
window.clearTimeout(t) window.clearTimeout(t)
ac.abort() ac.abort()
} }
}, [globalFilterActive, fetchAllDoneJobs]) }, [effectiveAllMode, fetchAllDoneJobs])
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt // ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
useEffect(() => { useEffect(() => {
if (refillTick === 0) return if (refillTick === 0) return
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (globalFilterActive) {
const ac = new AbortController() const ac = new AbortController()
let alive = true
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) {
;(async () => { ;(async () => {
try { try {
// (Wenn fetchAllDoneJobs selbst setIsLoading macht: reicht das.)
await fetchAllDoneJobs(ac.signal) await fetchAllDoneJobs(ac.signal)
} catch {} } catch {
// ignore
}
})() })()
return () => ac.abort()
return () => {
alive = false
ac.abort()
}
} }
const ac = new AbortController() // ✅ paged refill → hier Loading setzen
setIsLoading(true)
;(async () => { ;(async () => {
try { try {
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt // 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
const listRes = await fetch( const [listRes, metaRes] = await Promise.all([
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`, fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal } { cache: 'no-store' as any, signal: ac.signal }
) ),
fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
])
let okAll = true
if (listRes.ok) { if (listRes.ok) {
const data = await listRes.json().catch(() => null) const data = await listRes.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] const items = Array.isArray(data?.items)
const count = Number(data?.count ?? data?.totalCount ?? items.length) ? (data.items as RecordJob[])
: Array.isArray(data)
? data
: []
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
if (Number.isFinite(count) && count >= 0) { }
if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null)
const countRaw = Number(meta?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneTotal(count) setOverrideDoneTotal(count)
const totalPages = Math.max(1, Math.ceil(count / pageSize)) const totalPages = Math.max(1, Math.ceil(count / pageSize))
@ -509,24 +572,42 @@ export default function FinishedDownloads({
return return
} }
} }
if (okAll) {
refillRetryRef.current = 0
} else if (alive && refillRetryRef.current < 2) {
refillRetryRef.current++
window.setTimeout(() => {
if (!ac.signal.aborted) setRefillTick((n) => n + 1)
}, 400 * refillRetryRef.current)
} }
} catch { } catch {
// Abort / Fehler ignorieren // Abort / Fehler ignorieren
} finally {
if (alive) setIsLoading(false)
} }
})() })()
return () => ac.abort() return () => {
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs, includeKeep]) alive = false
ac.abort()
}
}, [
refillTick,
effectiveAllMode,
fetchAllDoneJobs,
page,
pageSize,
sortMode,
includeKeep,
onPageChange,
])
useEffect(() => { useEffect(() => {
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1) if (effectiveAllMode) return
if (globalFilterActive) return
// ✅ Overrides nur zurücksetzen, wenn sich die "Query" ändert,
// nicht wenn App optimistisch doneJobs filtert.
setOverrideDoneJobs(null) setOverrideDoneJobs(null)
setOverrideDoneTotal(null) setOverrideDoneTotal(null)
}, [page, pageSize, sortMode, includeKeep, globalFilterActive]) }, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
useEffect(() => { useEffect(() => {
if (!includeKeep) { if (!includeKeep) {
@ -621,6 +702,36 @@ export default function FinishedDownloads({
const durationsRef = React.useRef<Record<string, number>>({}) const durationsRef = React.useRef<Record<string, number>>({})
const durationsFlushTimerRef = React.useRef<number | null>(null) const durationsFlushTimerRef = React.useRef<number | null>(null)
// 🔹 hier sammeln wir die Videoauflösung pro Job/Datei
const [resolutions, setResolutions] = React.useState<Record<string, { w: number; h: number }>>({})
// ✅ Perf: resolutions gesammelt flushen (wie durations)
const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
const resolutionsFlushTimerRef = React.useRef<number | null>(null)
const refillRetryRef = React.useRef(0)
React.useEffect(() => {
resolutionsRef.current = resolutions
}, [resolutions])
const flushResolutionsSoon = React.useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => {
resolutionsFlushTimerRef.current = null
setResolutions({ ...resolutionsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (resolutionsFlushTimerRef.current != null) {
window.clearTimeout(resolutionsFlushTimerRef.current)
resolutionsFlushTimerRef.current = null
}
}
}, [])
React.useEffect(() => { React.useEffect(() => {
durationsRef.current = durations durationsRef.current = durations
}, [durations]) }, [durations])
@ -751,20 +862,17 @@ export default function FinishedDownloads({
const animateRemove = useCallback( const animateRemove = useCallback(
(key: string) => { (key: string) => {
// 1) rot + fade-out starten // ✅ Refill sofort starten (parallel zur Animation)
markRemoving(key, true) queueRefill()
// ggf. alten Timer entfernen (wenn mehrfach getriggert) markRemoving(key, true)
cancelRemoveTimer(key) cancelRemoveTimer(key)
// 2) nach der Animation wirklich ausblenden + Seite auffüllen
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
removeTimersRef.current.delete(key) removeTimersRef.current.delete(key)
markDeleted(key) markDeleted(key)
markRemoving(key, false) markRemoving(key, false)
queueRefill()
}, 320) }, 320)
removeTimersRef.current.set(key, t) removeTimersRef.current.set(key, t)
@ -814,7 +922,7 @@ export default function FinishedDownloads({
// ✅ OPTIMISTIK + Pagination refill + count hint // ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key) animateRemove(key)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } })) emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok: // animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill() // queueRefill()
@ -830,22 +938,23 @@ export default function FinishedDownloads({
// ✅ Backend liefert undoToken (Trash) // ✅ Backend liefert undoToken (Trash)
const data = (await res.json().catch(() => null)) as any const data = (await res.json().catch(() => null)) as any
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file }) if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, from })
else setLastAction(null) else setLastAction(null)
animateRemove(key) animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf) // ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } })) emitCountHint(-1)
return true return true
} catch (e: any) { } catch (e: any) {
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen // ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
restoreRow(key) restoreRow(key)
notify.error('Löschen fehlgeschlagen', String(e?.message || e)) notify.error('Löschen fehlgeschlagen: ', file)
return false return false
} finally { } finally {
markDeleting(key, false) markDeleting(key, false)
@ -894,11 +1003,11 @@ export default function FinishedDownloads({
animateRemove(key) animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf) // ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } })) emitCountHint(includeKeep ? 0 : -1)
return true return true
} catch (e: any) { } catch (e: any) {
notify.error('Keep fehlgeschlagen', String(e?.message || e)) notify.error('Keep fehlgeschlagen', file)
return false return false
} finally { } finally {
markKeeping(key, false) markKeeping(key, false)
@ -917,42 +1026,14 @@ export default function FinishedDownloads({
const applyRename = useCallback((oldFile: string, newFile: string) => { const applyRename = useCallback((oldFile: string, newFile: string) => {
if (!oldFile || !newFile || oldFile === newFile) return if (!oldFile || !newFile || oldFile === newFile) return
// 1) renamedFiles: alte/konfliktierende Kanten entfernen, dann neue setzen
setRenamedFiles((prev) => { setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev } const next: Record<string, string> = { ...prev }
// entferne alles, was mit old/new kollidiert (Keys ODER Values)
for (const [k, v] of Object.entries(next)) { for (const [k, v] of Object.entries(next)) {
if (k === oldFile || k === newFile || v === oldFile || v === newFile) { if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
delete next[k]
} }
}
next[oldFile] = newFile next[oldFile] = newFile
return next return next
}) })
// ✅ Inline/Teaser Keys mitziehen, damit Playback nicht “verloren” geht
setInlinePlay((prev) => (prev?.key === oldFile ? { ...prev, key: newFile } : prev))
setTeaserKey((prev) => (prev === oldFile ? newFile : prev))
setHoverTeaserKey((prev) => (prev === oldFile ? newFile : prev))
// 2) durations-Key mitziehen + Ref/State synchron halten
const cur = durationsRef.current || {}
const v = (cur as any)[oldFile]
if (typeof v === 'number') {
const next = { ...(cur as any) }
delete next[oldFile]
next[newFile] = v
durationsRef.current = next
setDurations(next)
} else if (oldFile in cur) {
const next = { ...(cur as any) }
delete next[oldFile]
durationsRef.current = next
setDurations(next)
}
}, []) }, [])
const toggleHotVideo = useCallback( const toggleHotVideo = useCallback(
@ -994,7 +1075,9 @@ export default function FinishedDownloads({
// ✅ Undo erst jetzt setzen (nach Erfolg) // ✅ Undo erst jetzt setzen (nach Erfolg)
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew }) setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill() queueRefill()
}
return return
} }
@ -1033,26 +1116,6 @@ export default function FinishedDownloads({
[baseName, notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, setLastAction] [baseName, notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, setLastAction]
) )
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend)
const sec = (job as any)?.durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
// 2) Fallback: endedAt-startedAt (only if plausible)
const start = Date.parse(String((job as any)?.startedAt || ''))
const end = Date.parse(String((job as any)?.endedAt || ''))
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
const diffSec = (end - start) / 1000
// guard: ignore absurd values (move/copy can skew modtime)
// accept e.g. 1s..24h
if (diffSec >= 1 && diffSec <= 24 * 60 * 60) return diffSec
}
// 3) Unknown durations last
return Number.POSITIVE_INFINITY
}, [])
const applyRenamedOutput = useCallback( const applyRenamedOutput = useCallback(
(job: RecordJob): RecordJob => { (job: RecordJob): RecordJob => {
const out = norm(job.output || '') const out = norm(job.output || '')
@ -1100,12 +1163,24 @@ export default function FinishedDownloads({
const viewRows = rows const viewRows = rows
const fileToKeyRef = React.useRef<Map<string, string>>(new Map())
useEffect(() => {
const m = new Map<string, string>()
for (const j of viewRows) {
const f = baseName(j.output || '')
if (!f) continue
m.set(f, keyFor(j))
}
fileToKeyRef.current = m
}, [viewRows])
useEffect(() => { useEffect(() => {
const onExternalDelete = (ev: Event) => { const onExternalDelete = (ev: Event) => {
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
if (!detail?.file) return if (!detail?.file) return
const key = detail.file const key = fileToKeyRef.current.get(detail.file) || detail.file
if (detail.phase === 'start') { if (detail.phase === 'start') {
markDeleting(key, true) markDeleting(key, true)
@ -1137,6 +1212,20 @@ export default function FinishedDownloads({
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, queueRefill, restoreRow]) }, [animateRemove, markDeleting, queueRefill, restoreRow])
useEffect(() => {
const onReload = () => {
// ✅ wichtig: das soll "ALL neu laden" triggern
// Option A (wenn vorhanden):
queueRefill()
// Option B (falls du kein queueRefill hast):
// void fetchAllDoneJobs(new AbortController().signal)
}
window.addEventListener('finished-downloads:reload', onReload as any)
return () => window.removeEventListener('finished-downloads:reload', onReload as any)
}, [queueRefill /* oder fetchAllDoneJobs */])
useEffect(() => { useEffect(() => {
const onExternalRename = (ev: Event) => { const onExternalRename = (ev: Event) => {
const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
@ -1163,7 +1252,7 @@ export default function FinishedDownloads({
const modelKey = lower(model) const modelKey = lower(model)
const tags = modelTags.tagsByModelKey[modelKey] ?? [] const tags = modelTags.tagsByModelKey[modelKey] ?? []
const hay = lower([file, stripHotPrefix(file), model, j.id, j.status, tags.join(' ')].join(' ')) const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
for (const t of searchTokens) { for (const t of searchTokens) {
if (!hay.includes(t)) return false if (!hay.includes(t)) return false
@ -1188,21 +1277,23 @@ export default function FinishedDownloads({
}) })
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
const totalItemsForPagination = globalFilterActive ? visibleRows.length : doneTotalPage const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
const pageRows = useMemo(() => { const pageRows = useMemo(() => {
if (!globalFilterActive) return visibleRows if (!effectiveAllMode) return visibleRows
const start = (page - 1) * pageSize const start = (page - 1) * pageSize
const end = start + pageSize const end = start + pageSize
return visibleRows.slice(Math.max(0, start), Math.max(0, end)) return visibleRows.slice(Math.max(0, start), Math.max(0, end))
}, [globalFilterActive, visibleRows, page, pageSize]) }, [effectiveAllMode, visibleRows, page, pageSize])
// ✅ "Ordner wirklich leer" -> serverseitiger Count ist am zuverlässigsten const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyFolder = !globalFilterActive && totalItemsForPagination === 0
// ✅ "Filter liefert keine Treffer"
const emptyByFilter = globalFilterActive && visibleRows.length === 0 const emptyByFilter = globalFilterActive && visibleRows.length === 0
// Optional zusätzlich: wenn allMode und wirklich gar nix da:
const emptyAll = allMode && visibleRows.length === 0
const showLoadingCard = isLoading && (emptyFolder || emptyAll || emptyByFilter)
useEffect(() => { useEffect(() => {
if (!globalFilterActive) return if (!globalFilterActive) return
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize)) const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
@ -1319,271 +1410,16 @@ export default function FinishedDownloads({
flushDurationsSoon() flushDurationsSoon()
}, [flushDurationsSoon]) }, [flushDurationsSoon])
const columns: Column<RecordJob>[] = [ const handleResolution = useCallback((job: RecordJob, w: number, h: number) => {
{ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
return ( const k = keyFor(job)
<div const prev = resolutionsRef.current[k]
ref={registerTeaserHost(k)} if (prev && prev.w === w && prev.h === h) return
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</div>
)
},
},
{
key: 'Model',
header: 'Model',
sortable: true,
sortValue: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
},
cell: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
const show = tags.slice(0, 6)
const rest = tags.length - show.length
const full = tags.join(', ')
return ( resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } }
<div className="min-w-0"> flushResolutionsSoon()
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div> }, [flushResolutionsSoon])
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
{/* Zeile 1: Filename + HOT */}
<div className="flex items-center gap-2 min-w-0">
<span className="truncate" title={file}>
{file || '—'}
</span>
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Zeile 2: Tags unter dem Filename */}
{show.length > 0 ? (
<div className="mt-1 flex flex-wrap items-center gap-1 min-w-0">
{show.map((t) => (
<TagBadge key={t} tag={t} active={activeTagSet.has(lower(t))} onClick={toggleTagFilter} />
))}
{rest > 0 ? (
<span
className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10"
title={full}
>
+{rest}
</span>
) : null}
</div>
) : null}
</div>
</div>
)
},
},
{
key: 'status',
header: 'Status',
sortable: true,
sortValue: (j) =>
j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9,
cell: (j) => {
const base =
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset'
if (j.status === 'failed') {
const code = httpCodeFromError(j.error)
const label = code ? `failed (${code})` : 'failed'
return (
<span
className={`${base} bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/30`}
title={j.error || ''}
>
{label}
</span>
)
}
if (j.status === 'finished') {
return (
<span
className={`${base} bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30`}
>
finished
</span>
)
}
if (j.status === 'stopped') {
return (
<span
className={`${base} bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30`}
>
stopped
</span>
)
}
return (
<span className={`${base} bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10`}>
{j.status}
</span>
)
},
},
{
key: 'completedAt',
header: 'Fertiggestellt am',
sortable: true,
widthClassName: 'w-[150px]',
sortValue: (j) => {
const t = Date.parse(String(j.endedAt || ''))
return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY
},
cell: (j) => {
const t = Date.parse(String(j.endedAt || ''))
if (!Number.isFinite(t)) return <span className="text-xs text-gray-400"></span>
const d = new Date(t)
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return (
<time
dateTime={d.toISOString()}
title={`${date} ${time}`}
className="tabular-nums whitespace-nowrap text-sm text-gray-900 dark:text-white"
>
<span className="font-medium">{date}</span>
<span className="mx-1 text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-600 dark:text-gray-300">{time}</span>
</time>
)
},
},
{
key: 'runtime',
header: 'Dauer',
align: 'right',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
key: 'size',
header: 'Größe',
align: 'right',
sortable: true,
sortValue: (j) => {
const s = sizeBytesOf(j)
return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY
},
cell: (j) => (
<span className="font-medium text-gray-900 dark:text-white">
{formatBytes(sizeBytesOf(j))}
</span>
),
},
{
key: 'actions',
header: 'Aktionen',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<RecordJobActions
job={j}
variant="table"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={toggleHotVideo}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1"
/>
)
},
}]
const sortStateToMode = (s: SortState): SortMode => {
if (!s) return 'completed_desc'
const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
const dirRaw = String(anyS.dir ?? anyS.direction ?? anyS.order ?? '').toLowerCase()
const asc = dirRaw === 'asc' || dirRaw === '1' || dirRaw === 'true'
if (key === 'completedAt') return asc ? 'completed_asc' : 'completed_desc'
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
if (key === 'size') return asc ? 'size_asc' : 'size_desc'
if (key === 'Model') return asc ? 'file_asc' : 'file_desc'
if (key === 'video') return asc ? 'file_asc' : 'file_desc'
// fallback
return asc ? 'completed_asc' : 'completed_desc'
}
const handleTableSortChange = (s: SortState) => {
setSort(s) // lässt Table weiterhin Pfeil anzeigen
const mode = sortStateToMode(s)
onSortModeChange(mode)
if (page !== 1) onPageChange(1)
}
// ✅ Hooks immer zuerst unabhängig von rows // ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)') const isSmall = useMediaQuery('(max-width: 639px)')
@ -1634,6 +1470,9 @@ export default function FinishedDownloads({
{/* Right: Controls */} {/* Right: Controls */}
<div className="flex items-center gap-2 ml-auto shrink-0"> <div className="flex items-center gap-2 ml-auto shrink-0">
{isLoading ? (
<LoadingSpinner size="lg" className="text-indigo-500" srLabel="Lade Downloads…" />
) : null}
{/* Desktop: Suche soll den Platz füllen */} {/* Desktop: Suche soll den Platz füllen */}
<div className="hidden sm:flex items-center gap-2 min-w-0 flex-1"> <div className="hidden sm:flex items-center gap-2 min-w-0 flex-1">
<input <input
@ -1702,6 +1541,7 @@ export default function FinishedDownloads({
<Button <Button
size={isSmall ? 'sm' : 'md'} size={isSmall ? 'sm' : 'md'}
variant="soft" variant="soft"
className={isSmall ? 'h-9' : 'h-10'}
disabled={!lastAction || undoing} disabled={!lastAction || undoing}
onClick={undoLastAction} onClick={undoLastAction}
title={ title={
@ -1891,7 +1731,26 @@ export default function FinishedDownloads({
</div> </div>
</div> </div>
{emptyFolder ? ( {showLoadingCard ? (
<Card grayBody>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Lade Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte einen Moment.
</div>
</div>
<LoadingSpinner
size="lg"
className="text-indigo-500"
srLabel="Lade Downloads…"
/>
</div>
</Card>
) : (emptyFolder || emptyAll) ? (
<Card grayBody> <Card grayBody>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10"> <div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
@ -1943,6 +1802,7 @@ export default function FinishedDownloads({
<FinishedDownloadsCardsView <FinishedDownloadsCardsView
rows={pageRows} rows={pageRows}
isSmall={isSmall} isSmall={isSmall}
isLoading={isLoading}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}
teaserKey={teaserKey} teaserKey={teaserKey}
@ -1986,31 +1846,50 @@ export default function FinishedDownloads({
{view === 'table' && ( {view === 'table' && (
<FinishedDownloadsTableView <FinishedDownloadsTableView
rows={pageRows} rows={pageRows}
columns={columns} isLoading={isLoading}
getRowKey={(j) => keyFor(j)} keyFor={keyFor}
sort={sort} baseName={baseName}
onSortChange={handleTableSortChange} lower={lower}
onRowClick={onOpenPlayer} modelNameFromOutput={modelNameFromOutput}
rowClassName={(j) => { runtimeOf={runtimeOf}
const k = keyFor(j) sizeBytesOf={sizeBytesOf}
return [ formatBytes={formatBytes}
'transition-all duration-300', resolutions={resolutions}
(deletingKeys.has(k) || removingKeys.has(k)) && durations={durations}
'bg-red-50/60 dark:bg-red-500/10 pointer-events-none', canHover={canHover}
deletingKeys.has(k) && 'animate-pulse', teaserAudio={teaserAudio}
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none', hoverTeaserKey={hoverTeaserKey}
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', setHoverTeaserKey={setHoverTeaserKey}
removingKeys.has(k) && 'opacity-0', teaserPlayback={teaserPlaybackMode}
] teaserKey={teaserKey}
.filter(Boolean) registerTeaserHost={registerTeaserHost}
.join(' ') handleDuration={handleDuration}
}} handleResolution={handleResolution}
blurPreviews={blurPreviews}
assetNonce={assetNonce}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onOpenPlayer={onOpenPlayer}
onSortModeChange={onSortModeChange}
page={page}
onPageChange={onPageChange}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
/> />
)} )}
{view === 'gallery' && ( {view === 'gallery' && (
<FinishedDownloadsGalleryView <FinishedDownloadsGalleryView
rows={pageRows} rows={pageRows}
isLoading={isLoading}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}
handleDuration={handleDuration} handleDuration={handleDuration}

View File

@ -1,5 +1,4 @@
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx // frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -12,18 +11,16 @@ import {
HeartIcon as HeartSolidIcon, HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon, EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
type InlinePlayState = { key: string; nonce: number } | null type InlinePlayState = { key: string; nonce: number } | null
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean
isSmall: boolean isSmall: boolean
teaserPlayback: 'still' | 'hover' | 'all' teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
@ -35,7 +32,6 @@ type Props = {
inlinePlay: InlinePlayState inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>> setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string> deletingKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -97,10 +93,10 @@ const parseTags = (raw?: string): string[] => {
return out return out
} }
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
isLoading,
teaserPlayback, teaserPlayback,
teaserAudio, teaserAudio,
hoverTeaserKey, hoverTeaserKey,
@ -134,8 +130,6 @@ export default function FinishedDownloadsCardsView({
deleteVideo, deleteVideo,
keepVideo, keepVideo,
releasePlayingFile,
modelsByKey, modelsByKey,
activeTagSet, activeTagSet,
onToggleTagFilter, onToggleTagFilter,
@ -143,47 +137,29 @@ export default function FinishedDownloadsCardsView({
onToggleHot, onToggleHot,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch onToggleWatch,
}: Props) { }: Props) {
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
const h =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null) if (w > 0 && h > 0) return { w, h }
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null) return null
}, [])
React.useEffect(() => { const metaChipCls = 'rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium backdrop-blur-[2px]'
if (!openTagsKey) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
return ( return (
<div className="relative">
<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) => {
const k = keyFor(j) const k = keyFor(j)
const inlineActive = inlinePlay?.key === k const inlineActive = inlinePlay?.key === k
// Sound nur, wenn Setting aktiv UND (Inline aktiv ODER Hover auf diesem Teaser)
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k) const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
@ -193,59 +169,54 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)] const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
const statusCls = const tags = parseTags(flags?.tags)
j.status === 'failed'
? 'bg-red-500/35'
: j.status === 'finished'
? 'bg-emerald-500/35'
: j.status === 'stopped'
? 'bg-amber-500/35'
: 'bg-black/40'
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const inlineDomId = `inline-prev-${encodeURIComponent(k)}` const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const motionCls = isSmall ? '' : 'transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none'
// ✅ Shell an Gallery angelehnt
const shellCls = [
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')
const cardInner = ( const cardInner = (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className={[ className={shellCls}
'group',
'content-visibility-auto',
'[contain-intrinsic-size:180px_120px]',
motionCls,
'rounded-xl',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
keepingKeys.has(k) &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={isSmall ? undefined : () => openPlayer(j)} onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}} }}
> >
<Card noBodyPadding className="overflow-hidden"> {/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */} {/* Preview */}
<div <div
id={inlineDomId} id={inlineDomId}
ref={registerTeaserHost(k)} ref={registerTeaserHost(k)}
className="relative aspect-video bg-black/5 dark:bg-white/5" className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)} onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)} onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
onClick={(e) => { onClick={(e) => {
@ -255,13 +226,14 @@ export default function FinishedDownloadsCardsView({
startInline(k) startInline(k)
}} }}
> >
{/* media */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<FinishedVideoPreview <FinishedVideoPreview
job={j} job={j}
getFileName={baseName} getFileName={baseName}
className="w-full h-full" className="h-full w-full"
showPopover={false} showPopover={false}
blur={isSmall ? false : (inlineActive ? false : blurPreviews)} blur={isSmall ? false : inlineActive ? false : blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false} animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
@ -275,39 +247,33 @@ export default function FinishedDownloadsCardsView({
/> />
</div> </div>
{/* Gradient overlay bottom */} {/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
<div <div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
className={[ <RecordJobActions
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent', job={j}
'transition-opacity duration-150', variant="overlay"
inlineActive ? 'opacity-0' : 'opacity-100', busy={busy}
].join(' ')} collapseToMenu
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/> />
{/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
<div
className={[
'pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
>
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className={cn('rounded px-1.5 py-0.5 font-semibold', statusCls)}>
{j.status}
</span>
<div className="flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
</div>
</div>
</div> </div>
{!isSmall && inlinePlay?.key === k && ( {/* Restart (wenn inline läuft) */}
{!isSmall && inlinePlay?.key === k ? (
<button <button
type="button" type="button"
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60" className="absolute left-2 top-10 z-10 rounded-md bg-black/45 px-2 py-1 text-xs font-semibold text-white hover:bg-black/60"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -318,163 +284,70 @@ export default function FinishedDownloadsCardsView({
> >
</button> </button>
)} ) : null}
{/* Actions top-right */} {/* Bottom overlay (ohne Gradient) */}
<div <div
className="absolute right-2 top-2 flex items-center gap-2" className="
onClick={(e) => e.stopPropagation()} pointer-events-none absolute inset-x-0 bottom-0
onMouseDown={(e) => e.stopPropagation()} px-2 pb-2 pt-8 text-white
"
> >
<RecordJobActions <div className="flex items-center justify-end gap-2">
job={j}
variant="overlay"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={
onToggleHot
? async (job) => {
const file = baseName(job.output || '')
if (file) {
// wichtig gegen File-Lock beim Rename:
await releasePlayingFile(file, { close: true })
await new Promise((r) => setTimeout(r, 150))
}
await onToggleHot(job)
}
: undefined
}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="flex items-center gap-2"
/>
</div>
</div>
{/* Footer / Meta */}
<div
className={[
'px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10',
isSmall ? 'bg-white/90 dark:bg-gray-950/80' : 'bg-white/60 backdrop-blur dark:bg-white/5',
].join(' ')}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<div className="shrink-0 flex items-center gap-1.5"> <div className="shrink-0 flex items-center gap-1.5">
<span className={metaChipCls}>{dur}</span>
{resLabel ? (
<span className={metaChipCls} title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}>
{resLabel}
</span>
) : null}
<span className={metaChipCls}>{size}</span>
</div>
</div>
</div>
</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null} {isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null} {isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null} {isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div> </div>
</div> </div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400"> {/* Tags */}
<span className="truncate">{stripHotPrefix(fileRaw) || '—'}</span> <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
{isHot ? ( rowKey={k}
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300"> tags={tags}
HOT activeTagSet={activeTagSet}
</span> lower={lower}
) : null} onToggleTagFilter={onToggleTagFilter}
</div>
{/* Tags: 1 Zeile, +N öffnet Popover */}
<div
className="mt-2 h-6 relative flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* links: Tags (nowrap, werden ggf. geclippt) */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/> />
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
}}
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className={[
'absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5',
isSmall ? '' : 'backdrop-blur',
'dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
</div>
</div>
) : null}
</div> </div>
</div> </div>
</Card> </Card>
</div> </div>
) )
// ✅ Mobile: SwipeCard, Desktop: normale Card
return isSmall ? ( return isSmall ? (
<SwipeCard <SwipeCard
ref={(h) => { ref={(h) => {
@ -487,39 +360,17 @@ export default function FinishedDownloadsCardsView({
ignoreFromBottomPx={110} ignoreFromBottomPx={110}
doubleTapMs={360} doubleTapMs={360}
doubleTapMaxMovePx={48} doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => { onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}` const domId = `inline-prev-${encodeURIComponent(k)}`
startInline(k) startInline(k)
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) { if (!tryAutoplayInline(domId)) requestAnimationFrame(() => tryAutoplayInline(domId))
requestAnimationFrame(() => tryAutoplayInline(domId))
}
}) })
}} }}
onDoubleTap={
onToggleHot
? async () => {
if (isHot) return false
try {
const file = baseName(j.output || '')
if (file) {
// ✅ NICHT schließen, wenn dieser Teaser gerade inline spielt
const isThisInline = inlinePlay?.key === k
if (!isThisInline) {
await releasePlayingFile(file, { close: true })
await new Promise((r) => setTimeout(r, 150))
}
}
await onToggleHot(j)
return true
} catch {
return false
}
}
: undefined
}
onSwipeLeft={() => deleteVideo(j)} onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)} onSwipeRight={() => keepVideo(j)}
> >
@ -530,5 +381,16 @@ export default function FinishedDownloadsCardsView({
) )
})} })}
</div> </div>
{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">
{/* Spinner (zentriert) */}
<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="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
) )
} }

View File

@ -1,5 +1,4 @@
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx // frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -10,13 +9,14 @@ import {
HeartIcon as HeartSolidIcon, HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon, EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
teaserPlayback: 'still' | 'hover' | 'all' teaserPlayback: 'still' | 'hover' | 'all'
@ -24,7 +24,6 @@ type Props = {
hoverTeaserKey?: string | null hoverTeaserKey?: string | null
teaserKey: string | null teaserKey: string | null
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
@ -59,6 +58,7 @@ type Props = {
export default function FinishedDownloadsGalleryView({ export default function FinishedDownloadsGalleryView({
rows, rows,
isLoading,
blurPreviews, blurPreviews,
durations, durations,
teaserPlayback, teaserPlayback,
@ -95,9 +95,6 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch, onToggleWatch,
}: Props) { }: Props) {
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all' const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
@ -105,7 +102,6 @@ export default function FinishedDownloadsGalleryView({
const registerTeaserHostIfNeeded = React.useCallback( const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => { (key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) { if (!shouldObserveTeasers) {
// wichtig: sauber unhooken, falls vorher beobachtet wurde
registerTeaserHost(key)(null) registerTeaserHost(key)(null)
return return
} }
@ -114,36 +110,6 @@ export default function FinishedDownloadsGalleryView({
[registerTeaserHost, shouldObserveTeasers] [registerTeaserHost, shouldObserveTeasers]
) )
React.useEffect(() => {
if (!openTagsKey) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const parseTags = (raw?: string): string[] => { const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim() const s = String(raw ?? '').trim()
if (!s) return [] if (!s) return []
@ -163,13 +129,25 @@ export default function FinishedDownloadsGalleryView({
return out return out
} }
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
const h =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
if (w > 0 && h > 0) return { w, h }
return null
}, [])
return ( return (
<> <div className="relative">
<div <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
>
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser // Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound const previewMuted = !allowSound
@ -180,17 +158,19 @@ export default function FinishedDownloadsGalleryView({
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags) const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k) const deleted = deletedKeys.has(k)
@ -245,46 +225,22 @@ export default function FinishedDownloadsGalleryView({
/> />
</div> </div>
{/* Gradient overlay bottom */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 h-16
bg-gradient-to-t from-black/65 to-transparent
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
/>
{/* Bottom overlay meta */} {/* Bottom overlay meta */}
<div <div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
className=" <div className="flex items-end justify-end gap-2">
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white {/* Right bottom: Duration + Resolution(label) + Size */}
"
>
<div className="flex items-end justify-between gap-2">
{/* Left: File + Status unten links */}
<div className="min-w-0">
<div>
<span
className={[
'inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold',
j.status === 'finished'
? 'bg-emerald-600/70'
: j.status === 'stopped'
? 'bg-amber-600/70'
: j.status === 'failed'
? 'bg-red-600/70'
: 'bg-black/50',
].join(' ')}
>
{j.status}
</span>
</div>
</div>
{/* Right bottom: Duration + Size */}
<div className="shrink-0 flex items-center gap-1.5"> <div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span> <span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span>
{resLabel ? (
<span
className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium"
title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}
>
{resLabel}
</span>
) : null}
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span> <span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
</div> </div>
</div> </div>
@ -292,10 +248,7 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
{/* Actions (top-right) */} {/* Actions (top-right) */}
<div <div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
className="absolute inset-x-2 top-2 z-10 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RecordJobActions <RecordJobActions
job={j} job={j}
variant="overlay" variant="overlay"
@ -318,7 +271,7 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
{/* Footer / Meta */} {/* Footer / Meta */}
<div className="px-4 py-3 rounded-b-lg border-t border-gray-200/60 bg-white/60 backdrop-blur dark:border-white/10 dark:bg-white/5"> <div className="relative min-h-[92px] overflow-hidden px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div> <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="shrink-0 flex items-center gap-1.5"> <div className="shrink-0 flex items-center gap-1.5">
@ -338,90 +291,30 @@ export default function FinishedDownloadsGalleryView({
) : null} ) : null}
</div> </div>
{/* Tags: 1 Zeile, +N öffnet Popover */} {/* Tags */}
<div <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
className="mt-2 h-6 relative flex items-center gap-1.5" <TagOverflowRow
onClick={(e) => e.stopPropagation()} rowKey={k}
onMouseDown={(e) => e.stopPropagation()} tags={tags}
> activeTagSet={activeTagSet}
{/* links: Tags (nowrap, werden ggf. geclippt) */} lower={lower}
<div className="min-w-0 flex-1 overflow-hidden"> onToggleTagFilter={onToggleTagFilter}
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/> />
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
}}
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className="absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5 backdrop-blur dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
</div>
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
</>
{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="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="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
) )
} }

View File

@ -2,42 +2,468 @@
'use client' 'use client'
import * as React from 'react'
import Table, { type Column, type SortState } from './Table' import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
columns: Column<RecordJob>[] isLoading?: boolean
getRowKey: (j: RecordJob) => string
sort: SortState // helpers
onSortChange: (s: SortState) => void keyFor: (j: RecordJob) => string
onRowClick: (job: RecordJob) => void baseName: (p: string) => string
rowClassName?: (job: RecordJob) => string lower: (s: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
resolutions: Record<string, { w: number; h: number }>
durations: Record<string, number>
// teaser/preview
canHover: boolean
teaserAudio?: boolean
hoverTeaserKey: string | null
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
teaserPlayback?: 'still' | 'hover' | 'all'
teaserKey: string | null
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
blurPreviews?: boolean
assetNonce?: number
// state/flags for actions row + rowClassName
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
// actions
onOpenPlayer: (job: RecordJob) => void
onSortModeChange: (m: SortMode) => void
page: number
onPageChange: (page: number) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
rows, rows,
columns, isLoading,
getRowKey, keyFor,
sort, baseName,
onSortChange, lower,
onRowClick, modelNameFromOutput,
rowClassName, runtimeOf,
sizeBytesOf,
formatBytes,
resolutions,
durations,
canHover,
teaserAudio,
hoverTeaserKey,
setHoverTeaserKey,
teaserPlayback = 'hover',
teaserKey,
registerTeaserHost,
handleDuration,
handleResolution,
blurPreviews,
assetNonce,
deletingKeys,
keepingKeys,
removingKeys,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onOpenPlayer,
onSortModeChange,
page,
onPageChange,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
deleteVideo,
keepVideo,
}: Props) { }: Props) {
const [sort, setSort] = React.useState<SortState>(null)
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend)
const sec = (job as any)?.durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
// 2) Fallback: endedAt-startedAt (only if plausible)
const start = Date.parse(String((job as any)?.startedAt || ''))
const end = Date.parse(String((job as any)?.endedAt || ''))
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
const diffSec = (end - start) / 1000
if (diffSec >= 1 && diffSec <= 24 * 60 * 60) return diffSec
}
return Number.POSITIVE_INFINITY
}, [])
const parseTags = React.useCallback((raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}, [])
const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
return ( return (
<div
ref={registerTeaserHost(k)}
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
onResolution={handleResolution}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</div>
)
},
},
{
key: 'Model',
header: 'Model',
sortable: true,
sortValue: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
},
cell: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
return (
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate" title={file}>
{file || '—'}
</span>
{(() => {
const k = keyFor(j)
const res = resolutions[k] ?? null
const label = formatResolution(res)
if (!label) return null
return (
<span
className="shrink-0 rounded-md bg-gray-100 px-1.5 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200
dark:bg-white/5 dark:text-gray-200 dark:ring-white/10"
title={res ? `${res.w}×${res.h}` : 'Auflösung'}
>
{label}
</span>
)
})()}
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{tags.length > 0 ? (
<div className="mt-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={keyFor(j)}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
maxWidthClassName="max-w-[11rem]"
/>
</div>
) : null}
</div>
</div>
)
},
},
{
key: 'completedAt',
header: 'Fertiggestellt am',
sortable: true,
widthClassName: 'w-[150px]',
sortValue: (j) => {
const t = Date.parse(String(j.endedAt || ''))
return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY
},
cell: (j) => {
const t = Date.parse(String(j.endedAt || ''))
if (!Number.isFinite(t)) return <span className="text-xs text-gray-400"></span>
const d = new Date(t)
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return (
<time
dateTime={d.toISOString()}
title={`${date} ${time}`}
className="tabular-nums whitespace-nowrap text-sm text-gray-900 dark:text-white"
>
<span className="font-medium">{date}</span>
<span className="mx-1 text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-600 dark:text-gray-300">{time}</span>
</time>
)
},
},
{
key: 'runtime',
header: 'Dauer',
align: 'right',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
key: 'size',
header: 'Größe',
align: 'right',
sortable: true,
sortValue: (j) => {
const s = sizeBytesOf(j)
return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY
},
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{formatBytes(sizeBytesOf(j))}</span>,
},
{
key: 'actions',
header: 'Aktionen',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<RecordJobActions
job={j}
variant="table"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1"
/>
)
},
},
]
}, [
keyFor,
baseName,
durations,
teaserAudio,
hoverTeaserKey,
registerTeaserHost,
canHover,
setHoverTeaserKey,
handleDuration,
handleResolution,
blurPreviews,
teaserPlayback,
teaserKey,
assetNonce,
lower,
modelNameFromOutput,
modelsByKey,
activeTagSet,
onToggleTagFilter,
resolutions,
deletingKeys,
keepingKeys,
removingKeys,
onToggleWatch,
onToggleFavorite,
onToggleLike,
onToggleHot,
keepVideo,
deleteVideo,
sizeBytesOf,
formatBytes,
runtimeOf,
parseTags,
runtimeSecondsForSort,
])
const sortStateToMode = React.useCallback((s: SortState): SortMode => {
if (!s) return 'completed_desc'
const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
const dirRaw = String(anyS.dir ?? anyS.direction ?? anyS.order ?? '').toLowerCase()
const asc = dirRaw === 'asc' || dirRaw === '1' || dirRaw === 'true'
if (key === 'completedAt') return asc ? 'completed_asc' : 'completed_desc'
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
if (key === 'size') return asc ? 'size_asc' : 'size_desc'
if (key === 'Model') return asc ? 'file_asc' : 'file_desc'
if (key === 'video') return asc ? 'file_asc' : 'file_desc'
return asc ? 'completed_asc' : 'completed_desc'
}, [])
const handleSortChange = React.useCallback(
(s: SortState) => {
setSort(s) // Table Pfeil
onSortModeChange(sortStateToMode(s))
if (page !== 1) onPageChange(1)
},
[onSortModeChange, sortStateToMode, page, onPageChange]
)
const rowClassName = React.useCallback(
(j: RecordJob) => {
const k = keyFor(j)
return [
'transition-all duration-300',
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse',
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
removingKeys.has(k) && 'opacity-0',
]
.filter(Boolean)
.join(' ')
},
[keyFor, deletingKeys, keepingKeys, removingKeys]
)
return (
<div className="relative">
<Table <Table
rows={rows} rows={rows}
columns={columns} columns={columns}
getRowKey={getRowKey} getRowKey={(j) => keyFor(j)}
striped striped
fullWidth fullWidth
stickyHeader stickyHeader
compact={false} compact={false}
card card
sort={sort} sort={sort}
onSortChange={onSortChange} onSortChange={handleSortChange}
onRowClick={onRowClick} onRowClick={onOpenPlayer}
rowClassName={rowClassName} rowClassName={rowClassName}
/> />
{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="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="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
) )
} }

View File

@ -1,3 +1,4 @@
// frontend\src\components\ui\FinishedVideoPreview.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
@ -9,12 +10,18 @@ type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover' type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips' | 'teaser' type AnimatedMode = 'frames' | 'clips' | 'teaser'
type AnimatedTrigger = 'always' | 'hover' type AnimatedTrigger = 'always' | 'hover'
type ProgressKind = 'inline' | 'teaser' | 'clips'
export type FinishedVideoPreviewProps = { export type FinishedVideoPreviewProps = {
job: RecordJob job: RecordJob
getFileName: (path: string) => string getFileName: (path: string) => string
/** optional legacy override (z.B. aus Cache im Parent) */
durationSeconds?: number durationSeconds?: number
/** Callbacks für Parent-State */
onDuration?: (job: RecordJob, seconds: number) => void onDuration?: (job: RecordJob, seconds: number) => void
onResolution?: (job: RecordJob, w: number, h: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */ /** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
animated?: boolean animated?: boolean
@ -35,7 +42,6 @@ export type FinishedVideoPreviewProps = {
className?: string className?: string
showPopover?: boolean showPopover?: boolean
blur?: boolean blur?: boolean
/** /**
@ -60,7 +66,6 @@ export type FinishedVideoPreviewProps = {
popoverMuted?: boolean popoverMuted?: boolean
noGenerateTeaser?: boolean noGenerateTeaser?: boolean
} }
export default function FinishedVideoPreview({ export default function FinishedVideoPreview({
@ -68,10 +73,12 @@ export default function FinishedVideoPreview({
getFileName, getFileName,
durationSeconds, durationSeconds,
onDuration, onDuration,
onResolution,
animated = false, animated = false,
animatedMode = 'frames', animatedMode = 'frames',
animatedTrigger = 'always', animatedTrigger = 'always',
autoTickMs = 15000, autoTickMs = 15000,
thumbStepSec, thumbStepSec,
thumbSpread, thumbSpread,
@ -98,6 +105,160 @@ export default function FinishedVideoPreview({
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
// ✅ meta robust normalisieren (job.meta kann string sein)
const meta = useMemo(() => {
const m: any = (job as any)?.meta
if (!m) return null
if (typeof m === 'string') {
try {
return JSON.parse(m)
} catch {
return null
}
}
return m
}, [job])
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
const metaForPreview = meta ?? fetchedMeta
const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
type PreviewClip = { startSeconds: number; durationSeconds: number }
type PreviewClipMap = { start: number; dur: number; cumStart: number; cumEnd: number }
const previewClipMap = useMemo<PreviewClipMap[] | null>(() => {
let pcsAny: any = (metaForPreview as any)?.previewClips
// ✅ falls previewClips als JSON-string gespeichert ist
if (typeof pcsAny === 'string') {
try {
pcsAny = JSON.parse(pcsAny)
} catch {
pcsAny = null
}
}
// ✅ falls es verschachtelt ist (falls du sowas irgendwo hast)
if (!Array.isArray(pcsAny) && Array.isArray((metaForPreview as any)?.preview?.clips)) {
pcsAny = (metaForPreview as any).preview.clips
}
const pcs = pcsAny as PreviewClip[] | null
if (!Array.isArray(pcs) || pcs.length === 0) return null
let cum = 0
const out: PreviewClipMap[] = []
for (const c of pcs) {
const start = Number((c as any)?.startSeconds)
const dur = Number((c as any)?.durationSeconds)
if (!Number.isFinite(start) || start < 0) continue
if (!Number.isFinite(dur) || dur <= 0) continue
const cumStart = cum
const cumEnd = cum + dur
out.push({ start, dur, cumStart, cumEnd })
cum = cumEnd
}
return out.length ? out : null
}, [metaForPreview])
const previewClipMapKey = useMemo(() => {
if (!previewClipMap) return ''
// stabiler key für effect-deps
return previewClipMap.map((c) => `${c.start.toFixed(3)}:${c.dur.toFixed(3)}`).join('|')
}, [previewClipMap])
// (aktuell ungenutzt, aber bewusst drin gelassen)
const mapPreviewTimeToGlobalTime = (tPreview: number, totalSeconds?: number) => {
const m = previewClipMap
if (!m || !m.length) return tPreview
// clamp
if (tPreview <= 0) return m[0].start
const last = m[m.length - 1]
if (tPreview >= last.cumEnd) {
// ✅ Wenn wir die Vollvideo-Dauer kennen: Teaser-Ende = 100% (Vollvideo-Ende)
if (typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0) return totalSeconds
// Fallback
return last.start + last.dur
}
// binary search nach cumStart/cumEnd
let lo = 0
let hi = m.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
// innerhalb des Segments
return c.start + (tPreview - c.cumStart)
}
}
// Fallback: nächster Clip
const idx = Math.max(0, Math.min(m.length - 1, lo))
const c = m[idx]
return c.start
}
void mapPreviewTimeToGlobalTime
const effectiveW =
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) && meta.videoWidth > 0 ? meta.videoWidth : undefined) ??
(typeof job.videoWidth === 'number' && Number.isFinite(job.videoWidth) && job.videoWidth > 0 ? job.videoWidth : undefined)
const effectiveH =
(typeof meta?.videoHeight === 'number' &&
Number.isFinite(meta.videoHeight) &&
meta.videoHeight > 0
? meta.videoHeight
: undefined) ??
(typeof job.videoHeight === 'number' && Number.isFinite(job.videoHeight) && job.videoHeight > 0 ? job.videoHeight : undefined)
const effectiveSizeBytes =
(typeof meta?.fileSize === 'number' && Number.isFinite(meta.fileSize) && meta.fileSize > 0 ? meta.fileSize : undefined) ??
(typeof job.sizeBytes === 'number' && Number.isFinite(job.sizeBytes) && job.sizeBytes > 0 ? job.sizeBytes : undefined)
const effectiveFPS =
(typeof meta?.fps === 'number' && Number.isFinite(meta.fps) && meta.fps > 0 ? meta.fps : undefined) ??
(typeof job.fps === 'number' && Number.isFinite(job.fps) && job.fps > 0 ? job.fps : undefined)
// --- Duration normalisieren: manche Quellen liefern ms statt s
const rawDuration =
(typeof (metaForPreview as any)?.durationSeconds === 'number' &&
Number.isFinite((metaForPreview as any).durationSeconds) &&
(metaForPreview as any).durationSeconds > 0
? (metaForPreview as any).durationSeconds
: undefined) ??
(typeof job.durationSeconds === 'number' && Number.isFinite(job.durationSeconds) && job.durationSeconds > 0
? job.durationSeconds
: undefined) ??
(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : undefined)
// Heuristik: > 24h in Sekunden ist unplausibel für Clips; außerdem sieht ms typischerweise wie 600000 aus.
const effectiveDurationSec =
typeof rawDuration === 'number' && Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration > 24 * 60 * 60
? rawDuration / 1000
: rawDuration
: undefined
const hasDuration = typeof effectiveDurationSec === 'number' && Number.isFinite(effectiveDurationSec) && effectiveDurationSec > 0
const hasResolution =
typeof effectiveW === 'number' &&
typeof effectiveH === 'number' &&
Number.isFinite(effectiveW) &&
Number.isFinite(effectiveH) &&
effectiveW > 0 &&
effectiveH > 0
const commonVideoProps = { const commonVideoProps = {
muted, muted,
playsInline: true, playsInline: true,
@ -115,8 +276,8 @@ export default function FinishedVideoPreview({
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
// ✅ NEU: sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen) // ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false) const [, setEverInView] = useState(false)
// Tick nur für frames-Mode // Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
@ -125,29 +286,65 @@ export default function FinishedVideoPreview({
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const inlineMode: 'never' | 'always' | 'hover' = const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
? 'always'
: inlineVideo === 'hover' // --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
? 'hover' const wantsHover =
: 'never' inlineMode === 'hover' || (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const previewId = useMemo(() => { const previewId = useMemo(() => {
const file = getFileName(job.output || '') const f = getFileName(job.output || '')
if (!file) return '' if (!f) return ''
const base = file.replace(/\.[^.]+$/, '') // ext weg const base = f.replace(/\.[^.]+$/, '') // ext weg
return stripHot(base).trim() return stripHot(base).trim()
}, [job.output, getFileName]) }, [job.output, getFileName])
// Vollvideo (für Inline-Playback + Duration-Metadaten) // ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
const videoSrc = useMemo( useEffect(() => {
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), if (!previewId) return
[file] if (!animated || animatedMode !== 'teaser') return
) if (!(inView || (wantsHover && hovered))) return
const hasDuration = const pcs = (meta as any)?.previewClips
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 const hasPcs =
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
if (hasPcs) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile
if (!aborted && j) setFetchedMeta(j)
})()
return () => {
aborted = true
ctrl.abort()
}
}, [previewId, file, animated, animatedMode, meta, inView, wantsHover, hovered])
// Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata)
const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file])
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16' const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
@ -155,9 +352,95 @@ export default function FinishedVideoPreview({
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null) const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null) const clipsRef = useRef<HTMLVideoElement | null>(null)
const lastMountedRef = useRef<{
inline: HTMLVideoElement | null
teaser: HTMLVideoElement | null
clips: HTMLVideoElement | null
}>({
inline: null,
teaser: null,
clips: null,
})
const bumpMountTickIfNew = (slot: 'inline' | 'teaser' | 'clips', el: HTMLVideoElement | null) => {
if (!el) return
if (lastMountedRef.current[slot] === el) return // ✅ gleicher DOM-Node -> NICHT nochmal tickern
lastMountedRef.current[slot] = el // ✅ erst merken, dann state setzen
setProgressMountTick((x) => x + 1)
}
// ▶️ Progressbar für abgespieltes Preview/Inline-Video
const [playRatio, setPlayRatio] = useState(0)
const [, setPlayGlobalSec] = useState(0)
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
// ✅ FIX: Teaser-Mapping darf NICHT von currentSrc abhängen.
// Ratio basiert auf vvDur (z.B. 2/18) — unabhängig von totalSeconds.
const readProgressStepped = (
vv: HTMLVideoElement | null,
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
stepSec = 1,
forceTeaserMap = false
): { ratio: number; globalSec: number; vvDur: number } => {
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
const vvDur = Number(vv.duration)
const vvDurOk = Number.isFinite(vvDur) && vvDur > 0
if (!vvDurOk) return { ratio: 0, globalSec: 0, vvDur: 0 }
const tPreview = Number(vv.currentTime)
if (!Number.isFinite(tPreview) || tPreview < 0) return { ratio: 0, globalSec: 0, vvDur }
let globalSec = 0
const m = previewClipMap
if (forceTeaserMap && Array.isArray(m) && m.length > 0) {
const last = m[m.length - 1]
// Ende -> global = totalSeconds (falls bekannt), sonst Segment-Ende
if (tPreview >= last.cumEnd) {
globalSec =
typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0
? totalSeconds
: last.start + last.dur
} else {
// Segment finden
let lo = 0
let hi = m.length - 1
let seg = m[0]
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
seg = c
break
}
}
const within = Math.max(0, tPreview - seg.cumStart)
const snapped = Math.floor(within / stepSec) * stepSec
globalSec = seg.start + Math.min(snapped, seg.dur)
}
} else {
// inline/clips: global = currentTime (gesnappt)
globalSec = Math.floor(tPreview / stepSec) * stepSec
}
// ✅ Balken-Ratio basiert auf vvDur
const g = Math.max(0, Math.min(globalSec, vvDur))
const ratio = clamp01(g / vvDur)
return { ratio, globalSec: g, vvDur }
}
const hardStop = (v: HTMLVideoElement | null) => { const hardStop = (v: HTMLVideoElement | null) => {
if (!v) return if (!v) return
try { v.pause() } catch {} try {
v.pause()
} catch {}
try { try {
v.removeAttribute('src') v.removeAttribute('src')
// @ts-ignore // @ts-ignore
@ -196,11 +479,11 @@ export default function FinishedVideoPreview({
(entries) => { (entries) => {
const hit = Boolean(entries[0]?.isIntersecting) const hit = Boolean(entries[0]?.isIntersecting)
setInView(hit) setInView(hit)
if (hit) setEverInView(true) // ✅ NEU if (hit) setEverInView(true)
}, },
{ {
threshold: 0.01, threshold: 0.01,
rootMargin: '350px 0px', // ✅ lädt erst "bei Bedarf", aber schon etwas vor dem Viewport rootMargin: '120px 0px', // oder '0px' wenn dus hart willst
} }
) )
@ -224,7 +507,7 @@ export default function FinishedVideoPreview({
if (animatedMode !== 'frames') return null if (animatedMode !== 'frames') return null
if (!hasDuration) return null if (!hasDuration) return null
const dur = durationSeconds! const dur = effectiveDurationSec!
const step = Math.max(0.25, thumbStepSec ?? 3) const step = Math.max(0.25, thumbStepSec ?? 3)
if (thumbSpread) { if (thumbSpread) {
@ -239,15 +522,15 @@ export default function FinishedVideoPreview({
const total = Math.max(dur - 0.1, step) const total = Math.max(dur - 0.1, step)
const t = (localTick * step) % total const t = (localTick * step) % total
return Math.min(dur - 0.05, Math.max(0.05, t)) return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples]) }, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples])
const v = assetNonce ?? 0 const v = assetNonce ?? 0
const thumbSrc = useMemo(() => { const thumbSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}` if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}-${localTick}` return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
}, [previewId, thumbTimeSec, localTick, v]) }, [previewId, thumbTimeSec, v])
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
@ -255,17 +538,51 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser]) }, [previewId, v, noGenerateTeaser])
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!) // ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
useEffect(() => {
let did = false
if (onDuration && hasDuration) {
onDuration(job, Number(effectiveDurationSec))
did = true
}
if (onResolution && hasResolution) {
onResolution(job, Number(effectiveW), Number(effectiveH))
did = true
}
if (did) setMetaLoaded(true)
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true) setMetaLoaded(true)
if (!onDuration) return
const secs = e.currentTarget.duration const vv = e.currentTarget
if (onDuration && !hasDuration) {
const secs = Number(vv.duration)
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs) if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
} }
if (onResolution && !hasResolution) {
const w = Number(vv.videoWidth)
const h = Number(vv.videoHeight)
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
onResolution(job, w, h)
}
}
}
useEffect(() => { useEffect(() => {
setThumbOk(true) setThumbOk(true)
setVideoOk(true) setVideoOk(true)
// ✅ Mount-Guards zurücksetzen
lastMountedRef.current.inline = null
lastMountedRef.current.teaser = null
lastMountedRef.current.clips = null
}, [previewId, assetNonce]) }, [previewId, assetNonce])
if (!videoSrc) { if (!videoSrc) {
@ -274,10 +591,7 @@ export default function FinishedVideoPreview({
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
inlineMode !== 'never' && inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
inView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover) // --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
const teaserActive = const teaserActive =
@ -287,26 +601,87 @@ export default function FinishedVideoPreview({
videoOk && videoOk &&
!showingInlineVideo && !showingInlineVideo &&
(animatedTrigger === 'always' || hovered) && (animatedTrigger === 'always' || hovered) &&
( ((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(animatedMode === 'clips' && hasDuration)
)
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
const wantsHover =
inlineMode === 'hover' ||
(animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
// ✅ Nur dann echte Asset-Requests auslösen, wenn wir sie brauchen // ✅ Nur dann echte Asset-Requests auslösen, wenn wir sie brauchen
const shouldLoadAssets = everInView || (wantsHover && hovered) const shouldLoadAssets = inView || (wantsHover && hovered)
// --- Legacy "clips" Logik (wenn du es noch nutzt) // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef =
showingInlineVideo
? inlineRef
: !showingInlineVideo && teaserActive && animatedMode === 'teaser'
? teaserMp4Ref
: !showingInlineVideo && teaserActive && animatedMode === 'clips'
? clipsRef
: null
const showProgressBar =
Boolean(progressVideoRef) &&
inView &&
typeof progressTotalSeconds === 'number' &&
Number.isFinite(progressTotalSeconds) &&
progressTotalSeconds > 0
const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
// ✅ Frames-Progress: zeigt Position des aktuellen Thumbnails relativ zur Gesamtdauer
const showFrameProgress =
animated &&
animatedMode === 'frames' &&
hasDuration &&
typeof thumbTimeSec === 'number' &&
Number.isFinite(thumbTimeSec) &&
thumbTimeSec >= 0
const frameRatio = showFrameProgress ? clamp01(thumbTimeSec! / effectiveDurationSec!) : 0
// finaler Balken: Video-Progress hat Priorität, sonst Frames-Progress
const progressRatio = showProgressBar ? playRatio : showFrameProgress ? frameRatio : 0
const showAnyProgress = showProgressBar || showFrameProgress
const clipOverlay = useMemo(() => {
if (!hasDuration) return null
const total = effectiveDurationSec!
if (!(total > 0)) return null
const pcsAny: any = (metaForPreview as any)?.previewClips
let pcs: any = pcsAny
if (typeof pcs === 'string') {
try {
pcs = JSON.parse(pcs)
} catch {
pcs = null
}
}
if (!Array.isArray(pcs) || pcs.length === 0) return null
const clips = pcs
.map((c: any) => ({
start: Number(c?.startSeconds),
dur: Number(c?.durationSeconds),
}))
.filter((c: any) => Number.isFinite(c.start) && c.start >= 0 && Number.isFinite(c.dur) && c.dur > 0)
if (!clips.length) return null
return clips.map((c: any) => {
const left = clamp01(c.start / total)
const width = clamp01(c.dur / total)
return { left, width, start: c.start, dur: c.dur }
})
}, [metaForPreview, hasDuration, effectiveDurationSec])
// --- Legacy "clips" Logik
const clipTimes = useMemo(() => { const clipTimes = useMemo(() => {
if (!animated) return [] if (!animated) return []
if (animatedMode !== 'clips') return [] if (animatedMode !== 'clips') return []
if (!hasDuration) return [] if (!hasDuration) return []
const dur = durationSeconds! const dur = effectiveDurationSec!
const clipLen = Math.max(0.25, clipSeconds) const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur))) const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
@ -319,7 +694,7 @@ export default function FinishedVideoPreview({
times.push(Math.min(dur - 0.05, Math.max(0.05, t))) times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
} }
return times return times
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds]) }, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes]) const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
@ -327,21 +702,75 @@ export default function FinishedVideoPreview({
const clipStartRef = useRef(0) const clipStartRef = useRef(0)
useEffect(() => { useEffect(() => {
const v = teaserMp4Ref.current const vv = teaserMp4Ref.current
if (!v) return if (!vv) return
const active = teaserActive && animatedMode === 'teaser' const active = teaserActive && animatedMode === 'teaser'
if (!active) { if (!active) {
try { v.pause() } catch {} try {
vv.pause()
} catch {}
return return
} }
applyInlineVideoPolicy(v, { muted }) applyInlineVideoPolicy(vv, { muted })
const p = v.play?.() const p = vv.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {}) if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted]) }, [teaserActive, animatedMode, teaserSrc, muted])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => {
if (!showProgressBar) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
const vv = progressVideoRef?.current ?? null
if (!vv) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
let stopped = false
let timer: number | null = null
const sync = () => {
if (stopped) return
if (!vv.isConnected) return
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
const p = readProgressStepped(vv, progressTotalSeconds, 1, forceMap)
setPlayRatio(p.ratio)
setPlayGlobalSec(p.globalSec)
}
// initial sofort
sync()
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
timer = window.setInterval(sync, 1000)
// optional: bei metadata/timeupdate sofort einmal syncen
const onLoaded = () => sync()
const onTime = () => sync()
vv.addEventListener('loadedmetadata', onLoaded)
vv.addEventListener('durationchange', onLoaded)
vv.addEventListener('timeupdate', onTime)
return () => {
stopped = true
if (timer != null) window.clearInterval(timer)
vv.removeEventListener('loadedmetadata', onLoaded)
vv.removeEventListener('durationchange', onLoaded)
vv.removeEventListener('timeupdate', onTime)
}
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
useEffect(() => { useEffect(() => {
if (!showingInlineVideo) return if (!showingInlineVideo) return
applyInlineVideoPolicy(inlineRef.current, { muted }) applyInlineVideoPolicy(inlineRef.current, { muted })
@ -349,12 +778,11 @@ export default function FinishedVideoPreview({
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => { useEffect(() => {
const v = clipsRef.current const vv = clipsRef.current
if (!v) return if (!vv) return
if (!(teaserActive && animatedMode === 'clips')) { if (!(teaserActive && animatedMode === 'clips')) {
// bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv if (!teaserActive) vv.pause()
if (!teaserActive) v.pause()
return return
} }
@ -365,9 +793,9 @@ export default function FinishedVideoPreview({
const start = () => { const start = () => {
try { try {
v.currentTime = clipStartRef.current vv.currentTime = clipStartRef.current
} catch {} } catch {}
const p = v.play() const p = vv.play()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {}) if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
} }
@ -375,41 +803,48 @@ export default function FinishedVideoPreview({
const onTimeUpdate = () => { const onTimeUpdate = () => {
if (!clipTimes.length) return if (!clipTimes.length) return
if (v.currentTime - clipStartRef.current >= clipSeconds) { if (vv.currentTime - clipStartRef.current >= clipSeconds) {
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current] clipStartRef.current = clipTimes[clipIdxRef.current]
try { try {
v.currentTime = clipStartRef.current + 0.01 vv.currentTime = clipStartRef.current + 0.01
} catch {} } catch {}
} }
} }
v.addEventListener('loadedmetadata', onLoaded) vv.addEventListener('loadedmetadata', onLoaded)
v.addEventListener('timeupdate', onTimeUpdate) vv.addEventListener('timeupdate', onTimeUpdate)
if (v.readyState >= 1) start() if (vv.readyState >= 1) start()
return () => { return () => {
v.removeEventListener('loadedmetadata', onLoaded) vv.removeEventListener('loadedmetadata', onLoaded)
v.removeEventListener('timeupdate', onTimeUpdate) vv.removeEventListener('timeupdate', onTimeUpdate)
v.pause() vv.pause()
} }
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes]) }, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
inView &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
((onDuration && !hasDuration) || (onResolution && !hasResolution))
const previewNode = ( const previewNode = (
<div <div
ref={rootRef} ref={rootRef}
className={[ className={['group rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative isolate', sizeClass, className ?? ''].join(' ')}
'rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative',
sizeClass,
className ?? '',
].join(' ')}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined} onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined} onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined} onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined} onBlur={wantsHover ? () => setHovered(false) : undefined}
data-duration={hasDuration ? String(effectiveDurationSec) : undefined}
data-res={hasResolution ? `${effectiveW}x${effectiveH}` : undefined}
data-size={typeof effectiveSizeBytes === 'number' ? String(effectiveSizeBytes) : undefined}
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
> >
{/* 1) Inline Full Video (mit Controls) */}
{/* ✅ Thumb IMMER als Fallback/Background */} {/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadAssets && thumbSrc && thumbOk ? ( {shouldLoadAssets && thumbSrc && thumbOk ? (
<img <img
@ -428,14 +863,19 @@ export default function FinishedVideoPreview({
{showingInlineVideo ? ( {showingInlineVideo ? (
<video <video
{...commonVideoProps} {...commonVideoProps}
ref={inlineRef} ref={(el) => {
inlineRef.current = el
bumpMountTickIfNew('inline', el)
}}
key={`inline-${previewId}-${inlineNonce}`} key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc} src={videoSrc}
className={[ className={[
'absolute inset-0 w-full h-full object-cover', 'absolute inset-0 w-full h-full object-cover',
blurCls, blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none', inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')} ]
.filter(Boolean)
.join(' ')}
autoPlay autoPlay
muted={muted} muted={muted}
controls={inlineControls} controls={inlineControls}
@ -446,10 +886,13 @@ export default function FinishedVideoPreview({
/> />
) : null} ) : null}
{/* ✅ Teaser MP4: nur im Viewport (teaserActive) Thumb bleibt drunter sichtbar */} {/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video <video
ref={teaserMp4Ref} ref={(el) => {
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}`} key={`teaser-mp4-${previewId}`}
src={teaserSrc} src={teaserSrc}
className={[ className={[
@ -457,7 +900,9 @@ export default function FinishedVideoPreview({
blurCls, blurCls,
teaserReady ? 'opacity-100' : 'opacity-0', teaserReady ? 'opacity-100' : 'opacity-0',
'transition-opacity duration-150', 'transition-opacity duration-150',
].filter(Boolean).join(' ')} ]
.filter(Boolean)
.join(' ')}
muted={muted} muted={muted}
playsInline playsInline
autoPlay autoPlay
@ -467,22 +912,22 @@ export default function FinishedVideoPreview({
onLoadedData={() => setTeaserReady(true)} onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)} onPlaying={() => setTeaserReady(true)}
onError={() => { onError={() => {
setTeaserOk(false) // ✅ nur teaser abschalten setTeaserOk(false)
setTeaserReady(false) // ✅ overlay wieder weg setTeaserReady(false)
}} }}
/> />
) : null} ) : null}
{/* ✅ Legacy clips (falls noch genutzt) */} {/* ✅ Legacy clips */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video <video
ref={clipsRef} ref={(el) => {
clipsRef.current = el
bumpMountTickIfNew('clips', el)
}}
key={`clips-${previewId}-${clipTimesKey}`} key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc} src={videoSrc}
className={[ className={['absolute inset-0 w-full h-full object-cover pointer-events-none', blurCls].filter(Boolean).join(' ')}
'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
].filter(Boolean).join(' ')}
muted={muted} muted={muted}
playsInline playsInline
preload="metadata" preload="metadata"
@ -491,21 +936,66 @@ export default function FinishedVideoPreview({
/> />
) : null} ) : null}
{/* Metadaten nur laden wenn nötig (und nicht inline) */} {/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && ( {showAnyProgress ? (
<video <div
src={videoSrc} aria-hidden="true"
preload="metadata" className={[
muted={muted} 'absolute left-0 right-0 bottom-0 z-10 pointer-events-none',
playsInline // etwas höher + bei hover deutlich
className="hidden" 'h-0.5 group-hover:h-1.5',
onLoadedMetadata={handleLoadedMetadata} 'transition-[height] duration-150 ease-out',
// Track: heller + border/inset für Kontrast
'rounded-none group-hover:rounded-full',
'bg-black/35 dark:bg-white/10',
// darf beim Hover raus “glowen”
'overflow-hidden group-hover:overflow-visible',
].join(' ')}
>
{/* 1) Segmente (previewClips) als Markierungen */}
{progressKind === 'teaser' && clipOverlay ? (
<div className="absolute inset-0">
{clipOverlay.map((c, i) => (
<div
key={`seg-${i}-${c.left.toFixed(6)}-${c.width.toFixed(6)}`}
className="absolute top-0 bottom-0 bg-white/15 dark:bg-white/20"
style={{
left: `${c.left * 100}%`,
width: `${c.width * 100}%`,
}}
/> />
)} ))}
</div>
) : null}
{/* 2) Kontinuierlicher Fortschritt (SOLID, kein Gradient) */}
<div
className="absolute inset-0 origin-left transition-transform duration-150 ease-out"
style={{
transform: `scaleX(${clamp01(progressRatio)})`,
background: 'rgba(99,102,241,0.95)', // indigo-500-ish, kräftig
}}
/>
{/* 3) Knob am Ende (macht Progress sofort klar) */}
<div
className="absolute top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150"
style={{
left: `calc(${clamp01(progressRatio) * 100}% - 4px)`,
}}
>
<div className="h-1.5 w-1.5 rounded-full bg-white/90 shadow-[0_0_0_2px_rgba(0,0,0,0.25),0_0_10px_rgba(168,85,247,0.55)]" />
</div>
</div>
) : null}
{/* ✅ Metadaten-Fallback nur wenn nötig (und nicht inline) */}
{needHiddenMeta ? (
<video src={videoSrc} preload="metadata" muted={muted} playsInline className="hidden" onLoadedMetadata={handleLoadedMetadata} />
) : null}
</div> </div>
) )
// Gallery: kein HoverPopover
if (!showPopover) return previewNode if (!showPopover) return previewNode
return ( return (

View File

@ -1,8 +1,10 @@
// frontend\src\components\ui\GenerateAssetsTask.tsx
'use client' 'use client'
import { useRef, useState, useEffect, useCallback } from 'react' import { useEffect, useRef, useState } from 'react'
import Button from './Button' import Button from './Button'
import ProgressBar from './ProgressBar' import { subscribeSSE } from '../../lib/sseSingleton'
type TaskState = { type TaskState = {
running: boolean running: boolean
@ -16,6 +18,17 @@ type TaskState = {
error?: string error?: string
} }
type Progress = { done: number; total: number }
type Props = {
onFinished?: () => void
onStart?: (ac: AbortController) => void
onProgress?: (p: Progress) => void
onDone?: () => void
onCancelled?: () => void
onError?: (message: string) => void
}
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> { async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { const res = await fetch(url, {
cache: 'no-store' as any, cache: 'no-store' as any,
@ -41,212 +54,173 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return data as T return data as T
} }
type Props = { export default function GenerateAssetsTask({
onFinished?: () => void onFinished,
} onStart,
onProgress,
export default function GenerateAssetsTask({ onFinished }: Props) { onDone,
onCancelled,
onError,
}: Props) {
const [state, setState] = useState<TaskState | null>(null) const [state, setState] = useState<TaskState | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const [stopping, setStopping] = useState(false) const [startError, setStartError] = useState<string | null>(null)
const loadStatus = useCallback(async () => {
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets')
setState(st)
} catch (e: any) {
setError(e?.message ?? String(e))
}
}, [])
// Plumbing (für Abbrechen über TaskList)
const abortRef = useRef<AbortController | null>(null)
const armedRef = useRef(false) // onStart nur 1× pro Lauf feuern
const cancelledRef = useRef(false)
const stopInFlightRef = useRef(false)
const prevRunningRef = useRef(false) const prevRunningRef = useRef(false)
const lastErrorRef = useRef('')
const onProgressRef = useRef<Props['onProgress']>(onProgress)
const onErrorRef = useRef<Props['onError']>(onError)
useEffect(() => {
onProgressRef.current = onProgress
}, [onProgress])
useEffect(() => {
onErrorRef.current = onError
}, [onError])
async function stopInternal() {
if (stopInFlightRef.current) return
stopInFlightRef.current = true
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
} finally {
stopInFlightRef.current = false
}
}
function ensureControllerCreated() {
if (abortRef.current) return abortRef.current
const ac = new AbortController()
abortRef.current = ac
const onAbort = () => {
cancelledRef.current = true
void stopInternal()
}
ac.signal.addEventListener('abort', onAbort, { once: true })
return ac
}
function armTaskList(ac: AbortController) {
if (armedRef.current) return
armedRef.current = true
onStart?.(ac)
}
// Detect finish (running -> false)
useEffect(() => { useEffect(() => {
const prev = prevRunningRef.current const prev = prevRunningRef.current
const cur = Boolean(state?.running) const cur = Boolean(state?.running)
prevRunningRef.current = cur prevRunningRef.current = cur
// Task ist gerade fertig geworden
if (prev && !cur) { if (prev && !cur) {
const errText = String(state?.error ?? '').trim()
// Reset pro Run
abortRef.current = null
armedRef.current = false
if (cancelledRef.current || errText === 'abgebrochen') {
cancelledRef.current = false
onCancelled?.()
} else if (errText) {
// Fehlerfälle werden über onError schon gemeldet
} else {
onDone?.()
}
onFinished?.() onFinished?.()
} }
}, [state?.running, onFinished]) }, [state?.running, state?.error, onFinished, onDone, onCancelled])
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => { useEffect(() => {
loadStatus() const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
}, [loadStatus]) setState(st)
useEffect(() => { if (st?.running) {
if (!state?.running) return const ac = ensureControllerCreated()
const t = window.setInterval(loadStatus, 1200) armTaskList(ac)
return () => window.clearInterval(t) onProgressRef.current?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
}, [state?.running, loadStatus]) }
const errText = String(st?.error ?? '').trim()
if (errText && errText !== lastErrorRef.current) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)
}
})
return () => unsub()
}, [])
async function start() { async function start() {
setError(null) if (state?.running) return
setStartError(null)
setStarting(true) setStarting(true)
cancelledRef.current = false
lastErrorRef.current = ''
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren
const ac = ensureControllerCreated()
try { try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' }) const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
setState(st) setState(st)
// TaskList jetzt aktivieren
armTaskList(ac)
if (st?.running) {
onProgress?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
}
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? String(e)) // Start fehlgeschlagen -> Controller/Flags zurücksetzen
abortRef.current = null
armedRef.current = false
const msg = e?.message ?? String(e)
setStartError(msg)
onError?.(msg)
} finally { } finally {
setStarting(false) setStarting(false)
} }
} }
async function stop() {
setError(null)
setStopping(true)
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch (e: any) {
// ignore wir holen danach Status neu
} finally {
await loadStatus()
setStopping(false)
}
}
const running = !!state?.running const running = !!state?.running
const total = state?.total ?? 0
const done = state?.done ?? 0
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
const fmtTime = (iso?: string) => {
const s = String(iso ?? '').trim()
if (!s) return null
const d = new Date(s)
if (!Number.isFinite(d.getTime())) return null
return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const started = fmtTime(state?.startedAt)
const finished = fmtTime(state?.finishedAt)
return ( return (
<div <div className="flex items-center justify-between gap-4">
className="
rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/50 dark:supports-[backdrop-filter]:bg-gray-950/35
"
>
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Assets-Generator Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
</div> </div>
{/* Status badge */} {startError ? (
{running ? ( <div className="mt-2 text-xs text-red-700 dark:text-red-200">
<span className="inline-flex items-center rounded-full bg-indigo-500/10 px-2 py-0.5 text-[11px] font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/30"> {startError}
läuft
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10">
bereit
</span>
)}
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-white/70">
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/&lt;id&gt;/</span>{' '}
<span className="font-mono">thumbs.jpg</span>, <span className="font-mono">preview.mp4</span>{' '}
und <span className="font-mono">meta.json</span> für schnelle Listen & zuverlässige Duration.
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{running ? (
<Button
variant="secondary"
color="red"
onClick={stop}
disabled={stopping}
className="w-full sm:w-auto"
>
{stopping ? 'Stoppe…' : 'Stop'}
</Button>
) : null} ) : null}
</div>
<Button <div className="shrink-0">
variant="primary" <Button variant="primary" onClick={start} disabled={starting || running}>
onClick={start} {starting ? 'Starte…' : 'Start'}
disabled={starting || running}
className="w-full sm:w-auto"
>
{starting ? 'Starte…' : running ? 'Läuft…' : 'Generieren'}
</Button> </Button>
</div> </div>
</div> </div>
{/* Errors */}
{error ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{error}
</div>
) : null}
{state?.error ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{state.error}
</div>
) : null}
{/* Body */}
{state ? (
<div className="mt-4 space-y-3">
<ProgressBar
value={pct}
showPercent
rightLabel={total ? `${done}/${total} Dateien` : '—'}
/>
{/* Stats */}
<div className="grid grid-cols-3 gap-2">
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Thumbs</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedThumbs ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Previews</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedPreviews ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Übersprungen</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.skipped ?? 0}
</div>
</div>
</div>
{/* Times */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-white/70">
<span>
{started ? <>Start: <span className="font-medium text-gray-900 dark:text-white">{started}</span></> : 'Start: —'}
</span>
<span>
{finished ? <>Ende: <span className="font-medium text-gray-900 dark:text-white">{finished}</span></> : 'Ende: —'}
</span>
</div>
</div>
) : (
<div className="mt-4 text-xs text-gray-600 dark:text-white/70">
Status wird geladen
</div>
)}
</div>
) )
} }

View File

@ -0,0 +1,87 @@
// frontend\src\components\ui\LoadingSpinner.tsx
'use client'
import * as React from 'react'
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
export type LoadingSpinnerProps = {
/** Größe als Preset oder px (number) */
size?: SpinnerSize
/** Farbe via Tailwind (z.B. "text-indigo-500") */
className?: string
/** Optionaler Text neben dem Spinner (sichtbar) */
label?: React.ReactNode
/** Screenreader-Text (wenn label nicht gesetzt ist) */
srLabel?: string
/** Zentriert Spinner + Label als Inline-Flex */
center?: boolean
}
function sizeToPx(size: SpinnerSize): number {
if (typeof size === 'number') return size
if (size === 'xs') return 12
if (size === 'sm') return 16
if (size === 'lg') return 28
return 20 // md default
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export default function LoadingSpinner({
size = 'md',
className,
label,
srLabel = 'Lädt…',
center = false,
}: LoadingSpinnerProps) {
const px = sizeToPx(size)
return (
<span
className={cn(
'inline-flex items-center gap-2',
center && 'justify-center w-full',
)}
role="status"
aria-live="polite"
>
<svg
width={px}
height={px}
viewBox="0 0 24 24"
className={cn('animate-spin text-gray-500', className)}
aria-hidden="true"
>
{/* Hintergrund-Ring (leicht transparent) */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="3"
opacity="0.25"
/>
{/* “Arc” vorne */}
<path
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
d="M21 12a9 9 0 0 0-9-9"
opacity="0.95"
/>
</svg>
{label ? (
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
) : (
<span className="sr-only">{srLabel}</span>
)}
</span>
)
}

View File

@ -1,5 +1,5 @@
// frontend/src/components/ui/LoginPage.tsx // frontend/src/components/ui/LoginPage.tsx
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Button from './Button' import Button from './Button'
type Props = { type Props = {
@ -45,13 +45,67 @@ function getNextFromLocation(): string {
} }
} }
const LOGIN_STATE_KEY = 'recorder_login_state_v1'
type PersistedLoginState = {
stage: 'login' | 'verify' | 'setup'
username: string
code: string
setupAuthUrl: string | null
setupSecret: string | null
setupInfo: string | null
ts: number
}
function loadLoginState(): PersistedLoginState | null {
try {
const raw = sessionStorage.getItem(LOGIN_STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedLoginState
// optional: nach 15 Minuten verwerfen
if (!parsed?.ts || Date.now() - parsed.ts > 15 * 60 * 1000) return null
return parsed
} catch {
return null
}
}
function saveLoginState(s: PersistedLoginState) {
try {
sessionStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(s))
} catch {
// ignore
}
}
function clearLoginState() {
try {
sessionStorage.removeItem(LOGIN_STATE_KEY)
} catch {
// ignore
}
}
function splitCodeToDigits(code: string): string[] {
const only = (code ?? '').replace(/\D/g, '').slice(0, 6)
const arr = only.split('')
while (arr.length < 6) arr.push('')
return arr
}
function digitsToCode(d: string[]) {
return (d ?? []).join('')
}
export default function LoginPage({ onLoggedIn }: Props) { export default function LoginPage({ onLoggedIn }: Props) {
const nextPath = useMemo(() => getNextFromLocation(), []) const nextPath = useMemo(() => getNextFromLocation(), [])
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [code, setCode] = useState('') // 6x inputs
const [codeDigits, setCodeDigits] = useState<string[]>(['', '', '', '', '', ''])
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([])
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -62,6 +116,145 @@ export default function LoginPage({ onLoggedIn }: Props) {
const [setupSecret, setSetupSecret] = useState<string | null>(null) const [setupSecret, setSetupSecret] = useState<string | null>(null)
const [setupInfo, setSetupInfo] = useState<string | null>(null) const [setupInfo, setSetupInfo] = useState<string | null>(null)
const submittedOnceRef = useRef(false)
const codeStr = useMemo(() => digitsToCode(codeDigits), [codeDigits])
function focusDigit(i: number) {
const el = codeInputsRef.current[i]
if (el) el.focus()
}
function clearAllDigits() {
setCodeDigits(['', '', '', '', '', ''])
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(0), 0)
}
function setDigitAt(i: number, val: string) {
const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer
setCodeDigits((prev) => {
const next = [...prev]
next[i] = v
return next
})
}
function handleDigitChange(i: number, raw: string) {
const only = (raw ?? '').replace(/\D/g, '')
if (!only) {
setDigitAt(i, '')
submittedOnceRef.current = false
return
}
// falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen
if (only.length > 1) {
setCodeDigits((prev) => {
const next = [...prev]
let k = i
for (const ch of only) {
if (k > 5) break
next[k] = ch
k++
}
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
return
}
setDigitAt(i, only)
submittedOnceRef.current = false
if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0)
}
function handleDigitKeyDown(i: number, ev: React.KeyboardEvent<HTMLInputElement>) {
if (ev.key === 'Backspace') {
const cur = codeDigits[i]
if (cur) {
ev.preventDefault()
setDigitAt(i, '')
submittedOnceRef.current = false
return
}
if (i > 0) {
ev.preventDefault()
focusDigit(i - 1)
setDigitAt(i - 1, '')
submittedOnceRef.current = false
}
return
}
if (ev.key === 'ArrowLeft') {
ev.preventDefault()
if (i > 0) focusDigit(i - 1)
return
}
if (ev.key === 'ArrowRight') {
ev.preventDefault()
if (i < 5) focusDigit(i + 1)
return
}
}
function handleDigitPaste(i: number, ev: React.ClipboardEvent<HTMLInputElement>) {
const text = ev.clipboardData.getData('text') || ''
const only = text.replace(/\D/g, '').slice(0, 6)
if (!only) return
ev.preventDefault()
setCodeDigits((prev) => {
const next = [...prev]
let k = i
for (const ch of only) {
if (k > 5) break
next[k] = ch
k++
}
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
}
// Restore aus sessionStorage
useEffect(() => {
const st = loadLoginState()
if (!st) return
setStage(st.stage ?? 'login')
setUsername(st.username ?? '')
setCodeDigits(splitCodeToDigits(st.code ?? ''))
setSetupAuthUrl(st.setupAuthUrl ?? null)
setSetupSecret(st.setupSecret ?? null)
setSetupInfo(st.setupInfo ?? null)
}, [])
// Persist in sessionStorage (wichtig für Mobile + Bitwarden Wechsel)
useEffect(() => {
saveLoginState({
stage,
username,
code: codeStr,
setupAuthUrl,
setupSecret,
setupInfo,
ts: Date.now(),
})
}, [stage, username, codeStr, setupAuthUrl, setupSecret, setupInfo])
// Bei Stage-Wechsel: Fokus / Submit-Guard reset
useEffect(() => {
submittedOnceRef.current = false
if (stage === 'verify' || stage === 'setup') {
window.setTimeout(() => focusDigit(0), 0)
}
}, [stage])
// Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter // Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter
useEffect(() => { useEffect(() => {
@ -78,6 +271,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
// Setup-Infos laden (QR/otpauth) // Setup-Infos laden (QR/otpauth)
void ensure2FASetup() void ensure2FASetup()
} else { } else {
clearLoginState()
window.location.assign(nextPath || '/') window.location.assign(nextPath || '/')
} }
return return
@ -113,6 +307,8 @@ export default function LoginPage({ onLoggedIn }: Props) {
// 2FA ist aktiv → Code-Abfrage // 2FA ist aktiv → Code-Abfrage
if (data?.totpRequired) { if (data?.totpRequired) {
setStage('verify') setStage('verify')
// Code-Felder leeren (sauberer Start)
clearAllDigits()
return return
} }
@ -121,12 +317,13 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (me?.authenticated && !me?.totpConfigured) { if (me?.authenticated && !me?.totpConfigured) {
setStage('setup') setStage('setup')
clearAllDigits()
await ensure2FASetup() await ensure2FASetup()
return return
} }
// normaler Fall: eingeloggt + entweder 2FA schon configured oder bewusst nicht erzwingen
if (onLoggedIn) await onLoggedIn() if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/') window.location.assign(nextPath || '/')
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? String(e)) setError(e?.message ?? String(e))
@ -135,20 +332,24 @@ export default function LoginPage({ onLoggedIn }: Props) {
} }
} }
const submit2FA = async () => { const submit2FA = async () => {
const c = codeStr.trim()
if (!/^\d{6}$/.test(c)) return
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', { await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }), body: JSON.stringify({ code: c }),
}) })
if (onLoggedIn) await onLoggedIn() if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/') window.location.assign(nextPath || '/')
} catch (e: any) { } catch (e: any) {
submittedOnceRef.current = false
setError(e?.message ?? String(e)) setError(e?.message ?? String(e))
} finally { } finally {
setBusy(false) setBusy(false)
@ -161,9 +362,9 @@ export default function LoginPage({ onLoggedIn }: Props) {
try { try {
const data = await apiJSON<SetupResp>('/api/auth/2fa/setup', { const data = await apiJSON<SetupResp>('/api/auth/2fa/setup', {
method: 'POST', // ✅ dein Backend prüft Method nicht, aber POST ist sauber method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // optional leer body: JSON.stringify({}),
}) })
const otpauth = (data?.otpauth ?? '').trim() const otpauth = (data?.otpauth ?? '').trim()
@ -177,14 +378,50 @@ export default function LoginPage({ onLoggedIn }: Props) {
} }
} }
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => { // Auto-submit sobald 6 Ziffern befüllt sind (verify + setup)
if (ev.key !== 'Enter') return useEffect(() => {
ev.preventDefault()
if (busy) return if (busy) return
if (stage !== 'verify' && stage !== 'setup') return
if (!/^\d{6}$/.test(codeStr)) return
if (submittedOnceRef.current) return
if (stage === 'verify' || stage === 'setup') void submit2FA() submittedOnceRef.current = true
else void submitLogin() void submit2FA()
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeStr, stage, busy])
const Code6Inputs = (
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<div className="flex items-center justify-between gap-2">
{codeDigits.map((d, i) => (
<input
key={i}
ref={(el) => {
codeInputsRef.current[i] = el
}}
value={d}
onChange={(e) => handleDigitChange(i, e.target.value)}
onKeyDown={(e) => handleDigitKeyDown(i, e)}
onPaste={(e) => handleDigitPaste(i, e)}
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
enterKeyHint={i === 5 ? 'done' : 'next'}
autoCapitalize="none"
autoCorrect="off"
disabled={busy}
className="h-12 w-12 rounded-lg text-center text-lg tabular-nums bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
aria-label={`2FA Ziffer ${i + 1}`}
/>
))}
</div>
</div>
)
return ( return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> <div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
@ -197,20 +434,24 @@ export default function LoginPage({ onLoggedIn }: Props) {
<div className="w-full max-w-md rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5"> <div className="w-full max-w-md rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1> <h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">Bitte melde dich an, um fortzufahren.</p>
Bitte melde dich an, um fortzufahren.
</p>
</div> </div>
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
{stage === 'login' ? ( {stage === 'login' ? (
<> <form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submitLogin()
}}
className="space-y-3"
>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label> <label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
<input <input
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
autoComplete="username" autoComplete="username"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="admin" placeholder="admin"
@ -224,7 +465,6 @@ export default function LoginPage({ onLoggedIn }: Props) {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
autoComplete="current-password" autoComplete="current-password"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="••••••••••" placeholder="••••••••••"
@ -232,70 +472,58 @@ export default function LoginPage({ onLoggedIn }: Props) {
/> />
</div> </div>
<Button <Button type="submit" variant="primary" className="w-full rounded-lg" disabled={busy || !username.trim() || !password}>
variant="primary"
className="w-full rounded-lg"
disabled={busy || !username.trim() || !password}
onClick={() => void submitLogin()}
>
{busy ? 'Login…' : 'Login'} {busy ? 'Login…' : 'Login'}
</Button> </Button>
</> </form>
) : stage === 'verify' ? ( ) : stage === 'verify' ? (
<> <form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"> <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist aktiv bitte gib den Code aus deiner Authenticator-App ein. 2FA ist aktiv bitte gib den Code aus deiner Authenticator-App ein.
</div> </div>
<div className="space-y-1"> {Code6Inputs}
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<input
id="id_code"
name="code"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
required
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="flex-1 rounded-lg" className="flex-1 rounded-lg"
disabled={busy} disabled={busy}
onClick={() => { onClick={() => {
setSetupAuthUrl(null) setStage('login')
setSetupSecret(null) clearAllDigits()
setSetupInfo(null)
}} }}
> >
Zurück Zurück
</Button> </Button>
<Button <Button
type="submit"
variant="primary" variant="primary"
className="flex-1 rounded-lg" className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6} disabled={busy || !/^\d{6}$/.test(codeStr)}
onClick={() => void submit2FA()}
> >
{busy ? 'Prüfe…' : 'Bestätigen'} {busy ? 'Prüfe…' : 'Bestätigen'}
</Button> </Button>
</div> </div>
</> </form>
) : ( ) : (
<> <form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200"> <div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
2FA ist noch nicht eingerichtet bitte richte es jetzt ein (empfohlen). 2FA ist noch nicht eingerichtet bitte richte es jetzt ein (empfohlen).
</div> </div>
@ -329,12 +557,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
</div> </div>
) : null} ) : null}
{setupInfo ? ( {setupInfo ? <div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div> : null}
<div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div>
) : null}
<div className="mt-3"> <div className="mt-3">
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="w-full rounded-lg" className="w-full rounded-lg"
disabled={busy} disabled={busy}
@ -346,38 +573,19 @@ export default function LoginPage({ onLoggedIn }: Props) {
</div> </div>
</div> </div>
<div className="space-y-1"> {/* gleiche 6-fach Eingabe auch hier */}
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code (zum Aktivieren)</label> {Code6Inputs}
<input
id="totp-setup"
name="code"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
required
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="flex-1 rounded-lg" className="flex-1 rounded-lg"
disabled={busy} disabled={busy}
onClick={() => { onClick={() => {
// optional: Setup überspringen (nicht empfohlen) // optional: Setup überspringen (nicht empfohlen)
if (onLoggedIn) void onLoggedIn() if (onLoggedIn) void onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/') window.location.assign(nextPath || '/')
}} }}
title="Ohne 2FA fortfahren (nicht empfohlen)" title="Ohne 2FA fortfahren (nicht empfohlen)"
@ -385,16 +593,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
Später Später
</Button> </Button>
<Button <Button type="submit" variant="primary" className="flex-1 rounded-lg" disabled={busy || !/^\d{6}$/.test(codeStr)}>
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6}
onClick={() => void submit2FA()}
>
{busy ? 'Aktiviere…' : '2FA aktivieren'} {busy ? 'Aktiviere…' : '2FA aktivieren'}
</Button> </Button>
</div> </div>
</> </form>
)} )}
{error ? ( {error ? (

View File

@ -1,9 +1,14 @@
// frontend\src\components\ui\Modal.tsx
'use client' 'use client'
import { Fragment, type ReactNode } from 'react' import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
type ModalLayout = 'single' | 'split'
type ModalScroll = 'body' | 'right' | 'none'
type ModalProps = { type ModalProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
@ -17,6 +22,59 @@ type ModalProps = {
* "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl" * "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl"
*/ */
width?: string width?: string
/**
* Layout:
* - single: klassisches Modal (ein Content-Bereich)
* - split: 2 Spalten (links fix, rechts content)
*/
layout?: ModalLayout
/**
* Split-Layout: linker Inhalt (fixe Spalte)
*/
left?: ReactNode
/**
* Split-Layout: Breite der linken Spalte (Tailwind).
* Default: "lg:w-80" (320px)
*/
leftWidthClass?: string
/**
* Scroll-Verhalten:
* - body: der Body-Bereich scrollt (bei single default)
* - right: nur rechte Spalte scrollt (bei split default)
* - none: kein Scroll (nur sinnvoll wenn Inhalt garantiert passt)
*/
scroll?: ModalScroll
/**
* Optional: Zusatzklassen
*/
bodyClassName?: string
leftClassName?: string
rightClassName?: string
/**
* Split-Layout: Header über dem scrollbaren rechten Bereich
* (z.B. Tabs/Actions)
*/
rightHeader?: ReactNode
/**
* Optional: Zusatzklassen nur für den scrollbaren RIGHT-BODY
*/
rightBodyClassName?: string
/** Optional: kleines Bild im mobilen collapsed Header */
mobileCollapsedImageSrc?: string
mobileCollapsedImageAlt?: string
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
} }
export default function Modal({ export default function Modal({
@ -27,10 +85,85 @@ export default function Modal({
footer, footer,
icon, icon,
width = 'max-w-lg', width = 'max-w-lg',
layout = 'single',
left,
leftWidthClass = 'lg:w-80',
scroll,
bodyClassName,
leftClassName,
rightClassName,
rightHeader,
rightBodyClassName,
mobileCollapsedImageSrc,
mobileCollapsedImageAlt,
}: ModalProps) { }: ModalProps) {
// sensible defaults
const scrollMode: ModalScroll =
scroll ?? (layout === 'split' ? 'right' : 'body')
// --- mobile collapse-on-scroll (only used in split+mobile stacked) ---
const mobileScrollRef = useRef<HTMLDivElement | null>(null)
const [mobileCollapsed, setMobileCollapsed] = useState(false)
useEffect(() => {
if (!open) return
const html = document.documentElement
const body = document.body
const prevHtmlOverflow = html.style.overflow
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
// verhindert Layout-Shift wenn Scrollbar verschwindet
const scrollBarWidth = window.innerWidth - html.clientWidth
html.style.overflow = 'hidden'
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
html.style.overflow = prevHtmlOverflow
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
}
}, [open])
useEffect(() => {
if (!open) return
// reset when opening
setMobileCollapsed(false)
}, [open])
useEffect(() => {
if (!open) return
const el = mobileScrollRef.current
if (!el) return
const THRESHOLD = 72 // px, ab wann "kompakt" wird
const onScroll = () => {
const y = el.scrollTop || 0
// nur updaten wenn sich der boolean ändert (verhindert re-render spam)
setMobileCollapsed((prev) => {
const next = y > THRESHOLD
return prev === next ? prev : next
})
}
// initial
onScroll()
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll as any)
}, [open])
return ( return (
<Transition show={open} as={Fragment}> <Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}> <Dialog open={open} as="div" className="relative z-50" onClose={onClose}>
{/* Backdrop */} {/* Backdrop */}
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -45,7 +178,7 @@ export default function Modal({
</Transition.Child> </Transition.Child>
{/* Modal Panel */} {/* Modal Panel */}
<div className="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-6"> <div className="fixed inset-0 z-50 overflow-hidden px-4 py-6 sm:px-6">
<div className="min-h-full flex items-start justify-center sm:items-center"> <div className="min-h-full flex items-start justify-center sm:items-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -57,25 +190,31 @@ export default function Modal({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel <Dialog.Panel
className={[ className={cn(
'relative w-full transform rounded-lg bg-white text-left shadow-xl transition-all', 'relative w-full rounded-lg bg-white text-left shadow-xl transition-all',
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]', 'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
'flex flex-col', // panel is a flex column so we can create a real scroll area
'flex flex-col min-h-0',
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10', 'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
width, // <- hier greift deine max-w-… Klasse width
].join(' ')} )}
> >
{icon && ( {icon ? (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10"> <div className="mx-auto mb-4 mt-6 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
{icon} {icon}
</div> </div>
)} ) : null}
{/* Header */} {/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
<div className="px-6 pt-6 flex items-start justify-between gap-3"> <div
className={cn(
'shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 items-start justify-between gap-3',
layout === 'split' ? 'hidden lg:flex' : 'flex'
)}
>
<div className="min-w-0"> <div className="min-w-0">
{title ? ( {title ? (
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white truncate"> <Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate">
{title} {title}
</Dialog.Title> </Dialog.Title>
) : null} ) : null}
@ -84,12 +223,12 @@ export default function Modal({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className=" className={cn(
inline-flex shrink-0 items-center justify-center rounded-lg p-1.5 'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
text-gray-500 hover:text-gray-900 hover:bg-black/5 'text-gray-500 hover:text-gray-900 hover:bg-black/5',
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500 'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
" )}
aria-label="Schließen" aria-label="Schließen"
title="Schließen" title="Schließen"
> >
@ -97,14 +236,157 @@ export default function Modal({
</button> </button>
</div> </div>
{/* Body (scrollable) */} {/* Body */}
<div className="px-6 pb-6 pt-4 text-sm text-gray-700 dark:text-gray-300 overflow-y-auto"> {layout === 'single' ? (
<div
className={cn(
'flex-1 min-h-0 h-full',
scrollMode === 'body'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightClassName
)}
>
{children} {children}
</div> </div>
) : (
// split layout
<div
className={cn(
'px-2 pb-4 pt-3 sm:px-4 sm:pb-6 sm:pt-4',
'flex-1 min-h-0',
'overflow-hidden',
'flex flex-col',
bodyClassName
)}
>
{/* ========================= */}
{/* MOBILE: stacked (no split) */}
{/* ========================= */}
<div
ref={mobileScrollRef}
className={cn(
'lg:hidden flex-1 min-h-0 relative',
// auf Mobile: EIN Scrollcontainer für alles
(scrollMode === 'right' || scrollMode === 'body') ? 'overflow-y-auto overscroll-contain' : 'overflow-hidden'
)}
>
{/* Sticky top area: app bar (shrinks) + left (collapses) + tabs/actions (sticky) */}
<div
className={cn(
'sticky top-0 z-50',
'bg-white/95 backdrop-blur dark:bg-gray-800/95',
'border-b border-gray-200/70 dark:border-white/10'
)}
>
{/* App bar (always visible, shrinks when collapsed) */}
<div
className={cn(
'flex items-center justify-between gap-3 px-3',
mobileCollapsed ? 'py-2' : 'py-3'
)}
>
<div className="min-w-0 flex items-center gap-2">
{mobileCollapsedImageSrc ? (
<img
src={mobileCollapsedImageSrc}
alt={mobileCollapsedImageAlt || title || ''}
className={cn(
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
mobileCollapsed ? 'size-8' : 'size-10'
)}
loading="lazy"
decoding="async"
/>
) : null}
<div className="min-w-0">
{title ? (
<div
className={cn(
'truncate font-semibold text-gray-900 dark:text-white',
mobileCollapsed ? 'text-sm' : 'text-base'
)}
>
{title}
</div>
) : null}
</div>
</div>
<button
type="button"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div>
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */}
{rightHeader ? <div>{rightHeader}</div> : null}
</div>
{/* LEFT content on mobile (scrolls away, not sticky) */}
{left ? (
<div className={cn('lg:hidden px-2 pb-2', leftClassName)}>
{left}
</div>
) : null}
{/* Body (only the right content) */}
<div className={cn('px-2 pt-0 min-h-0', rightClassName)}>
<div className={cn('min-h-0', rightBodyClassName)}>{children}</div>
</div>
</div>
{/* ========================= */}
{/* DESKTOP: real split layout */}
{/* ========================= */}
<div className="hidden lg:flex flex-1 min-h-0 gap-3">
{/* LEFT (fixed) */}
<div
className={cn(
'min-h-0',
leftWidthClass,
'shrink-0',
'overflow-hidden',
leftClassName
)}
>
{left}
</div>
{/* RIGHT */}
<div className={cn('flex-1 min-h-0 flex flex-col', rightClassName)}>
{rightHeader ? <div className="shrink-0">{rightHeader}</div> : null}
<div
className={cn(
'flex-1 min-h-0',
scrollMode === 'right'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightBodyClassName
)}
>
{children}
</div>
</div>
</div>
</div>
)}
{/* Footer */} {/* Footer */}
{footer ? ( {footer ? (
<div className="px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3"> <div className="shrink-0 px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
{footer} {footer}
</div> </div>
) : null} ) : null}

File diff suppressed because it is too large Load Diff

View File

@ -14,16 +14,13 @@ type Props = {
className?: string className?: string
fit?: 'cover' | 'contain' fit?: 'cover' | 'contain'
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
alignStartAt?: string | number | Date alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null alignEndAt?: string | number | Date | null
alignEveryMs?: number alignEveryMs?: number
// ✅ NEU: schneller Retry am Anfang (nur bei Running sinnvoll)
fastRetryMs?: number fastRetryMs?: number
fastRetryMax?: number fastRetryMax?: number
fastRetryWindowMs?: number fastRetryWindowMs?: number
} }
export default function ModelPreview({ export default function ModelPreview({
@ -39,24 +36,25 @@ export default function ModelPreview({
fastRetryWindowMs, fastRetryWindowMs,
className, className,
}: Props) { }: Props) {
const [pageVisible, setPageVisible] = useState(() => {
if (typeof document === 'undefined') return true
return !document.hidden
})
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
const rootRef = useRef<HTMLDivElement | null>(null)
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
const pageVisibleRef = useRef(true)
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
const [inView, setInView] = useState(false)
const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const retryT = useRef<number | null>(null) const retryT = useRef<number | null>(null)
const fastTries = useRef(0) const fastTries = useRef(0)
const hadSuccess = useRef(false) const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false) const enteredViewOnce = useRef(false)
const toMs = (v: any): number => { const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime() if (v instanceof Date) return v.getTime()
@ -64,34 +62,61 @@ export default function ModelPreview({
return Number.isFinite(ms) ? ms : NaN return Number.isFinite(ms) ? ms : NaN
} }
// ✅ visibilitychange -> nur REF updaten
useEffect(() => { useEffect(() => {
const onVis = () => setPageVisible(!document.hidden) const onVis = () => {
pageVisibleRef.current = !document.hidden
}
pageVisibleRef.current = !document.hidden
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis)
}, []) }, [])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
} }
}, []) }, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => {
const el = rootRef.current
if (!el) return
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)
},
{
root: null,
threshold: 0,
rootMargin: '300px 0px',
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
useEffect(() => { useEffect(() => {
if (typeof thumbTick === 'number') return if (typeof thumbTick === 'number') return
if (!inView || !pageVisible) return if (!inView) return
if (!pageVisibleRef.current) return
if (enteredViewOnce.current) return if (enteredViewOnce.current) return
enteredViewOnce.current = true enteredViewOnce.current = true
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
}, [inView, thumbTick, pageVisible]) }, [inView, thumbTick])
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
useEffect(() => { useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return if (typeof thumbTick === 'number') return
if (!inView) return
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar if (!pageVisibleRef.current) return
if (!inView || !pageVisible) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000) const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return if (!Number.isFinite(period) || period <= 0) return
@ -99,11 +124,13 @@ export default function ModelPreview({
const startMs = alignStartAt ? toMs(alignStartAt) : NaN const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// 1) ✅ Aligned: tick genau auf Vielfachen von period seit startMs // aligned schedule
if (Number.isFinite(startMs)) { if (Number.isFinite(startMs)) {
let t: number | undefined let t: number | undefined
const schedule = () => { const schedule = () => {
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
if (!pageVisibleRef.current) return
const now = Date.now() const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return if (Number.isFinite(endMs) && now >= endMs) return
@ -112,6 +139,9 @@ export default function ModelPreview({
const wait = rem === 0 ? period : period - rem const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => { t = window.setTimeout(() => {
// ✅ nochmal checken, falls inzwischen offscreen/hidden
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
schedule() schedule()
}, wait) }, wait)
@ -123,62 +153,56 @@ export default function ModelPreview({
} }
} }
// 2) Fallback: normales Interval (nicht aligned) // fallback interval
const id = window.setInterval(() => { const id = window.setInterval(() => {
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
}, period) }, period)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs]) }, [thumbTick, autoTickMs, inView, 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(() => { useEffect(() => {
const el = rootRef.current if (!inView) return
if (!el) return if (!pageVisibleRef.current) return
frozenTickRef.current = rawTick
setFrozenTick(rawTick)
}, [rawTick, inView])
const obs = new IntersectionObserver( // bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
(entries) => {
const entry = entries[0]
setInView(Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)))
},
{
root: null,
threshold: 0, // wichtiger: nicht 0.1
rootMargin: '300px 0px', // preload: 300px vor/nach Viewport
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
const tick = typeof thumbTick === 'number' ? thumbTick : localTick
// bei neuem Tick Error-Flag zurücksetzen (damit wir retries erlauben)
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
}, [tick]) }, [frozenTick])
useEffect(() => { useEffect(() => {
// bei Job-Wechsel alles sauber neu starten // bei Job-Wechsel reset
hadSuccess.current = false hadSuccess.current = false
fastTries.current = 0 fastTries.current = 0
enteredViewOnce.current = false enteredViewOnce.current = false
setImgError(false) setImgError(false)
setLocalTick((x) => x + 1) // sofort neuer Request
// ✅ sofort neuer Request, aber nur wenn wir auch wirklich sichtbar sind
if (inViewRef.current && pageVisibleRef.current) {
setLocalTick((x) => x + 1)
}
}, [jobId]) }, [jobId])
// Thumbnail mit Cache-Buster (?v=...)
const thumb = useMemo( const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`, () => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, tick] [jobId, frozenTick]
) )
// HLS nur für große Vorschau im Popover
const hq = useMemo( const hq = useMemo(
() => () => `/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
`/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
[jobId] [jobId]
) )
@ -188,15 +212,13 @@ export default function ModelPreview({
open && ( open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black"> <div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0'].filter(Boolean).join(' ')} /> <LiveHlsVideo src={hq} muted={false} className="w-full h-full relative z-0" />
{/* LIVE badge */}
<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"> <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">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" /> <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live Live
</div> </div>
{/* Close */}
<button <button
type="button" type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55" className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
@ -227,6 +249,7 @@ export default function ModelPreview({
src={thumb} src={thumb}
loading={inView ? 'eager' : 'lazy'} loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'} fetchPriority={inView ? 'high' : 'auto'}
decoding="async"
alt="" alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => { onLoad={() => {
@ -238,9 +261,8 @@ export default function ModelPreview({
onError={() => { onError={() => {
setImgError(true) setImgError(true)
// ✅ Fast-Retry nur wenn aktiviert & sinnvoll
if (!fastRetryMs) return if (!fastRetryMs) return
if (!inView || !pageVisible) return if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN const startMs = alignStartAt ? toMs(alignStartAt) : NaN
@ -254,7 +276,7 @@ export default function ModelPreview({
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => { retryT.current = window.setTimeout(() => {
fastTries.current += 1 fastTries.current += 1
setLocalTick((x) => x + 1) // triggert neuen Request via ?v= setLocalTick((x) => x + 1)
}, fastRetryMs) }, fastRetryMs)
}} }}
/> />

View File

@ -216,7 +216,7 @@ export default function PerformanceMonitor({
{/* DISK */} {/* DISK */}
<div className="flex items-center gap-2" title={diskTitle}> <div className="flex items-center gap-2" title={diskTitle}>
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Disk</span> <span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Disk</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden"> <div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div <div
className={`h-full ${barTone(diskTone)}`} className={`h-full ${barTone(diskTone)}`}
style={{ width: `${Math.round(usedFill * 100)}%` }} style={{ width: `${Math.round(usedFill * 100)}%` }}
@ -230,7 +230,7 @@ export default function PerformanceMonitor({
{/* PING */} {/* PING */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Ping</span> <span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Ping</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden"> <div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div <div
className={`h-full ${barTone(pingTone)}`} className={`h-full ${barTone(pingTone)}`}
style={{ width: `${Math.round(pingFill * 100)}%` }} style={{ width: `${Math.round(pingFill * 100)}%` }}
@ -244,7 +244,7 @@ export default function PerformanceMonitor({
{/* FPS */} {/* FPS */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">FPS</span> <span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">FPS</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden"> <div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div <div
className={`h-full ${barTone(fpsTone)}`} className={`h-full ${barTone(fpsTone)}`}
style={{ width: `${Math.round(fpsFill * 100)}%` }} style={{ width: `${Math.round(fpsFill * 100)}%` }}
@ -258,7 +258,7 @@ export default function PerformanceMonitor({
{/* CPU */} {/* CPU */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">CPU</span> <span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">CPU</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden"> <div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div <div
className={`h-full ${barTone(cpuTone)}`} className={`h-full ${barTone(cpuTone)}`}
style={{ width: `${Math.round(cpuFill * 100)}%` }} style={{ width: `${Math.round(cpuFill * 100)}%` }}

View File

@ -458,7 +458,12 @@ export type PlayerProps = {
// actions // actions
onKeep?: (job: RecordJob) => void | Promise<void> onKeep?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void> onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (
job: RecordJob
) =>
| void
| { ok?: boolean; oldFile?: string; newFile?: string }
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
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>
@ -499,6 +504,24 @@ export default function Player({
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job]) const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any const anyJob = job as any
const [fullDurationSec, setFullDurationSec] = React.useState<number>(() => {
return (
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
)
})
const [metaReady, setMetaReady] = React.useState<boolean>(() => {
// live ist egal, finished: erst mal false (wir holen gleich)
return job.status === 'running'
})
const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({
h: 0,
fps: null,
}))
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running") // ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isRunning = job.status === 'running' const isRunning = job.status === 'running'
const [hlsReady, setHlsReady] = React.useState(false) const [hlsReady, setHlsReady] = React.useState(false)
@ -514,6 +537,48 @@ export default function Player({
[isRunning, job.id, finishedStem] [isRunning, job.id, finishedStem]
) )
React.useEffect(() => {
if (isRunning) return
if (fullDurationSec > 0) return
const fileName = baseName(job.output?.trim() || '')
if (!fileName) return
let alive = true
const ctrl = new AbortController()
;(async () => {
try {
// ✅ Backend-Endpoint existiert bei dir bereits: /api/record/done/meta
// Ich gebe hier file mit, weil du finished oft darüber mapst.
const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })
if (!res.ok) return
const j = await res.json()
const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
if (!alive || dur <= 0) return
setFullDurationSec(dur)
// ✅ Video.js Duration-Shim nachträglich füttern + UI refreshen
const p: any = playerRef.current
if (p && !p.isDisposed?.()) {
try {
p.__fullDurationSec = dur
p.trigger?.('durationchange')
p.trigger?.('timeupdate')
} catch {}
}
} catch {}
})()
return () => {
alive = false
ctrl.abort()
}
}, [isRunning, fullDurationSec, job.output])
const isHotFile = fileRaw.startsWith('HOT ') const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => { const model = React.useMemo(() => {
const k = (modelKey || '').trim() const k = (modelKey || '').trim()
@ -522,13 +587,9 @@ export default function Player({
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw]) const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
const runtimeLabel = React.useMemo(() => { const runtimeLabel = React.useMemo(() => {
const sec = const sec = Number(fullDurationSec || 0) || 0
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
return sec > 0 ? formatDuration(sec * 1000) : '—' return sec > 0 ? formatDuration(sec * 1000) : '—'
}, [job]) }, [fullDurationSec])
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit // Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
const dateLabel = React.useMemo(() => { const dateLabel = React.useMemo(() => {
@ -562,7 +623,7 @@ export default function Player({
) )
const previewB = React.useMemo( const previewB = React.useMemo(
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.jpg`), () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.webp`),
[previewId] [previewId]
) )
@ -579,12 +640,13 @@ export default function Player({
}, [previewA]) }, [previewA])
const videoH = React.useMemo( const videoH = React.useMemo(
() => pickNum(anyJob.videoHeight, anyJob.height, anyJob.meta?.height), () => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[anyJob.videoHeight, anyJob.height, anyJob.meta?.height] [metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
) )
const fps = React.useMemo( const fps = React.useMemo(
() => pickNum(anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate), () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate] [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
) )
const [intrH, setIntrH] = React.useState<number | null>(null) const [intrH, setIntrH] = React.useState<number | null>(null)
@ -709,7 +771,11 @@ export default function Player({
// ✅ Live wird NICHT mehr über Video.js gespielt // ✅ Live wird NICHT mehr über Video.js gespielt
if (isRunning) return { src: '', type: '' } if (isRunning) return { src: '', type: '' }
// ✅ Warten bis meta.json existiert + Infos geladen
if (!metaReady) return { src: '', type: '' }
const file = baseName(job.output?.trim() || '') const file = baseName(job.output?.trim() || '')
if (file) { if (file) {
const ext = file.toLowerCase().split('.').pop() const ext = file.toLowerCase().split('.').pop()
const type = const type =
@ -718,7 +784,7 @@ export default function Player({
} }
return { src: buildVideoSrc({ id: job.id, quality: appliedQuality }), type: 'video/mp4' } return { src: buildVideoSrc({ id: job.id, quality: appliedQuality }), type: 'video/mp4' }
}, [isRunning, job.output, job.id, appliedQuality, buildVideoSrc]) }, [isRunning, metaReady, job.output, job.id, appliedQuality, buildVideoSrc])
const containerRef = React.useRef<HTMLDivElement | null>(null) const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null) const playerRef = React.useRef<VideoJsPlayer | null>(null)
@ -769,6 +835,76 @@ export default function Player({
} }
}, [playbackKey, defaultQuality]) }, [playbackKey, defaultQuality])
React.useEffect(() => {
if (isRunning) {
setMetaReady(true)
return
}
const fileName = baseName(job.output?.trim() || '')
if (!fileName) {
// wenn kein file → fail-open
setMetaReady(true)
return
}
let alive = true
const ctrl = new AbortController()
setMetaReady(false)
;(async () => {
// Poll bis metaExists=true (oder fail-open nach N Versuchen)
for (let i = 0; i < 80 && alive && !ctrl.signal.aborted; i++) {
try {
const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })
if (res.ok) {
const j = await res.json()
const exists = Boolean(j?.metaExists)
const dur = Number(j?.durationSeconds || 0) || 0
const h = Number(j?.height || 0) || 0
const fps = Number(j?.fps || 0) || 0
// ✅ Infos neu in den Player-State übernehmen
if (dur > 0) {
setFullDurationSec(dur)
const p: any = playerRef.current
if (p && !p.isDisposed?.()) {
try {
p.__fullDurationSec = dur
p.trigger?.('durationchange')
p.trigger?.('timeupdate')
} catch {}
}
}
if (h > 0) {
setMetaDims({ h, fps: fps > 0 ? fps : null })
}
if (exists) {
setMetaReady(true)
return
}
}
} catch {}
await new Promise((r) => setTimeout(r, 250))
}
// fail-open (damit der Player nicht “für immer” blockiert)
if (alive) setMetaReady(true)
})()
return () => {
alive = false
ctrl.abort()
}
}, [isRunning, playbackKey, job.output])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender // ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0) const [, setVvTick] = React.useState(0)
@ -874,11 +1010,7 @@ export default function Player({
startSec, startSec,
}) })
const knownFull = const knownFull = Number(fullDurationSec || 0) || 0
Number((job as any)?.meta?.durationSeconds) ||
Number((anyJob as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
if (knownFull > 0) p.__fullDurationSec = knownFull if (knownFull > 0) p.__fullDurationSec = knownFull
try { try {
@ -924,7 +1056,7 @@ export default function Player({
p.load?.() p.load?.()
} catch {} } catch {}
}, },
[isRunning, buildVideoSrc, updateIntrinsicDims, job, anyJob] [isRunning, buildVideoSrc, updateIntrinsicDims, fullDurationSec]
) )
// ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten // ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten
@ -1138,7 +1270,7 @@ export default function Player({
if (!fileName) return if (!fileName) return
// volle Dauer: nimm was du hast (durationSeconds ist bei finished normalerweise da) // volle Dauer: nimm was du hast (durationSeconds ist bei finished normalerweise da)
const knownFull = Number((job as any).durationSeconds ?? anyJob?.meta?.durationSeconds ?? 0) || 0 const knownFull = Number(fullDurationSec || 0) || 0
if (knownFull > 0) p.__fullDurationSec = knownFull if (knownFull > 0) p.__fullDurationSec = knownFull
// absolute server-seek // absolute server-seek
@ -1265,13 +1397,14 @@ export default function Player({
delete p.__serverSeekAbs delete p.__serverSeekAbs
} catch {} } catch {}
} }
}, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, anyJob, job, videoH]) }, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, videoH])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!mounted) return if (!mounted) return
if (!containerRef.current) return if (!containerRef.current) return
if (playerRef.current) return if (playerRef.current) return
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
if (!metaReady) return
const videoEl = document.createElement('video') const videoEl = document.createElement('video')
videoEl.className = 'video-js vjs-big-play-centered w-full h-full' videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
@ -1390,7 +1523,7 @@ export default function Player({
} }
} }
} }
}, [mounted, startMuted, isRunning, videoH, updateIntrinsicDims]) }, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims])
React.useEffect(() => { React.useEffect(() => {
const p = playerRef.current const p = playerRef.current
@ -1402,8 +1535,27 @@ export default function Player({
el.classList.toggle('is-live-download', Boolean(isLive)) el.classList.toggle('is-live-download', Boolean(isLive))
}, [isLive]) }, [isLive])
const releaseMedia = React.useCallback(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
try {
p.pause()
;(p as any).reset?.()
} catch {}
try {
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => { React.useEffect(() => {
if (!mounted) return if (!mounted) return
if (!isRunning && !metaReady) {
releaseMedia()
return
}
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
@ -1429,9 +1581,18 @@ export default function Player({
;(p as any).__timeOffsetSec = 0 ;(p as any).__timeOffsetSec = 0
// volle Dauer kennen wir bei finished meistens schon: // volle Dauer kennen wir bei finished meistens schon:
const knownFull = Number((job as any).durationSeconds ?? (job as any).meta?.durationSeconds ?? 0) || 0 const knownFull = Number(fullDurationSec || 0) || 0
;(p as any).__fullDurationSec = knownFull ;(p as any).__fullDurationSec = knownFull
// ✅ NICHT neu setzen, wenn Source identisch ist (verhindert "cancelled" durch unnötige Reloads)
const curSrc = String((p as any).currentSrc?.() || '')
if (curSrc && curSrc === media.src) {
// trotzdem versuchen zu spielen (z.B. wenn nur muted/state geändert wurde)
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
return
}
p.src({ src: media.src, type: media.type }) p.src({ src: media.src, type: media.type })
const tryPlay = () => { const tryPlay = () => {
@ -1447,7 +1608,7 @@ export default function Player({
// ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration()) // ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration())
try { try {
const knownFull = Number((job as any).durationSeconds ?? anyJob?.meta?.durationSeconds ?? 0) || 0 const knownFull = Number(fullDurationSec || 0) || 0
if (knownFull > 0) (p as any).__fullDurationSec = knownFull if (knownFull > 0) (p as any).__fullDurationSec = knownFull
} catch {} } catch {}
@ -1474,7 +1635,7 @@ export default function Player({
}) })
tryPlay() tryPlay()
}, [mounted, media.src, media.type, startMuted, updateIntrinsicDims, job, anyJob]) }, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
React.useEffect(() => { React.useEffect(() => {
if (!mounted) return if (!mounted) return
@ -1499,21 +1660,6 @@ export default function Player({
queueMicrotask(() => p.trigger('resize')) queueMicrotask(() => p.trigger('resize'))
}, [expanded]) }, [expanded])
const releaseMedia = React.useCallback(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
try {
p.pause()
;(p as any).reset?.()
} catch {}
try {
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => { React.useEffect(() => {
const onRelease = (ev: Event) => { const onRelease = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail const detail = (ev as CustomEvent<{ file?: string }>).detail

View File

@ -1,11 +1,13 @@
// frontend\src\components\ui\RecorderSettings.tsx // frontend\src\components\ui\RecorderSettings.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import Button from './Button' import Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask' import GenerateAssetsTask from './GenerateAssetsTask'
import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
type RecorderSettings = { type RecorderSettings = {
recordDir: string recordDir: string
@ -62,6 +64,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null) const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
// ✅ Tasklist (Assets generieren)
const assetsAbortRef = useRef<AbortController | null>(null)
const [assetsTask, setAssetsTask] = useState<TaskItem>({
id: 'generate-assets',
status: 'idle',
title: 'Assets generieren', // oder '' wenn du es komplett weg willst
text: '',
cancellable: true,
fading: false,
})
const [cleanupTask, setCleanupTask] = useState<TaskItem>({
id: 'cleanup',
status: 'idle',
title: 'Aufräumen',
text: '',
cancellable: false,
fading: false,
})
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
@ -225,6 +247,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) {
window.setTimeout(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
setter((t) => ({ ...t, status: 'idle', text: '', err: undefined, done: 0, total: 0, fading: false }))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() { async function cleanupSmallDone() {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -236,7 +267,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setErr('doneDir ist leer.') setErr('doneDir ist leer.')
return return
} }
if (!mb || mb <= 0) { if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.') setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return return
@ -250,7 +280,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) )
if (!ok) return if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList)
setCleaning(true) setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen', // ✅ HINZUFÜGEN (reset)
text: 'Räume auf…',
err: undefined,
done: 0,
total: 1,
fading: false,
}))
try { try {
const res = await fetch('/api/settings/cleanup', { const res = await fetch('/api/settings/cleanup', {
method: 'POST', method: 'POST',
@ -264,19 +306,60 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const data = await res.json() const data = await res.json()
setMsg( const scannedFiles = Number(data.scannedFiles ?? 0)
`🧹 Aufräumen fertig:\n` +
`• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` + const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
`• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` + const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
`• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)`
) const orphansTotalRemoved = orphanRemoved + genRemoved
setCleanupTask((t) => ({
...t,
status: 'done',
done: 1,
total: 1,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
}))
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) const msg = e?.message ?? String(e)
setErr(msg)
setCleanupTask((t) => ({
...t,
status: 'error',
text: 'Fehler beim Aufräumen.',
err: msg,
}))
fadeOutTask(setCleanupTask)
} finally { } finally {
setCleaning(false) setCleaning(false)
} }
} }
async function cancelAssetsTask() {
const ac = assetsAbortRef.current
assetsAbortRef.current = null
// UI sofort
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
if (ac) {
ac.abort()
return
}
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
}
}
return ( return (
<Card <Card
header={ header={
@ -295,6 +378,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
grayBody grayBody
> >
<div className="space-y-4"> <div className="space-y-4">
<TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask()
}}
/>
{/* Alerts */} {/* Alerts */}
{err && ( {err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200"> <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
@ -307,25 +397,78 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
)} )}
{/* ✅ Tasks (als erstes) */} {/* Aufgaben */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40"> <div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten. Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
</div>
</div> </div>
</div> </div>
<div className="shrink-0"> <div className="mt-3 space-y-3">
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 dark:bg-white/10 dark:text-gray-200"> <div className="flex items-center justify-between gap-3">
Utilities <div className="min-w-0 flex-1">
</span> <GenerateAssetsTask
</div> onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
text: '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
</div> </div>
<div className="mt-3"> <div className="shrink-0 flex items-center gap-2">
<GenerateAssetsTask onFinished={onAssetsGenerated} /> <Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div>
</div>
</div> </div>
</div> </div>
@ -474,7 +617,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
<div className="sm:col-span-8"> <div className="sm:col-span-8">
<div className="flex items-center gap-2"> <div className="flex items-center justify-end gap-2">
<input <input
type="number" type="number"
min={0} min={0}
@ -486,21 +629,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0), autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0),
})) }))
} }
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/> />
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span> <span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 shrink-0 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,11 +4,35 @@
import * as React from 'react' import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline' import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
} }
function getGlobalFxLayer(): HTMLDivElement | null {
if (typeof document === 'undefined') return null
const ID = '__swipecard_hot_fx_layer__'
let el = document.getElementById(ID) as HTMLDivElement | null
if (!el) {
el = document.createElement('div')
el.id = ID
el.style.position = 'fixed'
el.style.inset = '0'
el.style.pointerEvents = 'none'
el.style.zIndex = '2147483647'
// optional, aber nice:
;(el.style as any).contain = 'layout style paint'
document.body.appendChild(el)
}
return el
}
export type SwipeAction = { export type SwipeAction = {
label: React.ReactNode label: React.ReactNode
className?: string className?: string
@ -96,9 +120,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
className, className,
leftAction = { leftAction = {
label: ( label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight"> <span className="inline-flex items-center gap-2 font-semibold">
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" /> <BookmarkSquareIcon className="h-6 w-6" />
<span>Behalten</span> <span className="hidden sm:inline">Behalten</span>
</span> </span>
), ),
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
@ -112,10 +136,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
), ),
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300', className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
}, },
//thresholdPx = 120, thresholdPx = 140,
thresholdPx = 180, thresholdRatio = 0.28,
//thresholdRatio = 0.35,
thresholdRatio = 0.1,
ignoreFromBottomPx = 72, ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]', ignoreSelector = '[data-swipe-ignore]',
snapMs = 180, snapMs = 180,
@ -144,8 +166,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const tapTimerRef = React.useRef<number | null>(null) const tapTimerRef = React.useRef<number | null>(null)
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null) const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
const fxLayerRef = React.useRef<HTMLDivElement | null>(null)
const pointer = React.useRef<{ const pointer = React.useRef<{
id: number | null id: number | null
x: number x: number
@ -153,7 +173,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dragging: boolean dragging: boolean
captured: boolean captured: boolean
tapIgnored: boolean tapIgnored: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false }) noSwipe: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false })
const [dx, setDx] = React.useState(0) const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null) const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
@ -246,34 +267,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const card = cardRef.current const card = cardRef.current
if (!outer || !card) return if (!outer || !card) return
const layer = fxLayerRef.current const layer = getGlobalFxLayer()
if (!layer) return if (!layer) return
const outerRect = outer.getBoundingClientRect() // ✅ Start im Viewport (da wo getippt wurde, fallback: Mitte)
let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2
let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2
// ✅ Start: da wo getippt wurde (fallback: Mitte) // Ziel: HOT Button (falls gefunden) ebenfalls im Viewport
let startX = outerRect.width / 2
let startY = outerRect.height / 2
if (typeof clientX === 'number' && typeof clientY === 'number') {
startX = clientX - outerRect.left
startY = clientY - outerRect.top
// optional: innerhalb der Card halten
startX = Math.max(0, Math.min(outerRect.width, startX))
startY = Math.max(0, Math.min(outerRect.height, startY))
}
// Ziel: HOT Button (falls gefunden)
const targetEl = hotTargetSelector const targetEl = hotTargetSelector
? (card.querySelector(hotTargetSelector) as HTMLElement | null) ? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
(card.querySelector(hotTargetSelector) as HTMLElement | null))
: null : null
let endX = startX let endX = startX
let endY = startY let endY = startY
if (targetEl) { if (targetEl) {
const tr = targetEl.getBoundingClientRect() const tr = targetEl.getBoundingClientRect()
endX = tr.left - outerRect.left + tr.width / 2 endX = tr.left + tr.width / 2
endY = tr.top - outerRect.top + tr.height / 2 endY = tr.top + tr.height / 2
} }
const dx = endX - startX const dx = endX - startX
@ -281,17 +293,33 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// Flame node // Flame node
const flame = document.createElement('div') const flame = document.createElement('div')
flame.textContent = '🔥'
flame.style.position = 'absolute' flame.style.position = 'absolute'
flame.style.left = `${startX}px` flame.style.left = `${startX}px`
flame.style.top = `${startY}px` flame.style.top = `${startY}px`
flame.style.transform = 'translate(-50%, -50%)' flame.style.transform = 'translate(-50%, -50%)'
flame.style.fontSize = '30px'
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
flame.style.pointerEvents = 'none' flame.style.pointerEvents = 'none'
flame.style.willChange = 'transform, opacity' flame.style.willChange = 'transform, opacity'
flame.style.zIndex = '2147483647'
flame.style.lineHeight = '1'
flame.style.userSelect = 'none'
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
// ✅ Heroicon per React-Root rendern (Client-sicher)
const inner = document.createElement('div')
inner.style.width = '30px'
inner.style.height = '30px'
inner.style.color = '#f59e0b' // amber
// optional: damit SVG nicht “inline” komisch sitzt
inner.style.display = 'block'
flame.appendChild(inner)
layer.appendChild(flame) layer.appendChild(flame)
const root = createRoot(inner)
root.render(<FireSolidIcon className="w-full h-full" aria-hidden="true" />)
void flame.getBoundingClientRect()
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms // ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
const popMs = 200 const popMs = 200
const holdMs = 500 const holdMs = 500
@ -346,7 +374,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, popMs + holdMs + Math.round(flyMs * 0.75)) }, popMs + holdMs + Math.round(flyMs * 0.75))
} }
anim.onfinish = () => flame.remove() anim.onfinish = () => {
try {
root.unmount()
} catch {}
flame.remove()
}
}, },
[hotTargetSelector] [hotTargetSelector]
) )
@ -382,24 +415,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)} )}
/> />
<div className={cn('absolute inset-0 flex items-center transition-all duration-200 ease-out')} <div
className="absolute inset-0 flex items-center"
style={{ style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1, opacity: dx === 0 ? 0 : 1,
justifyContent: dx > 0 ? 'flex-start' : 'flex-end', justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
paddingLeft: dx > 0 ? 16 : 0, paddingLeft: dx > 0 ? 16 : 0,
paddingRight: dx > 0 ? 0 : 16, paddingRight: dx > 0 ? 0 : 16,
}} }}
>
<div
style={{
transform: armedDir ? 'scale(1.05)' : 'scale(1)',
transition: 'transform 180ms ease, opacity 180ms ease',
opacity: armedDir ? 1 : 0.9,
}}
> >
{dx > 0 ? leftAction.label : rightAction.label} {dx > 0 ? leftAction.label : rightAction.label}
</div> </div>
</div> </div>
</div>
{/* FX Layer (Flame) */}
<div
ref={fxLayerRef}
className="pointer-events-none absolute inset-0 z-50"
/>
{/* Foreground (moves) */} {/* Foreground (moves) */}
<div <div
@ -411,16 +446,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
transition: animMs ? `transform ${animMs}ms ease` : undefined, transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined, willChange: dx !== 0 ? 'transform' : undefined,
boxShadow: dx !== 0 ? '0 10px 24px rgba(0,0,0,0.18)' : undefined,
borderRadius: dx !== 0 ? '12px' : undefined,
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
const tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
// Tap ignorieren (SingleTap nicht auslösen), DoubleTap soll aber weiter gehen
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
// Harte Ignore-Zone: da wollen wir wirklich gar nichts (wie vorher)
if (ignoreSelector && target?.closest?.(ignoreSelector)) return if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// NEW: wenn true -> wir lassen Tap/DoubleTap zu, aber starten niemals Swipe/Drag
let noSwipe = false
const root = e.currentTarget as HTMLElement const root = e.currentTarget as HTMLElement
const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[] const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
const ctlVideo = videos.find((v) => v.controls) const ctlVideo = videos.find((v) => v.controls)
@ -435,25 +477,34 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
e.clientY <= vr.bottom e.clientY <= vr.bottom
if (inVideo) { if (inVideo) {
// unten frei für Timeline/Scrub (iPhone braucht meist etwas mehr) // unten frei für Timeline/Scrub
const fromBottomVideo = vr.bottom - e.clientY const fromBottomVideo = vr.bottom - e.clientY
const scrubZonePx = 72 const scrubZonePx = 72
if (fromBottomVideo <= scrubZonePx) return if (fromBottomVideo <= scrubZonePx) {
noSwipe = true
tapIgnored = true // SingleTap nicht in unsere Logik -> Video controls sollen gewinnen
} else {
// Swipe nur aus den Seitenrändern // Swipe nur aus den Seitenrändern
const edgeZonePx = 64 const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX const xFromRight = vr.right - e.clientX
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
if (!inEdge) return if (!inEdge) {
noSwipe = true
tapIgnored = true // Video-Interaktion nicht stören
}
}
} }
} }
// ✅ 3) Optional: generelle Card-Bottom-Sperre (bei dir in CardsView auf 0 lassen) // Generelle Card-Bottom-Sperre: Swipe verhindern, aber Tap darf bleiben
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
noSwipe = true
// tapIgnored NICHT automatisch setzen Footer-Taps sollen funktionieren
}
pointer.current = { pointer.current = {
id: e.pointerId, id: e.pointerId,
@ -461,21 +512,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
y: e.clientY, y: e.clientY,
dragging: false, dragging: false,
captured: false, captured: false,
tapIgnored, // ✅ WICHTIG: nicht "false" tapIgnored,
noSwipe,
} }
// ✅ Perf: pro Gesture einmal Threshold berechnen
const el = cardRef.current const el = cardRef.current
const w = el?.offsetWidth || 360 const w = el?.offsetWidth || 360
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio) thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
// ✅ dxRef reset (neue Gesture)
dxRef.current = 0 dxRef.current = 0
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return
const ddx = e.clientX - pointer.current.x const ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y const ddy = e.clientY - pointer.current.y
@ -508,8 +559,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
} }
// ✅ dx nur pro Frame in React-State schreiben const applyResistance = (x: number, w: number) => {
dxRef.current = ddx const limit = w * 0.9
const ax = Math.abs(x)
if (ax <= limit) return x
const extra = ax - limit
const resisted = limit + extra * 0.25
return Math.sign(x) * resisted
}
const w = cardRef.current?.offsetWidth || 360
dxRef.current = applyResistance(ddx, w)
if (rafRef.current == null) { if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
@ -521,7 +581,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ armedDir nur updaten wenn geändert // ✅ armedDir nur updaten wenn geändert
const threshold = thresholdRef.current const threshold = thresholdRef.current
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => (prev === nextDir ? prev : nextDir)) setArmedDir((prev) => {
if (prev === nextDir) return prev
// mini feedback beim “arming”
if (nextDir) {
try { navigator.vibrate?.(10) } catch {}
}
return nextDir
})
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
@ -549,14 +616,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y' ;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) { if (!wasDragging) {
// Tap auf Video/Controls => NICHT anfassen
if (wasTapIgnored) {
setAnimMs(0)
setDx(0)
setArmedDir(null)
return
}
const now = Date.now() const now = Date.now()
const last = lastTapRef.current const last = lastTapRef.current
@ -578,26 +637,40 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (doubleTapBusyRef.current) return if (doubleTapBusyRef.current) return
doubleTapBusyRef.current = true doubleTapBusyRef.current = true
// ✅ FX sofort starten (ohne irgendwas am Video zu resetten) // ✅ FX sofort anlegen
requestAnimationFrame(() => {
try { try {
runHotFx(e.clientX, e.clientY) runHotFx(e.clientX, e.clientY)
} catch {} } catch (err) {
}) // optional zum Debuggen:
// console.error('runHotFx failed', err)
}
// ✅ Toggle erst NACH dem nächsten Paint-Frame starten
requestAnimationFrame(() => {
;(async () => { ;(async () => {
try { try {
await onDoubleTap?.() await onDoubleTap?.()
} catch {
// optional: error feedback
} finally { } finally {
doubleTapBusyRef.current = false doubleTapBusyRef.current = false
} }
})() })()
})
return return
} }
// ✅ nur SingleTap "blocken" wenn tapIgnored DoubleTap bleibt möglich
if (wasTapIgnored) {
// wichtig: lastTapRef trotzdem setzen, damit DoubleTap beim 2. Tap klappt
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
}, onDoubleTap ? doubleTapMs : 0)
return
}
// ✅ NUR bei SingleTap soft resetten // ✅ NUR bei SingleTap soft resetten
softResetForTap() softResetForTap()
@ -612,18 +685,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, onDoubleTap ? doubleTapMs : 0) }, onDoubleTap ? doubleTapMs : 0)
return return
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
onTap?.()
}, onDoubleTap ? doubleTapMs : 0)
return
} }
const finalDx = dxRef.current const finalDx = dxRef.current
@ -651,7 +712,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id) ;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {} } catch {}
} }
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false } pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false }
if (rafRef.current != null) { if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null rafRef.current = null

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\Tabs.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -237,10 +239,13 @@ export default function Tabs({
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400' disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
)} )}
> >
<span className="inline-flex items-center justify-center"> <span className="inline-flex max-w-full min-w-0 items-center justify-center">
<span className="min-w-0 truncate whitespace-nowrap" title={tab.label}>
{tab.label} {tab.label}
</span>
{tab.count !== undefined ? ( {tab.count !== undefined ? (
<span className="ml-2 rounded-full bg-white/70 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white"> <span className="ml-2 shrink-0 tabular-nums min-w-[2.25rem] rounded-full bg-white/70 px-2 py-0.5 text-center text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
{tab.count} {tab.count}
</span> </span>
) : null} ) : null}

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\TagBadge.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -40,7 +42,7 @@ export default function TagBadge({
// Styling: Basis wie in ModelsTab // Styling: Basis wie in ModelsTab
const base = clsx( const base = clsx(
'inline-flex items-center truncate rounded-md px-2 py-0.5 text-xs', 'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
maxWidthClassName, maxWidthClassName,
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200' 'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
) )

View File

@ -0,0 +1,265 @@
// frontend\src\components\ui\TagOverflowRow.tsx
'use client'
import * as React from 'react'
import TagBadge from './TagBadge'
type Props = {
rowKey: string
tags: string[]
activeTagSet: Set<string>
lower: (s: string) => string
onToggleTagFilter: (tag: string) => void
maxVisible?: number
maxWidthClassName?: string
gapPx?: number
className?: string
}
export default function TagOverflowRow({
rowKey,
tags,
activeTagSet,
lower,
onToggleTagFilter,
maxVisible,
maxWidthClassName,
gapPx = 6,
className,
}: Props) {
const [open, setOpen] = React.useState(false)
// ✅ misst die komplette Zeile (Tags + Button)
const rowWrapRef = React.useRef<HTMLDivElement | null>(null)
// ✅ hidden “all tags” measurement
const measureRef = React.useRef<HTMLDivElement | null>(null)
// ✅ worst-case +X measurement
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null)
React.useEffect(() => setOpen(false), [rowKey])
React.useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open])
const cap = React.useMemo(() => {
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
}, [maxVisible])
const [visibleCount, setVisibleCount] = React.useState<number>(() => {
const total = Math.min(tags.length, cap)
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
})
const recalc = React.useCallback(() => {
const rowWrap = rowWrapRef.current
const measure = measureRef.current
if (!rowWrap || !measure) return
const rowW = Math.floor(rowWrap.getBoundingClientRect().width)
if (rowW <= 0) return
const totalTags = Math.min(tags.length, cap)
if (totalTags <= 0) {
setVisibleCount(0)
return
}
// Breiten der TagBadges (aus hidden measure row)
const widths = Array.from(measure.children).map((el) => (el as HTMLElement).offsetWidth)
const widthsCapped = widths.slice(0, totalTags)
const plusW = plusMeasureRef.current?.offsetWidth ?? 0
const EPS = 2 // ✅ verhindert “letzter Tag halb abgeschnitten”
const fitCount = (availableW: number) => {
let used = 0
let count = 0
for (let i = 0; i < widthsCapped.length; i++) {
const w = widthsCapped[i] + (count > 0 ? gapPx : 0)
if (used + w <= availableW - EPS) {
used += w
count++
} else {
break
}
}
return count
}
// 1) Ohne +X versuchen
let count = fitCount(rowW)
// 2) Wenn Overflow: Platz für +X reservieren und nochmal fitten
if (count < totalTags) {
const reserved = plusW > 0 ? plusW + gapPx : 0
const availableForTags = Math.max(0, rowW - reserved)
count = fitCount(availableForTags)
// Safety: wenn sogar 1 Tag + Button nicht passt, mindestens 0 Tags zulassen
count = Math.max(0, count)
}
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [tags, cap, gapPx])
React.useLayoutEffect(() => {
recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recalc, tags.join('\u0000'), maxWidthClassName, cap])
React.useEffect(() => {
const el = rowWrapRef.current
if (!el) return
const ro = new ResizeObserver(() => recalc())
ro.observe(el)
return () => ro.disconnect()
}, [recalc])
const totalTags = Math.min(tags.length, cap)
const visibleTags = tags.slice(0, Math.min(visibleCount, totalTags))
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
const restAll = tags.length - visibleTags.length
return (
<>
{/* collapsed row (in footer) */}
{!open ? (
<div
ref={rowWrapRef}
className={['mt-2 h-6 flex items-center gap-1.5', className].filter(Boolean).join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{visibleTags.length > 0 ? (
visibleTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{restAll > 0 ? (
<button
type="button"
className={[
// TagBadge-like sizing + shape
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
// TagBadge-like focus behavior
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
// neutral colors (damit es sich als “Control” abhebt)
'bg-gray-100 text-gray-700 hover:bg-gray-200/70',
'dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(true)
}}
title="Alle Tags anzeigen"
aria-haspopup="dialog"
aria-expanded={false}
>
+{restAll}
</button>
) : null}
</div>
) : null}
{/* overlay that covers the whole footer host */}
{open ? (
<div
className={[
'absolute inset-0 z-30',
'bg-white/60 dark:bg-gray-950',
'px-3 py-3', // etwas weniger padding -> mehr Platz
'pointer-events-auto',
].join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
role="dialog"
aria-label="Tags"
>
{/* Close nur als Icon oben rechts */}
<button
type="button"
className="
absolute right-2 top-2 z-10
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
hover:bg-gray-200/80
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
"
onClick={() => setOpen(false)}
aria-label="Schließen"
title="Schließen"
>
</button>
{/* volle Fläche für Tags */}
<div className="h-full overflow-auto pr-1">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
</div>
</div>
) : null}
{/* hidden measure area */}
<div className="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none">
<div ref={measureRef} className="flex flex-nowrap items-center gap-1.5">
{tags.slice(0, totalTags).map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
<button
ref={plusMeasureRef}
type="button"
className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200"
>
+99
</button>
</div>
</>
)
}

View File

@ -0,0 +1,150 @@
// frontend\src\components\ui\TaskList.tsx
'use client'
export type TaskItem = {
id: string
status: 'idle' | 'running' | 'done' | 'error' | 'cancelled'
title: string
text?: string
done?: number
total?: number
err?: string
cancellable?: boolean // zeigt X nur wenn true
fading?: boolean // für sanftes Ausblenden
}
type Props = {
tasks: TaskItem[]
onCancel?: (id: string) => void
}
function pct(done?: number, total?: number) {
const d = Number(done ?? 0)
const t = Number(total ?? 0)
if (!t || t <= 0) return 0
return Math.max(0, Math.min(100, Math.round((d / t) * 100)))
}
export default function TaskList({ tasks, onCancel }: Props) {
const visible = (tasks || []).filter((t) => t.status !== 'idle')
return (
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Hintergrundaufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Laufende Hintergrundaufgaben (z.B. Assets/Previews).
</div>
</div>
</div>
<div className="mt-3 space-y-3">
{visible.length === 0 ? (
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine laufenden Aufgaben.
</div>
) : null}
{visible.map((t) => {
const p = pct(t.done, t.total)
const isRunning = t.status === 'running'
const hasProgress = isRunning && (t.total ?? 0) > 0
const title = (t.title ?? '').trim()
const suffix = (t.text ?? '').trim()
return (
<div
key={t.id}
className={
"rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5 " +
"transition-opacity duration-500 " +
(t.fading ? "opacity-0" : "opacity-100")
}
>
<div className="flex items-center gap-3">
{/* Left icon: Cancel X (running+cancellable) OR green check (done) */}
<div className="shrink-0">
{isRunning && t.cancellable && onCancel ? (
<button
type="button"
onClick={() => onCancel?.(t.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md
text-red-700 hover:bg-red-50 hover:text-red-900
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200"
title="Abbrechen"
aria-label="Task abbrechen"
>
</button>
) : t.status === 'done' ? (
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-green-700 dark:text-green-300"
title="Fertig"
aria-label="Fertig"
>
</span>
) : (
<span className="inline-block h-7 w-7" />
)}
</div>
{/* One-line row: title (optional) + status + progress */}
<div className="min-w-0 flex-1 flex items-center gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{title || 'Aufgabe'}
{suffix ? (
<span className="font-normal text-gray-600 dark:text-gray-300">
{' · '}{suffix}
</span>
) : null}
</div>
{/* Status badge (running bewusst ohne Badge) */}
{t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
fertig
</span>
) : t.status === 'cancelled' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
abgebrochen
</span>
) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
fehler
</span>
) : null}
{/* Spacer */}
<div className="flex-1" />
{/* Progress compact on same line */}
{hasProgress ? (
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
<span className="tabular-nums">{p}%</span>
{/* Progressbar: auf Mobile ausblenden, ab sm anzeigen */}
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
</div>
</div>
) : null}
</div>
</div>
{/* Error text optional (unter der Zeile) */}
{t.status === 'error' && t.err ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t.err}
</div>
) : null}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
// frontend/src/components/ui/formatters.ts
export function formatResolution(r?: { w: number; h: number } | null): string {
if (!r) return ''
const w = Math.round(Number(r.w))
const h = Math.round(Number(r.h))
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return ''
// "p" orientiert sich an der kleineren Seite:
// - Landscape: min = Höhe
// - Portrait: min = Breite (damit 1080×1920 => 1080p)
const p = Math.min(w, h)
// Toleranz, weil Encoder manchmal krumme Werte liefern (z.B. 1072 statt 1080)
const tol = Math.max(12, Math.round(p * 0.02))
const pick = (target: number) => Math.abs(p - target) <= tol
if (pick(4320)) return '8K'
if (pick(2160) || p >= 2000) return '4K'
if (pick(1440)) return '1440p'
if (pick(1080)) return '1080p'
if (pick(720)) return '720p'
if (pick(480)) return '480p'
if (pick(360)) return '360p'
if (pick(240)) return '240p'
// Fallback: z.B. 1600p (Ultrawide) oder 800p
return `${p}p`
}

View File

@ -0,0 +1,32 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { RecordJob } from '../types'
import { subscribeSSE } from './sseSingleton'
export function useRecordJobsSSE(initialJobs: RecordJob[]) {
const [jobs, setJobs] = useState<RecordJob[]>(initialJobs)
// optional: super simple dedupe (hilft, falls Server identische snapshots pusht)
const lastLenRef = useRef<number>(initialJobs.length)
useEffect(() => {
const unsub = subscribeSSE<RecordJob[]>(
'/api/record/stream',
'jobs',
(data) => {
if (!Array.isArray(data)) return
// kleine Heuristik gegen “same snapshot” (billig)
if (data.length === lastLenRef.current) {
// trotzdem setzen ist ok; wenn du härter dedupen willst, siehe Stufe 2/3
}
lastLenRef.current = data.length
setJobs(data)
}
)
return () => unsub()
}, [])
return jobs
}

View File

@ -8,6 +8,24 @@ export type PostWorkKeyStatus = {
maxParallel?: number // cap(ffmpegSem) maxParallel?: number // cap(ffmpegSem)
} }
export type VideoMeta = {
version?: number
// entspricht meta.json
durationSeconds: number
fileSize: number
fileModUnix: number
videoWidth?: number
videoHeight?: number
fps?: number
resolution?: string
sourceUrl?: string
updatedAtUnix?: number
}
export type RecordJob = { export type RecordJob = {
id: string id: string
sourceUrl?: string sourceUrl?: string
@ -22,6 +40,8 @@ export type RecordJob = {
videoHeight?: number videoHeight?: number
fps?: number fps?: number
meta?: VideoMeta
phase?: string phase?: string
progress?: number progress?: number