This commit is contained in:
Linrador 2026-03-18 16:10:34 +01:00
parent 6682b51f76
commit 32a74e0be0
18 changed files with 3017 additions and 1011 deletions

View File

@ -163,6 +163,13 @@ type EnsureAssetsResult struct {
MetaOK bool
}
type EnsurePrimaryAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
MetaOK bool
}
// Public wrappers (kompatibel zu deinem bisherigen API)
func ensureAssetsForVideo(videoPath string) error {
@ -187,6 +194,454 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
return res, err
}
func ensurePrimaryAssetsForVideoWithProgressCtx(
ctx context.Context,
videoPath string,
sourceURL string,
onRatio func(r float64),
) (EnsurePrimaryAssetsResult, error) {
var out EnsurePrimaryAssetsResult
var sourceInputInvalid bool
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return out, nil
}
fi, statErr := os.Stat(videoPath)
if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
return out, nil
}
if age := time.Since(fi.ModTime()); age < 10*time.Second {
wait := 10*time.Second - age
if wait > 0 {
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-t.C:
case <-ctx.Done():
return out, ctx.Err()
}
}
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return out, nil
}
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
progress := func(r float64) {
if onRatio == nil {
return
}
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
onRatio(r)
}
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
}()
// Basis-Meta nur für Dauer/Props sicherstellen
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
if thumbBefore && previewBefore {
out.Skipped = true
progress(1)
return out, nil
}
const (
thumbsW = 0.25
previewW = 0.75
)
progress(0)
// ----------------
// Thumb
// ----------------
if thumbBefore {
progress(thumbsW)
} else {
progress(0.05)
func() {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
if err := thumbSem.Acquire(genCtx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
return
}
defer thumbSem.Release()
progress(0.10)
img, e1 := extractLastFrameJPG(videoPath)
if e1 != nil || len(img) == 0 {
if meta.durSec > 0 {
t := meta.durSec - 0.25
if t < 0 {
t = 0
}
img, e1 = extractFrameAtTimeJPG(videoPath, t)
}
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75)
}
}
progress(0.20)
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}()
progress(thumbsW)
}
// ----------------
// Preview.mp4
// ----------------
const (
previewClipLenSec = 0.75
previewMaxClips = 12
)
if previewBefore {
progress(1)
} else {
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
progress(thumbsW + 0.02)
if err := genSem.Acquire(genCtx); err != nil {
return
}
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(
genCtx,
videoPath,
previewPath,
previewClipLenSec,
previewMaxClips,
func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
},
); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return
}
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
}()
}
_ = sourceInputInvalid
progress(1)
return out, nil
}
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return perr
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
// vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
var computedPreviewClips []previewClip
var spriteMeta *previewSpriteMeta
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil {
if len(oldMeta.PreviewClips) > 0 {
computedPreviewClips = oldMeta.PreviewClips
}
if oldMeta.PreviewSprite != nil {
spriteMeta = oldMeta.PreviewSprite
}
}
// previewClips aus Dauer ableiten, falls noch nicht vorhanden
if len(computedPreviewClips) == 0 && meta.durSec > 0 {
const (
previewClipLenSec = 0.75
previewMaxClips = 12
)
opts := TeaserPreviewOptions{
Segments: previewMaxClips,
SegmentDuration: previewClipLenSec,
}
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,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
computedPreviewClips = clips
}
// Sprite erzeugen, wenn noch nicht vorhanden
spriteExists := false
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
spriteExists = true
}
if !spriteExists && meta.durSec > 0 {
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err == nil {
func() {
defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteJPG(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return
}
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}()
}
}
// Falls Sprite existiert, aber spriteMeta noch fehlt
if spriteMeta == nil {
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && meta.durSec > 0 {
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}
}
// meta.json mit previewClips + sprite ergänzen
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
// AI erst NACH Sprite
if !hasAIResultsForOutput(videoPath) {
if spriteMeta != nil {
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw")
if aerr != nil {
fmt.Println("⚠️ deferred analyze:", aerr)
return nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
}
return nil
}
func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false
}
key := "enrich:" + id
ok := enrichQ.Enqueue(PostWorkTask{
Key: key,
Added: time.Now(),
Run: func(ctx context.Context) error {
if job != nil {
st := enrichQ.StatusForKey(key)
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
"Ergänze Sprite / AI / Meta…",
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
}
err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL)
if job != nil {
if err != nil {
publishFinishedPostworkStateForJob(
job,
"enrich",
"error",
"assets",
"Ergänzung fehlgeschlagen",
0,
0,
0,
0,
)
} else {
publishFinishedPostworkStateForJob(
job,
"enrich",
"done",
"",
"Sprite / AI / Meta fertig",
0,
0,
0,
0,
)
}
}
return err
},
})
if ok && job != nil {
st := enrichQ.StatusForKey(key)
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
"Warte auf Sprite / AI / Meta…",
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
}
return ok
}
// Core: generiert thumbs/preview/sprite/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
@ -477,7 +932,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return
}
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath, err)
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return
}
@ -579,40 +1034,77 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, fmt.Errorf("video datei nicht gefunden")
}
// 1) Assets sicherstellen (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil)
// 1) Nur schnelle / primäre Assets sicherstellen:
// - preview.jpg
// - preview.mp4
// - basis-meta (duration / width / height / fps)
primaryRes, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, nil)
if err != nil {
return out, err
}
_ = assetsRes
out.MetaOK = primaryRes.MetaOK
id := assetIDFromVideoPath(videoPath)
if id == "" {
return out, fmt.Errorf("konnte asset id nicht ableiten")
}
ps := previewSpriteTruthForID(id)
out.SpriteReady = ps.Exists
out.AssetsReady = ps.Exists
out.MetaOK = true
// 2) AI-Segmente prüfen
if hasAIResultsForOutput(videoPath) {
out.AnalyzeReady = true
return out, nil
_, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
thumbExists := func() bool {
if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return true
}
return false
}()
previewExists := func() bool {
if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return true
}
return false
}()
out.AssetsReady = thumbExists && previewExists
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
}
// 3) AI nur ausführen, wenn Sprite vorhanden ist
if !ps.Exists {
// 2) Für Split brauchen wir zusätzlich Sprite + AI.
// Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen.
// (Im normalen Record-Flow laufen sie separat im Hintergrund.)
if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL); err != nil {
// best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten
fmt.Println("⚠️ prepareVideoForSplit deferred enrich:", err)
}
spriteExists := func() bool {
if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return true
}
return false
}()
out.SpriteReady = spriteExists
// 3) AI-Segmente prüfen
if hasAIResultsForOutput(videoPath) {
out.AnalyzeReady = true
return out, nil
}
// 4) Falls trotz Deferred-Step noch kein Sprite da ist, können wir AI nicht ausführen
if !spriteExists {
return out, nil
}
// 5) Letzter Fallback: AI jetzt direkt berechnen
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal)
if aerr != nil {

View File

@ -11,7 +11,6 @@ import (
"strings"
)
/*
const (
previewSpriteCols = 10
previewSpriteRows = 8
@ -19,15 +18,6 @@ const (
previewSpriteCellW = 160
previewSpriteCellH = 90
)
*/
const (
previewSpriteCols = 6
previewSpriteRows = 5
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 120
previewSpriteCellH = 68
)
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH

View File

@ -247,10 +247,71 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
}
// global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 6) // maxParallelFFmpeg = 6
var postWorkQ = NewPostWorkQueue(512, 6) // schneller kritischer Pfad
var enrichQ = NewPostWorkQueue(512, 2) // langsame Hintergrundaufgaben
// --- Status Refresher (ehemals postwork_refresh.go) ---
func startEnrichStatusRefresher() {
t := time.NewTicker(1 * time.Second)
go func() {
defer t.Stop()
for range t.C {
jobsMu.Lock()
snapshot := make([]*RecordJob, 0, len(jobs))
for _, job := range jobs {
if job == nil {
continue
}
snapshot = append(snapshot, job)
}
jobsMu.Unlock()
for _, job := range snapshot {
if job == nil {
continue
}
videoPath := strings.TrimSpace(job.Output)
if videoPath == "" {
continue
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
continue
}
key := "enrich:" + id
st := enrichQ.StatusForKey(key)
// Nur senden, wenn der Job tatsächlich gerade queued/running ist
if st.State != "queued" && st.State != "running" {
continue
}
label := "Warte auf Sprite / AI / Meta…"
if st.State == "running" {
label = "Ergänze Sprite / AI / Meta…"
}
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
label,
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
}
}
}()
}
func startPostWorkStatusRefresher() {
t := time.NewTicker(1 * time.Second)
go func() {

View File

@ -456,7 +456,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
return
}
// harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken
// harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken
{
fi, serr := os.Stat(out)
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
@ -495,8 +495,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
// ✅ Wenn AutoDelete aktiv ist und Datei unter Schwellwert liegt:
// NICHT in die Postwork-Queue aufnehmen, sondern direkt löschen + return.
if s.AutoDeleteSmallDownloads && minMB > 0 {
threshold := int64(minMB) * 1024 * 1024
@ -532,6 +530,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
postOut := out
postTarget := target
postKey := "postwork:" + job.ID
postFile := filepath.Base(out)
postAssetID := assetIDFromVideoPath(out)
jobsMu.Lock()
job.Phase = "postwork"
@ -555,6 +555,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
setJobProgress(job, "postwork", 0)
publishJobUpsert(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "running",
Phase: "postwork",
Label: "Nachbearbeitung läuft…",
Position: st.Position,
Waiting: st.Waiting,
Running: st.Running,
MaxParallel: st.MaxParallel,
})
}
out := strings.TrimSpace(postOut)
@ -566,18 +579,55 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
publishJobUpsert(job)
publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "done",
Label: "Primäre Nachbearbeitung fertig",
})
return nil
}
setPhase := func(phase string, pct int) {
setJobProgress(job, phase, pct)
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
job.PostWork = &st
jobsMu.Unlock()
publishJobUpsert(job)
label := "Nachbearbeitung läuft…"
switch strings.ToLower(strings.TrimSpace(phase)) {
case "remuxing":
label = "Remux läuft…"
case "moving":
label = "Verschiebe Datei…"
case "probe":
label = "Analysiere Datei…"
case "assets":
label = "Erstelle Preview…"
case "analyze":
label = "Analysiere Inhalt…"
}
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "running",
Phase: phase,
Label: label,
Position: st.Position,
Waiting: st.Waiting,
Running: st.Running,
MaxParallel: st.MaxParallel,
})
}
// 1) Remux
@ -595,7 +645,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// 2) Move to done
setPhase("moving", 10)
// auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben
// auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben
{
fi, serr := os.Stat(out)
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
@ -609,6 +659,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "moving",
Label: "Nachbearbeitung fehlgeschlagen",
})
if shouldLogRecordInfo(req) {
if serr != nil {
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")")
@ -660,7 +719,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}
}
// 5) Assets with progress
// 5) Nur primäre Assets synchron: preview.jpg + preview.mp4
setPhase("assets", 0)
lastPct := -1
@ -694,52 +753,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
setPhase("assets", pct)
}
if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
fmt.Println("⚠️ ensureAssetsForVideo:", err)
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
fmt.Println("⚠️ ensurePrimaryAssetsForVideo:", err)
}
setPhase("assets", 100)
// 6) AI Analyze -> meta.json.ai
setPhase("analyze", 5)
{
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
// Finalize primary postwork
finalFile := filepath.Base(out)
finalAssetID := assetIDFromVideoPath(out)
id := assetIDFromVideoPath(out)
if strings.TrimSpace(id) == "" {
fmt.Println("⚠️ postwork analyze: keine asset id ableitbar")
} else {
ps := previewSpriteTruthForID(id)
if !ps.Exists {
fmt.Println("⚠️ postwork analyze: preview-sprite.jpg nicht gefunden")
} else {
durationSec, _ := durationSecondsForAnalyze(actx, out)
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
if aerr != nil {
fmt.Println("⚠️ postwork analyze:", aerr)
} else {
setPhase("analyze", 65)
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
}
}
}
setPhase("analyze", 100)
// Finalize
jobsMu.Lock()
job.Status = postTarget
job.Phase = ""
@ -750,6 +772,28 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile,
AssetID: finalAssetID,
Queue: "postwork",
State: "done",
Label: "Primäre Nachbearbeitung fertig",
})
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
fmt.Println("⚠️ deferred enrichment enqueue failed:", out)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile,
AssetID: finalAssetID,
Queue: "enrich",
State: "error",
Phase: "assets",
Label: "Späte Nachbearbeitung konnte nicht gestartet werden",
})
}
return nil
},
})
@ -760,6 +804,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = &st
jobsMu.Unlock()
publishJobUpsert(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "queued",
Phase: "postwork",
Label: "Warte auf Nachbearbeitung…",
Position: st.Position,
Waiting: st.Waiting,
Running: st.Running,
MaxParallel: st.MaxParallel,
})
} else {
jobsMu.Lock()
job.Status = postTarget
@ -771,5 +828,14 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "error",
Phase: "postwork",
Label: "Nachbearbeitung konnte nicht eingeplant werden",
})
}
}

