updated
This commit is contained in:
parent
6cd9dcd41e
commit
478e2696da
@ -4,7 +4,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -40,94 +42,160 @@ func u64ToI64(x uint64) int64 {
|
||||
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 {
|
||||
// Default: keine SourceURL (für Covers egal)
|
||||
return ensureAssetsForVideoWithProgress(videoPath, "", nil)
|
||||
}
|
||||
|
||||
// Optional: für Stellen, wo du die URL hast (z.B. Postwork / Jobs)
|
||||
func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error {
|
||||
return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil)
|
||||
}
|
||||
|
||||
// onRatio: 0..1 (Assets-Gesamtfortschritt)
|
||||
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)
|
||||
if videoPath == "" {
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
fi, statErr := os.Stat(videoPath)
|
||||
if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
|
||||
base := filepath.Base(videoPath)
|
||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
id = stripHotPrefix(id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
assetDir, gerr := ensureGeneratedDir(id)
|
||||
if gerr != nil || strings.TrimSpace(assetDir) == "" {
|
||||
return fmt.Errorf("generated dir: %v", gerr)
|
||||
_, thumbPath, previewPath, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
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) {
|
||||
if onRatio == nil {
|
||||
return
|
||||
@ -141,73 +209,108 @@ func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRati
|
||||
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)
|
||||
|
||||
// ----------------
|
||||
// Thumbs
|
||||
// Thumbs (WebP-only)
|
||||
// ----------------
|
||||
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
|
||||
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
|
||||
if thumbBefore {
|
||||
progress(thumbsW)
|
||||
} else {
|
||||
progress(0.05)
|
||||
|
||||
genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
func() {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Acquire; wenn Context cancelled → Fehler zurück
|
||||
if err := thumbSem.Acquire(genCtx); err != nil {
|
||||
// best-effort
|
||||
progress(thumbsW)
|
||||
goto PREVIEW
|
||||
// wenn ctx cancelled -> hart zurück, sonst best-effort weiter
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
defer thumbSem.Release()
|
||||
|
||||
progress(0.10)
|
||||
|
||||
t := 0.0
|
||||
if durSec > 0 {
|
||||
t = durSec * 0.5
|
||||
if meta.durSec > 0 {
|
||||
t = meta.durSec * 0.5
|
||||
}
|
||||
|
||||
progress(0.15)
|
||||
|
||||
img, e1 := extractFrameAtTimeJPEG(videoPath, t)
|
||||
img, e1 := extractFrameAtTimeWebP(videoPath, t)
|
||||
if e1 != nil || len(img) == 0 {
|
||||
img, e1 = extractLastFrameJPEG(videoPath)
|
||||
img, e1 = extractLastFrameWebP(videoPath)
|
||||
if e1 != nil || len(img) == 0 {
|
||||
img, e1 = extractFirstFrameJPEG(videoPath)
|
||||
img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75)
|
||||
}
|
||||
}
|
||||
|
||||
progress(0.20)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
progress(thumbsW)
|
||||
}
|
||||
|
||||
PREVIEW:
|
||||
// ----------------
|
||||
// Preview
|
||||
// ----------------
|
||||
previewPath := filepath.Join(assetDir, "preview.mp4")
|
||||
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||
if previewBefore {
|
||||
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()
|
||||
|
||||
progress(thumbsW + 0.02)
|
||||
|
||||
if err := genSem.Acquire(genCtx); err != nil {
|
||||
progress(1)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
defer genSem.Release()
|
||||
|
||||
@ -223,8 +326,46 @@ PREVIEW:
|
||||
progress(thumbsW + r*previewW)
|
||||
}); err != nil {
|
||||
fmt.Println("⚠️ preview clips:", err)
|
||||
return
|
||||
}
|
||||
|
||||
progress(1)
|
||||
return nil
|
||||
out.PreviewGenerated = true
|
||||
|
||||
// ✅ 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
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -57,6 +58,13 @@ type ChaturbateOnlineRoomLite struct {
|
||||
CurrentShow string `json:"current_show"`
|
||||
ChatRoomURL string `json:"chat_room_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 {
|
||||
@ -85,6 +93,74 @@ var (
|
||||
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.
|
||||
func setChaturbateOnlineModelStore(store *ModelStore) {
|
||||
cbModelStore = store
|
||||
@ -161,6 +237,12 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
|
||||
CurrentShow: rm.CurrentShow,
|
||||
ChatRoomURL: rm.ChatRoomURL,
|
||||
ImageURL: rm.ImageURL,
|
||||
|
||||
Gender: rm.Gender,
|
||||
Country: rm.Country,
|
||||
NumUsers: rm.NumUsers,
|
||||
IsHD: rm.IsHD,
|
||||
Tags: rm.Tags,
|
||||
}
|
||||
}
|
||||
return m
|
||||
@ -300,6 +382,14 @@ func setCachedOnline(key string, body []byte) {
|
||||
type cbOnlineReq struct {
|
||||
Q []string `json:"q"` // usernames
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -327,6 +417,19 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var users []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 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
|
||||
|
||||
@ -346,6 +449,18 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
seenU := map[string]bool{}
|
||||
for _, u := range req.Q {
|
||||
@ -369,6 +484,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
shows = append(shows, s)
|
||||
}
|
||||
sort.Strings(shows)
|
||||
allowedShow = toSet(shows)
|
||||
} else {
|
||||
// GET (legacy)
|
||||
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)
|
||||
}
|
||||
|
||||
// ✅ 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
|
||||
|
||||
// show allow-set
|
||||
allowedShow := map[string]bool{}
|
||||
for _, s := range shows {
|
||||
allowedShow[s] = true
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Response Cache (2s)
|
||||
// ---------------------------
|
||||
cacheKey := "cb_online:" + hashKey(
|
||||
fmt.Sprintf("enabled=%v", enabled),
|
||||
"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),
|
||||
"lite=1",
|
||||
)
|
||||
@ -463,21 +618,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
|
||||
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:
|
||||
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
|
||||
@ -550,6 +690,65 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
|
||||
if onlySpecificUsers && liteByUser != nil {
|
||||
@ -558,13 +757,9 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// show filter
|
||||
if len(allowedShow) > 0 {
|
||||
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
||||
if !allowedShow[s] {
|
||||
if !matches(rm) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
outRooms = append(outRooms, outRoom{
|
||||
Username: rm.Username,
|
||||
CurrentShow: rm.CurrentShow,
|
||||
|
||||
@ -22,6 +22,10 @@ type cleanupResp struct {
|
||||
// Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei)
|
||||
OrphanIDsScanned int `json:"orphanIdsScanned"`
|
||||
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.
|
||||
@ -78,8 +82,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
|
||||
// damit die Zahlen in der JSON-Response landen.
|
||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||
resp.OrphanIDsScanned += gcStats.Checked
|
||||
resp.OrphanIDsRemoved += gcStats.Removed
|
||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||
|
||||
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -21,7 +21,7 @@ type generatedGCStats struct {
|
||||
|
||||
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
|
||||
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
|
||||
// gleiches "nur 1 GC gleichzeitig" Verhalten wie async
|
||||
// nur 1 GC gleichzeitig
|
||||
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
|
||||
fmt.Println("🧹 [gc] skip: already running")
|
||||
return generatedGCStats{}
|
||||
|
||||
2224
backend/main.go
2224
backend/main.go
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,11 @@ import (
|
||||
// generated/meta/<id>/meta.json
|
||||
// --------------------------
|
||||
|
||||
type previewClip struct {
|
||||
StartSeconds float64 `json:"startSeconds"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
type videoMeta struct {
|
||||
Version int `json:"version"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
@ -26,6 +31,7 @@ type videoMeta struct {
|
||||
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
|
||||
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
PreviewClips []previewClip `json:"previewClips,omitempty"`
|
||||
|
||||
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) {
|
||||
d, _, _, _, ok := readVideoMeta(metaPath, fi)
|
||||
return d, ok
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil || m.DurationSeconds <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return m.DurationSeconds, true
|
||||
}
|
||||
|
||||
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
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() {
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil {
|
||||
return "", false
|
||||
}
|
||||
u := strings.TrimSpace(m.SourceURL)
|
||||
@ -95,10 +98,6 @@ func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
||||
return u, true
|
||||
}
|
||||
|
||||
// altes v1 ohne SourceURL -> keine URL
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
|
||||
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
|
||||
}
|
||||
|
||||
func generatedMetaFile(id string) (string, error) {
|
||||
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
|
||||
if err != nil {
|
||||
return "", err
|
||||
func generatedMetaFile(assetID string) (string, error) {
|
||||
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
||||
if assetID == "" {
|
||||
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>/...
|
||||
@ -176,7 +207,7 @@ func generatedThumbFile(id string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "thumbs.jpg"), nil
|
||||
return filepath.Join(dir, "thumbs.webp"), nil
|
||||
}
|
||||
|
||||
func generatedPreviewFile(id string) (string, error) {
|
||||
|
||||
Binary file not shown.
1177
backend/preview_covers.go
Normal file
1177
backend/preview_covers.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
||||
// backend\preview_hls.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -384,8 +386,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
|
||||
jobsMu.Unlock()
|
||||
}()
|
||||
|
||||
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.jpg regelmäßig neu)
|
||||
startLiveThumbLoop(ctx, job)
|
||||
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.webp regelmäßig neu)
|
||||
startLiveThumbWebPLoop(ctx, job)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -99,36 +98,3 @@ func rewriteAttrURI(line, base string) string {
|
||||
|
||||
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
744
backend/preview_webp.go
Normal 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)
|
||||
}
|
||||
@ -40,6 +40,17 @@ type doneListResponse struct {
|
||||
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 {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
@ -153,9 +164,11 @@ func writeSSE(w http.ResponseWriter, data []byte) {
|
||||
}
|
||||
|
||||
func handleDoneStream(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
// wichtig für nginx / reverse proxies
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
@ -163,23 +176,36 @@ func handleDoneStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan []byte, 16)
|
||||
// pro client ein channel
|
||||
ch := make(chan []byte, 32)
|
||||
doneHub.add(ch)
|
||||
defer doneHub.remove(ch)
|
||||
|
||||
// optional: initial ping/hello, damit Client sofort "lebt"
|
||||
fmt.Fprintf(w, "event: doneChanged\ndata: {\"type\":\"doneChanged\",\"seq\":%d,\"ts\":%d}\n\n",
|
||||
atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
|
||||
// ✅ KEIN doneChanged als hello – nur Kommentar
|
||||
fmt.Fprintf(w, ": hello seq=%d ts=%d\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
ping := time.NewTicker(15 * time.Second)
|
||||
defer ping.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case b := <-ch:
|
||||
// wichtig: event-name setzen -> Client kann addEventListener("doneChanged", ...)
|
||||
fmt.Fprintf(w, "event: doneChanged\ndata: %s\n\n", b)
|
||||
|
||||
case <-ping.C:
|
||||
// ✅ 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()
|
||||
}
|
||||
}
|
||||
@ -233,6 +259,100 @@ func (t *rwTrack) Write(p []byte) (int, error) {
|
||||
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) {
|
||||
// ---- wrap writer to detect "already wrote" ----
|
||||
tw := &rwTrack{ResponseWriter: w}
|
||||
@ -485,17 +605,15 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- convert progress fraction to seconds (if needed) ----
|
||||
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)
|
||||
dur, derr := getVideoDurationSecondsCached(pctx, outPath)
|
||||
cancel()
|
||||
defer 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 {
|
||||
startSec = int(startFrac * dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitize + optional bucket align (wie bei GOP-ish seeking)
|
||||
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 ----
|
||||
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)
|
||||
args := buildFFmpegStreamArgsAt(inPath, prof, startSec)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@ -1085,18 +1206,7 @@ type doneIndexCache struct {
|
||||
|
||||
var doneCache doneIndexCache
|
||||
|
||||
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)
|
||||
normalizeQueryModel := func(raw string) string {
|
||||
func normalizeQueryModel(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return ""
|
||||
@ -1104,7 +1214,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
s = strings.TrimPrefix(s, "http://")
|
||||
s = strings.TrimPrefix(s, "https://")
|
||||
|
||||
// letzter URL-Segment, falls jemand "…/modelname" übergibt
|
||||
// letzter URL-Segment, falls jemand ".../modelname" übergibt
|
||||
if strings.Contains(s, "/") {
|
||||
parts := strings.Split(s, "/")
|
||||
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))
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
// 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")))
|
||||
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) {
|
||||
if j.DurationSeconds > 0 {
|
||||
return j.DurationSeconds, true
|
||||
@ -1336,177 +1596,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
curSeq := atomic.LoadUint64(&doneSeq)
|
||||
now := time.Now()
|
||||
@ -1520,9 +1609,16 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
// Wenn doneAbs nicht existiert: leere Daten im Cache
|
||||
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
|
||||
doneCache.items = nil
|
||||
doneCache.sortedIdx = map[string][]int{
|
||||
"0|completed_desc": {},
|
||||
"1|completed_desc": {},
|
||||
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
|
||||
@ -1611,30 +1707,27 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
// ✅ Kopie erzeugen (wichtig: keine Race/Mutations am Cache-Objekt)
|
||||
c := *base
|
||||
|
||||
// ✅ Meta immer aus meta.json (ggf. generieren, wenn fehlt)
|
||||
// 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
|
||||
// Size immer korrekt setzen
|
||||
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
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)
|
||||
@ -2073,7 +2166,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unkeep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "unkeep fehlgeschlagen: "+file, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@ -2211,7 +2304,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "keep fehlgeschlagen: "+file, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -77,8 +77,9 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
SourceURL: url,
|
||||
Status: JobRunning,
|
||||
StartedAt: startedAt,
|
||||
Output: outPath, // ✅ sofort befüllt
|
||||
Hidden: req.Hidden, // ✅ NEU
|
||||
StartedAtMs: startedAt.UnixMilli(), // ✅ NEU
|
||||
Output: outPath,
|
||||
Hidden: req.Hidden,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
@ -106,6 +107,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
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)
|
||||
setJobProgress(job, "recording", 0)
|
||||
notifyJobsChanged()
|
||||
@ -198,6 +213,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
// EndedAt + Error speichern (kurz locken)
|
||||
jobsMu.Lock()
|
||||
job.EndedAt = &end
|
||||
job.EndedAtMs = end.UnixMilli() // ✅ NEU
|
||||
if 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”
|
||||
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)
|
||||
jobsMu.Unlock()
|
||||
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.
|
||||
jobsMu.Lock()
|
||||
job.Phase = "postwork"
|
||||
if job.Progress < 70 {
|
||||
job.Progress = 70
|
||||
}
|
||||
|
||||
job.PostWorkKey = postKey
|
||||
// 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 (
|
||||
assetsStart = 86
|
||||
assetsEnd = 99
|
||||
|
||||
@ -47,6 +47,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/record/preview", recordPreview)
|
||||
api.HandleFunc("/api/record/list", recordList)
|
||||
api.HandleFunc("/api/record/stream", recordStream)
|
||||
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||
api.HandleFunc("/api/record/video", recordVideo)
|
||||
api.HandleFunc("/api/record/done", recordDoneList)
|
||||
api.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||
@ -65,6 +66,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
// Tasks
|
||||
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
||||
|
||||
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
|
||||
|
||||
// --------------------------
|
||||
// 3) ModelStore
|
||||
// --------------------------
|
||||
|
||||
401
backend/sse.go
Normal file
401
backend/sse.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
// backend\tasks_assets.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -31,12 +33,30 @@ var assetsTaskMu sync.Mutex
|
||||
var assetsTaskState AssetsTaskState
|
||||
var assetsTaskCancel context.CancelFunc
|
||||
|
||||
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// updateAssetsState mutiert den State atomar und triggert danach SSE notify.
|
||||
// notifyAssetsChanged() muss außerhalb des Locks passieren.
|
||||
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
|
||||
assetsTaskMu.Lock()
|
||||
fn(&assetsTaskState)
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
notifyAssetsChanged()
|
||||
return st
|
||||
}
|
||||
|
||||
func snapshotAssetsState() AssetsTaskState {
|
||||
assetsTaskMu.Lock()
|
||||
st := assetsTaskState
|
||||
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)
|
||||
return
|
||||
|
||||
@ -49,17 +69,28 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ cancelbaren Context erzeugen
|
||||
// cancelbarer Context (pro Run)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
assetsTaskCancel = cancel
|
||||
|
||||
now := time.Now()
|
||||
assetsTaskState = AssetsTaskState{
|
||||
Running: true,
|
||||
StartedAt: time.Now(),
|
||||
Total: 0,
|
||||
Done: 0,
|
||||
GeneratedThumbs: 0,
|
||||
GeneratedPreviews: 0,
|
||||
Skipped: 0,
|
||||
StartedAt: now,
|
||||
FinishedAt: nil,
|
||||
Error: "",
|
||||
}
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
// ✅ SSE: Start pushen
|
||||
notifyAssetsChanged()
|
||||
|
||||
go runGenerateMissingAssets(ctx)
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
@ -72,48 +103,60 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
if !running || cancel == nil {
|
||||
// nichts zu stoppen
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber
|
||||
cancel()
|
||||
|
||||
// optional: sofortiges Feedback in state.error
|
||||
assetsTaskMu.Lock()
|
||||
if assetsTaskState.Running {
|
||||
assetsTaskState.Error = "abgebrochen"
|
||||
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
|
||||
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
|
||||
st := updateAssetsState(func(st *AssetsTaskState) {
|
||||
if st.Running {
|
||||
st.Error = "abgebrochen"
|
||||
}
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
|
||||
default:
|
||||
http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed)
|
||||
http.Error(w, "Nur GET/POST/DELETE", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerateMissingAssets(ctx context.Context) {
|
||||
finishWithErr := func(err error) {
|
||||
now := time.Now()
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.Running = false
|
||||
assetsTaskState.FinishedAt = &now
|
||||
if err != nil {
|
||||
assetsTaskState.Error = err.Error()
|
||||
}
|
||||
assetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
|
||||
defer func() {
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskCancel = nil
|
||||
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()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||
@ -150,7 +193,6 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dedupe
|
||||
if _, ok := seen[full]; ok {
|
||||
return
|
||||
}
|
||||
@ -164,7 +206,6 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, e := range ents {
|
||||
// .trash-Ordner nie scannen
|
||||
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
|
||||
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(filepath.Join(doneAbs, "keep"))
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.Total = len(items)
|
||||
assetsTaskState.Done = 0
|
||||
assetsTaskState.GeneratedThumbs = 0
|
||||
assetsTaskState.GeneratedPreviews = 0
|
||||
assetsTaskState.Skipped = 0
|
||||
assetsTaskState.Error = ""
|
||||
assetsTaskMu.Unlock()
|
||||
// ✅ Initialisierung: Total etc. + SSE Push
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Total = len(items)
|
||||
st.Done = 0
|
||||
st.GeneratedThumbs = 0
|
||||
st.GeneratedPreviews = 0
|
||||
st.Skipped = 0
|
||||
// Start hat Error schon geleert — hier nur sicherheitshalber:
|
||||
st.Error = ""
|
||||
})
|
||||
|
||||
for i, it := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@ -206,175 +249,63 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ID aus Dateiname
|
||||
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
||||
id := stripHotPrefix(base)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.Done = i + 1
|
||||
assetsTaskMu.Unlock()
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Done = i + 1
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
assetDir, derr := ensureGeneratedDir(id)
|
||||
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)
|
||||
// Datei-Info (validieren)
|
||||
vfi, verr := os.Stat(it.path)
|
||||
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.Done = i + 1
|
||||
assetsTaskMu.Unlock()
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Done = i + 1
|
||||
})
|
||||
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 := ""
|
||||
if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok {
|
||||
sourceURL = u
|
||||
}
|
||||
|
||||
// ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet
|
||||
durSec := 0.0
|
||||
vw, vh := 0, 0
|
||||
fps := 0.0
|
||||
|
||||
// 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 defer’t, 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)
|
||||
// Generate/Ensure (einheitliche Core-Funktion)
|
||||
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
||||
if e != nil {
|
||||
finishWithErr(e)
|
||||
return
|
||||
}
|
||||
|
||||
err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18)
|
||||
|
||||
genSem.Release()
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.GeneratedPreviews++
|
||||
assetsTaskMu.Unlock()
|
||||
} else {
|
||||
fmt.Println("⚠️ preview clips:", err)
|
||||
// ✅ Progress + Counters + SSE Push
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
if res.Skipped {
|
||||
st.Skipped++
|
||||
}
|
||||
if res.ThumbGenerated {
|
||||
st.GeneratedThumbs++
|
||||
}
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskState.Done = i + 1
|
||||
assetsTaskMu.Unlock()
|
||||
if res.PreviewGenerated {
|
||||
st.GeneratedPreviews++
|
||||
}
|
||||
st.Done = i + 1
|
||||
})
|
||||
}
|
||||
|
||||
finishWithErr(nil)
|
||||
|
||||
@ -165,6 +165,53 @@ func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur flo
|
||||
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(
|
||||
ctx context.Context,
|
||||
srcPath, outPath string,
|
||||
@ -223,26 +270,9 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
return err
|
||||
}
|
||||
|
||||
// Startpunkte wie "die andere": offset + i*stepSize
|
||||
stepSize, offset := opts.stepSizeAndOffset(dur)
|
||||
|
||||
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)
|
||||
}
|
||||
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
|
||||
// segDur ist später im Code benutzt -> segDur damit überschreiben:
|
||||
segDur = segDurComputed
|
||||
|
||||
expectedOutSec := float64(len(starts)) * segDur
|
||||
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
|
||||
|
||||
1
backend/web/dist/assets/index-Cd67oQ3U.css
vendored
Normal file
1
backend/web/dist/assets/index-Cd67oQ3U.css
vendored
Normal file
File diff suppressed because one or more lines are too long
419
backend/web/dist/assets/index-JupgTTdL.js
vendored
419
backend/web/dist/assets/index-JupgTTdL.js
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-SqYhLYXQ.css
vendored
1
backend/web/dist/assets/index-SqYhLYXQ.css
vendored
File diff suppressed because one or more lines are too long
413
backend/web/dist/assets/index-rrLyu52u.js
vendored
Normal file
413
backend/web/dist/assets/index-rrLyu52u.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-JupgTTdL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-SqYhLYXQ.css">
|
||||
<script type="module" crossorigin src="/assets/index-rrLyu52u.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cd67oQ3U.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -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(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
|
||||
} catch {}
|
||||
}, [doneSort])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return
|
||||
void loadDoneCount()
|
||||
}, [authed, loadDoneCount])
|
||||
|
||||
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
|
||||
const [sourceUrl, setSourceUrl] = useState('')
|
||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||
@ -594,13 +691,6 @@ export default function App() {
|
||||
|
||||
const [assetNonce, setAssetNonce] = useState(0)
|
||||
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 recSettingsRef = useRef(recSettings)
|
||||
@ -1108,251 +1198,37 @@ export default function App() {
|
||||
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
||||
}, [cookies, cookiesLoaded])
|
||||
|
||||
// ✅ done count polling über /api/record/done (kein /done/meta mehr)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let t: number | undefined
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1x initial / bei sort-wechsel (für Badge)
|
||||
void loadDoneCount()
|
||||
|
||||
const onVis = () => {
|
||||
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)
|
||||
|
||||
// ❌ das hier empfehle ich rauszuwerfen, siehe Schritt C
|
||||
// window.addEventListener('hover', onVis)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
stopFallbackPolling()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
// window.removeEventListener('hover', onVis)
|
||||
es?.removeEventListener('jobs', onJobs as any)
|
||||
es?.close()
|
||||
es = null
|
||||
}
|
||||
}, [authed])
|
||||
}, [loadDoneCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTab !== 'finished') return
|
||||
const refreshDoneNow = useCallback(
|
||||
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 loadDone = async () => {
|
||||
if (cancelled || inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
doneFetchAbortRef.current = ac
|
||||
doneFetchInFlightRef.current = true
|
||||
|
||||
try {
|
||||
const wanted = typeof preferPage === 'number' ? preferPage : donePage
|
||||
|
||||
const res = await fetch(
|
||||
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
|
||||
`&sort=${encodeURIComponent(doneSort)}` +
|
||||
`&withCount=1`,
|
||||
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
|
||||
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
|
||||
{ cache: 'no-store' as any, signal: ac.signal }
|
||||
)
|
||||
|
||||
@ -1360,69 +1236,12 @@ export default function App() {
|
||||
|
||||
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)
|
||||
? (data.items as RecordJob[])
|
||||
: Array.isArray(data)
|
||||
? (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 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)
|
||||
if (target !== donePage) setDonePage(target)
|
||||
|
||||
// wenn target anders ist, optional nochmal mit target laden:
|
||||
if (target === wanted) {
|
||||
setDoneJobs(items)
|
||||
} else {
|
||||
const data2 = await apiJSON<any>(
|
||||
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
|
||||
if (target !== wanted) {
|
||||
const res2 = await fetch(
|
||||
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
|
||||
`&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[]) : []
|
||||
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]
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
try {
|
||||
es = new EventSource('/api/record/done/stream')
|
||||
} catch {
|
||||
let timer: number | null = null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const onDone = () => {
|
||||
// wenn finished tab offen: liste aktualisieren
|
||||
lastFireRef.t = now
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void refreshDoneNow()
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
} else {
|
||||
// sonst nur count aktualisieren (leicht)
|
||||
// optional: void loadDoneCount() wenn du es aus dem Scope verfügbar machst
|
||||
void loadDoneCount()
|
||||
}
|
||||
}
|
||||
|
||||
es.addEventListener('doneChanged', onDone as any)
|
||||
es.onerror = () => {
|
||||
// fallback: dein bestehendes polling bleibt als sicherheit
|
||||
// initial
|
||||
void loadDoneCount()
|
||||
|
||||
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 () => {
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
if (coalesceTimer != null) window.clearTimeout(coalesceTimer)
|
||||
stopPoll()
|
||||
es?.removeEventListener('doneChanged', onDone as any)
|
||||
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 {
|
||||
const norm = normalizeHttpUrl(raw)
|
||||
@ -1517,21 +1473,24 @@ export default function App() {
|
||||
const onHint = (ev: Event) => {
|
||||
const e = ev as CustomEvent<{ delta?: number }>
|
||||
const delta = Number(e.detail?.delta ?? 0)
|
||||
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
void refreshDoneNow()
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Tabs sofort updaten (optimistisch)
|
||||
setDoneCount((c) => Math.max(0, c + delta))
|
||||
|
||||
// ✅ danach einmal server-truth holen (Pagination + count 100% korrekt)
|
||||
void refreshDoneNow()
|
||||
// ✅ danach server-truth holen + ALL reload
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
}
|
||||
|
||||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
}, [refreshDoneNow])
|
||||
}, [loadDoneCount, requestFinishedReload])
|
||||
|
||||
useEffect(() => {
|
||||
const onNav = (ev: Event) => {
|
||||
@ -1608,9 +1567,46 @@ export default function App() {
|
||||
)
|
||||
|
||||
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))
|
||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||
|
||||
// ✅ Buffer direkt wieder nachfüllen (background)
|
||||
void prefetchDonePage(donePage + 1)
|
||||
}, 320)
|
||||
|
||||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
||||
@ -1619,7 +1615,7 @@ export default function App() {
|
||||
window.dispatchEvent(
|
||||
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
|
||||
}
|
||||
},
|
||||
@ -1652,7 +1648,7 @@ export default function App() {
|
||||
|
||||
} catch (e: any) {
|
||||
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
|
||||
}
|
||||
},
|
||||
@ -1665,6 +1661,11 @@ export default function App() {
|
||||
if (!file) return
|
||||
|
||||
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 }>(
|
||||
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
|
||||
{ method: 'POST' }
|
||||
@ -1697,8 +1698,11 @@ export default function App() {
|
||||
return match ? { ...j, output: apply(j.output || '') } : j
|
||||
})
|
||||
)
|
||||
|
||||
return res
|
||||
} catch (e: any) {
|
||||
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
|
||||
return
|
||||
}
|
||||
},
|
||||
[notify]
|
||||
@ -2384,7 +2388,7 @@ export default function App() {
|
||||
|
||||
getShow: () => ['public', 'private', 'hidden', 'away'],
|
||||
|
||||
intervalMs: 12000,
|
||||
intervalMs: 8000,
|
||||
|
||||
onData: (data: ChaturbateOnlineResponse) => {
|
||||
void (async () => {
|
||||
@ -2830,6 +2834,7 @@ export default function App() {
|
||||
setDoneSort(m)
|
||||
setDonePage(1)
|
||||
}}
|
||||
loadMode="all"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -2871,6 +2876,12 @@ export default function App() {
|
||||
|
||||
{playerJob ? (
|
||||
<Player
|
||||
key={[
|
||||
String((playerJob as any)?.id ?? ''),
|
||||
baseName(playerJob.output || ''),
|
||||
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
|
||||
String(assetNonce),
|
||||
].join('::')}
|
||||
job={playerJob}
|
||||
modelKey={playerModelKey ?? undefined}
|
||||
modelsByKey={modelsByKey}
|
||||
|
||||
@ -26,9 +26,9 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const sizeMap: Record<Size, { btn: string; icon: string }> = {
|
||||
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5' },
|
||||
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5' },
|
||||
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
||||
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', iconOnly: 'h-10 w-10' },
|
||||
}
|
||||
|
||||
export default function ButtonGroup({
|
||||
@ -57,7 +57,7 @@ export default function ButtonGroup({
|
||||
onClick={() => onChange(it.id)}
|
||||
aria-pressed={active}
|
||||
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 && 'rounded-l-md',
|
||||
isLast && 'rounded-r-md',
|
||||
@ -73,7 +73,7 @@ export default function ButtonGroup({
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
// 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}
|
||||
>
|
||||
|
||||
@ -62,11 +62,11 @@ function modelKeyFromFilename(fileOrPath: string): string | 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 {
|
||||
const id = assetIdFromOutput(output)
|
||||
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) {
|
||||
@ -77,7 +77,11 @@ async function ensureCover(category: string, thumbPath: string, modelName: strin
|
||||
(m ? `&model=${encodeURIComponent(m)}` : ``) +
|
||||
(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 = {
|
||||
@ -101,6 +105,7 @@ export default function CategoriesTab() {
|
||||
const [err, setErr] = React.useState<string | null>(null)
|
||||
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
|
||||
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({})
|
||||
const [hasCoverByTag, setHasCoverByTag] = React.useState<Record<string, boolean>>({})
|
||||
|
||||
const [renewing, setRenewing] = React.useState(false)
|
||||
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' }))
|
||||
|
||||
let coverInfoByTag = new Map<string, CoverInfoListItem>()
|
||||
// coverinfo/list holen (optional)
|
||||
const coverInfoByTag = new Map<string, CoverInfoListItem>()
|
||||
try {
|
||||
const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', {
|
||||
cache: 'no-store' as any,
|
||||
signal: ac.signal as any,
|
||||
})
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
// optional: still weiterlaufen ohne Fallback
|
||||
// weiter ohne coverinfo
|
||||
}
|
||||
|
||||
// stabiles Model pro Tag (für &model= in <img>)
|
||||
const coverModelByTag: Record<string, string> = {}
|
||||
// ✅ 1) hasCoverByTag befüllen (Default: false)
|
||||
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) {
|
||||
// 1) bevorzugt aus candidates (doneJobs)
|
||||
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)
|
||||
|
||||
// nur cache-bust der IMG URLs (wenn du willst)
|
||||
setCoverBust(Date.now())
|
||||
// ❗ Optional: NICHT immer busten (sonst unnötige Reloads).
|
||||
// Wenn du trotzdem jedes Mal neu laden willst, uncomment:
|
||||
// setCoverBust(Date.now())
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
|
||||
@ -295,6 +322,8 @@ export default function CategoriesTab() {
|
||||
setRows([])
|
||||
candidatesRef.current = {}
|
||||
setCoverModelByTag({})
|
||||
setHasCoverByTag({})
|
||||
setCoverState({})
|
||||
} finally {
|
||||
// nur "aus" schalten, wenn dieser refresh noch der aktuelle ist
|
||||
if (refreshAbortRef.current === ac) {
|
||||
@ -324,15 +353,29 @@ export default function CategoriesTab() {
|
||||
rows.map(async (r) => {
|
||||
try {
|
||||
const list = candMap[r.tag] || []
|
||||
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
|
||||
const thumb = pick ? thumbUrlFromOutput(pick) : null
|
||||
|
||||
if (thumb) {
|
||||
const model = pick ? modelKeyFromFilename(pick) : null
|
||||
// ✅ wenn es keine Kandidaten gibt -> NICHT versuchen, /cover zu fetchen (vermeidet 404 komplett)
|
||||
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)
|
||||
|
||||
// ✅ 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) => {
|
||||
const next = { ...prev }
|
||||
if (model?.trim()) next[r.tag] = model.trim()
|
||||
@ -340,18 +383,14 @@ export default function CategoriesTab() {
|
||||
return next
|
||||
})
|
||||
|
||||
return { tag: r.tag, ok: true, status: 200, text: '' }
|
||||
}
|
||||
|
||||
const model = coverModelByTag[r.tag] ?? ''
|
||||
|
||||
const res = await fetch(coverSrc(r.tag, Date.now(), true, model), {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
// ✅ CoverState resetten, damit <img> neu lädt und onLoad wieder "ok" setzen kann
|
||||
setCoverState((s) => {
|
||||
const n = { ...s }
|
||||
delete n[r.tag]
|
||||
return n
|
||||
})
|
||||
const text = !res.ok ? await res.text().catch(() => '') : ''
|
||||
const ok = res.ok || res.status === 404
|
||||
return { tag: r.tag, ok, status: res.status, text }
|
||||
|
||||
return { tag: r.tag, ok: true, status: 200, text: '' }
|
||||
} catch (e: any) {
|
||||
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
|
||||
} finally {
|
||||
@ -360,18 +399,18 @@ export default function CategoriesTab() {
|
||||
})
|
||||
)
|
||||
|
||||
const failedNo404 = results.filter((x) => !x.ok && x.status !== 404)
|
||||
if (failedNo404.length) {
|
||||
console.warn('Cover renew failed:', failedNo404.slice(0, 20))
|
||||
const sample = failedNo404.slice(0, 8).map((f) => `${f.tag} (${f.status || 'ERR'})`).join(', ')
|
||||
setErr(`Covers fehlgeschlagen: ${failedNo404.length}/${results.length} — z.B.: ${sample}`)
|
||||
const failed = results.filter((x) => !x.ok)
|
||||
if (failed.length) {
|
||||
console.warn('Cover renew failed:', failed.slice(0, 20))
|
||||
const sample = failed.slice(0, 8).map((f) => `${f.tag} (ERR)`).join(', ')
|
||||
setErr(`Covers fehlgeschlagen: ${failed.length}/${results.length} — z.B.: ${sample}`)
|
||||
} else {
|
||||
setErr(null)
|
||||
}
|
||||
} finally {
|
||||
// ✅ nach Batch einmal busten reicht
|
||||
setCoverBust(Date.now())
|
||||
setRenewing(false)
|
||||
// optional: kurz stehen lassen, dann ausblenden
|
||||
setTimeout(() => setRenewProgress(null), 400)
|
||||
}
|
||||
}, [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">
|
||||
{rows.map((r) => {
|
||||
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 isErr = coverState[r.tag] === 'error'
|
||||
|
||||
@ -451,10 +497,27 @@ export default function CategoriesTab() {
|
||||
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">
|
||||
{/* Wenn Fehler: hübscher Placeholder statt broken image */}
|
||||
{isErr ? (
|
||||
{/* ✅ 0) Noch nicht bekannt ob Cover existiert -> KEIN <img>, nur Skeleton */}
|
||||
{!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 opacity-70"
|
||||
<div
|
||||
className="absolute inset-0 opacity-70"
|
||||
style={{
|
||||
background:
|
||||
'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="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
Cover nicht verfügbar
|
||||
Kein Cover vorhanden
|
||||
</div>
|
||||
{model ? (
|
||||
<div className="mt-0.5 text-[11px] text-gray-700 dark:text-gray-300">
|
||||
Model: <span className="font-semibold">{model}</span>
|
||||
</div>
|
||||
) : 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">
|
||||
<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
|
||||
@ -477,7 +561,7 @@ export default function CategoriesTab() {
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// retry: alle IMG-URLs neu laden (einfach)
|
||||
// retry: state reset + bust
|
||||
setCoverState((s) => {
|
||||
const n = { ...s }
|
||||
delete n[r.tag]
|
||||
@ -492,8 +576,8 @@ export default function CategoriesTab() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ✅ 3) Cover existiert -> <img> rendern */
|
||||
<>
|
||||
{/* subtle top sheen + bottom gradient for readability */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* blurred fill */}
|
||||
<img
|
||||
src={img}
|
||||
@ -519,17 +604,23 @@ export default function CategoriesTab() {
|
||||
className="absolute inset-0 z-0 h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
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 */}
|
||||
{isOk && model ? (
|
||||
<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',
|
||||
'bg-black/40 text-white backdrop-blur-md',
|
||||
'ring-1 ring-white/15'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{model}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,7 @@ import ProgressBar from './ProgressBar'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
|
||||
import { subscribeSSE } from '../../lib/sseSingleton'
|
||||
import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE'
|
||||
|
||||
type PendingWatchedRoom = WaitingModelRow & {
|
||||
currentShow: string // public / private / hidden / away / unknown
|
||||
@ -601,10 +602,21 @@ const formatDuration = (ms: number): string => {
|
||||
}
|
||||
|
||||
const runtimeOf = (j: RecordJob, nowMs: number) => {
|
||||
const start = Date.parse(String(j.startedAt || ''))
|
||||
if (!Number.isFinite(start)) return '—'
|
||||
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
|
||||
if (!Number.isFinite(end)) return '—'
|
||||
const anyJ = j as any
|
||||
|
||||
const start =
|
||||
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)
|
||||
}
|
||||
|
||||
@ -648,6 +660,20 @@ const isPostworkJob = (job: RecordJob): boolean => {
|
||||
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({
|
||||
jobs,
|
||||
pending = [],
|
||||
@ -661,6 +687,8 @@ export default function Downloads({
|
||||
blurPreviews
|
||||
}: Props) {
|
||||
|
||||
const jobsLive = useRecordJobsSSE(jobs)
|
||||
|
||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||
|
||||
const [watchedPaused, setWatchedPaused] = useState(false)
|
||||
@ -761,7 +789,7 @@ export default function Downloads({
|
||||
|
||||
const next: Record<string, true> = {}
|
||||
for (const id of keys) {
|
||||
const j = jobs.find((x) => x.id === id)
|
||||
const j = jobsLive.find((x) => x.id === id)
|
||||
if (!j) continue
|
||||
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||
@ -771,15 +799,15 @@ export default function Downloads({
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [jobs])
|
||||
}, [jobsLive])
|
||||
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
||||
const hasActive = useMemo(() => {
|
||||
// tickt solange mind. ein Job noch nicht beendet ist
|
||||
return jobs.some((j) => !j.endedAt && j.status === 'running')
|
||||
}, [jobs])
|
||||
return jobsLive.some((j) => !j.endedAt && j.status === 'running')
|
||||
}, [jobsLive])
|
||||
|
||||
const postworkQueueInfoById = useMemo(() => {
|
||||
const infoById = new Map<string, { pos: number; total: number }>()
|
||||
@ -803,7 +831,7 @@ export default function Downloads({
|
||||
const running: RecordJob[] = []
|
||||
const queued: RecordJob[] = []
|
||||
|
||||
for (const j of jobs) {
|
||||
for (const j of jobsLive) {
|
||||
const pw = (j as any)?.postWork
|
||||
if (!pw) continue
|
||||
|
||||
@ -834,7 +862,7 @@ export default function Downloads({
|
||||
// }
|
||||
|
||||
return infoById
|
||||
}, [jobs])
|
||||
}, [jobsLive])
|
||||
|
||||
const postworkInfoOf = useCallback(
|
||||
(job: RecordJob) => {
|
||||
@ -846,12 +874,12 @@ export default function Downloads({
|
||||
|
||||
useEffect(() => {
|
||||
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)
|
||||
}, [hasActive])
|
||||
|
||||
const stoppableIds = useMemo(() => {
|
||||
return jobs
|
||||
return jobsLive
|
||||
.filter((j) => {
|
||||
if (isPostworkJob(j)) return false
|
||||
if ((j as any).endedAt) return false
|
||||
@ -864,7 +892,7 @@ export default function Downloads({
|
||||
return !isStopping
|
||||
})
|
||||
.map((j) => j.id)
|
||||
}, [jobs, stopRequestedIds])
|
||||
}, [jobsLive, stopRequestedIds])
|
||||
|
||||
const columns = useMemo<Column<DownloadRow>[]>(() => {
|
||||
return [
|
||||
@ -1157,22 +1185,36 @@ export default function Downloads({
|
||||
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
|
||||
|
||||
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
||||
const list = jobs
|
||||
.filter((j) => !isPostworkJob(j))
|
||||
const list = jobsLive
|
||||
.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)
|
||||
|
||||
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
|
||||
return list
|
||||
}, [jobs])
|
||||
}, [jobsLive])
|
||||
|
||||
const postworkRows = useMemo<DownloadRow[]>(() => {
|
||||
const list = jobs
|
||||
.filter((j) => isPostworkJob(j))
|
||||
const list = jobsLive
|
||||
.filter((j) => {
|
||||
if (!isPostworkJob(j)) return false
|
||||
if (isTerminalStatus((j as any)?.status)) return false
|
||||
return true
|
||||
})
|
||||
.map((job) => ({ kind: 'job', job }) as const)
|
||||
|
||||
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
|
||||
return list
|
||||
}, [jobs])
|
||||
}, [jobsLive])
|
||||
|
||||
const pendingRows = useMemo<DownloadRow[]>(() => {
|
||||
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)
|
||||
|
||||
@ -4,10 +4,8 @@
|
||||
|
||||
import * as React from 'react'
|
||||
import { useMemo, useEffect, useCallback } from 'react'
|
||||
import { type Column, type SortState } from './Table'
|
||||
import Card from './Card'
|
||||
import type { RecordJob } from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import ButtonGroup from './ButtonGroup'
|
||||
import {
|
||||
TableCellsIcon,
|
||||
@ -23,12 +21,12 @@ import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
|
||||
import Pagination from './Pagination'
|
||||
import { applyInlineVideoPolicy } from './videoPolicy'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import Button from './Button'
|
||||
import { useNotify } from './notify'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
import Switch from './Switch'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
type SortMode =
|
||||
| 'completed_desc'
|
||||
@ -53,7 +51,9 @@ type Props = {
|
||||
onDeleteJob?: (
|
||||
job: RecordJob
|
||||
) => 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>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
@ -64,6 +64,7 @@ type Props = {
|
||||
assetNonce?: number
|
||||
sortMode: SortMode
|
||||
onSortModeChange: (m: SortMode) => void
|
||||
loadMode?: 'paged' | 'all'
|
||||
}
|
||||
|
||||
const norm = (p: string) => (p || '').replaceAll('\\', '/')
|
||||
@ -73,7 +74,16 @@ const baseName = (p: string) => {
|
||||
const parts = n.split('/')
|
||||
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 p = norm(String(output ?? ''))
|
||||
@ -125,11 +135,6 @@ function useMediaQuery(query: string) {
|
||||
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 fileRaw = baseName(output || '')
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
@ -211,7 +216,9 @@ export default function FinishedDownloads({
|
||||
sortMode,
|
||||
onSortModeChange,
|
||||
modelsByKey,
|
||||
loadMode = 'paged',
|
||||
}: Props) {
|
||||
const allMode = loadMode === 'all'
|
||||
|
||||
const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
||||
|
||||
@ -231,8 +238,10 @@ export default function FinishedDownloads({
|
||||
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
||||
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
|
||||
type UndoAction =
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string }
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string; from?: 'done' | 'keep' }
|
||||
| { kind: 'keep'; keptFile: string; originalFile: string }
|
||||
| { kind: 'hot'; currentFile: string }
|
||||
|
||||
@ -254,7 +263,27 @@ export default function FinishedDownloads({
|
||||
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'
|
||||
const VIEW_KEY = 'finishedDownloads_view'
|
||||
@ -311,7 +340,8 @@ export default function FinishedDownloads({
|
||||
unhide(lastAction.originalFile)
|
||||
unhide(restoredFile)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } }))
|
||||
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
|
||||
emitCountHint(visibleDelta)
|
||||
queueRefill()
|
||||
setLastAction(null)
|
||||
return
|
||||
@ -332,7 +362,7 @@ export default function FinishedDownloads({
|
||||
unhide(lastAction.originalFile)
|
||||
unhide(restoredFile)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } }))
|
||||
emitCountHint(+1)
|
||||
queueRefill()
|
||||
setLastAction(null)
|
||||
return
|
||||
@ -407,9 +437,12 @@ export default function FinishedDownloads({
|
||||
}, [])
|
||||
|
||||
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
|
||||
const effectiveAllMode = globalFilterActive || allMode
|
||||
|
||||
const fetchAllDoneJobs = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||
{
|
||||
@ -425,6 +458,9 @@ export default function FinishedDownloads({
|
||||
|
||||
setOverrideDoneJobs(items)
|
||||
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[sortMode, includeKeep]
|
||||
)
|
||||
@ -453,11 +489,10 @@ export default function FinishedDownloads({
|
||||
}, []) // nur einmal beim Mount
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalFilterActive) return
|
||||
if (!effectiveAllMode) return
|
||||
|
||||
const ac = new AbortController()
|
||||
|
||||
// debounce: erst fetchen wenn User kurz aufgehört hat zu tippen
|
||||
const t = window.setTimeout(() => {
|
||||
fetchAllDoneJobs(ac.signal).catch(() => {})
|
||||
}, 250)
|
||||
@ -466,40 +501,68 @@ export default function FinishedDownloads({
|
||||
window.clearTimeout(t)
|
||||
ac.abort()
|
||||
}
|
||||
}, [globalFilterActive, fetchAllDoneJobs])
|
||||
}, [effectiveAllMode, fetchAllDoneJobs])
|
||||
|
||||
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
|
||||
useEffect(() => {
|
||||
if (refillTick === 0) return
|
||||
|
||||
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
||||
if (globalFilterActive) {
|
||||
const ac = new AbortController()
|
||||
let alive = true
|
||||
|
||||
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
||||
if (effectiveAllMode) {
|
||||
;(async () => {
|
||||
try {
|
||||
// (Wenn fetchAllDoneJobs selbst setIsLoading macht: reicht das.)
|
||||
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 () => {
|
||||
try {
|
||||
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
|
||||
const listRes = await fetch(
|
||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||
const [listRes, metaRes] = await Promise.all([
|
||||
fetch(
|
||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
|
||||
includeKeep ? '&includeKeep=1' : ''
|
||||
}`,
|
||||
{ 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) {
|
||||
const data = await listRes.json().catch(() => null)
|
||||
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||||
const count = Number(data?.count ?? data?.totalCount ?? items.length)
|
||||
|
||||
const items = Array.isArray(data?.items)
|
||||
? (data.items as RecordJob[])
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
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)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / pageSize))
|
||||
@ -509,24 +572,42 @@ export default function FinishedDownloads({
|
||||
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 {
|
||||
// Abort / Fehler ignorieren
|
||||
} finally {
|
||||
if (alive) setIsLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => ac.abort()
|
||||
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs, includeKeep])
|
||||
return () => {
|
||||
alive = false
|
||||
ac.abort()
|
||||
}
|
||||
}, [
|
||||
refillTick,
|
||||
effectiveAllMode,
|
||||
fetchAllDoneJobs,
|
||||
page,
|
||||
pageSize,
|
||||
sortMode,
|
||||
includeKeep,
|
||||
onPageChange,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
|
||||
if (globalFilterActive) return
|
||||
|
||||
// ✅ Overrides nur zurücksetzen, wenn sich die "Query" ändert,
|
||||
// nicht wenn App optimistisch doneJobs filtert.
|
||||
if (effectiveAllMode) return
|
||||
setOverrideDoneJobs(null)
|
||||
setOverrideDoneTotal(null)
|
||||
}, [page, pageSize, sortMode, includeKeep, globalFilterActive])
|
||||
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!includeKeep) {
|
||||
@ -621,6 +702,36 @@ export default function FinishedDownloads({
|
||||
const durationsRef = React.useRef<Record<string, number>>({})
|
||||
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(() => {
|
||||
durationsRef.current = durations
|
||||
}, [durations])
|
||||
@ -751,20 +862,17 @@ export default function FinishedDownloads({
|
||||
|
||||
const animateRemove = useCallback(
|
||||
(key: string) => {
|
||||
// 1) rot + fade-out starten
|
||||
markRemoving(key, true)
|
||||
// ✅ Refill sofort starten (parallel zur Animation)
|
||||
queueRefill()
|
||||
|
||||
// ggf. alten Timer entfernen (wenn mehrfach getriggert)
|
||||
markRemoving(key, true)
|
||||
cancelRemoveTimer(key)
|
||||
|
||||
// 2) nach der Animation wirklich ausblenden + Seite auffüllen
|
||||
const t = window.setTimeout(() => {
|
||||
removeTimersRef.current.delete(key)
|
||||
|
||||
markDeleted(key)
|
||||
markRemoving(key, false)
|
||||
|
||||
queueRefill()
|
||||
}, 320)
|
||||
|
||||
removeTimersRef.current.set(key, t)
|
||||
@ -814,7 +922,7 @@ export default function FinishedDownloads({
|
||||
|
||||
// ✅ OPTIMISTIK + Pagination refill + count hint
|
||||
animateRemove(key)
|
||||
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
|
||||
emitCountHint(-1)
|
||||
// animateRemove queued already queueRefill(), aber extra ist ok:
|
||||
// queueRefill()
|
||||
|
||||
@ -830,22 +938,23 @@ export default function FinishedDownloads({
|
||||
|
||||
// ✅ Backend liefert undoToken (Trash)
|
||||
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 : ''
|
||||
|
||||
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file })
|
||||
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, from })
|
||||
else setLastAction(null)
|
||||
|
||||
animateRemove(key)
|
||||
|
||||
// ✅ Tab-Count sofort korrigieren (App hört drauf)
|
||||
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
|
||||
emitCountHint(-1)
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
|
||||
restoreRow(key)
|
||||
|
||||
notify.error('Löschen fehlgeschlagen', String(e?.message || e))
|
||||
notify.error('Löschen fehlgeschlagen: ', file)
|
||||
return false
|
||||
} finally {
|
||||
markDeleting(key, false)
|
||||
@ -894,11 +1003,11 @@ export default function FinishedDownloads({
|
||||
animateRemove(key)
|
||||
|
||||
// ✅ 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
|
||||
} catch (e: any) {
|
||||
notify.error('Keep fehlgeschlagen', String(e?.message || e))
|
||||
notify.error('Keep fehlgeschlagen', file)
|
||||
return false
|
||||
} finally {
|
||||
markKeeping(key, false)
|
||||
@ -917,42 +1026,14 @@ export default function FinishedDownloads({
|
||||
|
||||
const applyRename = useCallback((oldFile: string, newFile: string) => {
|
||||
if (!oldFile || !newFile || oldFile === newFile) return
|
||||
|
||||
// 1) renamedFiles: alte/konfliktierende Kanten entfernen, dann neue setzen
|
||||
setRenamedFiles((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)) {
|
||||
if (k === oldFile || k === newFile || v === oldFile || v === newFile) {
|
||||
delete next[k]
|
||||
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
|
||||
}
|
||||
}
|
||||
|
||||
next[oldFile] = newFile
|
||||
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(
|
||||
@ -994,7 +1075,9 @@ export default function FinishedDownloads({
|
||||
// ✅ Undo erst jetzt setzen (nach Erfolg)
|
||||
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
|
||||
|
||||
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
|
||||
queueRefill()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -1033,26 +1116,6 @@ export default function FinishedDownloads({
|
||||
[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(
|
||||
(job: RecordJob): RecordJob => {
|
||||
const out = norm(job.output || '')
|
||||
@ -1100,12 +1163,24 @@ export default function FinishedDownloads({
|
||||
|
||||
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(() => {
|
||||
const onExternalDelete = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
||||
if (!detail?.file) return
|
||||
|
||||
const key = detail.file
|
||||
const key = fileToKeyRef.current.get(detail.file) || detail.file
|
||||
|
||||
if (detail.phase === 'start') {
|
||||
markDeleting(key, true)
|
||||
@ -1137,6 +1212,20 @@ export default function FinishedDownloads({
|
||||
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||||
}, [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(() => {
|
||||
const onExternalRename = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
|
||||
@ -1163,7 +1252,7 @@ export default function FinishedDownloads({
|
||||
const modelKey = lower(model)
|
||||
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) {
|
||||
if (!hay.includes(t)) return false
|
||||
@ -1188,21 +1277,23 @@ export default function FinishedDownloads({
|
||||
})
|
||||
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
|
||||
|
||||
const totalItemsForPagination = globalFilterActive ? visibleRows.length : doneTotalPage
|
||||
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
|
||||
|
||||
const pageRows = useMemo(() => {
|
||||
if (!globalFilterActive) return visibleRows
|
||||
if (!effectiveAllMode) return visibleRows
|
||||
const start = (page - 1) * pageSize
|
||||
const end = start + pageSize
|
||||
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 = !globalFilterActive && totalItemsForPagination === 0
|
||||
|
||||
// ✅ "Filter liefert keine Treffer"
|
||||
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 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(() => {
|
||||
if (!globalFilterActive) return
|
||||
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
|
||||
@ -1319,271 +1410,16 @@ export default function FinishedDownloads({
|
||||
flushDurationsSoon()
|
||||
}, [flushDurationsSoon])
|
||||
|
||||
const columns: Column<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
widthClassName: 'w-[140px]',
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||
const previewMuted = !allowSound
|
||||
const handleResolution = useCallback((job: RecordJob, w: number, h: number) => {
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) 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}
|
||||
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(', ')
|
||||
const k = keyFor(job)
|
||||
const prev = resolutionsRef.current[k]
|
||||
if (prev && prev.w === w && prev.h === h) return
|
||||
|
||||
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">
|
||||
{/* 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)
|
||||
}
|
||||
resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } }
|
||||
flushResolutionsSoon()
|
||||
}, [flushResolutionsSoon])
|
||||
|
||||
// ✅ Hooks immer zuerst – unabhängig von rows
|
||||
const isSmall = useMediaQuery('(max-width: 639px)')
|
||||
@ -1634,6 +1470,9 @@ export default function FinishedDownloads({
|
||||
|
||||
{/* Right: Controls */}
|
||||
<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 */}
|
||||
<div className="hidden sm:flex items-center gap-2 min-w-0 flex-1">
|
||||
<input
|
||||
@ -1702,6 +1541,7 @@ export default function FinishedDownloads({
|
||||
<Button
|
||||
size={isSmall ? 'sm' : 'md'}
|
||||
variant="soft"
|
||||
className={isSmall ? 'h-9' : 'h-10'}
|
||||
disabled={!lastAction || undoing}
|
||||
onClick={undoLastAction}
|
||||
title={
|
||||
@ -1891,7 +1731,26 @@ export default function FinishedDownloads({
|
||||
</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>
|
||||
<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">
|
||||
@ -1943,6 +1802,7 @@ export default function FinishedDownloads({
|
||||
<FinishedDownloadsCardsView
|
||||
rows={pageRows}
|
||||
isSmall={isSmall}
|
||||
isLoading={isLoading}
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
teaserKey={teaserKey}
|
||||
@ -1986,31 +1846,50 @@ export default function FinishedDownloads({
|
||||
{view === 'table' && (
|
||||
<FinishedDownloadsTableView
|
||||
rows={pageRows}
|
||||
columns={columns}
|
||||
getRowKey={(j) => keyFor(j)}
|
||||
sort={sort}
|
||||
onSortChange={handleTableSortChange}
|
||||
onRowClick={onOpenPlayer}
|
||||
rowClassName={(j) => {
|
||||
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(' ')
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
lower={lower}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
resolutions={resolutions}
|
||||
durations={durations}
|
||||
canHover={canHover}
|
||||
teaserAudio={teaserAudio}
|
||||
hoverTeaserKey={hoverTeaserKey}
|
||||
setHoverTeaserKey={setHoverTeaserKey}
|
||||
teaserPlayback={teaserPlaybackMode}
|
||||
teaserKey={teaserKey}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
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' && (
|
||||
<FinishedDownloadsGalleryView
|
||||
rows={pageRows}
|
||||
isLoading={isLoading}
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
handleDuration={handleDuration}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -12,18 +11,16 @@ import {
|
||||
HeartIcon as HeartSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
import { formatResolution } from './formatters'
|
||||
|
||||
type InlinePlayState = { key: string; nonce: number } | null
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
isSmall: boolean
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
@ -35,7 +32,6 @@ type Props = {
|
||||
inlinePlay: InlinePlayState
|
||||
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
|
||||
|
||||
|
||||
deletingKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
@ -97,10 +93,10 @@ const parseTags = (raw?: string): string[] => {
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
export default function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
isLoading,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
@ -134,8 +130,6 @@ export default function FinishedDownloadsCardsView({
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
|
||||
releasePlayingFile,
|
||||
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
@ -143,47 +137,29 @@ export default function FinishedDownloadsCardsView({
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch
|
||||
onToggleWatch,
|
||||
}: 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)
|
||||
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
|
||||
if (w > 0 && h > 0) return { w, h }
|
||||
return null
|
||||
}, [])
|
||||
|
||||
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 metaChipCls = 'rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium backdrop-blur-[2px]'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
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 previewMuted = !allowSound
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
@ -193,59 +169,54 @@ export default function FinishedDownloadsCardsView({
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
|
||||
const flags = modelsByKey[lower(model)]
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
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 =
|
||||
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 tags = parseTags(flags?.tags)
|
||||
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const resObj = resolutionObjOf(j)
|
||||
const resLabel = formatResolution(resObj)
|
||||
|
||||
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 = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'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(' ')}
|
||||
className={shellCls}
|
||||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||||
onKeyDown={(e) => {
|
||||
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 */}
|
||||
<div
|
||||
id={inlineDomId}
|
||||
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)}
|
||||
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
|
||||
onClick={(e) => {
|
||||
@ -255,13 +226,14 @@ export default function FinishedDownloadsCardsView({
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0">
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
className="w-full h-full"
|
||||
className="h-full w-full"
|
||||
showPopover={false}
|
||||
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
|
||||
blur={isSmall ? false : inlineActive ? false : blurPreviews}
|
||||
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
@ -275,39 +247,33 @@ export default function FinishedDownloadsCardsView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gradient overlay bottom */}
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
|
||||
'transition-opacity duration-150',
|
||||
inlineActive ? 'opacity-0' : 'opacity-100',
|
||||
].join(' ')}
|
||||
{/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
|
||||
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="overlay"
|
||||
busy={busy}
|
||||
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>
|
||||
|
||||
{!isSmall && inlinePlay?.key === k && (
|
||||
{/* Restart (wenn inline läuft) */}
|
||||
{!isSmall && inlinePlay?.key === k ? (
|
||||
<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) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -318,163 +284,70 @@ export default function FinishedDownloadsCardsView({
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Actions top-right */}
|
||||
{/* Bottom overlay (ohne Gradient) */}
|
||||
<div
|
||||
className="absolute right-2 top-2 flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="
|
||||
pointer-events-none absolute inset-x-0 bottom-0
|
||||
px-2 pb-2 pt-8 text-white
|
||||
"
|
||||
>
|
||||
<RecordJobActions
|
||||
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="flex items-center justify-end gap-2">
|
||||
<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}
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="truncate">{stripHotPrefix(fileRaw) || '—'}</span>
|
||||
|
||||
{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}
|
||||
</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}
|
||||
{/* Tags */}
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={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>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ✅ Mobile: SwipeCard, Desktop: normale Card
|
||||
return isSmall ? (
|
||||
<SwipeCard
|
||||
ref={(h) => {
|
||||
@ -487,39 +360,17 @@ export default function FinishedDownloadsCardsView({
|
||||
ignoreFromBottomPx={110}
|
||||
doubleTapMs={360}
|
||||
doubleTapMaxMovePx={48}
|
||||
onDoubleTap={async () => {
|
||||
if (isHot) return
|
||||
await onToggleHot?.(j)
|
||||
}}
|
||||
onTap={() => {
|
||||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||||
startInline(k)
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(domId)) {
|
||||
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||
}
|
||||
if (!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)}
|
||||
onSwipeRight={() => keepVideo(j)}
|
||||
>
|
||||
@ -530,5 +381,16 @@ export default function FinishedDownloadsCardsView({
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -10,13 +9,14 @@ import {
|
||||
HeartIcon as HeartSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
|
||||
import { formatResolution } from './formatters'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
@ -24,7 +24,6 @@ type Props = {
|
||||
hoverTeaserKey?: string | null
|
||||
teaserKey: string | null
|
||||
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
@ -59,6 +58,7 @@ type Props = {
|
||||
|
||||
export default function FinishedDownloadsGalleryView({
|
||||
rows,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
teaserPlayback,
|
||||
@ -95,9 +95,6 @@ export default function FinishedDownloadsGalleryView({
|
||||
onToggleWatch,
|
||||
}: 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
|
||||
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
|
||||
|
||||
@ -105,7 +102,6 @@ export default function FinishedDownloadsGalleryView({
|
||||
const registerTeaserHostIfNeeded = React.useCallback(
|
||||
(key: string) => (el: HTMLDivElement | null) => {
|
||||
if (!shouldObserveTeasers) {
|
||||
// wichtig: sauber unhooken, falls vorher beobachtet wurde
|
||||
registerTeaserHost(key)(null)
|
||||
return
|
||||
}
|
||||
@ -114,36 +110,6 @@ export default function FinishedDownloadsGalleryView({
|
||||
[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 s = String(raw ?? '').trim()
|
||||
if (!s) return []
|
||||
@ -163,13 +129,25 @@ export default function FinishedDownloadsGalleryView({
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
|
||||
// Sound nur bei Hover auf genau diesem Teaser
|
||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||
const previewMuted = !allowSound
|
||||
@ -180,17 +158,19 @@ export default function FinishedDownloadsGalleryView({
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
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 fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
|
||||
const dur = runtimeOf(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 deleted = deletedKeys.has(k)
|
||||
|
||||
@ -245,46 +225,22 @@ export default function FinishedDownloadsGalleryView({
|
||||
/>
|
||||
</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 */}
|
||||
<div
|
||||
className="
|
||||
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
|
||||
"
|
||||
>
|
||||
<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="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
|
||||
<div className="flex items-end justify-end gap-2">
|
||||
{/* Right bottom: Duration + Resolution(label) + Size */}
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
@ -292,10 +248,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
</div>
|
||||
|
||||
{/* Actions (top-right) */}
|
||||
<div
|
||||
className="absolute inset-x-2 top-2 z-10 flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="overlay"
|
||||
@ -318,7 +271,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
</div>
|
||||
|
||||
{/* 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="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">
|
||||
@ -338,90 +291,30 @@ export default function FinishedDownloadsGalleryView({
|
||||
) : null}
|
||||
</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}
|
||||
{/* Tags */}
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={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>
|
||||
</>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,42 +2,468 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Table, { type Column, type SortState } from './Table'
|
||||
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 = {
|
||||
rows: RecordJob[]
|
||||
columns: Column<RecordJob>[]
|
||||
getRowKey: (j: RecordJob) => string
|
||||
sort: SortState
|
||||
onSortChange: (s: SortState) => void
|
||||
onRowClick: (job: RecordJob) => void
|
||||
rowClassName?: (job: RecordJob) => string
|
||||
isLoading?: boolean
|
||||
|
||||
// helpers
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => 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({
|
||||
rows,
|
||||
columns,
|
||||
getRowKey,
|
||||
sort,
|
||||
onSortChange,
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
isLoading,
|
||||
keyFor,
|
||||
baseName,
|
||||
lower,
|
||||
modelNameFromOutput,
|
||||
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) {
|
||||
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 (
|
||||
<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
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={getRowKey}
|
||||
getRowKey={(j) => keyFor(j)}
|
||||
striped
|
||||
fullWidth
|
||||
stickyHeader
|
||||
compact={false}
|
||||
card
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
onRowClick={onRowClick}
|
||||
onSortChange={handleSortChange}
|
||||
onRowClick={onOpenPlayer}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// frontend\src\components\ui\FinishedVideoPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
||||
@ -9,12 +10,18 @@ type Variant = 'thumb' | 'fill'
|
||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||
type AnimatedMode = 'frames' | 'clips' | 'teaser'
|
||||
type AnimatedTrigger = 'always' | 'hover'
|
||||
type ProgressKind = 'inline' | 'teaser' | 'clips'
|
||||
|
||||
export type FinishedVideoPreviewProps = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
|
||||
/** optional legacy override (z.B. aus Cache im Parent) */
|
||||
durationSeconds?: number
|
||||
|
||||
/** Callbacks für Parent-State */
|
||||
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?: boolean
|
||||
@ -35,7 +42,6 @@ export type FinishedVideoPreviewProps = {
|
||||
className?: string
|
||||
showPopover?: boolean
|
||||
|
||||
|
||||
blur?: boolean
|
||||
|
||||
/**
|
||||
@ -60,7 +66,6 @@ export type FinishedVideoPreviewProps = {
|
||||
popoverMuted?: boolean
|
||||
|
||||
noGenerateTeaser?: boolean
|
||||
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -68,10 +73,12 @@ export default function FinishedVideoPreview({
|
||||
getFileName,
|
||||
durationSeconds,
|
||||
onDuration,
|
||||
onResolution,
|
||||
|
||||
animated = false,
|
||||
animatedMode = 'frames',
|
||||
animatedTrigger = 'always',
|
||||
|
||||
autoTickMs = 15000,
|
||||
thumbStepSec,
|
||||
thumbSpread,
|
||||
@ -98,6 +105,160 @@ export default function FinishedVideoPreview({
|
||||
const file = getFileName(job.output || '')
|
||||
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 = {
|
||||
muted,
|
||||
playsInline: true,
|
||||
@ -115,8 +276,8 @@ export default function FinishedVideoPreview({
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
// ✅ NEU: sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
|
||||
const [everInView, setEverInView] = useState(false)
|
||||
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
|
||||
const [, setEverInView] = useState(false)
|
||||
|
||||
// Tick nur für frames-Mode
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
@ -125,29 +286,65 @@ export default function FinishedVideoPreview({
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
||||
|
||||
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
|
||||
const wantsHover =
|
||||
inlineMode === 'hover' || (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
|
||||
|
||||
const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||
|
||||
const previewId = useMemo(() => {
|
||||
const file = getFileName(job.output || '')
|
||||
if (!file) return ''
|
||||
const base = file.replace(/\.[^.]+$/, '') // ext weg
|
||||
const f = getFileName(job.output || '')
|
||||
if (!f) return ''
|
||||
const base = f.replace(/\.[^.]+$/, '') // ext weg
|
||||
return stripHot(base).trim()
|
||||
}, [job.output, getFileName])
|
||||
|
||||
// Vollvideo (für Inline-Playback + Duration-Metadaten)
|
||||
const videoSrc = useMemo(
|
||||
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
|
||||
[file]
|
||||
)
|
||||
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
|
||||
useEffect(() => {
|
||||
if (!previewId) return
|
||||
if (!animated || animatedMode !== 'teaser') return
|
||||
if (!(inView || (wantsHover && hovered))) return
|
||||
|
||||
const hasDuration =
|
||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
const pcs = (meta as any)?.previewClips
|
||||
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'
|
||||
|
||||
@ -155,9 +352,95 @@ export default function FinishedVideoPreview({
|
||||
const teaserMp4Ref = 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) => {
|
||||
if (!v) return
|
||||
try { v.pause() } catch {}
|
||||
try {
|
||||
v.pause()
|
||||
} catch {}
|
||||
try {
|
||||
v.removeAttribute('src')
|
||||
// @ts-ignore
|
||||
@ -196,11 +479,11 @@ export default function FinishedVideoPreview({
|
||||
(entries) => {
|
||||
const hit = Boolean(entries[0]?.isIntersecting)
|
||||
setInView(hit)
|
||||
if (hit) setEverInView(true) // ✅ NEU
|
||||
if (hit) setEverInView(true)
|
||||
},
|
||||
{
|
||||
threshold: 0.01,
|
||||
rootMargin: '350px 0px', // ✅ lädt erst "bei Bedarf", aber schon etwas vor dem Viewport
|
||||
rootMargin: '120px 0px', // oder '0px' wenn du’s hart willst
|
||||
}
|
||||
)
|
||||
|
||||
@ -224,7 +507,7 @@ export default function FinishedVideoPreview({
|
||||
if (animatedMode !== 'frames') return null
|
||||
if (!hasDuration) return null
|
||||
|
||||
const dur = durationSeconds!
|
||||
const dur = effectiveDurationSec!
|
||||
const step = Math.max(0.25, thumbStepSec ?? 3)
|
||||
|
||||
if (thumbSpread) {
|
||||
@ -239,15 +522,15 @@ export default function FinishedVideoPreview({
|
||||
const total = Math.max(dur - 0.1, step)
|
||||
const t = (localTick * step) % total
|
||||
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 thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}`
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}-${localTick}`
|
||||
}, [previewId, thumbTimeSec, localTick, v])
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
|
||||
}, [previewId, thumbTimeSec, v])
|
||||
|
||||
const teaserSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
@ -255,17 +538,51 @@ export default function FinishedVideoPreview({
|
||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
|
||||
}, [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>) => {
|
||||
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 (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(() => {
|
||||
setThumbOk(true)
|
||||
setVideoOk(true)
|
||||
|
||||
// ✅ Mount-Guards zurücksetzen
|
||||
lastMountedRef.current.inline = null
|
||||
lastMountedRef.current.teaser = null
|
||||
lastMountedRef.current.clips = null
|
||||
}, [previewId, assetNonce])
|
||||
|
||||
if (!videoSrc) {
|
||||
@ -274,10 +591,7 @@ export default function FinishedVideoPreview({
|
||||
|
||||
// --- Inline Video sichtbar?
|
||||
const showingInlineVideo =
|
||||
inlineMode !== 'never' &&
|
||||
inView &&
|
||||
videoOk &&
|
||||
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||
|
||||
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
|
||||
const teaserActive =
|
||||
@ -287,26 +601,87 @@ export default function FinishedVideoPreview({
|
||||
videoOk &&
|
||||
!showingInlineVideo &&
|
||||
(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 wantsHover =
|
||||
inlineMode === 'hover' ||
|
||||
(animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
|
||||
const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
|
||||
|
||||
// ✅ 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(() => {
|
||||
if (!animated) return []
|
||||
if (animatedMode !== 'clips') return []
|
||||
if (!hasDuration) return []
|
||||
|
||||
const dur = durationSeconds!
|
||||
const dur = effectiveDurationSec!
|
||||
const clipLen = Math.max(0.25, clipSeconds)
|
||||
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)))
|
||||
}
|
||||
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])
|
||||
|
||||
@ -327,21 +702,75 @@ export default function FinishedVideoPreview({
|
||||
const clipStartRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const v = teaserMp4Ref.current
|
||||
if (!v) return
|
||||
const vv = teaserMp4Ref.current
|
||||
if (!vv) return
|
||||
|
||||
const active = teaserActive && animatedMode === 'teaser'
|
||||
if (!active) {
|
||||
try { v.pause() } catch {}
|
||||
try {
|
||||
vv.pause()
|
||||
} catch {}
|
||||
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(() => {})
|
||||
}, [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(() => {
|
||||
if (!showingInlineVideo) return
|
||||
applyInlineVideoPolicy(inlineRef.current, { muted })
|
||||
@ -349,12 +778,11 @@ export default function FinishedVideoPreview({
|
||||
|
||||
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
||||
useEffect(() => {
|
||||
const v = clipsRef.current
|
||||
if (!v) return
|
||||
const vv = clipsRef.current
|
||||
if (!vv) return
|
||||
|
||||
if (!(teaserActive && animatedMode === 'clips')) {
|
||||
// bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv
|
||||
if (!teaserActive) v.pause()
|
||||
if (!teaserActive) vv.pause()
|
||||
return
|
||||
}
|
||||
|
||||
@ -365,9 +793,9 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const start = () => {
|
||||
try {
|
||||
v.currentTime = clipStartRef.current
|
||||
vv.currentTime = clipStartRef.current
|
||||
} catch {}
|
||||
const p = v.play()
|
||||
const p = vv.play()
|
||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
||||
}
|
||||
|
||||
@ -375,41 +803,48 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!clipTimes.length) return
|
||||
if (v.currentTime - clipStartRef.current >= clipSeconds) {
|
||||
if (vv.currentTime - clipStartRef.current >= clipSeconds) {
|
||||
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
|
||||
clipStartRef.current = clipTimes[clipIdxRef.current]
|
||||
try {
|
||||
v.currentTime = clipStartRef.current + 0.01
|
||||
vv.currentTime = clipStartRef.current + 0.01
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
v.addEventListener('loadedmetadata', onLoaded)
|
||||
v.addEventListener('timeupdate', onTimeUpdate)
|
||||
vv.addEventListener('loadedmetadata', onLoaded)
|
||||
vv.addEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
if (v.readyState >= 1) start()
|
||||
if (vv.readyState >= 1) start()
|
||||
|
||||
return () => {
|
||||
v.removeEventListener('loadedmetadata', onLoaded)
|
||||
v.removeEventListener('timeupdate', onTimeUpdate)
|
||||
v.pause()
|
||||
vv.removeEventListener('loadedmetadata', onLoaded)
|
||||
vv.removeEventListener('timeupdate', onTimeUpdate)
|
||||
vv.pause()
|
||||
}
|
||||
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
|
||||
|
||||
// ✅ brauchen wir noch hidden-metadata-load?
|
||||
const needHiddenMeta =
|
||||
inView &&
|
||||
(onDuration || onResolution) &&
|
||||
!metaLoaded &&
|
||||
!showingInlineVideo &&
|
||||
((onDuration && !hasDuration) || (onResolution && !hasResolution))
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={[
|
||||
'rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative',
|
||||
sizeClass,
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
className={['group rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative isolate', sizeClass, className ?? ''].join(' ')}
|
||||
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
|
||||
onFocus={wantsHover ? () => setHovered(true) : 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 */}
|
||||
{shouldLoadAssets && thumbSrc && thumbOk ? (
|
||||
<img
|
||||
@ -428,14 +863,19 @@ export default function FinishedVideoPreview({
|
||||
{showingInlineVideo ? (
|
||||
<video
|
||||
{...commonVideoProps}
|
||||
ref={inlineRef}
|
||||
ref={(el) => {
|
||||
inlineRef.current = el
|
||||
bumpMountTickIfNew('inline', el)
|
||||
}}
|
||||
key={`inline-${previewId}-${inlineNonce}`}
|
||||
src={videoSrc}
|
||||
className={[
|
||||
'absolute inset-0 w-full h-full object-cover',
|
||||
blurCls,
|
||||
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
autoPlay
|
||||
muted={muted}
|
||||
controls={inlineControls}
|
||||
@ -446,10 +886,13 @@ export default function FinishedVideoPreview({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Teaser MP4: nur im Viewport (teaserActive) – Thumb bleibt drunter sichtbar */}
|
||||
{/* ✅ Teaser MP4 */}
|
||||
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
|
||||
<video
|
||||
ref={teaserMp4Ref}
|
||||
ref={(el) => {
|
||||
teaserMp4Ref.current = el
|
||||
bumpMountTickIfNew('teaser', el)
|
||||
}}
|
||||
key={`teaser-mp4-${previewId}`}
|
||||
src={teaserSrc}
|
||||
className={[
|
||||
@ -457,7 +900,9 @@ export default function FinishedVideoPreview({
|
||||
blurCls,
|
||||
teaserReady ? 'opacity-100' : 'opacity-0',
|
||||
'transition-opacity duration-150',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
muted={muted}
|
||||
playsInline
|
||||
autoPlay
|
||||
@ -467,22 +912,22 @@ export default function FinishedVideoPreview({
|
||||
onLoadedData={() => setTeaserReady(true)}
|
||||
onPlaying={() => setTeaserReady(true)}
|
||||
onError={() => {
|
||||
setTeaserOk(false) // ✅ nur teaser abschalten
|
||||
setTeaserReady(false) // ✅ overlay wieder weg
|
||||
setTeaserOk(false)
|
||||
setTeaserReady(false)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Legacy clips (falls noch genutzt) */}
|
||||
{/* ✅ Legacy clips */}
|
||||
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
|
||||
<video
|
||||
ref={clipsRef}
|
||||
ref={(el) => {
|
||||
clipsRef.current = el
|
||||
bumpMountTickIfNew('clips', el)
|
||||
}}
|
||||
key={`clips-${previewId}-${clipTimesKey}`}
|
||||
src={videoSrc}
|
||||
className={[
|
||||
'absolute inset-0 w-full h-full object-cover pointer-events-none',
|
||||
blurCls,
|
||||
].filter(Boolean).join(' ')}
|
||||
className={['absolute inset-0 w-full h-full object-cover pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted={muted}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
@ -491,21 +936,66 @@ export default function FinishedVideoPreview({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
muted={muted}
|
||||
playsInline
|
||||
className="hidden"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
{/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
|
||||
{showAnyProgress ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'absolute left-0 right-0 bottom-0 z-10 pointer-events-none',
|
||||
// etwas höher + bei hover deutlich
|
||||
'h-0.5 group-hover:h-1.5',
|
||||
'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>
|
||||
)
|
||||
|
||||
// Gallery: kein HoverPopover
|
||||
if (!showPopover) return previewNode
|
||||
|
||||
return (
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// frontend\src\components\ui\GenerateAssetsTask.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Button from './Button'
|
||||
import ProgressBar from './ProgressBar'
|
||||
import { subscribeSSE } from '../../lib/sseSingleton'
|
||||
|
||||
type TaskState = {
|
||||
running: boolean
|
||||
@ -16,6 +18,17 @@ type TaskState = {
|
||||
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> {
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store' as any,
|
||||
@ -41,212 +54,173 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return data as T
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onFinished?: () => void
|
||||
}
|
||||
|
||||
export default function GenerateAssetsTask({ onFinished }: Props) {
|
||||
export default function GenerateAssetsTask({
|
||||
onFinished,
|
||||
onStart,
|
||||
onProgress,
|
||||
onDone,
|
||||
onCancelled,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [state, setState] = useState<TaskState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [stopping, setStopping] = useState(false)
|
||||
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets')
|
||||
setState(st)
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e))
|
||||
}
|
||||
}, [])
|
||||
const [startError, setStartError] = useState<string | null>(null)
|
||||
|
||||
// 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 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(() => {
|
||||
const prev = prevRunningRef.current
|
||||
const cur = Boolean(state?.running)
|
||||
prevRunningRef.current = cur
|
||||
|
||||
// Task ist gerade fertig geworden
|
||||
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?.()
|
||||
}
|
||||
}, [state?.running, onFinished])
|
||||
}, [state?.running, state?.error, onFinished, onDone, onCancelled])
|
||||
|
||||
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
}, [loadStatus])
|
||||
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
|
||||
setState(st)
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.running) return
|
||||
const t = window.setInterval(loadStatus, 1200)
|
||||
return () => window.clearInterval(t)
|
||||
}, [state?.running, loadStatus])
|
||||
if (st?.running) {
|
||||
const ac = ensureControllerCreated()
|
||||
armTaskList(ac)
|
||||
onProgressRef.current?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
|
||||
}
|
||||
|
||||
const errText = String(st?.error ?? '').trim()
|
||||
if (errText && errText !== lastErrorRef.current) {
|
||||
lastErrorRef.current = errText
|
||||
onErrorRef.current?.(errText)
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsub()
|
||||
}, [])
|
||||
|
||||
async function start() {
|
||||
setError(null)
|
||||
if (state?.running) return
|
||||
|
||||
setStartError(null)
|
||||
setStarting(true)
|
||||
cancelledRef.current = false
|
||||
lastErrorRef.current = ''
|
||||
|
||||
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren
|
||||
const ac = ensureControllerCreated()
|
||||
|
||||
try {
|
||||
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
|
||||
setState(st)
|
||||
|
||||
// TaskList jetzt aktivieren
|
||||
armTaskList(ac)
|
||||
|
||||
if (st?.running) {
|
||||
onProgress?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
|
||||
}
|
||||
} 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 {
|
||||
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 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 (
|
||||
<div
|
||||
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="flex items-center justify-between gap-4">
|
||||
<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 className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
{running ? (
|
||||
<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">
|
||||
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>
|
||||
)}
|
||||
{startError ? (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{startError}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-white/70">
|
||||
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/<id>/</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}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={start}
|
||||
disabled={starting || running}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{starting ? 'Starte…' : running ? 'Läuft…' : 'Generieren'}
|
||||
<div className="shrink-0">
|
||||
<Button variant="primary" onClick={start} disabled={starting || running}>
|
||||
{starting ? 'Starte…' : 'Start'}
|
||||
</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
87
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
87
frontend/src/components/ui/LoadingSpinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// frontend/src/components/ui/LoginPage.tsx
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Button from './Button'
|
||||
|
||||
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) {
|
||||
const nextPath = useMemo(() => getNextFromLocation(), [])
|
||||
|
||||
const [username, setUsername] = 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 [error, setError] = useState<string | null>(null)
|
||||
@ -62,6 +116,145 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
const [setupSecret, setSetupSecret] = 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
|
||||
useEffect(() => {
|
||||
@ -78,6 +271,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
// Setup-Infos laden (QR/otpauth)
|
||||
void ensure2FASetup()
|
||||
} else {
|
||||
clearLoginState()
|
||||
window.location.assign(nextPath || '/')
|
||||
}
|
||||
return
|
||||
@ -113,6 +307,8 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
// 2FA ist aktiv → Code-Abfrage
|
||||
if (data?.totpRequired) {
|
||||
setStage('verify')
|
||||
// Code-Felder leeren (sauberer Start)
|
||||
clearAllDigits()
|
||||
return
|
||||
}
|
||||
|
||||
@ -121,12 +317,13 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
|
||||
if (me?.authenticated && !me?.totpConfigured) {
|
||||
setStage('setup')
|
||||
clearAllDigits()
|
||||
await ensure2FASetup()
|
||||
return
|
||||
}
|
||||
|
||||
// normaler Fall: eingeloggt + entweder 2FA schon configured oder bewusst nicht erzwingen
|
||||
if (onLoggedIn) await onLoggedIn()
|
||||
clearLoginState()
|
||||
window.location.assign(nextPath || '/')
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e))
|
||||
@ -135,20 +332,24 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const submit2FA = async () => {
|
||||
const c = codeStr.trim()
|
||||
if (!/^\d{6}$/.test(c)) return
|
||||
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
body: JSON.stringify({ code: c }),
|
||||
})
|
||||
|
||||
if (onLoggedIn) await onLoggedIn()
|
||||
clearLoginState()
|
||||
window.location.assign(nextPath || '/')
|
||||
} catch (e: any) {
|
||||
submittedOnceRef.current = false
|
||||
setError(e?.message ?? String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@ -161,9 +362,9 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
|
||||
try {
|
||||
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' },
|
||||
body: JSON.stringify({}), // optional leer
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const otpauth = (data?.otpauth ?? '').trim()
|
||||
@ -177,14 +378,50 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key !== 'Enter') return
|
||||
ev.preventDefault()
|
||||
// Auto-submit sobald 6 Ziffern befüllt sind (verify + setup)
|
||||
useEffect(() => {
|
||||
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()
|
||||
else void submitLogin()
|
||||
}
|
||||
submittedOnceRef.current = true
|
||||
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 (
|
||||
<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="space-y-1">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Bitte melde dich an, um fortzufahren.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Bitte melde dich an, um fortzufahren.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{stage === 'login' ? (
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
void submitLogin()
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
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"
|
||||
placeholder="admin"
|
||||
@ -224,7 +465,6 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
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"
|
||||
placeholder="••••••••••"
|
||||
@ -232,70 +472,58 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full rounded-lg"
|
||||
disabled={busy || !username.trim() || !password}
|
||||
onClick={() => void submitLogin()}
|
||||
>
|
||||
<Button type="submit" variant="primary" className="w-full rounded-lg" disabled={busy || !username.trim() || !password}>
|
||||
{busy ? 'Login…' : 'Login'}
|
||||
</Button>
|
||||
</>
|
||||
</form>
|
||||
) : 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">
|
||||
2FA ist aktiv – bitte gib den Code aus deiner Authenticator-App ein.
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
{Code6Inputs}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1 rounded-lg"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSetupAuthUrl(null)
|
||||
setSetupSecret(null)
|
||||
setSetupInfo(null)
|
||||
setStage('login')
|
||||
clearAllDigits()
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1 rounded-lg"
|
||||
disabled={busy || code.trim().length < 6}
|
||||
onClick={() => void submit2FA()}
|
||||
disabled={busy || !/^\d{6}$/.test(codeStr)}
|
||||
>
|
||||
{busy ? 'Prüfe…' : 'Bestätigen'}
|
||||
</Button>
|
||||
</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">
|
||||
2FA ist noch nicht eingerichtet – bitte richte es jetzt ein (empfohlen).
|
||||
</div>
|
||||
@ -329,12 +557,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{setupInfo ? (
|
||||
<div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div>
|
||||
) : null}
|
||||
{setupInfo ? <div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div> : null}
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full rounded-lg"
|
||||
disabled={busy}
|
||||
@ -346,38 +573,19 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code (zum Aktivieren)</label>
|
||||
<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>
|
||||
{/* gleiche 6-fach Eingabe auch hier */}
|
||||
{Code6Inputs}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1 rounded-lg"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
// optional: Setup überspringen (nicht empfohlen)
|
||||
if (onLoggedIn) void onLoggedIn()
|
||||
clearLoginState()
|
||||
window.location.assign(nextPath || '/')
|
||||
}}
|
||||
title="Ohne 2FA fortfahren (nicht empfohlen)"
|
||||
@ -385,16 +593,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
||||
Später
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1 rounded-lg"
|
||||
disabled={busy || code.trim().length < 6}
|
||||
onClick={() => void submit2FA()}
|
||||
>
|
||||
<Button type="submit" variant="primary" className="flex-1 rounded-lg" disabled={busy || !/^\d{6}$/.test(codeStr)}>
|
||||
{busy ? 'Aktiviere…' : '2FA aktivieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
// frontend\src\components\ui\Modal.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ModalLayout = 'single' | 'split'
|
||||
type ModalScroll = 'body' | 'right' | 'none'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@ -17,6 +22,59 @@ type ModalProps = {
|
||||
* "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl"
|
||||
*/
|
||||
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({
|
||||
@ -27,10 +85,85 @@ export default function Modal({
|
||||
footer,
|
||||
icon,
|
||||
width = 'max-w-lg',
|
||||
|
||||
layout = 'single',
|
||||
left,
|
||||
leftWidthClass = 'lg:w-80',
|
||||
scroll,
|
||||
|
||||
bodyClassName,
|
||||
leftClassName,
|
||||
rightClassName,
|
||||
rightHeader,
|
||||
rightBodyClassName,
|
||||
mobileCollapsedImageSrc,
|
||||
mobileCollapsedImageAlt,
|
||||
}: 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 (
|
||||
<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 */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
@ -45,7 +178,7 @@ export default function Modal({
|
||||
</Transition.Child>
|
||||
|
||||
{/* 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">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
@ -57,25 +190,31 @@ export default function Modal({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={[
|
||||
'relative w-full transform rounded-lg bg-white text-left shadow-xl transition-all',
|
||||
className={cn(
|
||||
'relative w-full rounded-lg bg-white text-left shadow-xl transition-all',
|
||||
'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',
|
||||
width, // <- hier greift deine max-w-… Klasse
|
||||
].join(' ')}
|
||||
width
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
{icon ? (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 flex items-start justify-between gap-3">
|
||||
{/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
|
||||
<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">
|
||||
{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}
|
||||
</Dialog.Title>
|
||||
) : null}
|
||||
@ -84,12 +223,12 @@ export default function Modal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="
|
||||
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
|
||||
"
|
||||
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"
|
||||
>
|
||||
@ -97,14 +236,157 @@ export default function Modal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body (scrollable) */}
|
||||
<div className="px-6 pb-6 pt-4 text-sm text-gray-700 dark:text-gray-300 overflow-y-auto">
|
||||
{/* Body */}
|
||||
{layout === 'single' ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 min-h-0 h-full',
|
||||
scrollMode === 'body'
|
||||
? 'overflow-y-auto overscroll-contain'
|
||||
: 'overflow-hidden',
|
||||
rightClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</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 ? (
|
||||
<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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,16 +14,13 @@ type Props = {
|
||||
className?: string
|
||||
fit?: 'cover' | 'contain'
|
||||
|
||||
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
|
||||
alignStartAt?: string | number | Date
|
||||
alignEndAt?: string | number | Date | null
|
||||
alignEveryMs?: number
|
||||
|
||||
// ✅ NEU: schneller Retry am Anfang (nur bei Running sinnvoll)
|
||||
fastRetryMs?: number
|
||||
fastRetryMax?: number
|
||||
fastRetryWindowMs?: number
|
||||
|
||||
}
|
||||
|
||||
export default function ModelPreview({
|
||||
@ -39,24 +36,25 @@ export default function ModelPreview({
|
||||
fastRetryWindowMs,
|
||||
className,
|
||||
}: Props) {
|
||||
|
||||
const [pageVisible, setPageVisible] = useState(() => {
|
||||
if (typeof document === 'undefined') return true
|
||||
return !document.hidden
|
||||
})
|
||||
|
||||
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 [imgError, setImgError] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
const retryT = useRef<number | null>(null)
|
||||
const fastTries = useRef(0)
|
||||
const hadSuccess = useRef(false)
|
||||
const enteredViewOnce = useRef(false)
|
||||
|
||||
|
||||
const toMs = (v: any): number => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
if (v instanceof Date) return v.getTime()
|
||||
@ -64,34 +62,61 @@ export default function ModelPreview({
|
||||
return Number.isFinite(ms) ? ms : NaN
|
||||
}
|
||||
|
||||
// ✅ visibilitychange -> nur REF updaten
|
||||
useEffect(() => {
|
||||
const onVis = () => setPageVisible(!document.hidden)
|
||||
const onVis = () => {
|
||||
pageVisibleRef.current = !document.hidden
|
||||
}
|
||||
pageVisibleRef.current = !document.hidden
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
return () => document.removeEventListener('visibilitychange', onVis)
|
||||
}, [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (retryT.current) window.clearTimeout(retryT.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
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(() => {
|
||||
if (typeof thumbTick === 'number') return
|
||||
if (!inView || !pageVisible) return
|
||||
if (!inView) return
|
||||
if (!pageVisibleRef.current) return
|
||||
if (enteredViewOnce.current) return
|
||||
enteredViewOnce.current = true
|
||||
setLocalTick((x) => x + 1)
|
||||
}, [inView, thumbTick, pageVisible])
|
||||
|
||||
}, [inView, thumbTick])
|
||||
|
||||
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
|
||||
useEffect(() => {
|
||||
// Wenn Parent tickt, kein lokales Ticken
|
||||
if (typeof thumbTick === 'number') return
|
||||
|
||||
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
|
||||
if (!inView || !pageVisible) return
|
||||
if (!inView) return
|
||||
if (!pageVisibleRef.current) return
|
||||
|
||||
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
|
||||
if (!Number.isFinite(period) || period <= 0) return
|
||||
@ -99,11 +124,13 @@ export default function ModelPreview({
|
||||
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
|
||||
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
|
||||
|
||||
// 1) ✅ Aligned: tick genau auf Vielfachen von period seit startMs
|
||||
// aligned schedule
|
||||
if (Number.isFinite(startMs)) {
|
||||
let t: number | undefined
|
||||
|
||||
const schedule = () => {
|
||||
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
|
||||
if (!pageVisibleRef.current) return
|
||||
const now = Date.now()
|
||||
if (Number.isFinite(endMs) && now >= endMs) return
|
||||
|
||||
@ -112,6 +139,9 @@ export default function ModelPreview({
|
||||
const wait = rem === 0 ? period : period - rem
|
||||
|
||||
t = window.setTimeout(() => {
|
||||
// ✅ nochmal checken, falls inzwischen offscreen/hidden
|
||||
if (!inViewRef.current) return
|
||||
if (!pageVisibleRef.current) return
|
||||
setLocalTick((x) => x + 1)
|
||||
schedule()
|
||||
}, wait)
|
||||
@ -123,62 +153,56 @@ export default function ModelPreview({
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: normales Interval (nicht aligned)
|
||||
// fallback interval
|
||||
const id = window.setInterval(() => {
|
||||
if (!inViewRef.current) return
|
||||
if (!pageVisibleRef.current) return
|
||||
setLocalTick((x) => x + 1)
|
||||
}, period)
|
||||
|
||||
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(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
if (!inView) return
|
||||
if (!pageVisibleRef.current) return
|
||||
frozenTickRef.current = rawTick
|
||||
setFrozenTick(rawTick)
|
||||
}, [rawTick, inView])
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(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)
|
||||
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
|
||||
useEffect(() => {
|
||||
setImgError(false)
|
||||
}, [tick])
|
||||
}, [frozenTick])
|
||||
|
||||
useEffect(() => {
|
||||
// bei Job-Wechsel alles sauber neu starten
|
||||
// bei Job-Wechsel reset
|
||||
hadSuccess.current = false
|
||||
fastTries.current = 0
|
||||
enteredViewOnce.current = 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])
|
||||
|
||||
// Thumbnail mit Cache-Buster (?v=...)
|
||||
const thumb = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
|
||||
[jobId, tick]
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
|
||||
[jobId, frozenTick]
|
||||
)
|
||||
|
||||
// HLS nur für große Vorschau im Popover
|
||||
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]
|
||||
)
|
||||
|
||||
@ -188,15 +212,13 @@ export default function ModelPreview({
|
||||
open && (
|
||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||
<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">
|
||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||
Live
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<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"
|
||||
@ -227,6 +249,7 @@ export default function ModelPreview({
|
||||
src={thumb}
|
||||
loading={inView ? 'eager' : 'lazy'}
|
||||
fetchPriority={inView ? 'high' : 'auto'}
|
||||
decoding="async"
|
||||
alt=""
|
||||
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
|
||||
onLoad={() => {
|
||||
@ -238,9 +261,8 @@ export default function ModelPreview({
|
||||
onError={() => {
|
||||
setImgError(true)
|
||||
|
||||
// ✅ Fast-Retry nur wenn aktiviert & sinnvoll
|
||||
if (!fastRetryMs) return
|
||||
if (!inView || !pageVisible) return
|
||||
if (!inViewRef.current || !pageVisibleRef.current) return
|
||||
if (hadSuccess.current) return
|
||||
|
||||
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
|
||||
@ -254,7 +276,7 @@ export default function ModelPreview({
|
||||
if (retryT.current) window.clearTimeout(retryT.current)
|
||||
retryT.current = window.setTimeout(() => {
|
||||
fastTries.current += 1
|
||||
setLocalTick((x) => x + 1) // triggert neuen Request via ?v=
|
||||
setLocalTick((x) => x + 1)
|
||||
}, fastRetryMs)
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -216,7 +216,7 @@ export default function PerformanceMonitor({
|
||||
{/* DISK */}
|
||||
<div className="flex items-center gap-2" title={diskTitle}>
|
||||
<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
|
||||
className={`h-full ${barTone(diskTone)}`}
|
||||
style={{ width: `${Math.round(usedFill * 100)}%` }}
|
||||
@ -230,7 +230,7 @@ export default function PerformanceMonitor({
|
||||
{/* PING */}
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
className={`h-full ${barTone(pingTone)}`}
|
||||
style={{ width: `${Math.round(pingFill * 100)}%` }}
|
||||
@ -244,7 +244,7 @@ export default function PerformanceMonitor({
|
||||
{/* FPS */}
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
className={`h-full ${barTone(fpsTone)}`}
|
||||
style={{ width: `${Math.round(fpsFill * 100)}%` }}
|
||||
@ -258,7 +258,7 @@ export default function PerformanceMonitor({
|
||||
{/* CPU */}
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
className={`h-full ${barTone(cpuTone)}`}
|
||||
style={{ width: `${Math.round(cpuFill * 100)}%` }}
|
||||
|
||||
@ -458,7 +458,12 @@ export type PlayerProps = {
|
||||
// actions
|
||||
onKeep?: (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>
|
||||
onToggleLike?: (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 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")
|
||||
const isRunning = job.status === 'running'
|
||||
const [hlsReady, setHlsReady] = React.useState(false)
|
||||
@ -514,6 +537,48 @@ export default function Player({
|
||||
[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 model = React.useMemo(() => {
|
||||
const k = (modelKey || '').trim()
|
||||
@ -522,13 +587,9 @@ export default function Player({
|
||||
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
|
||||
|
||||
const runtimeLabel = React.useMemo(() => {
|
||||
const sec =
|
||||
Number((job as any)?.meta?.durationSeconds) ||
|
||||
Number((job as any)?.durationSeconds) ||
|
||||
0
|
||||
|
||||
const sec = Number(fullDurationSec || 0) || 0
|
||||
return sec > 0 ? formatDuration(sec * 1000) : '—'
|
||||
}, [job])
|
||||
}, [fullDurationSec])
|
||||
|
||||
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
|
||||
const dateLabel = React.useMemo(() => {
|
||||
@ -562,7 +623,7 @@ export default function Player({
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
@ -579,12 +640,13 @@ export default function Player({
|
||||
}, [previewA])
|
||||
|
||||
const videoH = React.useMemo(
|
||||
() => pickNum(anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
||||
[anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
||||
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
||||
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
||||
)
|
||||
|
||||
const fps = React.useMemo(
|
||||
() => pickNum(anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
|
||||
[anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
|
||||
() => pickNum(metaDims.fps, 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)
|
||||
@ -709,7 +771,11 @@ export default function Player({
|
||||
// ✅ Live wird NICHT mehr über Video.js gespielt
|
||||
if (isRunning) return { src: '', type: '' }
|
||||
|
||||
// ✅ Warten bis meta.json existiert + Infos geladen
|
||||
if (!metaReady) return { src: '', type: '' }
|
||||
|
||||
const file = baseName(job.output?.trim() || '')
|
||||
|
||||
if (file) {
|
||||
const ext = file.toLowerCase().split('.').pop()
|
||||
const type =
|
||||
@ -718,7 +784,7 @@ export default function Player({
|
||||
}
|
||||
|
||||
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 playerRef = React.useRef<VideoJsPlayer | null>(null)
|
||||
@ -769,6 +835,76 @@ export default function Player({
|
||||
}
|
||||
}, [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
|
||||
const [, setVvTick] = React.useState(0)
|
||||
|
||||
@ -874,11 +1010,7 @@ export default function Player({
|
||||
startSec,
|
||||
})
|
||||
|
||||
const knownFull =
|
||||
Number((job as any)?.meta?.durationSeconds) ||
|
||||
Number((anyJob as any)?.meta?.durationSeconds) ||
|
||||
Number((job as any)?.durationSeconds) ||
|
||||
0
|
||||
const knownFull = Number(fullDurationSec || 0) || 0
|
||||
if (knownFull > 0) p.__fullDurationSec = knownFull
|
||||
|
||||
try {
|
||||
@ -924,7 +1056,7 @@ export default function Player({
|
||||
p.load?.()
|
||||
} catch {}
|
||||
},
|
||||
[isRunning, buildVideoSrc, updateIntrinsicDims, job, anyJob]
|
||||
[isRunning, buildVideoSrc, updateIntrinsicDims, fullDurationSec]
|
||||
)
|
||||
|
||||
// ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten
|
||||
@ -1138,7 +1270,7 @@ export default function Player({
|
||||
if (!fileName) return
|
||||
|
||||
// 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
|
||||
|
||||
// absolute server-seek
|
||||
@ -1265,13 +1397,14 @@ export default function Player({
|
||||
delete p.__serverSeekAbs
|
||||
} catch {}
|
||||
}
|
||||
}, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, anyJob, job, videoH])
|
||||
}, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, videoH])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!mounted) return
|
||||
if (!containerRef.current) return
|
||||
if (playerRef.current) return
|
||||
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
|
||||
if (!metaReady) return
|
||||
|
||||
const videoEl = document.createElement('video')
|
||||
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(() => {
|
||||
const p = playerRef.current
|
||||
@ -1402,8 +1535,27 @@ export default function Player({
|
||||
el.classList.toggle('is-live-download', Boolean(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(() => {
|
||||
if (!mounted) return
|
||||
if (!isRunning && !metaReady) {
|
||||
releaseMedia()
|
||||
return
|
||||
}
|
||||
|
||||
const p = playerRef.current
|
||||
if (!p || (p as any).isDisposed?.()) return
|
||||
@ -1429,9 +1581,18 @@ export default function Player({
|
||||
;(p as any).__timeOffsetSec = 0
|
||||
|
||||
// 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
|
||||
|
||||
// ✅ 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 })
|
||||
|
||||
const tryPlay = () => {
|
||||
@ -1447,7 +1608,7 @@ export default function Player({
|
||||
|
||||
// ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration())
|
||||
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
|
||||
} catch {}
|
||||
|
||||
@ -1474,7 +1635,7 @@ export default function Player({
|
||||
})
|
||||
|
||||
tryPlay()
|
||||
}, [mounted, media.src, media.type, startMuted, updateIntrinsicDims, job, anyJob])
|
||||
}, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return
|
||||
@ -1499,21 +1660,6 @@ export default function Player({
|
||||
queueMicrotask(() => p.trigger('resize'))
|
||||
}, [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(() => {
|
||||
const onRelease = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
// frontend\src\components\ui\RecorderSettings.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Button from './Button'
|
||||
import Card from './Card'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
import GenerateAssetsTask from './GenerateAssetsTask'
|
||||
import TaskList from './TaskList'
|
||||
import type { TaskItem } from './TaskList'
|
||||
|
||||
type RecorderSettings = {
|
||||
recordDir: string
|
||||
@ -62,6 +64,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [err, setErr] = useState<string | 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 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() {
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
@ -236,7 +267,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setErr('doneDir ist leer.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!mb || mb <= 0) {
|
||||
setErr('Mindestgröße ist 0 – es würde nichts gelöscht.')
|
||||
return
|
||||
@ -250,7 +280,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
// ✅ Task starten (als letzter Eintrag in TaskList)
|
||||
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 {
|
||||
const res = await fetch('/api/settings/cleanup', {
|
||||
method: 'POST',
|
||||
@ -264,19 +306,60 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
setMsg(
|
||||
`🧹 Aufräumen fertig:\n` +
|
||||
`• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` +
|
||||
`• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` +
|
||||
`• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)`
|
||||
)
|
||||
const scannedFiles = Number(data.scannedFiles ?? 0)
|
||||
|
||||
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
|
||||
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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 (
|
||||
<Card
|
||||
header={
|
||||
@ -295,6 +378,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
grayBody
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<TaskList
|
||||
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
|
||||
onCancel={(id: string) => {
|
||||
if (id === 'generate-assets') cancelAssetsTask()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Alerts */}
|
||||
{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">
|
||||
@ -307,25 +397,78 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</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="flex items-start justify-between gap-4">
|
||||
<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">
|
||||
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
|
||||
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<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">
|
||||
Utilities
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<GenerateAssetsTask
|
||||
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 className="mt-3">
|
||||
<GenerateAssetsTask onFinished={onAssetsGenerated} />
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
@ -474,7 +617,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@ -486,21 +629,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@ -4,11 +4,35 @@
|
||||
|
||||
import * as React from 'react'
|
||||
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>) {
|
||||
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 = {
|
||||
label: React.ReactNode
|
||||
className?: string
|
||||
@ -96,9 +120,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
className,
|
||||
leftAction = {
|
||||
label: (
|
||||
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
|
||||
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
|
||||
<span>Behalten</span>
|
||||
<span className="inline-flex items-center gap-2 font-semibold">
|
||||
<BookmarkSquareIcon className="h-6 w-6" />
|
||||
<span className="hidden sm:inline">Behalten</span>
|
||||
</span>
|
||||
),
|
||||
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',
|
||||
},
|
||||
//thresholdPx = 120,
|
||||
thresholdPx = 180,
|
||||
//thresholdRatio = 0.35,
|
||||
thresholdRatio = 0.1,
|
||||
thresholdPx = 140,
|
||||
thresholdRatio = 0.28,
|
||||
ignoreFromBottomPx = 72,
|
||||
ignoreSelector = '[data-swipe-ignore]',
|
||||
snapMs = 180,
|
||||
@ -144,8 +166,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const tapTimerRef = React.useRef<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<{
|
||||
id: number | null
|
||||
x: number
|
||||
@ -153,7 +173,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
dragging: boolean
|
||||
captured: 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 [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
||||
@ -246,34 +267,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const card = cardRef.current
|
||||
if (!outer || !card) return
|
||||
|
||||
const layer = fxLayerRef.current
|
||||
const layer = getGlobalFxLayer()
|
||||
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)
|
||||
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)
|
||||
// Ziel: HOT Button (falls gefunden) – ebenfalls im Viewport
|
||||
const targetEl = hotTargetSelector
|
||||
? (card.querySelector(hotTargetSelector) as HTMLElement | null)
|
||||
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
|
||||
(card.querySelector(hotTargetSelector) as HTMLElement | null))
|
||||
: null
|
||||
|
||||
let endX = startX
|
||||
let endY = startY
|
||||
if (targetEl) {
|
||||
const tr = targetEl.getBoundingClientRect()
|
||||
endX = tr.left - outerRect.left + tr.width / 2
|
||||
endY = tr.top - outerRect.top + tr.height / 2
|
||||
endX = tr.left + tr.width / 2
|
||||
endY = tr.top + tr.height / 2
|
||||
}
|
||||
|
||||
const dx = endX - startX
|
||||
@ -281,17 +293,33 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
|
||||
// Flame node
|
||||
const flame = document.createElement('div')
|
||||
flame.textContent = '🔥'
|
||||
flame.style.position = 'absolute'
|
||||
flame.style.left = `${startX}px`
|
||||
flame.style.top = `${startY}px`
|
||||
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.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)
|
||||
|
||||
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
|
||||
const popMs = 200
|
||||
const holdMs = 500
|
||||
@ -346,7 +374,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}, popMs + holdMs + Math.round(flyMs * 0.75))
|
||||
}
|
||||
|
||||
anim.onfinish = () => flame.remove()
|
||||
anim.onfinish = () => {
|
||||
try {
|
||||
root.unmount()
|
||||
} catch {}
|
||||
flame.remove()
|
||||
}
|
||||
},
|
||||
[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={{
|
||||
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
|
||||
opacity: dx === 0 ? 0 : 1,
|
||||
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
|
||||
paddingLeft: dx > 0 ? 16 : 0,
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FX Layer (Flame) */}
|
||||
<div
|
||||
ref={fxLayerRef}
|
||||
className="pointer-events-none absolute inset-0 z-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Foreground (moves) */}
|
||||
<div
|
||||
@ -411,16 +446,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
transition: animMs ? `transform ${animMs}ms ease` : undefined,
|
||||
touchAction: 'pan-y',
|
||||
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) => {
|
||||
if (!enabled || disabled) return
|
||||
|
||||
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
|
||||
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
|
||||
|
||||
// NEW: wenn true -> wir lassen Tap/DoubleTap zu, aber starten niemals Swipe/Drag
|
||||
let noSwipe = false
|
||||
|
||||
const root = e.currentTarget as HTMLElement
|
||||
const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
|
||||
const ctlVideo = videos.find((v) => v.controls)
|
||||
@ -435,25 +477,34 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
e.clientY <= vr.bottom
|
||||
|
||||
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 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
|
||||
const edgeZonePx = 64
|
||||
const xFromLeft = e.clientX - vr.left
|
||||
const xFromRight = vr.right - e.clientX
|
||||
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 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 = {
|
||||
id: e.pointerId,
|
||||
@ -461,21 +512,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
y: e.clientY,
|
||||
dragging: false,
|
||||
captured: false,
|
||||
tapIgnored, // ✅ WICHTIG: nicht "false"
|
||||
tapIgnored,
|
||||
noSwipe,
|
||||
}
|
||||
|
||||
// ✅ Perf: pro Gesture einmal Threshold berechnen
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
// ✅ dxRef reset (neue Gesture)
|
||||
dxRef.current = 0
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
|
||||
if (pointer.current.noSwipe) return
|
||||
|
||||
const ddx = e.clientX - pointer.current.x
|
||||
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
|
||||
dxRef.current = ddx
|
||||
const applyResistance = (x: number, w: number) => {
|
||||
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) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
@ -521,7 +581,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
// ✅ armedDir nur updaten wenn geändert
|
||||
const threshold = thresholdRef.current
|
||||
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) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -549,14 +616,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||||
|
||||
if (!wasDragging) {
|
||||
// Tap auf Video/Controls => NICHT anfassen
|
||||
if (wasTapIgnored) {
|
||||
setAnimMs(0)
|
||||
setDx(0)
|
||||
setArmedDir(null)
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const last = lastTapRef.current
|
||||
|
||||
@ -578,26 +637,40 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
if (doubleTapBusyRef.current) return
|
||||
doubleTapBusyRef.current = true
|
||||
|
||||
// ✅ FX sofort starten (ohne irgendwas am Video zu resetten)
|
||||
requestAnimationFrame(() => {
|
||||
// ✅ FX sofort anlegen
|
||||
try {
|
||||
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 () => {
|
||||
try {
|
||||
await onDoubleTap?.()
|
||||
} catch {
|
||||
// optional: error feedback
|
||||
} finally {
|
||||
doubleTapBusyRef.current = false
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
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
|
||||
softResetForTap()
|
||||
|
||||
@ -612,18 +685,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}, onDoubleTap ? doubleTapMs : 0)
|
||||
|
||||
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
|
||||
@ -651,7 +712,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
||||
} 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) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\Tabs.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\TagBadge.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -40,7 +42,7 @@ export default function TagBadge({
|
||||
|
||||
// Styling: Basis wie in ModelsTab
|
||||
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,
|
||||
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
||||
)
|
||||
|
||||
265
frontend/src/components/ui/TagOverflowRow.tsx
Normal file
265
frontend/src/components/ui/TagOverflowRow.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
150
frontend/src/components/ui/TaskList.tsx
Normal file
150
frontend/src/components/ui/TaskList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/ui/formatters.ts
Normal file
30
frontend/src/components/ui/formatters.ts
Normal 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`
|
||||
}
|
||||
32
frontend/src/lib/useRecordJobsSSE.ts
Normal file
32
frontend/src/lib/useRecordJobsSSE.ts
Normal 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
|
||||
}
|
||||
@ -8,6 +8,24 @@ export type PostWorkKeyStatus = {
|
||||
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 = {
|
||||
id: string
|
||||
sourceUrl?: string
|
||||
@ -22,6 +40,8 @@ export type RecordJob = {
|
||||
videoHeight?: number
|
||||
fps?: number
|
||||
|
||||
meta?: VideoMeta
|
||||
|
||||
phase?: string
|
||||
progress?: number
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user