updated
This commit is contained in:
parent
6682b51f76
commit
32a74e0be0
@ -163,6 +163,13 @@ type EnsureAssetsResult struct {
|
|||||||
MetaOK bool
|
MetaOK bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EnsurePrimaryAssetsResult struct {
|
||||||
|
Skipped bool
|
||||||
|
ThumbGenerated bool
|
||||||
|
PreviewGenerated bool
|
||||||
|
MetaOK bool
|
||||||
|
}
|
||||||
|
|
||||||
// Public wrappers (kompatibel zu deinem bisherigen API)
|
// Public wrappers (kompatibel zu deinem bisherigen API)
|
||||||
|
|
||||||
func ensureAssetsForVideo(videoPath string) error {
|
func ensureAssetsForVideo(videoPath string) error {
|
||||||
@ -187,6 +194,454 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
|
|||||||
return res, err
|
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.
|
// 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) {
|
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
|
||||||
var out EnsureAssetsResult
|
var out EnsureAssetsResult
|
||||||
@ -477,7 +932,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath, err)
|
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,40 +1034,77 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
|||||||
return out, fmt.Errorf("video datei nicht gefunden")
|
return out, fmt.Errorf("video datei nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Assets sicherstellen (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
|
// 1) Nur schnelle / primäre Assets sicherstellen:
|
||||||
assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil)
|
// - preview.jpg
|
||||||
|
// - preview.mp4
|
||||||
|
// - basis-meta (duration / width / height / fps)
|
||||||
|
primaryRes, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = assetsRes
|
out.MetaOK = primaryRes.MetaOK
|
||||||
|
|
||||||
id := assetIDFromVideoPath(videoPath)
|
id := assetIDFromVideoPath(videoPath)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return out, fmt.Errorf("konnte asset id nicht ableiten")
|
return out, fmt.Errorf("konnte asset id nicht ableiten")
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := previewSpriteTruthForID(id)
|
_, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id)
|
||||||
out.SpriteReady = ps.Exists
|
if perr != nil {
|
||||||
out.AssetsReady = ps.Exists
|
return out, perr
|
||||||
out.MetaOK = true
|
|
||||||
|
|
||||||
// 2) AI-Segmente prüfen
|
|
||||||
if hasAIResultsForOutput(videoPath) {
|
|
||||||
out.AnalyzeReady = true
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
goal = strings.ToLower(strings.TrimSpace(goal))
|
||||||
if goal == "" {
|
if goal == "" {
|
||||||
goal = "nsfw"
|
goal = "nsfw"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) AI nur ausführen, wenn Sprite vorhanden ist
|
// 2) Für Split brauchen wir zusätzlich Sprite + AI.
|
||||||
if !ps.Exists {
|
// 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
|
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)
|
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
|
||||||
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal)
|
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal)
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
|
||||||
const (
|
const (
|
||||||
previewSpriteCols = 10
|
previewSpriteCols = 10
|
||||||
previewSpriteRows = 8
|
previewSpriteRows = 8
|
||||||
@ -19,15 +18,6 @@ const (
|
|||||||
previewSpriteCellW = 160
|
previewSpriteCellW = 160
|
||||||
previewSpriteCellH = 90
|
previewSpriteCellH = 90
|
||||||
)
|
)
|
||||||
*/
|
|
||||||
|
|
||||||
const (
|
|
||||||
previewSpriteCols = 6
|
|
||||||
previewSpriteRows = 5
|
|
||||||
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
|
|
||||||
previewSpriteCellW = 120
|
|
||||||
previewSpriteCellH = 68
|
|
||||||
)
|
|
||||||
|
|
||||||
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
|
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
|
||||||
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
|
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
|
||||||
|
|||||||
@ -247,10 +247,71 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global (oder in deinem app struct halten)
|
// global (oder in deinem app struct halten)
|
||||||
var postWorkQ = NewPostWorkQueue(512, 6) // maxParallelFFmpeg = 6
|
var postWorkQ = NewPostWorkQueue(512, 6) // schneller kritischer Pfad
|
||||||
|
var enrichQ = NewPostWorkQueue(512, 2) // langsame Hintergrundaufgaben
|
||||||
|
|
||||||
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
// --- 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() {
|
func startPostWorkStatusRefresher() {
|
||||||
t := time.NewTicker(1 * time.Second)
|
t := time.NewTicker(1 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@ -456,7 +456,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
return
|
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)
|
fi, serr := os.Stat(out)
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
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()
|
s := getSettings()
|
||||||
minMB := s.AutoDeleteSmallDownloadsBelowMB
|
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 {
|
if s.AutoDeleteSmallDownloads && minMB > 0 {
|
||||||
threshold := int64(minMB) * 1024 * 1024
|
threshold := int64(minMB) * 1024 * 1024
|
||||||
|
|
||||||
@ -532,6 +530,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
postOut := out
|
postOut := out
|
||||||
postTarget := target
|
postTarget := target
|
||||||
postKey := "postwork:" + job.ID
|
postKey := "postwork:" + job.ID
|
||||||
|
postFile := filepath.Base(out)
|
||||||
|
postAssetID := assetIDFromVideoPath(out)
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.Phase = "postwork"
|
job.Phase = "postwork"
|
||||||
@ -555,6 +555,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
|
|
||||||
setJobProgress(job, "postwork", 0)
|
setJobProgress(job, "postwork", 0)
|
||||||
publishJobUpsert(job)
|
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)
|
out := strings.TrimSpace(postOut)
|
||||||
@ -566,18 +579,55 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
|
||||||
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: postFile,
|
||||||
|
AssetID: postAssetID,
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "done",
|
||||||
|
Label: "Primäre Nachbearbeitung fertig",
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
setPhase := func(phase string, pct int) {
|
setPhase := func(phase string, pct int) {
|
||||||
setJobProgress(job, phase, pct)
|
setJobProgress(job, phase, pct)
|
||||||
st := postWorkQ.StatusForKey(postKey)
|
st := postWorkQ.StatusForKey(postKey)
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PostWork = &st
|
job.PostWork = &st
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
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
|
// 1) Remux
|
||||||
@ -595,7 +645,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
// 2) Move to done
|
// 2) Move to done
|
||||||
setPhase("moving", 10)
|
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)
|
fi, serr := os.Stat(out)
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
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)
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "moving",
|
||||||
|
Label: "Nachbearbeitung fehlgeschlagen",
|
||||||
|
})
|
||||||
|
|
||||||
if shouldLogRecordInfo(req) {
|
if shouldLogRecordInfo(req) {
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")")
|
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)
|
setPhase("assets", 0)
|
||||||
|
|
||||||
lastPct := -1
|
lastPct := -1
|
||||||
@ -694,52 +753,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
setPhase("assets", pct)
|
setPhase("assets", pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
|
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
|
||||||
fmt.Println("⚠️ ensureAssetsForVideo:", err)
|
fmt.Println("⚠️ ensurePrimaryAssetsForVideo:", err)
|
||||||
}
|
}
|
||||||
setPhase("assets", 100)
|
setPhase("assets", 100)
|
||||||
|
|
||||||
// 6) AI Analyze -> meta.json.ai
|
// Finalize primary postwork
|
||||||
setPhase("analyze", 5)
|
finalFile := filepath.Base(out)
|
||||||
{
|
finalAssetID := assetIDFromVideoPath(out)
|
||||||
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
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()
|
jobsMu.Lock()
|
||||||
job.Status = postTarget
|
job.Status = postTarget
|
||||||
job.Phase = ""
|
job.Phase = ""
|
||||||
@ -750,6 +772,28 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
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
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -760,6 +804,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWork = &st
|
job.PostWork = &st
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
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 {
|
} else {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.Status = postTarget
|
job.Status = postTarget
|
||||||
@ -771,5 +828,14 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: postFile,
|
||||||
|
AssetID: postAssetID,
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "postwork",
|
||||||
|
Label: "Nachbearbeitung konnte nicht eingeplant werden",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,12 @@
|
|||||||
"autoStartAddedDownloads": false,
|
"autoStartAddedDownloads": false,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": false,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 200,
|
"autoDeleteSmallDownloadsBelowMB": 200,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "hover",
|
"teaserPlayback": "hover",
|
||||||
"teaserAudio": true,
|
"teaserAudio": true,
|
||||||
"enableNotifications": true,
|
"enableNotifications": true,
|
||||||
"encryptedCookies": ""
|
"encryptedCookies": "Cq0QdJ72VtRxmtC+RGn1DVKb17COrGwrj+FEfgIwgeIn1hcrzvwFCfowqKgJVuPV1cAq9IFv4PnEN/+PiOIC5pxjO/+GWoSBb55E2OjSvknvWYbagMiTSRDvUaDQWzxvbP45VxXOIOelDneU1v5b8lT+cXR5IIY9tRK5hjYw7Yqam4fCkTjTP2EvgjVOvI7vmWfkQcbohhrs9fL06/5FFEuyUQJrsfESk+lge34rhxlJ3OYOqwF7sHke6NttG8CcX30cam5Hzl+/IgFkk34DYXcg4GZOrcgrRwfmEu4/8WeSBt7reBXglt2EkH4RMr9kdNNkXhMXTA9IKeoego9X22mwBXc0rPLT6fWhHykN90qZWtxOdy4JlRTSUaBswu0lnTLNfH0LrGUBzaU5Y5WFsrkFIa/TEEPWdI1EPH4b5GjH7MGjGBRr2U6Aqshy6PsrfHKrR4gIbhqCaoEaYxJjP9aUljFc3rqcw3UgAv1dp8eXo4lm+HPAdtVtNlD+pBsNZO5zXGkXFOk="
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ func main() {
|
|||||||
|
|
||||||
fixKeepRootFilesIntoModelSubdirs()
|
fixKeepRootFilesIntoModelSubdirs()
|
||||||
postWorkQ.StartWorkers(1)
|
postWorkQ.StartWorkers(1)
|
||||||
|
enrichQ.StartWorkers(2)
|
||||||
startPostWorkStatusRefresher()
|
startPostWorkStatusRefresher()
|
||||||
|
startEnrichStatusRefresher()
|
||||||
|
|
||||||
go startGeneratedGarbageCollector()
|
go startGeneratedGarbageCollector()
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@ -53,6 +54,84 @@ type ssePublishItem struct {
|
|||||||
Data []byte
|
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 {
|
func isTerminalJobStatusForSSE(status JobStatus) bool {
|
||||||
s := strings.ToLower(strings.TrimSpace(string(status)))
|
s := strings.ToLower(strings.TrimSpace(string(status)))
|
||||||
return s == "stopped" ||
|
return s == "stopped" ||
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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) {
|
func runGenerateMissingAssets(ctx context.Context) {
|
||||||
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
|
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -261,10 +319,18 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
st.CurrentFile = it.name
|
st.CurrentFile = it.name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
publishAssetsTaskFinishedPostworkState(
|
||||||
|
it.name,
|
||||||
|
"running",
|
||||||
|
"Erstelle Vorschau / Thumbnails…",
|
||||||
|
)
|
||||||
|
|
||||||
// ID aus Dateiname
|
// ID aus Dateiname
|
||||||
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
||||||
id := stripHotPrefix(base)
|
id := stripHotPrefix(base)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
|
clearAssetsTaskFinishedPostworkState(it.name)
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.Done = i + 1
|
st.Done = i + 1
|
||||||
})
|
})
|
||||||
@ -274,6 +340,8 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
// Datei-Info (validieren)
|
// Datei-Info (validieren)
|
||||||
vfi, verr := os.Stat(it.path)
|
vfi, verr := os.Stat(it.path)
|
||||||
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
|
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||||
|
clearAssetsTaskFinishedPostworkState(it.name)
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.Done = i + 1
|
st.Done = i + 1
|
||||||
})
|
})
|
||||||
@ -283,6 +351,12 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
// Pfade einmalig über zentralen Helper
|
// Pfade einmalig über zentralen Helper
|
||||||
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
|
publishAssetsTaskFinishedPostworkState(
|
||||||
|
it.name,
|
||||||
|
"error",
|
||||||
|
"Assets fehlgeschlagen",
|
||||||
|
)
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
// UI bekommt stabilen Hinweis, aber Task läuft weiter
|
// UI bekommt stabilen Hinweis, aber Task läuft weiter
|
||||||
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
|
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)
|
// Generate/Ensure (einheitliche Core-Funktion)
|
||||||
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
publishAssetsTaskFinishedPostworkState(
|
||||||
|
it.name,
|
||||||
|
"error",
|
||||||
|
"Assets fehlgeschlagen",
|
||||||
|
)
|
||||||
finishWithErr(e)
|
finishWithErr(e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -312,6 +391,16 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
//fmt.Println("⚠️ tasks generate assets analyze:", aerr)
|
//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) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
if res.Skipped {
|
if res.Skipped {
|
||||||
st.Skipped++
|
st.Skipped++
|
||||||
|
|||||||
@ -68,7 +68,7 @@ type RecorderSettingsState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JobEvent = {
|
type JobEvent = {
|
||||||
type?: 'job_upsert' | 'job_remove'
|
type?: 'job_upsert' | 'job_remove' | 'finished_postwork'
|
||||||
model?: string
|
model?: string
|
||||||
jobId?: string
|
jobId?: string
|
||||||
status?: string
|
status?: string
|
||||||
@ -97,6 +97,17 @@ type JobEvent = {
|
|||||||
running?: number
|
running?: number
|
||||||
maxParallel?: 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 = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||||
@ -364,6 +375,11 @@ function modelEventKeyFromJob(job: RecordJob): string {
|
|||||||
return keyFromFile
|
return keyFromFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelEventKeyFromDoneJob(job: RecordJob): string {
|
||||||
|
const { modelKey } = modelKeyAndHostFromJob(job)
|
||||||
|
return String(modelKey || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
|
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
|
||||||
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
|
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 msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||||||
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
|
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 (msg?.type === 'job_upsert') {
|
||||||
if (modelKey) {
|
if (modelKey) {
|
||||||
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
||||||
@ -1482,6 +1548,20 @@ export default function App() {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}, [modelsByKey, isChaturbateStoreModel])
|
}, [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)
|
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('doneChanged', onDoneChanged as any)
|
||||||
es.addEventListener('autostart', onAutostart as any)
|
es.addEventListener('autostart', onAutostart as any)
|
||||||
|
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
|
||||||
|
|
||||||
// initial nur Listener für wirklich relevante Jobs / Queue setzen
|
// initial nur Listener für wirklich relevante Jobs / Queue setzen
|
||||||
|
|
||||||
@ -2349,12 +2483,23 @@ export default function App() {
|
|||||||
if (key) ensureModelEventListener(key)
|
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) {
|
for (const p of pendingWatchedRooms) {
|
||||||
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||||
if (k) ensureModelEventListener(k)
|
if (k) ensureModelEventListener(k)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) watched Models ebenfalls abonnieren
|
||||||
|
for (const key of watchedModelKeysLower) {
|
||||||
|
if (key) ensureModelEventListener(key)
|
||||||
|
}
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
void loadJobs()
|
void loadJobs()
|
||||||
@ -2378,6 +2523,7 @@ export default function App() {
|
|||||||
if (es) {
|
if (es) {
|
||||||
es.removeEventListener('doneChanged', onDoneChanged as any)
|
es.removeEventListener('doneChanged', onDoneChanged as any)
|
||||||
es.removeEventListener('autostart', onAutostart as any)
|
es.removeEventListener('autostart', onAutostart as any)
|
||||||
|
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
|
||||||
|
|
||||||
const handler = onModelJobEventRef.current
|
const handler = onModelJobEventRef.current
|
||||||
if (handler) {
|
if (handler) {
|
||||||
@ -2399,6 +2545,9 @@ export default function App() {
|
|||||||
loadDoneCount,
|
loadDoneCount,
|
||||||
requestFinishedReload,
|
requestFinishedReload,
|
||||||
ensureModelEventListener,
|
ensureModelEventListener,
|
||||||
|
doneJobs,
|
||||||
|
pendingWatchedRooms,
|
||||||
|
watchedModelKeysLower,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -2420,24 +2569,35 @@ export default function App() {
|
|||||||
if (key) desired.add(key)
|
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) {
|
for (const p of pendingWatchedRooms) {
|
||||||
const key = String(p?.modelKey ?? '').trim().toLowerCase()
|
const key = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||||
if (key) desired.add(key)
|
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) {
|
for (const key of desired) {
|
||||||
ensureModelEventListener(key)
|
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)) {
|
for (const key of Array.from(modelEventNamesRef.current)) {
|
||||||
if (!desired.has(key)) {
|
if (!desired.has(key)) {
|
||||||
removeModelEventListener(key)
|
removeModelEventListener(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [jobs, pendingWatchedRooms, ensureModelEventListener, removeModelEventListener])
|
}, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener])
|
||||||
|
|
||||||
function isChaturbate(raw: string): boolean {
|
function isChaturbate(raw: string): boolean {
|
||||||
const norm = normalizeHttpUrl(raw)
|
const norm = normalizeHttpUrl(raw)
|
||||||
@ -2535,12 +2695,25 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddToDownloads = useCallback(
|
const handleAddToDownloads = useCallback(
|
||||||
async (job: RecordJob): Promise<boolean> => {
|
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)
|
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)
|
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 url = canonicalizeProviderUrl(norm0)
|
||||||
const ok = await startUrl(url, { silent: true })
|
const ok = await startUrl(url, { silent: true })
|
||||||
@ -2554,6 +2727,37 @@ export default function App() {
|
|||||||
[startUrl, notify]
|
[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'
|
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
||||||
|
|
||||||
async function runFinishedFileAction<T>(opts: {
|
async function runFinishedFileAction<T>(opts: {
|
||||||
@ -3305,6 +3509,7 @@ export default function App() {
|
|||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
onAddToDownloads={handleAddToDownloads}
|
onAddToDownloads={handleAddToDownloads}
|
||||||
|
onRemovePending={handleRemovePendingWatchedRoom}
|
||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -3326,6 +3531,7 @@ export default function App() {
|
|||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
onKeepJob={handleKeepJob}
|
onKeepJob={handleKeepJob}
|
||||||
onSplitJob={openSplitModal}
|
onSplitJob={openSplitModal}
|
||||||
|
onAddToDownloads={handleAddToDownloads}
|
||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||||
@ -3339,7 +3545,9 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'models' ? <ModelsTab /> : null}
|
{selectedTab === 'models' ? (
|
||||||
|
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
||||||
|
) : null}
|
||||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||||
{selectedTab === 'settings' ? (
|
{selectedTab === 'settings' ? (
|
||||||
<RecorderSettings
|
<RecorderSettings
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
813
frontend/src/components/ui/DownloadsCardRow.tsx
Normal file
813
frontend/src/components/ui/DownloadsCardRow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -39,6 +39,20 @@ type SortMode =
|
|||||||
| 'size_desc'
|
| 'size_desc'
|
||||||
| 'size_asc'
|
| '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 = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
doneJobs: RecordJob[]
|
doneJobs: RecordJob[]
|
||||||
@ -58,6 +72,7 @@ type Props = {
|
|||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
||||||
onSplitJob?: (job: RecordJob) => void | Promise<void>
|
onSplitJob?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
doneTotal: number
|
doneTotal: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@ -136,6 +151,40 @@ function useMediaQuery(query: string) {
|
|||||||
return matches
|
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 modelNameFromOutput = (output?: string) => {
|
||||||
const fileRaw = baseName(output || '')
|
const fileRaw = baseName(output || '')
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
@ -316,6 +365,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onKeepJob,
|
onKeepJob,
|
||||||
onSplitJob,
|
onSplitJob,
|
||||||
|
onAddToDownloads,
|
||||||
doneTotal,
|
doneTotal,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -356,6 +406,8 @@ export default function FinishedDownloads({
|
|||||||
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
|
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
|
||||||
const refillSessionRef = React.useRef(0)
|
const refillSessionRef = React.useRef(0)
|
||||||
|
|
||||||
|
const [postworkByFile, setPostworkByFile] = React.useState<Record<string, FinishedPostworkBadge>>({})
|
||||||
|
|
||||||
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
||||||
() => ({
|
() => ({
|
||||||
mode: teaserPlaybackMode,
|
mode: teaserPlaybackMode,
|
||||||
@ -482,6 +534,11 @@ export default function FinishedDownloads({
|
|||||||
return { tagsByModelKey, tagSetByModelKey }
|
return { tagsByModelKey, tagSetByModelKey }
|
||||||
}, [modelsByKey])
|
}, [modelsByKey])
|
||||||
|
|
||||||
|
const jobForDetails = useCallback(
|
||||||
|
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
|
||||||
|
[modelsByKey]
|
||||||
|
)
|
||||||
|
|
||||||
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
|
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
|
||||||
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
||||||
const deferredSearchQuery = React.useDeferredValue(searchQuery)
|
const deferredSearchQuery = React.useDeferredValue(searchQuery)
|
||||||
@ -1746,52 +1803,75 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const map = new Map<string, RecordJob>()
|
const map = new Map<string, RecordJob>()
|
||||||
|
const doneBaseKeys = new Set<string>()
|
||||||
|
|
||||||
// Basis: Files aus dem Done-Ordner
|
// Basis: Files aus dem Done-Ordner
|
||||||
for (const j of doneJobsPage) {
|
for (const j of doneJobsPage) {
|
||||||
const jj = applyRenamedOutput(j)
|
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) {
|
for (const j of jobs) {
|
||||||
const jj = applyRenamedOutput(j)
|
const jj = applyRenamedOutput(j)
|
||||||
const k = keyFor(jj)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: gleicher Dateiname, aber evtl. anderer key/ID
|
||||||
|
const file = baseName(jj.output || '')
|
||||||
|
if (!file) continue
|
||||||
|
|
||||||
|
for (const [existingKey, existingJob] of map.entries()) {
|
||||||
|
if (baseName(existingJob.output || '') === file) {
|
||||||
|
map.set(existingKey, { ...existingJob, ...jj })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = Array.from(map.values()).filter((j) => {
|
const list = Array.from(map.entries())
|
||||||
if (deletedKeys.has(keyFor(j))) return false
|
.map(([k, j]) => ({ k, j }))
|
||||||
|
.filter(({ k, j }) => {
|
||||||
|
if (deletedKeys.has(k)) return false
|
||||||
|
|
||||||
// ✅ .trash niemals anzeigen
|
// .trash niemals anzeigen
|
||||||
if (isTrashOutput(j.output)) return false
|
if (isTrashOutput(j.output)) return false
|
||||||
|
|
||||||
const anyJ = j as any
|
const anyJ = j as any
|
||||||
const phase = String(anyJ?.phase ?? '').trim().toLowerCase()
|
const status = String(anyJ?.status ?? '').trim().toLowerCase()
|
||||||
const pw = anyJ?.postWork
|
const endedAt = anyJ?.endedAt
|
||||||
const pwState = String(pw?.state ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
const isActiveFinishedPostwork =
|
// ✅ Wichtig:
|
||||||
phase === 'probe' ||
|
// Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben –
|
||||||
phase === 'remuxing' ||
|
// auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist.
|
||||||
phase === 'moving' ||
|
if (doneBaseKeys.has(k)) return true
|
||||||
phase === 'assets' ||
|
|
||||||
phase === 'postwork' ||
|
|
||||||
pwState === 'queued' ||
|
|
||||||
pwState === 'running'
|
|
||||||
|
|
||||||
// ✅ Alles, was noch in Nacharbeiten steckt, NICHT im Finished-Tab zeigen
|
// Fallback: terminale Jobs ebenfalls anzeigen
|
||||||
if (isActiveFinishedPostwork) return false
|
if (
|
||||||
|
status === 'finished' ||
|
||||||
|
status === 'failed' ||
|
||||||
|
status === 'stopped'
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
// Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist
|
||||||
j.status === 'finished' ||
|
if (endedAt) return true
|
||||||
j.status === 'failed' ||
|
|
||||||
j.status === 'stopped'
|
return false
|
||||||
)
|
})
|
||||||
})
|
.map(({ j }) => j)
|
||||||
|
|
||||||
return list
|
return list
|
||||||
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
|
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput, keyFor, baseName])
|
||||||
|
|
||||||
const viewRows = rows
|
const viewRows = rows
|
||||||
|
|
||||||
@ -1807,6 +1887,35 @@ export default function FinishedDownloads({
|
|||||||
fileToKeyRef.current = m
|
fileToKeyRef.current = m
|
||||||
}, [viewRows])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onExternalDelete = (ev: Event) => {
|
const onExternalDelete = (ev: Event) => {
|
||||||
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
||||||
@ -1937,8 +2046,8 @@ export default function FinishedDownloads({
|
|||||||
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
|
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active =
|
const active =
|
||||||
teaserState.mode === 'hover' &&
|
|
||||||
!canHover &&
|
!canHover &&
|
||||||
|
teaserState.mode !== 'still' &&
|
||||||
(view === 'cards' || view === 'gallery' || view === 'table')
|
(view === 'cards' || view === 'gallery' || view === 'table')
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@ -2026,6 +2135,7 @@ export default function FinishedDownloads({
|
|||||||
elToKeyRef.current.set(el, key)
|
elToKeyRef.current.set(el, key)
|
||||||
teaserIORef.current?.observe(el)
|
teaserIORef.current?.observe(el)
|
||||||
} else {
|
} else {
|
||||||
|
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
|
||||||
teaserHostsRef.current.delete(key)
|
teaserHostsRef.current.delete(key)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -2064,6 +2174,10 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall || view !== 'cards') return
|
if (!isSmall || view !== 'cards') return
|
||||||
|
if (teaserState.mode === 'still') {
|
||||||
|
setTeaserKey(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const top = pageRows[0]
|
const top = pageRows[0]
|
||||||
if (!top) {
|
if (!top) {
|
||||||
@ -2072,16 +2186,8 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topKey = keyFor(top)
|
const topKey = keyFor(top)
|
||||||
|
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
||||||
// ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card
|
}, [isSmall, view, pageRows, keyFor, teaserState.mode])
|
||||||
setTeaserKey((prev) => (prev === topKey ? prev : null))
|
|
||||||
|
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
|
||||||
}, 140) // 100–180ms testen
|
|
||||||
|
|
||||||
return () => window.clearTimeout(t)
|
|
||||||
}, [isSmall, view, pageRows, keyFor])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall) {
|
if (!isSmall) {
|
||||||
@ -2462,6 +2568,7 @@ export default function FinishedDownloads({
|
|||||||
<FinishedDownloadsCardsView
|
<FinishedDownloadsCardsView
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
isSmall={isSmall}
|
isSmall={isSmall}
|
||||||
|
jobForDetails={jobForDetails}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
durations={durations}
|
durations={durations}
|
||||||
@ -2496,6 +2603,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onSplit={onSplitJob}
|
onSplit={onSplitJob}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
@ -2503,6 +2611,7 @@ export default function FinishedDownloads({
|
|||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -2511,6 +2620,7 @@ export default function FinishedDownloads({
|
|||||||
<FinishedDownloadsTableView
|
<FinishedDownloadsTableView
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
jobForDetails={jobForDetails}
|
||||||
keyFor={keyFor}
|
keyFor={keyFor}
|
||||||
baseName={baseName}
|
baseName={baseName}
|
||||||
lower={lower}
|
lower={lower}
|
||||||
@ -2544,11 +2654,13 @@ export default function FinishedDownloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onSplit={onSplitJob}
|
onSplit={onSplitJob}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2556,6 +2668,7 @@ export default function FinishedDownloads({
|
|||||||
<FinishedDownloadsGalleryView
|
<FinishedDownloadsGalleryView
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
jobForDetails={jobForDetails}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
durations={durations}
|
durations={durations}
|
||||||
handleDuration={handleDuration}
|
handleDuration={handleDuration}
|
||||||
@ -2582,12 +2695,14 @@ export default function FinishedDownloads({
|
|||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onSplit={onSplitJob}
|
onSplit={onSplitJob}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type Props = {
|
|||||||
|
|
||||||
assetNonce?: number
|
assetNonce?: number
|
||||||
|
|
||||||
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
@ -76,16 +77,37 @@ type Props = {
|
|||||||
}>
|
}>
|
||||||
activeTagSet: Set<string>
|
activeTagSet: Set<string>
|
||||||
onToggleTagFilter: (tag: string) => void
|
onToggleTagFilter: (tag: string) => void
|
||||||
|
|
||||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
enqueueKeepVideo?: (job: RecordJob) => boolean
|
enqueueKeepVideo?: (job: RecordJob) => boolean
|
||||||
enqueueToggleHot?: (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[] => {
|
const parseTags = (raw?: string): string[] => {
|
||||||
@ -159,7 +181,7 @@ function chooseSpriteGrid(count: number): [number, number] {
|
|||||||
return [bestCols, bestRows]
|
return [bestCols, bestRows]
|
||||||
}
|
}
|
||||||
|
|
||||||
function postworkBadgeClass(tone: 'idle' | 'running' | 'done' | 'error') {
|
function postworkBadgeClass(tone: PostworkBadgeTone) {
|
||||||
if (tone === 'running') {
|
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'
|
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,
|
swipeRefs,
|
||||||
assetNonce,
|
assetNonce,
|
||||||
|
jobForDetails,
|
||||||
handleScrubberClickIndex,
|
handleScrubberClickIndex,
|
||||||
|
|
||||||
keyFor,
|
keyFor,
|
||||||
@ -567,10 +590,12 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onAddToDownloads,
|
||||||
|
|
||||||
enqueueDeleteVideo,
|
enqueueDeleteVideo,
|
||||||
enqueueKeepVideo,
|
enqueueKeepVideo,
|
||||||
enqueueToggleHot,
|
enqueueToggleHot,
|
||||||
|
postworkByFile
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||||
@ -730,6 +755,9 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
|
const livePostwork =
|
||||||
|
postworkByFile[fileRaw] ??
|
||||||
|
postworkByFile[stripHotPrefix(fileRaw)]
|
||||||
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
||||||
const modelKey = lower(model)
|
const modelKey = lower(model)
|
||||||
const flags = modelsByKey[modelKey]
|
const flags = modelsByKey[modelKey]
|
||||||
@ -859,7 +887,30 @@ export default function FinishedDownloadsCardsView({
|
|||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
const isWatching = Boolean(flags?.watching)
|
||||||
const 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)
|
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="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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<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">
|
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||||
{isHot ? (
|
{isHot ? (
|
||||||
@ -1089,20 +1156,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Meta + Actions (nicht im Video) */}
|
{/* Meta + Actions (nicht im Video) */}
|
||||||
@ -1115,7 +1168,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
<div className="w-full">
|
<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">
|
<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
|
<RecordJobActions
|
||||||
job={j}
|
job={jobForDetails(j)}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
collapseToMenu
|
collapseToMenu
|
||||||
@ -1131,6 +1184,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onKeep={keepVideo}
|
onKeep={keepVideo}
|
||||||
onDelete={deleteVideo}
|
onDelete={deleteVideo}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
className="w-full gap-1.5"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -36,16 +36,32 @@ type ModelFlags = {
|
|||||||
previewScrubberCount?: number
|
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 = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
durations: Record<string, number>
|
durations: Record<string, number>
|
||||||
|
postworkByFile: Record<string, FinishedPostworkBadge>
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
|
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
|
||||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
|
|
||||||
keyFor: (j: RecordJob) => string
|
keyFor: (j: RecordJob) => string
|
||||||
baseName: (p: string) => string
|
baseName: (p: string) => string
|
||||||
@ -76,6 +92,7 @@ type Props = {
|
|||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
@ -153,10 +170,12 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
isLoading,
|
isLoading,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
durations,
|
durations,
|
||||||
|
postworkByFile,
|
||||||
teaserState,
|
teaserState,
|
||||||
|
|
||||||
handleDuration,
|
handleDuration,
|
||||||
handleScrubberClickIndex,
|
handleScrubberClickIndex,
|
||||||
|
jobForDetails,
|
||||||
keyFor,
|
keyFor,
|
||||||
baseName,
|
baseName,
|
||||||
modelNameFromOutput,
|
modelNameFromOutput,
|
||||||
@ -184,6 +203,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onAddToDownloads,
|
||||||
enqueueDeleteVideo,
|
enqueueDeleteVideo,
|
||||||
enqueueKeepVideo,
|
enqueueKeepVideo,
|
||||||
enqueueToggleHot,
|
enqueueToggleHot,
|
||||||
@ -295,6 +315,16 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
const isHot = isHotName(fileRaw)
|
const isHot = isHotName(fileRaw)
|
||||||
const file = stripHotPrefix(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 dur = runtimeOf(j)
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
@ -605,12 +635,12 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
</div>
|
</div>
|
||||||
</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="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
|
<span
|
||||||
className={[
|
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' : '',
|
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -629,6 +659,42 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
>
|
>
|
||||||
{model || '—'}
|
{model || '—'}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<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">
|
||||||
<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">
|
<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
|
<RecordJobActions
|
||||||
job={j}
|
job={jobForDetails(j)}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
collapseToMenu
|
collapseToMenu
|
||||||
@ -681,6 +747,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
return deleteVideo(job)
|
return deleteVideo(job)
|
||||||
}}
|
}}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
className="w-full gap-1.5"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -26,6 +26,20 @@ type SortMode =
|
|||||||
| 'size_desc'
|
| 'size_desc'
|
||||||
| 'size_asc'
|
| '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 = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@ -40,6 +54,8 @@ type Props = {
|
|||||||
formatBytes: (bytes?: number | null) => string
|
formatBytes: (bytes?: number | null) => string
|
||||||
resolutions: Record<string, { w: number; h: number }>
|
resolutions: Record<string, { w: number; h: number }>
|
||||||
durations: Record<string, number>
|
durations: Record<string, number>
|
||||||
|
postworkByFile: Record<string, FinishedPostworkBadge>
|
||||||
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
|
|
||||||
// teaser/preview
|
// teaser/preview
|
||||||
canHover: boolean
|
canHover: boolean
|
||||||
@ -72,6 +88,7 @@ type Props = {
|
|||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
@ -94,6 +111,8 @@ export default function FinishedDownloadsTableView({
|
|||||||
formatBytes,
|
formatBytes,
|
||||||
resolutions,
|
resolutions,
|
||||||
durations,
|
durations,
|
||||||
|
postworkByFile,
|
||||||
|
jobForDetails,
|
||||||
|
|
||||||
canHover,
|
canHover,
|
||||||
teaserState,
|
teaserState,
|
||||||
@ -122,6 +141,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onAddToDownloads,
|
||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
@ -259,7 +279,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
job={j}
|
job={jobForDetails(j)}
|
||||||
getFileName={baseName}
|
getFileName={baseName}
|
||||||
durationSeconds={durations[k]}
|
durationSeconds={durations[k]}
|
||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
@ -312,9 +332,50 @@ export default function FinishedDownloadsTableView({
|
|||||||
const modelKey = lower(modelNameFromOutput(j.output))
|
const modelKey = lower(modelNameFromOutput(j.output))
|
||||||
const tags = parseTags(modelsByKey[modelKey]?.tags)
|
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 (
|
return (
|
||||||
<div className="min-w-0">
|
<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="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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@ -431,7 +492,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={j}
|
job={jobForDetails(j)}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
isHot={isHot}
|
isHot={isHot}
|
||||||
@ -467,6 +528,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
return deleteVideo(job)
|
return deleteVideo(job)
|
||||||
}}
|
}}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
className="flex items-center justify-end gap-1"
|
className="flex items-center justify-end gap-1"
|
||||||
/>
|
/>
|
||||||
@ -478,6 +540,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
keyFor,
|
keyFor,
|
||||||
baseName,
|
baseName,
|
||||||
durations,
|
durations,
|
||||||
|
postworkByFile,
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
canHover,
|
canHover,
|
||||||
setHoverTeaserKey,
|
setHoverTeaserKey,
|
||||||
|
|||||||
@ -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 [models, setModels] = React.useState<StoredModel[]>([])
|
||||||
const flagsInFlightRef = React.useRef<Record<string, true>>({})
|
const flagsInFlightRef = React.useRef<Record<string, true>>({})
|
||||||
|
|
||||||
@ -858,6 +862,7 @@ export default function ModelsTab() {
|
|||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={jobForDetails(m)}
|
job={jobForDetails(m)}
|
||||||
variant="table"
|
variant="table"
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['add', 'details']}
|
order={['add', 'details']}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
/>
|
/>
|
||||||
@ -1373,6 +1378,7 @@ export default function ModelsTab() {
|
|||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={jobForDetails(m)}
|
job={jobForDetails(m)}
|
||||||
variant="table"
|
variant="table"
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['add', 'details']}
|
order={['add', 'details']}
|
||||||
className="
|
className="
|
||||||
flex items-center gap-1
|
flex items-center gap-1
|
||||||
|
|||||||
242
frontend/src/components/ui/SwipeActionRow.tsx
Normal file
242
frontend/src/components/ui/SwipeActionRow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,7 +23,6 @@ function getGlobalFxLayer(): HTMLDivElement | null {
|
|||||||
el.style.inset = '0'
|
el.style.inset = '0'
|
||||||
el.style.pointerEvents = 'none'
|
el.style.pointerEvents = 'none'
|
||||||
el.style.zIndex = '2147483647'
|
el.style.zIndex = '2147483647'
|
||||||
// optional, aber nice:
|
|
||||||
;(el.style as any).contain = 'layout style paint'
|
;(el.style as any).contain = 'layout style paint'
|
||||||
document.body.appendChild(el)
|
document.body.appendChild(el)
|
||||||
}
|
}
|
||||||
@ -31,7 +30,6 @@ function getGlobalFxLayer(): HTMLDivElement | null {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type SwipeAction = {
|
export type SwipeAction = {
|
||||||
label: React.ReactNode
|
label: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
@ -39,66 +37,24 @@ export type SwipeAction = {
|
|||||||
|
|
||||||
export type SwipeCardProps = {
|
export type SwipeCardProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
||||||
/** Swipe an/aus (z.B. nur mobile view) */
|
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** blockiert Swipe + Tap */
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|
||||||
/** Tap ohne Swipe (z.B. Player öffnen) */
|
|
||||||
onTap?: () => void
|
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>
|
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
||||||
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
||||||
|
|
||||||
/** optionales Styling am äußeren Wrapper */
|
|
||||||
className?: string
|
className?: string
|
||||||
|
leftAction?: SwipeAction
|
||||||
/** Action-Bereiche */
|
rightAction?: SwipeAction
|
||||||
leftAction?: SwipeAction // standard: Behalten
|
|
||||||
rightAction?: SwipeAction // standard: Löschen
|
|
||||||
|
|
||||||
/** Ab welcher Strecke wird ausgelöst? */
|
|
||||||
thresholdPx?: number
|
thresholdPx?: number
|
||||||
thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35
|
thresholdRatio?: number
|
||||||
|
|
||||||
/** Animation timings */
|
|
||||||
snapMs?: number
|
snapMs?: number
|
||||||
commitMs?: 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
|
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
|
ignoreSelector?: string
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
|
|
||||||
* (z.B. Buttons/Inputs innerhalb der Karte)
|
|
||||||
*/
|
|
||||||
tapIgnoreSelector?: string
|
tapIgnoreSelector?: string
|
||||||
|
|
||||||
/** Doppeltippen (z.B. HOT togglen). Rückgabe false => Aktion fehlgeschlagen */
|
|
||||||
onDoubleTap?: () => boolean | void | Promise<boolean | void>
|
onDoubleTap?: () => boolean | void | Promise<boolean | void>
|
||||||
|
|
||||||
/** Wohin soll die Flamme fliegen? (Element innerhalb der Card) */
|
|
||||||
hotTargetSelector?: string
|
hotTargetSelector?: string
|
||||||
|
|
||||||
/** Double-Tap Zeitfenster */
|
|
||||||
doubleTapMs?: number
|
doubleTapMs?: number
|
||||||
|
|
||||||
/** Max. Bewegung zwischen taps (px) */
|
|
||||||
doubleTapMaxMovePx?: number
|
doubleTapMaxMovePx?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +64,31 @@ export type SwipeCardHandle = {
|
|||||||
reset: () => void
|
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(
|
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
@ -121,8 +102,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
thresholdRatio = 0.28,
|
thresholdRatio = 0.28,
|
||||||
ignoreFromBottomPx = 72,
|
ignoreFromBottomPx = 72,
|
||||||
ignoreSelector = '[data-swipe-ignore]',
|
ignoreSelector = '[data-swipe-ignore]',
|
||||||
snapMs = 180,
|
snapMs = 220,
|
||||||
commitMs = 180,
|
commitMs = 220,
|
||||||
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
||||||
onDoubleTap,
|
onDoubleTap,
|
||||||
hotTargetSelector = '[data-hot-target]',
|
hotTargetSelector = '[data-hot-target]',
|
||||||
@ -132,49 +113,82 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
const outerRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const doubleTapBusyRef = React.useRef(false)
|
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)
|
const rafRef = React.useRef<number | null>(null)
|
||||||
|
const dxRef = React.useRef(0)
|
||||||
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
|
|
||||||
const thresholdRef = React.useRef(0)
|
const thresholdRef = React.useRef(0)
|
||||||
|
const widthRef = React.useRef(360)
|
||||||
const outerRef = React.useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const tapTimerRef = React.useRef<number | null>(null)
|
const tapTimerRef = React.useRef<number | null>(null)
|
||||||
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
|
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const velocityXRef = React.useRef(0)
|
||||||
|
const lastMoveXRef = React.useRef(0)
|
||||||
|
const lastMoveTRef = React.useRef(0)
|
||||||
|
|
||||||
const pointer = React.useRef<{
|
const pointer = React.useRef<{
|
||||||
id: number | null
|
id: number | null
|
||||||
x: number
|
startX: number
|
||||||
y: number
|
startY: number
|
||||||
dragging: boolean
|
dragging: boolean
|
||||||
captured: boolean
|
captured: boolean
|
||||||
tapIgnored: boolean
|
tapIgnored: boolean
|
||||||
noSwipe: 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 [dx, setDx] = React.useState(0)
|
||||||
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
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(() => {
|
const reset = React.useCallback(() => {
|
||||||
// ✅ rAF cleanup
|
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
// ✅ TouchAction zurücksetzen (falls während Drag auf none gestellt)
|
velocityXRef.current = 0
|
||||||
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
|
|
||||||
|
if (cardRef.current) {
|
||||||
|
cardRef.current.style.touchAction = 'pan-y'
|
||||||
|
}
|
||||||
|
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setAnimMs(0)
|
||||||
|
}, snapMs)
|
||||||
}, [snapMs])
|
}, [snapMs])
|
||||||
|
|
||||||
const commit = React.useCallback(
|
const commit = React.useCallback(
|
||||||
@ -184,43 +198,36 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ TouchAction zurücksetzen (Sicherheit)
|
if (cardRef.current) {
|
||||||
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
|
cardRef.current.style.touchAction = 'pan-y'
|
||||||
|
}
|
||||||
|
|
||||||
const el = cardRef.current
|
const w = cardRef.current?.offsetWidth || widthRef.current || 360
|
||||||
const w = el?.offsetWidth || 360
|
const outDx = dir === 'right' ? w + 56 : -(w + 56)
|
||||||
|
|
||||||
// rausfliegen lassen
|
|
||||||
setAnimMs(commitMs)
|
setAnimMs(commitMs)
|
||||||
setArmedDir(dir === 'right' ? 'right' : 'left')
|
setArmedDir(dir)
|
||||||
const outDx = dir === 'right' ? w + 40 : -(w + 40)
|
|
||||||
dxRef.current = outDx
|
dxRef.current = outDx
|
||||||
setDx(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>
|
let actionPromise: Promise<boolean | void>
|
||||||
if (runAction) {
|
if (runAction) {
|
||||||
actionPromise = Promise.resolve(
|
actionPromise = Promise.resolve(dir === 'right' ? onSwipeRight() : onSwipeLeft()).catch(() => false)
|
||||||
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
|
||||||
).catch(() => false)
|
|
||||||
} else {
|
} else {
|
||||||
actionPromise = Promise.resolve(true)
|
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) => {
|
const animPromise = new Promise<void>((resolve) => {
|
||||||
window.setTimeout(resolve, commitMs)
|
window.setTimeout(resolve, commitMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [, ok] = await Promise.all([animPromise, actionPromise])
|
const [, ok] = await Promise.all([animPromise, actionPromise])
|
||||||
|
|
||||||
// wenn Aktion fehlschlägt => zurücksnappen
|
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
|
dxRef.current = 0
|
||||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -230,26 +237,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
||||||
)
|
)
|
||||||
|
|
||||||
const clearTapTimer = React.useCallback(() => {
|
|
||||||
if (tapTimerRef.current != null) {
|
|
||||||
window.clearTimeout(tapTimerRef.current)
|
|
||||||
tapTimerRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const softResetForTap = React.useCallback(() => {
|
const softResetForTap = React.useCallback(() => {
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
setAnimMs(0)
|
setAnimMs(0)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const el = cardRef.current
|
const el = cardRef.current
|
||||||
if (el) el.style.touchAction = 'pan-y'
|
if (el) el.style.touchAction = 'pan-y'
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const runHotFx = React.useCallback(
|
const runHotFx = React.useCallback(
|
||||||
@ -261,14 +265,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
const layer = getGlobalFxLayer()
|
const layer = getGlobalFxLayer()
|
||||||
if (!layer) return
|
if (!layer) return
|
||||||
|
|
||||||
// ✅ Start im Viewport (da wo getippt wurde, fallback: Mitte)
|
|
||||||
let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2
|
let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2
|
||||||
let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2
|
let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2
|
||||||
|
|
||||||
// Ziel: HOT Button (falls gefunden) – ebenfalls im Viewport
|
|
||||||
const targetEl = hotTargetSelector
|
const targetEl = hotTargetSelector
|
||||||
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
|
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
|
||||||
(card.querySelector(hotTargetSelector) as HTMLElement | null))
|
(card.querySelector(hotTargetSelector) as HTMLElement | null))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let endX = startX
|
let endX = startX
|
||||||
@ -282,7 +284,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
const dx = endX - startX
|
const dx = endX - startX
|
||||||
const dy = endY - startY
|
const dy = endY - startY
|
||||||
|
|
||||||
// Flame node
|
|
||||||
const flame = document.createElement('div')
|
const flame = document.createElement('div')
|
||||||
flame.style.position = 'absolute'
|
flame.style.position = 'absolute'
|
||||||
flame.style.left = `${startX}px`
|
flame.style.left = `${startX}px`
|
||||||
@ -295,12 +296,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
flame.style.userSelect = 'none'
|
flame.style.userSelect = 'none'
|
||||||
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
|
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')
|
const inner = document.createElement('div')
|
||||||
inner.style.width = '30px'
|
inner.style.width = '30px'
|
||||||
inner.style.height = '30px'
|
inner.style.height = '30px'
|
||||||
inner.style.color = '#f59e0b' // amber
|
inner.style.color = '#f59e0b'
|
||||||
// optional: damit SVG nicht “inline” komisch sitzt
|
|
||||||
inner.style.display = 'block'
|
inner.style.display = 'block'
|
||||||
|
|
||||||
flame.appendChild(inner)
|
flame.appendChild(inner)
|
||||||
@ -311,26 +310,20 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
void flame.getBoundingClientRect()
|
void flame.getBoundingClientRect()
|
||||||
|
|
||||||
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
|
|
||||||
const popMs = 200
|
const popMs = 200
|
||||||
const holdMs = 500
|
const holdMs = 500
|
||||||
const flyMs = 400
|
const flyMs = 400
|
||||||
const duration = popMs + holdMs + flyMs // 1100
|
const duration = popMs + holdMs + flyMs
|
||||||
|
|
||||||
const tPopEnd = popMs / duration
|
const tPopEnd = popMs / duration
|
||||||
const tHoldEnd = (popMs + holdMs) / duration
|
const tHoldEnd = (popMs + holdMs) / duration
|
||||||
|
|
||||||
const anim = flame.animate(
|
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(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.25)', opacity: 1, offset: tPopEnd * 0.55 },
|
||||||
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tPopEnd },
|
{ 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 },
|
{ 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)`,
|
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85)`,
|
||||||
opacity: 0.95,
|
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) {
|
if (targetEl) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
try {
|
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)' }
|
{ duration: 260, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)' }
|
||||||
)
|
)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, popMs + holdMs + Math.round(flyMs * 0.75))
|
}, popMs + holdMs + Math.round(flyMs * 0.75))
|
||||||
}
|
}
|
||||||
|
|
||||||
anim.onfinish = () => {
|
anim.onfinish = () => {
|
||||||
try {
|
try {
|
||||||
root.unmount()
|
root.unmount()
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
flame.remove()
|
flame.remove()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -378,6 +374,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
|
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 absDx = Math.abs(dx)
|
||||||
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
|
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
|
||||||
|
|
||||||
const activeThreshold =
|
const activeThreshold =
|
||||||
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
||||||
|
|
||||||
const reveal = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold)))
|
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
|
||||||
const revealSoft = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold * 1.35)))
|
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
||||||
|
|
||||||
// leichte Tinder-ähnliche Kippung beim Drag
|
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
||||||
const tiltDeg = Math.max(-6, Math.min(6, dx / 28))
|
const dragScale = dx === 0 ? 1 : Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
|
||||||
const dragScale = dx === 0 ? 1 : 0.995
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
|
||||||
ref={outerRef}
|
|
||||||
className={cn('relative isolate overflow-visible rounded-lg', className)}
|
|
||||||
>
|
|
||||||
{/* Foreground (moves) */}
|
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className="relative"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
// ✅ iOS Fix: im Idle kein transform
|
|
||||||
transform:
|
transform:
|
||||||
dx !== 0
|
dx !== 0
|
||||||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||||
: undefined,
|
: 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',
|
touchAction: 'pan-y',
|
||||||
willChange: dx !== 0 ? 'transform' : undefined,
|
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,
|
borderRadius: dx !== 0 ? '12px' : undefined,
|
||||||
filter:
|
filter: dx !== 0 ? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})` : undefined,
|
||||||
dx !== 0
|
|
||||||
? `saturate(${1 + reveal * 0.08}) brightness(${1 + reveal * 0.02})`
|
|
||||||
: undefined,
|
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
|
|
||||||
const target = e.target as HTMLElement | null
|
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))
|
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
||||||
|
|
||||||
// Harte Ignore-Zone: da wollen wir wirklich gar nichts
|
|
||||||
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
||||||
|
|
||||||
// Tap/DoubleTap erlauben, aber niemals swipe starten
|
|
||||||
let noSwipe = false
|
let noSwipe = false
|
||||||
|
|
||||||
const root = e.currentTarget as HTMLElement
|
const root = e.currentTarget as HTMLElement
|
||||||
@ -466,6 +444,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
if (inVideo) {
|
if (inVideo) {
|
||||||
const fromBottomVideo = vr.bottom - e.clientY
|
const fromBottomVideo = vr.bottom - e.clientY
|
||||||
const scrubZonePx = 72
|
const scrubZonePx = 72
|
||||||
|
|
||||||
if (fromBottomVideo <= scrubZonePx) {
|
if (fromBottomVideo <= scrubZonePx) {
|
||||||
noSwipe = true
|
noSwipe = true
|
||||||
tapIgnored = true
|
tapIgnored = true
|
||||||
@ -489,38 +468,55 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
noSwipe = true
|
noSwipe = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const w = cardRef.current?.offsetWidth || 360
|
||||||
|
widthRef.current = w
|
||||||
|
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
||||||
|
|
||||||
pointer.current = {
|
pointer.current = {
|
||||||
id: e.pointerId,
|
id: e.pointerId,
|
||||||
x: e.clientX,
|
startX: e.clientX,
|
||||||
y: e.clientY,
|
startY: e.clientY,
|
||||||
dragging: false,
|
dragging: false,
|
||||||
captured: false,
|
captured: false,
|
||||||
tapIgnored,
|
tapIgnored,
|
||||||
noSwipe,
|
noSwipe,
|
||||||
|
axisLocked: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = cardRef.current
|
lastMoveXRef.current = e.clientX
|
||||||
const w = el?.offsetWidth || 360
|
lastMoveTRef.current = performance.now()
|
||||||
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
velocityXRef.current = 0
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
if (pointer.current.id !== e.pointerId) return
|
if (pointer.current.id !== e.pointerId) return
|
||||||
|
|
||||||
if (pointer.current.noSwipe) return
|
if (pointer.current.noSwipe) return
|
||||||
|
|
||||||
const ddx = e.clientX - pointer.current.x
|
const ddx = e.clientX - pointer.current.startX
|
||||||
const ddy = e.clientY - pointer.current.y
|
const ddy = e.clientY - pointer.current.startY
|
||||||
|
const absX = Math.abs(ddx)
|
||||||
|
const absY = Math.abs(ddy)
|
||||||
|
|
||||||
if (!pointer.current.dragging) {
|
if (!pointer.current.axisLocked) {
|
||||||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
|
if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return
|
||||||
|
|
||||||
|
if (absY > absX * LOCK_RATIO) {
|
||||||
|
pointer.current.axisLocked = 'y'
|
||||||
pointer.current.id = null
|
pointer.current.id = null
|
||||||
return
|
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
|
pointer.current.dragging = true
|
||||||
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
|
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
|
||||||
setAnimMs(0)
|
setAnimMs(0)
|
||||||
@ -533,33 +529,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyResistance = (x: number, w: number) => {
|
const now = performance.now()
|
||||||
const limit = w * 0.9
|
const dt = Math.max(1, now - lastMoveTRef.current)
|
||||||
const ax = Math.abs(x)
|
const vx = (e.clientX - lastMoveXRef.current) / dt
|
||||||
if (ax <= limit) return x
|
velocityXRef.current = vx
|
||||||
const extra = ax - limit
|
lastMoveXRef.current = e.clientX
|
||||||
const resisted = limit + extra * 0.25
|
lastMoveTRef.current = now
|
||||||
return Math.sign(x) * resisted
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = cardRef.current?.offsetWidth || 360
|
const next = rubberBand(ddx, widthRef.current)
|
||||||
dxRef.current = applyResistance(ddx, w)
|
flushDx(next)
|
||||||
|
|
||||||
if (rafRef.current == null) {
|
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
|
||||||
rafRef.current = null
|
|
||||||
setDx(dxRef.current)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const threshold = thresholdRef.current
|
const threshold = thresholdRef.current
|
||||||
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
|
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
|
||||||
|
|
||||||
setArmedDir((prev) => {
|
setArmedDir((prev) => {
|
||||||
if (prev === nextDir) return prev
|
if (prev === nextDir) return prev
|
||||||
if (nextDir) {
|
if (nextDir) {
|
||||||
try {
|
try {
|
||||||
navigator.vibrate?.(10)
|
navigator.vibrate?.(10)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nextDir
|
return nextDir
|
||||||
})
|
})
|
||||||
@ -568,9 +558,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
if (pointer.current.id !== e.pointerId) return
|
if (pointer.current.id !== e.pointerId) return
|
||||||
|
|
||||||
const threshold =
|
const threshold = thresholdRef.current || Math.min(thresholdPx, widthRef.current * thresholdRatio)
|
||||||
thresholdRef.current ||
|
|
||||||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
|
||||||
|
|
||||||
const wasDragging = pointer.current.dragging
|
const wasDragging = pointer.current.dragging
|
||||||
const wasCaptured = pointer.current.captured
|
const wasCaptured = pointer.current.captured
|
||||||
@ -579,11 +567,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
pointer.current.id = null
|
pointer.current.id = null
|
||||||
pointer.current.dragging = false
|
pointer.current.dragging = false
|
||||||
pointer.current.captured = false
|
pointer.current.captured = false
|
||||||
|
pointer.current.axisLocked = null
|
||||||
|
|
||||||
if (wasCaptured) {
|
if (wasCaptured) {
|
||||||
try {
|
try {
|
||||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||||||
@ -596,11 +587,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
last &&
|
last &&
|
||||||
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
|
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
|
||||||
|
|
||||||
const isDouble =
|
const isDouble = Boolean(onDoubleTap) && last && now - last.t <= doubleTapMs && isNear
|
||||||
Boolean(onDoubleTap) &&
|
|
||||||
last &&
|
|
||||||
now - last.t <= doubleTapMs &&
|
|
||||||
isNear
|
|
||||||
|
|
||||||
if (isDouble) {
|
if (isDouble) {
|
||||||
lastTapRef.current = null
|
lastTapRef.current = null
|
||||||
@ -611,7 +598,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
runHotFx(e.clientX, e.clientY)
|
runHotFx(e.clientX, e.clientY)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@ -650,40 +639,66 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalDx = dxRef.current
|
|
||||||
|
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalDx > threshold) {
|
const finalDx = dxRef.current
|
||||||
void commit('right', true)
|
const abs = Math.abs(finalDx)
|
||||||
} else if (finalDx < -threshold) {
|
const vx = velocityXRef.current
|
||||||
void commit('left', true)
|
const fastRight = vx > FLICK_VELOCITY
|
||||||
} else {
|
const fastLeft = vx < -FLICK_VELOCITY
|
||||||
reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
dxRef.current = 0
|
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) => {
|
onPointerCancel={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
|
|
||||||
if (pointer.current.captured && pointer.current.id != null) {
|
if (pointer.current.captured && pointer.current.id != null) {
|
||||||
try {
|
try {
|
||||||
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
;(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) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
velocityXRef.current = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
}}
|
}}
|
||||||
@ -691,30 +706,28 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative z-10">{children}</div>
|
<div className="relative z-10">{children}</div>
|
||||||
|
|
||||||
{/* Overlay über dem Inhalt (subtiler, progressiv) */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
||||||
style={{
|
style={{
|
||||||
opacity: dx === 0 ? 0 : 0.12 + revealSoft * 0.18,
|
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
|
||||||
background:
|
background:
|
||||||
swipeDir === 'right'
|
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'
|
: 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',
|
: 'transparent',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Armed border glow */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
||||||
style={{
|
style={{
|
||||||
opacity: armedDir ? 1 : 0,
|
opacity: armedDir ? 1 : 0,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
armedDir === 'right'
|
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'
|
: 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',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user