View File

@ -8,12 +8,12 @@
"autoStartAddedDownloads": false,
"useChaturbateApi": true,
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 200,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "hover",
"teaserAudio": true,
"enableNotifications": true,
"encryptedCookies": ""
"encryptedCookies": "Cq0QdJ72VtRxmtC+RGn1DVKb17COrGwrj+FEfgIwgeIn1hcrzvwFCfowqKgJVuPV1cAq9IFv4PnEN/+PiOIC5pxjO/+GWoSBb55E2OjSvknvWYbagMiTSRDvUaDQWzxvbP45VxXOIOelDneU1v5b8lT+cXR5IIY9tRK5hjYw7Yqam4fCkTjTP2EvgjVOvI7vmWfkQcbohhrs9fL06/5FFEuyUQJrsfESk+lge34rhxlJ3OYOqwF7sHke6NttG8CcX30cam5Hzl+/IgFkk34DYXcg4GZOrcgrRwfmEu4/8WeSBt7reBXglt2EkH4RMr9kdNNkXhMXTA9IKeoego9X22mwBXc0rPLT6fWhHykN90qZWtxOdy4JlRTSUaBswu0lnTLNfH0LrGUBzaU5Y5WFsrkFIa/TEEPWdI1EPH4b5GjH7MGjGBRr2U6Aqshy6PsrfHKrR4gIbhqCaoEaYxJjP9aUljFc3rqcw3UgAv1dp8eXo4lm+HPAdtVtNlD+pBsNZO5zXGkXFOk="
}

View File

@ -18,7 +18,9 @@ func main() {
fixKeepRootFilesIntoModelSubdirs()
postWorkQ.StartWorkers(1)
enrichQ.StartWorkers(2)
startPostWorkStatusRefresher()
startEnrichStatusRefresher()
go startGeneratedGarbageCollector()

View File

@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"path/filepath"
"sort"
"strings"
"sync/atomic"
@ -53,6 +54,84 @@ type ssePublishItem struct {
Data []byte
}
type finishedPostworkEvent struct {
Type string `json:"type"` // "finished_postwork"
Model string `json:"model"` // model event name, z. B. "maypeach"
File string `json:"file"`
AssetID string `json:"assetId,omitempty"`
Queue string `json:"queue"` // "postwork" | "enrich"
State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing"
Phase string `json:"phase,omitempty"`
Label string `json:"label,omitempty"`
Position int `json:"position,omitempty"`
Waiting int `json:"waiting,omitempty"`
Running int `json:"running,omitempty"`
MaxParallel int `json:"maxParallel,omitempty"`
TS int64 `json:"ts"`
}
func publishFinishedPostworkStateForJob(
j *RecordJob,
queue string,
state string,
phase string,
label string,
position int,
waiting int,
running int,
maxParallel int,
) {
if j == nil {
return
}
file := strings.TrimSpace(filepath.Base(j.Output))
if file == "" {
return
}
assetID := assetIDFromVideoPath(j.Output)
publishFinishedPostworkStatusForJob(j, finishedPostworkEvent{
File: file,
AssetID: assetID,
Queue: queue,
State: state,
Phase: phase,
Label: label,
Position: position,
Waiting: waiting,
Running: running,
MaxParallel: maxParallel,
})
}
func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) {
if j == nil {
return
}
if strings.TrimSpace(ev.File) == "" {
return
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
return
}
ev.Type = "finished_postwork"
ev.Model = eventName
ev.TS = time.Now().UnixMilli()
b, _ := json.Marshal(ev)
// 1) modellbezogen für Running/Downloads
publishSSE(eventName, b)
// 2) global für FinishedDownloads
publishSSE("finishedPostwork", b)
}
func isTerminalJobStatusForSSE(status JobStatus) bool {
s := strings.ToLower(strings.TrimSpace(string(status)))
return s == "stopped" ||

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -133,6 +134,63 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
}
}
func publishAssetsTaskFinishedPostworkState(fileName string, state string, label string) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return
}
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
assetID := stripHotPrefix(base)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: fileName,
AssetID: assetID,
Queue: "enrich",
State: state, // "queued" | "running" | "done" | "error" | "missing"
Phase: "assets",
Label: label,
TS: time.Now().UnixMilli(),
}
b, err := json.Marshal(ev)
if err != nil {
return
}
// Für FinishedDownloads reicht der globale Kanal.
publishSSE("finishedPostwork", b)
}
func clearAssetsTaskFinishedPostworkState(fileName string) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return
}
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
assetID := stripHotPrefix(base)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: fileName,
AssetID: assetID,
Queue: "enrich",
State: "missing",
Phase: "assets",
Label: "",
TS: time.Now().UnixMilli(),
}
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("finishedPostwork", b)
}
func runGenerateMissingAssets(ctx context.Context) {
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
defer func() {
@ -261,10 +319,18 @@ func runGenerateMissingAssets(ctx context.Context) {
st.CurrentFile = it.name
})
publishAssetsTaskFinishedPostworkState(
it.name,
"running",
"Erstelle Vorschau / Thumbnails…",
)
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkState(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
@ -274,6 +340,8 @@ func runGenerateMissingAssets(ctx context.Context) {
// Datei-Info (validieren)
vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkState(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
@ -283,6 +351,12 @@ func runGenerateMissingAssets(ctx context.Context) {
// Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
publishAssetsTaskFinishedPostworkState(
it.name,
"error",
"Assets fehlgeschlagen",
)
updateAssetsState(func(st *AssetsTaskState) {
// UI bekommt stabilen Hinweis, aber Task läuft weiter
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
@ -301,6 +375,11 @@ func runGenerateMissingAssets(ctx context.Context) {
// Generate/Ensure (einheitliche Core-Funktion)
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if e != nil {
publishAssetsTaskFinishedPostworkState(
it.name,
"error",
"Assets fehlgeschlagen",
)
finishWithErr(e)
return
}
@ -312,6 +391,16 @@ func runGenerateMissingAssets(ctx context.Context) {
//fmt.Println("⚠️ tasks generate assets analyze:", aerr)
}
if res.Skipped {
clearAssetsTaskFinishedPostworkState(it.name)
} else {
publishAssetsTaskFinishedPostworkState(
it.name,
"done",
"Vorschau / Thumbnails fertig",
)
}
updateAssetsState(func(st *AssetsTaskState) {
if res.Skipped {
st.Skipped++

View File

@ -68,7 +68,7 @@ type RecorderSettingsState = {
}
type JobEvent = {
type?: 'job_upsert' | 'job_remove'
type?: 'job_upsert' | 'job_remove' | 'finished_postwork'
model?: string
jobId?: string
status?: string
@ -97,6 +97,17 @@ type JobEvent = {
running?: number
maxParallel?: number
}
// ✅ neu für finished_postwork
file?: string
assetId?: string
queue?: 'postwork' | 'enrich'
state?: 'queued' | 'running' | 'done' | 'error' | 'missing'
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
@ -364,6 +375,11 @@ function modelEventKeyFromJob(job: RecordJob): string {
return keyFromFile
}
function modelEventKeyFromDoneJob(job: RecordJob): string {
const { modelKey } = modelKeyAndHostFromJob(job)
return String(modelKey || '').trim().toLowerCase()
}
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
@ -1147,6 +1163,56 @@ export default function App() {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
// ✅ finished_postwork direkt an FinishedDownloads weiterreichen
if (msg?.type === 'finished_postwork') {
const file = String(msg?.file ?? '').trim()
if (file) {
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue:
msg?.queue === 'enrich'
? 'enrich'
: 'postwork',
state:
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing',
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
position:
typeof msg?.position === 'number' && Number.isFinite(msg.position)
? msg.position
: undefined,
waiting:
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
? msg.waiting
: undefined,
running:
typeof msg?.running === 'number' && Number.isFinite(msg.running)
? msg.running
: undefined,
maxParallel:
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
? msg.maxParallel
: undefined,
ts:
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
}
return
}
if (msg?.type === 'job_upsert') {
if (modelKey) {
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
@ -1482,6 +1548,20 @@ export default function App() {
return Array.from(set)
}, [modelsByKey, isChaturbateStoreModel])
const watchedModelKeysLower = useMemo(() => {
const set = new Set<string>()
for (const m of Object.values(modelsByKey)) {
if (!m?.watching) continue
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
set.add(k)
}
return Array.from(set)
}, [modelsByKey])
const selectedTabRef = useRef(selectedTab)
@ -2336,8 +2416,62 @@ export default function App() {
}
}
const onFinishedPostwork = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const file = String(msg?.file ?? '').trim()
if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue:
msg?.queue === 'enrich'
? 'enrich'
: 'postwork',
state:
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing',
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
position:
typeof msg?.position === 'number' && Number.isFinite(msg.position)
? msg.position
: undefined,
waiting:
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
? msg.waiting
: undefined,
running:
typeof msg?.running === 'number' && Number.isFinite(msg.running)
? msg.running
: undefined,
maxParallel:
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
? msg.maxParallel
: undefined,
ts:
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
} catch {
// ignore
}
}
es.addEventListener('doneChanged', onDoneChanged as any)
es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
// initial nur Listener für wirklich relevante Jobs / Queue setzen
@ -2349,12 +2483,23 @@ export default function App() {
if (key) ensureModelEventListener(key)
}
// 2) sichtbare Wartenden-Einträge
// 2) sichtbare Finished-Jobs der aktuellen Seite
for (const j of doneJobs) {
const key = modelEventKeyFromDoneJob(j)
if (key) ensureModelEventListener(key)
}
// 3) sichtbare Wartenden-Einträge
for (const p of pendingWatchedRooms) {
const k = String(p?.modelKey ?? '').trim().toLowerCase()
if (k) ensureModelEventListener(k)
}
// 4) watched Models ebenfalls abonnieren
for (const key of watchedModelKeysLower) {
if (key) ensureModelEventListener(key)
}
const onVis = () => {
if (document.hidden) return
void loadJobs()
@ -2378,6 +2523,7 @@ export default function App() {
if (es) {
es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
const handler = onModelJobEventRef.current
if (handler) {
@ -2399,6 +2545,9 @@ export default function App() {
loadDoneCount,
requestFinishedReload,
ensureModelEventListener,
doneJobs,
pendingWatchedRooms,
watchedModelKeysLower,
])
useEffect(() => {
@ -2420,24 +2569,35 @@ export default function App() {
if (key) desired.add(key)
}
// 3) Sichtbare Wartenden-Einträge
// 3) Sichtbare Finished-Jobs der aktuellen Seite
for (const j of doneJobs) {
const key = modelEventKeyFromDoneJob(j)
if (key) desired.add(key)
}
// 4) Sichtbare Wartenden-Einträge
for (const p of pendingWatchedRooms) {
const key = String(p?.modelKey ?? '').trim().toLowerCase()
if (key) desired.add(key)
}
// 4) fehlende Listener hinzufügen
// 5) watched Models
for (const key of watchedModelKeysLower) {
if (key) desired.add(key)
}
// 6) fehlende Listener hinzufügen
for (const key of desired) {
ensureModelEventListener(key)
}
// 5) alles entfernen, was nicht mehr in Downloads / Nacharbeiten / Wartend ist
// 7) alles entfernen, was nicht mehr sichtbar/relevant ist
for (const key of Array.from(modelEventNamesRef.current)) {
if (!desired.has(key)) {
removeModelEventListener(key)
}
}
}, [jobs, pendingWatchedRooms, ensureModelEventListener, removeModelEventListener])
}, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener])
function isChaturbate(raw: string): boolean {
const norm = normalizeHttpUrl(raw)
@ -2535,12 +2695,25 @@ export default function App() {
const handleAddToDownloads = useCallback(
async (job: RecordJob): Promise<boolean> => {
const raw = String((job as any)?.sourceUrl ?? '')
const raw = String(
(job as any)?.sourceUrl ??
(job as any)?.SourceURL ??
(job as any)?.sourceURL ??
(job as any)?.url ??
''
).trim()
const url0 = extractFirstUrl(raw)
if (!url0) return false
if (!url0) {
notify.error('Konnte URL nicht hinzufügen', 'Für diesen Eintrag ist keine Source-URL vorhanden.')
return false
}
const norm0 = normalizeHttpUrl(url0)
if (!norm0) return false
if (!norm0) {
notify.error('Konnte URL nicht hinzufügen', 'Die Source-URL ist ungültig.')
return false
}
const url = canonicalizeProviderUrl(norm0)
const ok = await startUrl(url, { silent: true })
@ -2554,6 +2727,37 @@ export default function App() {
[startUrl, notify]
)
const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
const url = String(pending?.url ?? '').trim()
// 1) Aus pendingAutoStartByKey entfernen
if (keyLower) {
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}) }
delete next[keyLower]
pendingAutoStartByKeyRef.current = next
return next
})
}
// 2) Aus sichtbarer Warteliste entfernen
setPendingWatchedRooms((prev) =>
prev.filter((x) => {
const xKey = String(x?.modelKey ?? '').trim().toLowerCase()
const xUrl = String(x?.url ?? '').trim()
// bevorzugt über modelKey entfernen
if (keyLower && xKey === keyLower) return false
// Fallback über URL
if (!keyLower && url && xUrl === url) return false
return true
})
)
}, [])
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
async function runFinishedFileAction<T>(opts: {
@ -3305,6 +3509,7 @@ export default function App() {
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onAddToDownloads={handleAddToDownloads}
onRemovePending={handleRemovePendingWatchedRoom}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
) : null}
@ -3326,6 +3531,7 @@ export default function App() {
onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
onSplitJob={openSplitModal}
onAddToDownloads={handleAddToDownloads}
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
@ -3339,7 +3545,9 @@ export default function App() {
/>
) : null}
{selectedTab === 'models' ? <ModelsTab /> : null}
{selectedTab === 'models' ? (
<ModelsTab onAddToDownloads={handleAddToDownloads} />
) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? (
<RecorderSettings

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,813 @@
// frontend\src\components\ui\DownloadsCardRow.tsx
'use client'
import Button from './Button'
import ModelPreview from './ModelPreview'
import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
export type PendingWatchedRoom = WaitingModelRow & {
currentShow: string
}
type WaitingModelRow = {
id: string
modelKey: string
url: string
imageUrl?: string
currentShow?: string
}
export type DownloadRow =
| { kind: 'job'; job: RecordJob }
| { kind: 'pending'; pending: PendingWatchedRoom }
type ModelFlags = {
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
}
type Props = {
r: DownloadRow
nowMs: number
blurPreviews?: boolean
modelsByKey: Record<string, ModelFlags>
roomStatusByModelKey: Record<string, string>
stopRequestedIds: Record<string, true>
growingByJobId: Record<string, boolean>
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
markStopRequested: (ids: string | string[]) => void
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
}
const pendingModelName = (p: PendingWatchedRoom) => {
const anyP = p as any
return (
anyP.modelName ??
anyP.model ??
anyP.modelKey ??
anyP.username ??
anyP.name ??
'—'
)
}
const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
}
const pendingImageUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.imageUrl ?? anyP.image_url ?? '').trim()
}
const normalizeRoomStatus = (v: unknown): string => {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'Public'
case 'private':
return 'Private'
case 'hidden':
return 'Hidden'
case 'away':
return 'Away'
case 'offline':
return 'Offline'
default:
return 'Unknown'
}
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const modelKeyFromJob = (job: RecordJob): string => {
const j = job as any
const rawUrl = String(j.sourceUrl ?? '').trim()
if (rawUrl) {
const m = rawUrl.match(/chaturbate\.com\/([^/?#]+)/i)
if (m?.[1]) {
try {
return decodeURIComponent(m[1]).trim().toLowerCase()
} catch {
return String(m[1]).trim().toLowerCase()
}
}
}
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>
): string => {
const j = job as any
const rawFromJob =
j.currentShow ??
j.roomStatus ??
j.modelStatus ??
j.show ??
j.statusText ??
j.model?.currentShow ??
j.model?.roomStatus ??
j.model?.status ??
j.room?.currentShow ??
j.room?.status
if (rawFromJob != null && String(rawFromJob).trim() !== '') {
return normalizeRoomStatus(rawFromJob)
}
const modelKey = modelKeyFromJob(job)
if (modelKey && roomStatusByModelKey?.[modelKey]) {
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
}
if (modelKey && modelsByKey?.[modelKey]?.roomStatus) {
return normalizeRoomStatus(modelsByKey[modelKey].roomStatus)
}
return 'Unknown'
}
function effectiveRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey)
if (raw !== 'Unknown') return raw
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
if (isActiveRecording && growingByJobId?.[job.id]) {
return 'Public'
}
return raw
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
return 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
case 'Private':
return 'bg-fuchsia-500/15 text-fuchsia-900 ring-fuchsia-500/30 dark:bg-fuchsia-400/10 dark:text-fuchsia-200 dark:ring-fuchsia-400/25'
case 'Hidden':
return 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
case 'Away':
return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
case 'Offline':
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
case 'Unknown':
default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
const toMs = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v < 1_000_000_000_000 ? v * 1000 : v
}
if (typeof v === 'string') {
const ms = Date.parse(v)
return Number.isFinite(ms) ? ms : 0
}
if (v instanceof Date) return v.getTime()
return 0
}
const absUrlMaybe = (u?: string | null): string => {
const s = String(u ?? '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
if (s.startsWith('/')) return s
return `/${s}`
}
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
const j = job as any
const direct = [
j.thumbsJPGUrl,
j.thumbsUrl,
j.previewThumbsUrl,
j.thumbnailSheetUrl,
]
const base = [
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
]
return [...direct, ...base]
.map((x) => absUrlMaybe(String(x ?? '')))
.filter(Boolean)
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob, nowMs: number) => {
const anyJ = j as any
const start =
toMs(anyJ.startedAtMs) ||
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)
}
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
const formatBytes = (bytes: number | null): string => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 10 ? 1 : 2
const s = v.toFixed(digits).replace(/\.0+$/, '')
return `${s} ${units[i]}`
}
const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stop wird angefordert…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
return 'Konvertiere Container zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau/Thumbnails…'
case 'analyze':
return 'AI analysiert Segmente…'
case 'postwork':
return 'Nacharbeiten laufen…'
default:
return ''
}
}
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'
)
}
const isPostworkJob = (job: RecordJob): boolean => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
if (phase === 'postwork') return true
return false
}
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
if (!isPostworkJob(job)) return 'none'
if (isTerminalStatus(anyJ?.status)) return 'none'
if (pwState === 'queued') return 'queued'
if (pwState === 'running') return 'running'
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
pw.position > 0
) {
return 'queued'
}
if (
typeof pw?.running === 'number' &&
Number.isFinite(pw.running) &&
pw.running > 0 &&
(!Number.isFinite(pw?.position) || pw.position <= 0)
) {
return 'running'
}
if (hasPwKey) {
return 'queued'
}
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'
}
function postWorkLabel(
job: RecordJob,
override?: { pos?: number; total?: number }
): string {
const anyJ = job as any
const pw = anyJ.postWork
const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'running') {
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…'
}
if (effectiveState === 'queued') {
const posServer = typeof pw?.position === 'number' ? pw.position : 0
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
const totalServer = Math.max(waitingServer, posServer)
const pos =
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
? override.pos
: posServer
const total =
typeof override?.total === 'number' && Number.isFinite(override.total) && override.total > 0
? override.total
: totalServer
return pos > 0 && total > 0
? `Warte auf Nacharbeiten… ${pos} / ${total}`
: 'Warte auf Nacharbeiten…'
}
return 'Warte auf Nacharbeiten…'
}
export default function DownloadsCardRow({
r,
nowMs,
blurPreviews,
modelsByKey,
roomStatusByModelKey,
stopRequestedIds,
growingByJobId,
postworkInfoOf,
markStopRequested,
onOpenPlayer,
onStopJob,
onRemoveQueuedPostworkJob,
onToggleFavorite,
onToggleLike,
onToggleWatch,
}: Props) {
if (r.kind === 'pending') {
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
const show = normalizeRoomStatus(p.currentShow)
const img = pendingImageUrl(p)
return (
<div
className="
group relative isolate overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950
"
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/25 dark:bg-white/5" />
<div className="relative p-3">
<div className="flex items-start gap-3">
<div
className="
relative
shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
{img ? (
<img
src={img}
alt={name}
className={[
'h-full w-full object-cover',
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div
className="truncate text-base font-semibold text-gray-900 dark:text-white"
title={name}
>
{name}
</div>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(show),
].join(' ')}
title={show}
>
{show}
</span>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Wartend auf Autostart
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
</span>
</div>
</div>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={url}
>
{url}
</a>
) : null}
</div>
</div>
<div className="mt-3">
<ProgressBar
label="Warte auf Online-Status…"
indeterminate
size="sm"
className="w-full"
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
<div className="flex items-center gap-1 opacity-60">
<span className="text-xs text-gray-500 dark:text-gray-400">
Wird automatisch gestartet, sobald das Model public ist.
</span>
</div>
<div className="hidden sm:block">
<Button
size="sm"
variant="primary"
color="red"
className="shrink-0"
disabled
>
Wartend
</Button>
</div>
</div>
</div>
</div>
)
}
const j = r.job
const name = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const phase = String((j as any).phase ?? '').trim()
const phaseLower = phase.toLowerCase()
const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
const showRemoveQueuedButton = isQueuedPostwork
const disableStopButton = showRemoveQueuedButton ? false : isStopping
const actionsBusy = showRemoveQueuedButton ? false : isStopping
const roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
let phaseText = ''
if (phaseLower === 'recording') {
phaseText = 'Recording läuft…'
} else if (isPostworkJob(j)) {
const pwState = getEffectivePostworkState(j)
if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') {
phaseText = phaseLabel(phase) || phase
} else if (pwState === 'queued') {
phaseText = postWorkLabel(j, postworkInfoOf(j))
} else if (pwState === 'running') {
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
}
} else {
phaseText = phase ? (phaseLabel(phase) || phase) : ''
}
const progressLabel = phaseText || roomStatus
const progress = Number((j as any).progress ?? 0)
const showBar =
!isRecording &&
Number.isFinite(progress) &&
progress > 0 &&
progress < 100
const showIndeterminate =
!isRecording &&
!showBar &&
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : ''
const flags = key ? modelsByKey[key] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<div
className="
group relative isolate overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950
"
onClick={() => onOpenPlayer(j)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/25 dark:bg-white/5" />
<div className="relative p-3">
<div className="flex items-start gap-3">
<div
className="
relative
shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
<ModelPreview
jobId={j.id}
blur={blurPreviews}
roomStatus={roomStatus}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
{name}
</div>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(roomStatus),
].join(' ')}
title={roomStatus}
>
{roomStatus}
</span>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
{file || '—'}
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{runtimeOf(j, nowMs)}
</span>
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{formatBytes(sizeBytesOf(j))}
</span>
</div>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={j.sourceUrl}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
{(showBar || showIndeterminate) ? (
<div className="mt-3">
{showBar ? (
<ProgressBar
label={progressLabel}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full"
/>
) : (
<ProgressBar
label={progressLabel}
indeterminate
size="sm"
className="w-full"
/>
)}
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
busy={actionsBusy}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
</div>
<div className="hidden sm:block">
<Button
size="sm"
variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (showRemoveQueuedButton) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@ -39,6 +39,20 @@ type SortMode =
| 'size_desc'
| 'size_asc'
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
@ -58,6 +72,7 @@ type Props = {
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
onSplitJob?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
doneTotal: number
page: number
pageSize: number
@ -136,6 +151,40 @@ function useMediaQuery(query: string) {
return matches
}
function modelHrefFromFlags(modelKey: string, flags?: StoredModelFlags): string | undefined {
const key = String(modelKey || '').trim()
if (!key) return undefined
const anyFlags = flags as any
const host = String(anyFlags?.host ?? anyFlags?.inputHost ?? '').trim().toLowerCase()
const input = String(anyFlags?.input ?? '').trim()
if (/^https?:\/\//i.test(input)) return input
if (host.includes('chaturbate')) {
return `https://chaturbate.com/${encodeURIComponent(key)}/`
}
if (host.includes('myfreecams')) {
return `https://www.myfreecams.com/#${encodeURIComponent(key)}`
}
return undefined
}
function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredModelFlags>): RecordJob {
const model = modelNameFromOutput(job.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const href = modelHrefFromFlags(model, flags)
return {
...job,
sourceUrl: job.sourceUrl || href,
}
}
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
@ -316,6 +365,7 @@ export default function FinishedDownloads({
onToggleWatch,
onKeepJob,
onSplitJob,
onAddToDownloads,
doneTotal,
page,
pageSize,
@ -356,6 +406,8 @@ export default function FinishedDownloads({
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0)
const [postworkByFile, setPostworkByFile] = React.useState<Record<string, FinishedPostworkBadge>>({})
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: teaserPlaybackMode,
@ -482,6 +534,11 @@ export default function FinishedDownloads({
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
const jobForDetails = useCallback(
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
[modelsByKey]
)
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const deferredSearchQuery = React.useDeferredValue(searchQuery)
@ -1746,52 +1803,75 @@ export default function FinishedDownloads({
const rows = useMemo(() => {
const map = new Map<string, RecordJob>()
const doneBaseKeys = new Set<string>()
// Basis: Files aus dem Done-Ordner
for (const j of doneJobsPage) {
const jj = applyRenamedOutput(j)
map.set(keyFor(jj), jj)
const k = keyFor(jj)
map.set(k, jj)
doneBaseKeys.add(k)
}
// Jobs aus /list drübermergen
// Jobs aus /list drübermergen, damit aktuelle Live-Felder
// (phase, postWork, progress, duration, size, etc.) sichtbar bleiben.
for (const j of jobs) {
const jj = applyRenamedOutput(j)
const k = keyFor(jj)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj })
if (map.has(k)) {
map.set(k, { ...map.get(k)!, ...jj })
continue
}
const list = Array.from(map.values()).filter((j) => {
if (deletedKeys.has(keyFor(j))) return false
// Fallback: gleicher Dateiname, aber evtl. anderer key/ID
const file = baseName(jj.output || '')
if (!file) continue
// ✅ .trash niemals anzeigen
for (const [existingKey, existingJob] of map.entries()) {
if (baseName(existingJob.output || '') === file) {
map.set(existingKey, { ...existingJob, ...jj })
break
}
}
}
const list = Array.from(map.entries())
.map(([k, j]) => ({ k, j }))
.filter(({ k, j }) => {
if (deletedKeys.has(k)) return false
// .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
const anyJ = j as any
const phase = String(anyJ?.phase ?? '').trim().toLowerCase()
const pw = anyJ?.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const status = String(anyJ?.status ?? '').trim().toLowerCase()
const endedAt = anyJ?.endedAt
const isActiveFinishedPostwork =
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets' ||
phase === 'postwork' ||
pwState === 'queued' ||
pwState === 'running'
// ✅ Wichtig:
// Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben
// auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist.
if (doneBaseKeys.has(k)) return true
// ✅ Alles, was noch in Nacharbeiten steckt, NICHT im Finished-Tab zeigen
if (isActiveFinishedPostwork) return false
// Fallback: terminale Jobs ebenfalls anzeigen
if (
status === 'finished' ||
status === 'failed' ||
status === 'stopped'
) {
return true
}
return (
j.status === 'finished' ||
j.status === 'failed' ||
j.status === 'stopped'
)
// Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist
if (endedAt) return true
return false
})
.map(({ j }) => j)
return list
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput, keyFor, baseName])
const viewRows = rows
@ -1807,6 +1887,35 @@ export default function FinishedDownloads({
fileToKeyRef.current = m
}, [viewRows])
useEffect(() => {
const onPostwork = (ev: Event) => {
const detail = (ev as CustomEvent<FinishedPostworkBadge>).detail
const file = String(detail?.file ?? '').trim()
if (!file) return
setPostworkByFile((prev) => {
const cur = prev[file]
const nextTs = Number(detail?.ts ?? 0)
const curTs = Number(cur?.ts ?? 0)
if (cur && curTs > nextTs) return prev
const next = { ...prev }
if (detail.state === 'missing') {
delete next[file]
return next
}
next[file] = detail
return next
})
}
window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
}, [])
useEffect(() => {
const onExternalDelete = (ev: Event) => {
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
@ -1937,8 +2046,8 @@ export default function FinishedDownloads({
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
useEffect(() => {
const active =
teaserState.mode === 'hover' &&
!canHover &&
teaserState.mode !== 'still' &&
(view === 'cards' || view === 'gallery' || view === 'table')
if (!active) {
@ -2026,6 +2135,7 @@ export default function FinishedDownloads({
elToKeyRef.current.set(el, key)
teaserIORef.current?.observe(el)
} else {
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
teaserHostsRef.current.delete(key)
}
},
@ -2064,6 +2174,10 @@ export default function FinishedDownloads({
useEffect(() => {
if (!isSmall || view !== 'cards') return
if (teaserState.mode === 'still') {
setTeaserKey(null)
return
}
const top = pageRows[0]
if (!top) {
@ -2072,16 +2186,8 @@ export default function FinishedDownloads({
}
const topKey = keyFor(top)
// ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card
setTeaserKey((prev) => (prev === topKey ? prev : null))
const t = window.setTimeout(() => {
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, 140) // 100180ms testen
return () => window.clearTimeout(t)
}, [isSmall, view, pageRows, keyFor])
}, [isSmall, view, pageRows, keyFor, teaserState.mode])
useEffect(() => {
if (!isSmall) {
@ -2462,6 +2568,7 @@ export default function FinishedDownloads({
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
@ -2496,6 +2603,7 @@ export default function FinishedDownloads({
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
@ -2503,6 +2611,7 @@ export default function FinishedDownloads({
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile}
/>
</div>
)}
@ -2511,6 +2620,7 @@ export default function FinishedDownloads({
<FinishedDownloadsTableView
rows={pageRows}
isLoading={isLoading}
jobForDetails={jobForDetails}
keyFor={keyFor}
baseName={baseName}
lower={lower}
@ -2544,11 +2654,13 @@ export default function FinishedDownloads({
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile}
/>
)}
@ -2556,6 +2668,7 @@ export default function FinishedDownloads({
<FinishedDownloadsGalleryView
rows={pageRows}
isLoading={isLoading}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
@ -2582,12 +2695,14 @@ export default function FinishedDownloads({
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
postworkByFile={postworkByFile}
/>
)}

View File

@ -37,6 +37,7 @@ type Props = {
assetNonce?: number
jobForDetails: (job: RecordJob) => RecordJob
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
// helpers
@ -76,16 +77,37 @@ type Props = {
}>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => 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>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
postworkByFile: Record<string, {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}>
}
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
type PostworkBadge = {
label: string
tone: PostworkBadgeTone
}
const parseTags = (raw?: string): string[] => {
@ -159,7 +181,7 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows]
}
function postworkBadgeClass(tone: 'idle' | 'running' | 'done' | 'error') {
function postworkBadgeClass(tone: PostworkBadgeTone) {
if (tone === 'running') {
return 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20'
}
@ -536,6 +558,7 @@ export default function FinishedDownloadsCardsView({
swipeRefs,
assetNonce,
jobForDetails,
handleScrubberClickIndex,
keyFor,
@ -567,10 +590,12 @@ export default function FinishedDownloadsCardsView({
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
postworkByFile
}: Props) {
const parseMeta = React.useCallback((j: RecordJob): any | null => {
@ -730,6 +755,9 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const livePostwork =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
@ -859,7 +887,30 @@ export default function FinishedDownloadsCardsView({
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const postworkStatus = finishedPostworkBadge(j)
const postworkStatus: PostworkBadge | null =
livePostwork && livePostwork.state !== 'missing'
? {
label:
livePostwork.label ||
(livePostwork.state === 'queued'
? 'Warte auf Nachbearbeitung…'
: livePostwork.state === 'running'
? 'Nachbearbeitung läuft…'
: livePostwork.state === 'error'
? 'Nachbearbeitung fehlgeschlagen'
: livePostwork.state === 'done'
? 'Nachbearbeitung fertig'
: ''),
tone:
livePostwork.state === 'running'
? 'running'
: livePostwork.state === 'queued'
? 'idle'
: livePostwork.state === 'error'
? 'error'
: 'done',
}
: finishedPostworkBadge(j)
const tags = parseTags(flags?.tags)
@ -1075,7 +1126,23 @@ export default function FinishedDownloadsCardsView({
<div className="relative min-h-[112px] 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="flex items-center gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
{postworkStatus ? (
<span
className={[
'shrink-0 inline-flex max-w-[220px] items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadgeClass(postworkStatus.tone),
].join(' ')}
title={postworkStatus.label}
>
<span className="truncate">{postworkStatus.label}</span>
</span>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
@ -1089,20 +1156,6 @@ export default function FinishedDownloadsCardsView({
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{postworkStatus ? (
<span
className={[
'inline-flex max-w-[220px] items-center rounded-full px-2 py-1 text-[11px] font-semibold ring-1 whitespace-nowrap',
postworkBadgeClass(postworkStatus.tone),
].join(' ')}
title={postworkStatus.label}
>
<span className="truncate">{postworkStatus.label}</span>
</span>
) : null}
</div>
</div>
{/* Meta + Actions (nicht im Video) */}
@ -1115,7 +1168,7 @@ export default function FinishedDownloadsCardsView({
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
@ -1131,6 +1184,7 @@ export default function FinishedDownloadsCardsView({
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>

View File

@ -36,16 +36,32 @@ type ModelFlags = {
previewScrubberCount?: number
}
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
type Props = {
rows: RecordJob[]
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge>
teaserState: FinishedDownloadsTeaserState
handleDuration: (job: RecordJob, seconds: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
jobForDetails: (job: RecordJob) => RecordJob
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
@ -76,6 +92,7 @@ type Props = {
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
@ -153,10 +170,12 @@ export default function FinishedDownloadsGalleryView({
isLoading,
blurPreviews,
durations,
postworkByFile,
teaserState,
handleDuration,
handleScrubberClickIndex,
jobForDetails,
keyFor,
baseName,
modelNameFromOutput,
@ -184,6 +203,7 @@ export default function FinishedDownloadsGalleryView({
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
@ -295,6 +315,16 @@ export default function FinishedDownloadsGalleryView({
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const postworkBadge =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
@ -605,12 +635,12 @@ export default function FinishedDownloadsGalleryView({
</div>
</div>
{/* ✅ Model darunter + Hover-Preview Trigger */}
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="min-w-0 flex items-center gap-2">
<span
className={[
'block truncate text-xs text-gray-500 dark:text-gray-400',
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
@ -629,6 +659,42 @@ export default function FinishedDownloadsGalleryView({
>
{model || '—'}
</span>
{showPostworkBadge ? (
<span
className={[
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')
}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</div>
<div className="shrink-0 flex items-center gap-1.5">
@ -647,7 +713,7 @@ export default function FinishedDownloadsGalleryView({
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
@ -681,6 +747,7 @@ export default function FinishedDownloadsGalleryView({
return deleteVideo(job)
}}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>

View File

@ -26,6 +26,20 @@ type SortMode =
| 'size_desc'
| 'size_asc'
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
type Props = {
rows: RecordJob[]
isLoading?: boolean
@ -40,6 +54,8 @@ type Props = {
formatBytes: (bytes?: number | null) => string
resolutions: Record<string, { w: number; h: number }>
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge>
jobForDetails: (job: RecordJob) => RecordJob
// teaser/preview
canHover: boolean
@ -72,6 +88,7 @@ type Props = {
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
@ -94,6 +111,8 @@ export default function FinishedDownloadsTableView({
formatBytes,
resolutions,
durations,
postworkByFile,
jobForDetails,
canHover,
teaserState,
@ -122,6 +141,7 @@ export default function FinishedDownloadsTableView({
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
deleteVideo,
keepVideo,
@ -259,7 +279,7 @@ export default function FinishedDownloadsTableView({
}}
>
<FinishedVideoPreview
job={j}
job={jobForDetails(j)}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
@ -312,9 +332,50 @@ export default function FinishedDownloadsTableView({
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
const postworkBadge =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
return (
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
{showPostworkBadge ? (
<span
className={[
'shrink-0 inline-flex max-w-[220px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={postworkBadge.label || 'Nacharbeiten laufen'}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</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">
@ -431,7 +492,7 @@ export default function FinishedDownloadsTableView({
return (
<RecordJobActions
job={j}
job={jobForDetails(j)}
variant="table"
busy={busy}
isHot={isHot}
@ -467,6 +528,7 @@ export default function FinishedDownloadsTableView({
return deleteVideo(job)
}}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="flex items-center justify-end gap-1"
/>
@ -478,6 +540,7 @@ export default function FinishedDownloadsTableView({
keyFor,
baseName,
durations,
postworkByFile,
registerTeaserHost,
canHover,
setHoverTeaserKey,

View File

@ -280,7 +280,11 @@ function TableIcon() {
)
}
export default function ModelsTab() {
type ModelsTabProps = {
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
}
export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const [models, setModels] = React.useState<StoredModel[]>([])
const flagsInFlightRef = React.useRef<Record<string, true>>({})
@ -858,6 +862,7 @@ export default function ModelsTab() {
<RecordJobActions
job={jobForDetails(m)}
variant="table"
onAddToDownloads={onAddToDownloads}
order={['add', 'details']}
className="flex items-center gap-2"
/>
@ -1373,6 +1378,7 @@ export default function ModelsTab() {
<RecordJobActions
job={jobForDetails(m)}
variant="table"
onAddToDownloads={onAddToDownloads}
order={['add', 'details']}
className="
flex items-center gap-1

View File

@ -0,0 +1,242 @@
// frontend\src\components\ui\SwipeActionRow.tsx
'use client'
import { useCallback, useRef, useState } from 'react'
type Props = {
children: React.ReactNode
actionLabel: string
actionColor?: 'red' | 'indigo'
disabled?: boolean
onAction: () => void | Promise<void>
}
function actionBgClass(color: 'red' | 'indigo') {
return color === 'red'
? 'bg-red-600 dark:bg-red-500'
: 'bg-indigo-600 dark:bg-indigo-500'
}
export default function SwipeActionRow({
children,
actionLabel,
actionColor = 'red',
disabled,
onAction,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [dragX, setDragX] = useState(0)
const [dragging, setDragging] = useState(false)
const pointerIdRef = useRef<number | null>(null)
const startXRef = useRef(0)
const lastXRef = useRef(0)
const lastTRef = useRef(0)
const velocityXRef = useRef(0)
const widthRef = useRef(1)
const actionBusyRef = useRef(false)
const ACTION_REVEAL_RATIO = 0.32
const FULL_SWIPE_RATIO = 0.82
const MAX_OVERSWIPE = 36
const FLICK_VELOCITY = 0.55 // px/ms
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
const reset = useCallback(() => {
setDragging(false)
setDragX(0)
pointerIdRef.current = null
velocityXRef.current = 0
}, [])
const runAction = useCallback(async () => {
if (disabled || actionBusyRef.current) {
reset()
return
}
actionBusyRef.current = true
try {
await onAction()
} finally {
actionBusyRef.current = false
reset()
}
}, [disabled, onAction, reset])
const getRevealWidth = useCallback(() => {
const width = Math.max(1, widthRef.current)
return Math.max(112, width * ACTION_REVEAL_RATIO)
}, [])
const applyDragResistance = useCallback((rawDelta: number) => {
const width = Math.max(1, widthRef.current)
const maxLeft = -(width + MAX_OVERSWIPE)
// nur nach links
if (rawDelta >= 0) return 0
// innerhalb normaler Breite = direkt
if (rawDelta >= -width) return rawDelta
// Overswipe mit Widerstand
const overshoot = Math.abs(rawDelta) - width
const resisted = width + overshoot * 0.22
return -Math.min(resisted, width + MAX_OVERSWIPE, Math.abs(maxLeft))
}, [])
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return
if (e.pointerType === 'mouse') return
const el = containerRef.current
widthRef.current = el?.offsetWidth || 1
pointerIdRef.current = e.pointerId
startXRef.current = e.clientX
lastXRef.current = e.clientX
lastTRef.current = performance.now()
velocityXRef.current = 0
setDragging(true)
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
}, [disabled])
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return
const now = performance.now()
const rawDelta = e.clientX - startXRef.current
const next = applyDragResistance(rawDelta)
const dt = Math.max(1, now - lastTRef.current)
const dx = e.clientX - lastXRef.current
velocityXRef.current = dx / dt
lastXRef.current = e.clientX
lastTRef.current = now
setDragX(next)
}, [dragging, applyDragResistance])
const onPointerEnd = useCallback(async (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return
const width = Math.max(1, widthRef.current)
const revealWidth = getRevealWidth()
const fullThreshold = width * FULL_SWIPE_RATIO
const abs = Math.abs(dragX)
const vx = velocityXRef.current
setDragging(false)
pointerIdRef.current = null
const isFastLeftFlick = vx < -FLICK_VELOCITY
const isFastRightFlick = vx > FLICK_VELOCITY
// kompletter Swipe oder schneller linker Flick weit genug
if (abs >= fullThreshold || (isFastLeftFlick && abs >= revealWidth * 1.15)) {
setDragX(-width)
await runAction()
return
}
// schneller Flick zurück => schließen
if (isFastRightFlick) {
setDragX(0)
return
}
// normaler Snap
if (abs >= revealWidth * 0.9) {
setDragX(-revealWidth)
return
}
setDragX(0)
}, [dragging, dragX, getRevealWidth, runAction])
const onActionClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
await runAction()
}, [runAction])
const revealedPx = Math.max(0, Math.abs(dragX))
const revealed = revealedPx > 0
const revealWidth = getRevealWidth()
const labelOpacity = clamp((revealedPx - 24) / 40, 0, 1)
const labelScale = 0.96 + labelOpacity * 0.04
return (
<div
ref={containerRef}
className="relative overflow-hidden rounded-2xl"
>
{/* Nur der wirklich freigelegte Bereich ist sichtbar */}
<div
className="absolute inset-y-0 right-0 z-0 overflow-hidden rounded-r-2xl"
style={{
width: revealedPx,
pointerEvents: revealed && !disabled ? 'auto' : 'none',
}}
onClick={disabled ? undefined : onActionClick}
aria-hidden="true"
>
<div
className={[
'absolute inset-y-0 right-0 rounded-r-2xl',
actionBgClass(actionColor),
].join(' ')}
style={{
width: Math.max(revealWidth, revealedPx),
}}
/>
<div
className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center"
style={{
width: Math.min(revealedPx, revealWidth),
opacity: labelOpacity,
transform: `translateZ(0) scale(${labelScale})`,
transformOrigin: 'center right',
}}
>
<span className="px-3 text-center text-sm font-semibold text-white">
{actionLabel}
</span>
</div>
</div>
{/* Vordergrund */}
<div
className={dragging ? 'relative z-10' : 'relative z-10'}
style={{
transform: `translateX(${dragX}px)`,
touchAction: 'pan-y',
transition: dragging
? 'none'
: 'transform 260ms cubic-bezier(0.22, 1, 0.36, 1)',
willChange: 'transform',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
>
{children}
</div>
</div>
)
}

View File

@ -23,7 +23,6 @@ function getGlobalFxLayer(): HTMLDivElement | null {
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)
}
@ -31,7 +30,6 @@ function getGlobalFxLayer(): HTMLDivElement | null {
return el
}
export type SwipeAction = {
label: React.ReactNode
className?: string
@ -39,66 +37,24 @@ export type SwipeAction = {
export type SwipeCardProps = {
children: React.ReactNode
/** Swipe an/aus (z.B. nur mobile view) */
enabled?: boolean
/** blockiert Swipe + Tap */
disabled?: boolean
/** Tap ohne Swipe (z.B. Player öffnen) */
onTap?: () => void
/**
* Rückgabe:
* - true/void => Aktion erfolgreich, Karte fliegt raus (translate offscreen)
* - false => Aktion fehlgeschlagen => Karte snappt zurück
*/
onSwipeLeft: () => boolean | void | Promise<boolean | void>
onSwipeRight: () => boolean | void | Promise<boolean | void>
/** optionales Styling am äußeren Wrapper */
className?: string
/** Action-Bereiche */
leftAction?: SwipeAction // standard: Behalten
rightAction?: SwipeAction // standard: Löschen
/** Ab welcher Strecke wird ausgelöst? */
leftAction?: SwipeAction
rightAction?: SwipeAction
thresholdPx?: number
thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35
/** Animation timings */
thresholdRatio?: number
snapMs?: number
commitMs?: number
/**
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
*/
ignoreFromBottomPx?: number
/**
* Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird.
* (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben)
*/
ignoreSelector?: string
/**
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
* (z.B. Buttons/Inputs innerhalb der Karte)
*/
tapIgnoreSelector?: string
/** Doppeltippen (z.B. HOT togglen). Rückgabe false => Aktion fehlgeschlagen */
onDoubleTap?: () => boolean | void | Promise<boolean | void>
/** Wohin soll die Flamme fliegen? (Element innerhalb der Card) */
hotTargetSelector?: string
/** Double-Tap Zeitfenster */
doubleTapMs?: number
/** Max. Bewegung zwischen taps (px) */
doubleTapMaxMovePx?: number
}
@ -108,6 +64,31 @@ export type SwipeCardHandle = {
reset: () => void
}
const HORIZONTAL_START_PX = 10
const LOCK_RATIO = 1.15
const FLICK_VELOCITY = 0.55
const MAX_OVERSWIPE_PX = 64
const ROTATE_MAX_DEG = 5
const SCALE_MIN = 0.992
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
function rubberBand(delta: number, width: number) {
if (delta === 0) return 0
const sign = Math.sign(delta)
const abs = Math.abs(delta)
const limit = width * 0.9
if (abs <= limit) return delta
const overflow = abs - limit
const resisted = limit + overflow * 0.18
return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX)
}
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
{
children,
@ -121,8 +102,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
thresholdRatio = 0.28,
ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]',
snapMs = 180,
commitMs = 180,
snapMs = 220,
commitMs = 220,
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
onDoubleTap,
hotTargetSelector = '[data-hot-target]',
@ -132,49 +113,82 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
ref
) {
const cardRef = React.useRef<HTMLDivElement | null>(null)
const outerRef = React.useRef<HTMLDivElement | null>(null)
const doubleTapBusyRef = React.useRef(false)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
const dxRef = React.useRef(0)
const rafRef = React.useRef<number | null>(null)
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
const dxRef = React.useRef(0)
const thresholdRef = React.useRef(0)
const outerRef = React.useRef<HTMLDivElement | null>(null)
const widthRef = React.useRef(360)
const tapTimerRef = React.useRef<number | null>(null)
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
const velocityXRef = React.useRef(0)
const lastMoveXRef = React.useRef(0)
const lastMoveTRef = React.useRef(0)
const pointer = React.useRef<{
id: number | null
x: number
y: number
startX: number
startY: number
dragging: boolean
captured: boolean
tapIgnored: boolean
noSwipe: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false })
axisLocked: 'x' | 'y' | null
}>({
id: null,
startX: 0,
startY: 0,
dragging: false,
captured: false,
tapIgnored: false,
noSwipe: false,
axisLocked: null,
})
const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
const [animMs, setAnimMs] = React.useState<number>(0)
const [animMs, setAnimMs] = React.useState(0)
const clearTapTimer = React.useCallback(() => {
if (tapTimerRef.current != null) {
window.clearTimeout(tapTimerRef.current)
tapTimerRef.current = null
}
}, [])
const flushDx = React.useCallback((nextDx: number) => {
dxRef.current = nextDx
if (rafRef.current != null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
setDx(dxRef.current)
})
}, [])
const reset = React.useCallback(() => {
// ✅ rAF cleanup
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
// ✅ TouchAction zurücksetzen (falls während Drag auf none gestellt)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
velocityXRef.current = 0
if (cardRef.current) {
cardRef.current.style.touchAction = 'pan-y'
}
setAnimMs(snapMs)
setDx(0)
setArmedDir(null)
window.setTimeout(() => setAnimMs(0), snapMs)
window.setTimeout(() => {
setAnimMs(0)
}, snapMs)
}, [snapMs])
const commit = React.useCallback(
@ -184,43 +198,36 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null
}
// ✅ TouchAction zurücksetzen (Sicherheit)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
if (cardRef.current) {
cardRef.current.style.touchAction = 'pan-y'
}
const el = cardRef.current
const w = el?.offsetWidth || 360
const w = cardRef.current?.offsetWidth || widthRef.current || 360
const outDx = dir === 'right' ? w + 56 : -(w + 56)
// rausfliegen lassen
setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
const outDx = dir === 'right' ? w + 40 : -(w + 40)
setArmedDir(dir)
dxRef.current = outDx
setDx(outDx)
// ✅ Action sofort starten, damit Parent die nächste Karte direkt zeigen kann.
// Animation läuft parallel weiter.
let actionPromise: Promise<boolean | void>
if (runAction) {
actionPromise = Promise.resolve(
dir === 'right' ? onSwipeRight() : onSwipeLeft()
).catch(() => false)
actionPromise = Promise.resolve(dir === 'right' ? onSwipeRight() : onSwipeLeft()).catch(() => false)
} else {
actionPromise = Promise.resolve(true)
}
// Mindestens die Commit-Animation abwarten (für sauberes Gefühl),
// aber Action bereits parallel laufen lassen.
const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs)
})
const [, ok] = await Promise.all([animPromise, actionPromise])
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
setAnimMs(snapMs)
setArmedDir(null)
setDx(0)
dxRef.current = 0
window.setTimeout(() => setAnimMs(0), snapMs)
return false
}
@ -230,26 +237,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
)
const clearTapTimer = React.useCallback(() => {
if (tapTimerRef.current != null) {
window.clearTimeout(tapTimerRef.current)
tapTimerRef.current = null
}
}, [])
const softResetForTap = React.useCallback(() => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
setAnimMs(0)
setDx(0)
setArmedDir(null)
try {
const el = cardRef.current
if (el) el.style.touchAction = 'pan-y'
} catch {}
} catch {
// ignore
}
}, [])
const runHotFx = React.useCallback(
@ -261,11 +265,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const layer = getGlobalFxLayer()
if (!layer) return
// ✅ 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
// Ziel: HOT Button (falls gefunden) ebenfalls im Viewport
const targetEl = hotTargetSelector
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
(card.querySelector(hotTargetSelector) as HTMLElement | null))
@ -282,7 +284,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const dx = endX - startX
const dy = endY - startY
// Flame node
const flame = document.createElement('div')
flame.style.position = 'absolute'
flame.style.left = `${startX}px`
@ -295,12 +296,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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.color = '#f59e0b'
inner.style.display = 'block'
flame.appendChild(inner)
@ -311,26 +310,20 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
void flame.getBoundingClientRect()
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
const popMs = 200
const holdMs = 500
const flyMs = 400
const duration = popMs + holdMs + flyMs // 1100
const duration = popMs + holdMs + flyMs
const tPopEnd = popMs / duration
const tHoldEnd = (popMs + holdMs) / duration
const anim = flame.animate(
[
// --- POP am Tap-Punkt ---
{ transform: 'translate(-50%, -50%) scale(0.15)', opacity: 0, offset: 0.0 },
{ transform: 'translate(-50%, -50%) scale(1.25)', opacity: 1, offset: tPopEnd * 0.55 },
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tPopEnd },
// --- HOLD (0.5s stehen bleiben) ---
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tHoldEnd },
// --- FLY zum HOT-Button ---
{
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85)`,
opacity: 0.95,
@ -349,7 +342,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
)
// Flash beim Ankommen (am Ende der Fly-Phase)
if (targetEl) {
window.setTimeout(() => {
try {
@ -361,14 +353,18 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
],
{ duration: 260, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)' }
)
} catch {}
} catch {
// ignore
}
}, popMs + holdMs + Math.round(flyMs * 0.75))
}
anim.onfinish = () => {
try {
root.unmount()
} catch {}
} catch {
// ignore
}
flame.remove()
}
},
@ -378,6 +374,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
React.useEffect(() => {
return () => {
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
}
}, [])
@ -393,61 +390,42 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const absDx = Math.abs(dx)
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
const activeThreshold =
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const reveal = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold)))
const revealSoft = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold * 1.35)))
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
// leichte Tinder-ähnliche Kippung beim Drag
const tiltDeg = Math.max(-6, Math.min(6, dx / 28))
const dragScale = dx === 0 ? 1 : 0.995
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
const dragScale = dx === 0 ? 1 : Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
return (
<div
ref={outerRef}
className={cn('relative isolate overflow-visible rounded-lg', className)}
>
{/* Foreground (moves) */}
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
<div
ref={cardRef}
className="relative"
style={{
// ✅ iOS Fix: im Idle kein transform
transform:
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
transition: animMs ? `transform ${animMs}ms ease` : undefined,
transition: animMs
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
: undefined,
touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined,
/*
boxShadow:
dx !== 0
? swipeDir === 'right'
? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})`
: `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})`
: undefined,
*/
borderRadius: dx !== 0 ? '12px' : undefined,
filter:
dx !== 0
? `saturate(${1 + reveal * 0.08}) brightness(${1 + reveal * 0.02})`
: undefined,
filter: dx !== 0 ? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})` : undefined,
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
const target = e.target as HTMLElement | null
// 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
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// Tap/DoubleTap erlauben, aber niemals swipe starten
let noSwipe = false
const root = e.currentTarget as HTMLElement
@ -466,6 +444,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (inVideo) {
const fromBottomVideo = vr.bottom - e.clientY
const scrubZonePx = 72
if (fromBottomVideo <= scrubZonePx) {
noSwipe = true
tapIgnored = true
@ -489,38 +468,55 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
noSwipe = true
}
const w = cardRef.current?.offsetWidth || 360
widthRef.current = w
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
pointer.current = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
startX: e.clientX,
startY: e.clientY,
dragging: false,
captured: false,
tapIgnored,
noSwipe,
axisLocked: null,
}
const el = cardRef.current
const w = el?.offsetWidth || 360
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
lastMoveXRef.current = e.clientX
lastMoveTRef.current = performance.now()
velocityXRef.current = 0
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
const ddx = e.clientX - pointer.current.startX
const ddy = e.clientY - pointer.current.startY
const absX = Math.abs(ddx)
const absY = Math.abs(ddy)
if (!pointer.current.dragging) {
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
if (!pointer.current.axisLocked) {
if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return
if (absY > absX * LOCK_RATIO) {
pointer.current.axisLocked = 'y'
pointer.current.id = null
return
}
if (Math.abs(ddx) < 12) return
if (absX > absY * LOCK_RATIO) {
pointer.current.axisLocked = 'x'
} else {
return
}
}
if (pointer.current.axisLocked !== 'x') return
if (!pointer.current.dragging) {
pointer.current.dragging = true
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
setAnimMs(0)
@ -533,33 +529,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
}
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 now = performance.now()
const dt = Math.max(1, now - lastMoveTRef.current)
const vx = (e.clientX - lastMoveXRef.current) / dt
velocityXRef.current = vx
lastMoveXRef.current = e.clientX
lastMoveTRef.current = now
const w = cardRef.current?.offsetWidth || 360
dxRef.current = applyResistance(ddx, w)
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
setDx(dxRef.current)
})
}
const next = rubberBand(ddx, widthRef.current)
flushDx(next)
const threshold = thresholdRef.current
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
setArmedDir((prev) => {
if (prev === nextDir) return prev
if (nextDir) {
try {
navigator.vibrate?.(10)
} catch {}
} catch {
// ignore
}
}
return nextDir
})
@ -568,9 +558,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
const threshold =
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const threshold = thresholdRef.current || Math.min(thresholdPx, widthRef.current * thresholdRatio)
const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
@ -579,11 +567,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
pointer.current.id = null
pointer.current.dragging = false
pointer.current.captured = false
pointer.current.axisLocked = null
if (wasCaptured) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {}
} catch {
// ignore
}
}
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
@ -596,11 +587,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
last &&
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
const isDouble =
Boolean(onDoubleTap) &&
last &&
now - last.t <= doubleTapMs &&
isNear
const isDouble = Boolean(onDoubleTap) && last && now - last.t <= doubleTapMs && isNear
if (isDouble) {
lastTapRef.current = null
@ -611,7 +598,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
try {
runHotFx(e.clientX, e.clientY)
} catch {}
} catch {
// ignore
}
requestAnimationFrame(() => {
;(async () => {
@ -650,40 +639,66 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return
}
const finalDx = dxRef.current
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (finalDx > threshold) {
void commit('right', true)
} else if (finalDx < -threshold) {
void commit('left', true)
} else {
reset()
}
const finalDx = dxRef.current
const abs = Math.abs(finalDx)
const vx = velocityXRef.current
const fastRight = vx > FLICK_VELOCITY
const fastLeft = vx < -FLICK_VELOCITY
dxRef.current = 0
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
void commit('right', true)
return
}
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
void commit('left', true)
return
}
reset()
}}
onPointerCancel={(e) => {
if (!enabled || disabled) return
if (pointer.current.captured && pointer.current.id != null) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {}
} catch {
// ignore
}
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false }
}
pointer.current = {
id: null,
startX: 0,
startY: 0,
dragging: false,
captured: false,
tapIgnored: false,
noSwipe: false,
axisLocked: null,
}
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
velocityXRef.current = 0
try {
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
} catch {}
} catch {
// ignore
}
reset()
}}
@ -691,30 +706,28 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
<div className="relative">
<div className="relative z-10">{children}</div>
{/* Overlay über dem Inhalt (subtiler, progressiv) */}
<div
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
style={{
opacity: dx === 0 ? 0 : 0.12 + revealSoft * 0.18,
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
background:
swipeDir === 'right'
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.04) 45%, rgba(0,0,0,0) 100%)'
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.05) 42%, rgba(0,0,0,0) 100%)'
: swipeDir === 'left'
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.04) 45%, rgba(0,0,0,0) 100%)'
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.05) 42%, rgba(0,0,0,0) 100%)'
: 'transparent',
}}
/>
{/* Armed border glow */}
<div
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
style={{
opacity: armedDir ? 1 : 0,
boxShadow:
armedDir === 'right'
? 'inset 0 0 0 1px rgba(16,185,129,0.45), inset 0 0 32px rgba(16,185,129,0.10)'
? 'inset 0 0 0 1px rgba(16,185,129,0.40), inset 0 0 28px rgba(16,185,129,0.10)'
: armedDir === 'left'
? 'inset 0 0 0 1px rgba(244,63,94,0.45), inset 0 0 32px rgba(244,63,94,0.10)'
? 'inset 0 0 0 1px rgba(244,63,94,0.40), inset 0 0 28px rgba(244,63,94,0.10)'
: 'none',
}}
/>