This commit is contained in:
Linrador 2026-02-23 17:00:22 +01:00
parent 478e2696da
commit e8bd9e9d68
44 changed files with 3755 additions and 3406 deletions

View File

@ -1,3 +1,5 @@
// backend\chaturbate_biocontext.go
package main package main
import ( import (

View File

@ -618,6 +618,20 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock() cbMu.RUnlock()
// ---------------------------
// Persist "last seen online/offline" für explizit angefragte User
// (nur wenn wir einen gültigen Snapshot haben)
// ---------------------------
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
// Persistiert den tatsächlichen Snapshot-Status (unabhängig von Filtern)
for _, u := range users {
_, isOnline := liteByUser[u]
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
}
}
// --------------------------- // ---------------------------
// Refresh/Bootstrap-Strategie: // Refresh/Bootstrap-Strategie:
// - Handler blockiert NICHT auf Remote-Fetch (Performance!) // - Handler blockiert NICHT auf Remote-Fetch (Performance!)

Binary file not shown.

Binary file not shown.

Binary file not shown.

299
backend/disk_guard.go Normal file
View File

@ -0,0 +1,299 @@
// backend\disk_guard.go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
godisk "github.com/shirou/gopsutil/v3/disk"
)
// -------------------------
// Low disk space guard
// - pausiert Autostart
// - stoppt laufende Downloads
// -------------------------
const (
diskGuardInterval = 5 * time.Second
)
var diskEmergency int32 // 0=false, 1=true
type diskStatusResp struct {
Emergency bool `json:"emergency"`
PauseGB int `json:"pauseGB"`
ResumeGB int `json:"resumeGB"`
FreeBytes uint64 `json:"freeBytes"`
FreeBytesHuman string `json:"freeBytesHuman"`
RecordPath string `json:"recordPath"`
}
func diskStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
s := getSettings()
pauseGB, resumeGB, _, _, _ := computeDiskThresholds()
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
dir := strings.TrimSpace(recordDirAbs)
if dir == "" {
dir = strings.TrimSpace(s.RecordDir)
}
free := uint64(0)
if dir != "" {
if u, err := godisk.Usage(dir); err == nil && u != nil {
free = u.Free
}
}
resp := diskStatusResp{
Emergency: atomic.LoadInt32(&diskEmergency) == 1,
PauseGB: pauseGB,
ResumeGB: resumeGB,
FreeBytes: free,
FreeBytesHuman: formatBytesSI(int64(free)),
RecordPath: dir,
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, resp)
}
// stopJobsInternal markiert Jobs als "stopping" und cancelt sie (inkl. Preview-FFmpeg Kill).
// Nutzt 2 notify-Pushes, damit die UI Phase/Progress sofort sieht.
func stopJobsInternal(list []*RecordJob) {
if len(list) == 0 {
return
}
type payload struct {
cmd *exec.Cmd
cancel context.CancelFunc
}
pl := make([]payload, 0, len(list))
jobsMu.Lock()
for _, job := range list {
if job == nil {
continue
}
job.Phase = "stopping"
job.Progress = 10
pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel})
job.previewCmd = nil
}
jobsMu.Unlock()
notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress)
for _, p := range pl {
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
if p.cancel != nil {
p.cancel()
}
}
notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen
}
func stopAllStoppableJobs() int {
stoppable := make([]*RecordJob, 0, 16)
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
phase := strings.ToLower(strings.TrimSpace(j.Phase))
// ✅ Im Disk-Notfall ALLES stoppen, was noch schreibt.
// Wir skippen nur Jobs, die sowieso schon im "stopping" sind.
if phase == "stopping" {
continue
}
stoppable = append(stoppable, j)
}
jobsMu.Unlock()
stopJobsInternal(stoppable)
return len(stoppable)
}
func sizeOfPathBestEffort(p string) uint64 {
p = strings.TrimSpace(p)
if p == "" {
return 0
}
// relativ -> absolut versuchen
if !filepath.IsAbs(p) {
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
p = abs
}
}
fi, err := os.Stat(p)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return 0
}
return uint64(fi.Size())
}
func inFlightBytesForJob(j *RecordJob) uint64 {
if j == nil {
return 0
}
// Prefer live-tracked bytes if available (accurate & cheap).
if j.SizeBytes > 0 {
return uint64(j.SizeBytes)
}
return sizeOfPathBestEffort(j.Output)
}
const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds:
// Pause = ceil( (2 * inFlightBytes) / GiB )
// Resume = Pause + 3 GB (Hysterese)
// Wenn inFlight==0 => Pause/Resume = 0
func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) {
inFlight = sumInFlightBytes()
if inFlight == 0 {
return 0, 0, 0, 0, 0
}
need := inFlight * 2
pauseGB = int((need + giB - 1) / giB) // ceil
// Safety cap (nur zur Sicherheit, falls irgendwas eskaliert)
if pauseGB > 10_000 {
pauseGB = 10_000
}
resumeGB = pauseGB + 3
if resumeGB > 10_000 {
resumeGB = 10_000
}
pauseNeed = uint64(pauseGB) * giB
resumeNeed = uint64(resumeGB) * giB
return
}
// ✅ Summe der "wachsenden" Daten (running + remuxing etc.)
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 {
var sum uint64
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
// Nimm die Datei, die gerade wächst.
// In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel).
// Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen.
sum += inFlightBytesForJob(j)
}
return sum
}
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz:
// - Autostart pausieren
// - laufende Jobs stoppen (nur Status=running und Phase leer)
func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval)
defer t.Stop()
for range t.C {
s := getSettings()
// Pfad bestimmen, auf dem wir freien Speicher prüfen
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
dir := strings.TrimSpace(recordDirAbs)
if dir == "" {
dir = strings.TrimSpace(s.RecordDir)
}
if dir == "" {
continue
}
u, err := godisk.Usage(dir)
if err != nil || u == nil {
continue
}
free := u.Free
// ✅ Dynamische Schwellen:
// Pause = ceil((2 * inFlight) / GiB)
// Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes
pauseGB, resumeGB, inFlight, pauseNeed, _ := computeDiskThresholds()
// Wenn nichts läuft, gibt es nichts zu reservieren.
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.)
if inFlight == 0 {
// Kein Auto-Recovery:
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird.
continue
}
// Wenn Emergency aktiv ist, niemals automatisch freigeben.
// (Manueller Reset erforderlich)
if atomic.LoadInt32(&diskEmergency) == 1 {
continue
}
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
if free >= pauseNeed {
continue
}
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
atomic.StoreInt32(&diskEmergency, 1)
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
}
}
}

View File

@ -1,5 +1,4 @@
// backend\assets_generate.go // backend/generate.go
package main package main
import ( import (
@ -57,27 +56,28 @@ func assetIDFromVideoPath(videoPath string) string {
return strings.TrimSpace(id) return strings.TrimSpace(id)
} }
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / meta.json) // Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / preview-sprite.webp / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, metaPath string, err error) { func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return "", "", "", "", fmt.Errorf("empty id") return "", "", "", "", "", fmt.Errorf("empty id")
} }
assetDir, err = ensureGeneratedDir(id) assetDir, err = ensureGeneratedDir(id)
if err != nil || strings.TrimSpace(assetDir) == "" { if err != nil || strings.TrimSpace(assetDir) == "" {
return "", "", "", "", fmt.Errorf("generated dir: %v", err) return "", "", "", "", "", fmt.Errorf("generated dir: %v", err)
} }
thumbPath = filepath.Join(assetDir, "thumbs.webp") thumbPath = filepath.Join(assetDir, "thumbs.webp")
previewPath = filepath.Join(assetDir, "preview.mp4") previewPath = filepath.Join(assetDir, "preview.mp4")
spritePath = filepath.Join(assetDir, "preview-sprite.webp")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" { if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json") metaPath = filepath.Join(assetDir, "meta.json")
} }
return assetDir, thumbPath, previewPath, metaPath, nil return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
} }
type ensuredMeta struct { type ensuredMeta struct {
@ -145,6 +145,7 @@ type EnsureAssetsResult struct {
Skipped bool Skipped bool
ThumbGenerated bool ThumbGenerated bool
PreviewGenerated bool PreviewGenerated bool
SpriteGenerated bool
MetaOK bool MetaOK bool
} }
@ -172,7 +173,7 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
return res, err return res, err
} }
// Core: generiert thumbs/preview/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
@ -191,7 +192,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return out, nil return out, nil
} }
_, thumbPath, previewPath, metaPath, perr := assetPathsForID(id) _, thumbPath, previewPath, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return out, perr return out, perr
} }
@ -216,6 +217,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
} }
return false return false
}() }()
previewBefore := func() bool { previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true return true
@ -223,12 +225,19 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return false return false
}() }()
spriteBefore := func() bool {
if sfi, err := os.Stat(spritePath); err == nil && !sfi.IsDir() && sfi.Size() > 0 {
return true
}
return false
}()
// Meta sicherstellen (dedupliziert) // Meta sicherstellen (dedupliziert)
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok out.MetaOK = meta.ok
// Wenn alles da ist: skipped // Wenn alles da ist: skipped
if thumbBefore && previewBefore && meta.ok { if thumbBefore && previewBefore && spriteBefore && meta.ok {
out.Skipped = true out.Skipped = true
progress(1) progress(1)
return out, nil return out, nil
@ -296,44 +305,43 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
} }
// ---------------- // ----------------
// Preview // Preview (MP4 teaser clips)
// ---------------- // ----------------
var computedPreviewClips []previewClip
if previewBefore { if previewBefore {
progress(1) // Preview ist schon da -> nicht returnen, weil Sprite evtl. noch fehlt
return out, nil progress(thumbsW + previewW)
} } 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, 1.0, 18, func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
}); err != nil {
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
// ✅ Preview-Clips (Starts + Dur) in meta.json schreiben (best-effort)
func() { func() {
// nur wenn wir die Original-Dauer kennen 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, 1.0, 18, func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
}); err != nil {
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
// ✅ Preview-Clips berechnen (noch NICHT direkt schreiben)
if !(meta.durSec > 0) { if !(meta.durSec > 0) {
return return
} }
@ -355,16 +363,121 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
clips := make([]previewClip, 0, len(starts)) clips := make([]previewClip, 0, len(starts))
for _, s := range starts { for _, s := range starts {
clips = append(clips, previewClip{ clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000, // 3 decimals wie ffmpeg arg StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000, // 3 decimals DurationSeconds: math.Round(segDur*1000) / 1000,
}) })
} }
// Originalvideo-fi (nicht preview-fi!), damit Validierung konsistent bleibt // ✅ merken für finalen Meta-Write
_ = writeVideoMetaWithPreviewClips(metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, clips) computedPreviewClips = clips
}() }()
}
}() // ----------------
// Preview Sprite (stashapp-like scrubber)
// ----------------
var spriteMeta *previewSpriteMeta
if spriteBefore {
// Meta trotzdem vorbereiten (für JSON)
if meta.durSec > 0 {
stepSec := 5.0
count := int(math.Floor(meta.durSec/stepSec)) + 1
if count < 1 {
count = 1
}
if count > 200 {
count = 200 // Schutz
}
cols, rows := chooseSpriteGrid(count)
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
}
}
} else {
func() {
// nur sinnvoll wenn wir Dauer kennen
if !(meta.durSec > 0) {
return
}
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {
return
}
defer genSem.Release()
stepSec := 5.0
count := int(math.Floor(meta.durSec/stepSec)) + 1
if count < 1 {
count = 1
}
if count > 200 {
count = 200 // Schutz gegen riesige Sprites
}
cols, rows := chooseSpriteGrid(count)
// Zellgröße (16:9) für Gallery-Thumbs
cellW := 160
cellH := 90
if err := generatePreviewSpriteWebP(genCtx, videoPath, spritePath, cols, rows, stepSec, cellW, cellH); err != nil {
fmt.Println("⚠️ preview sprite:", err)
return
}
out.SpriteGenerated = true
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}()
}
// ✅ Final meta write: Clips + Sprite zusammen persistieren
if meta.durSec > 0 {
// Falls wir in diesem Lauf keine neuen Clips berechnet haben:
// alte aus gültigem meta.json übernehmen (best effort)
if len(computedPreviewClips) == 0 {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 {
computedPreviewClips = oldMeta.PreviewClips
}
}
// Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben:
// altes aus gültigem meta.json übernehmen
if spriteMeta == nil {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
spriteMeta = oldMeta.PreviewSprite
}
}
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
progress(1) progress(1)
return out, nil return out, nil

152
backend/generate_sprite.go Normal file
View File

@ -0,0 +1,152 @@
// backend\generate_sprite.go
package main
import (
"context"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strings"
)
// chooseSpriteGrid wählt ein sinnvolles cols/rows-Grid für count Frames.
// Ziel: wenig leere Zellen + eher horizontales Layout (passt gut zu 16:9 Cells).
func chooseSpriteGrid(count int) (cols, rows int) {
if count <= 0 {
return 1, 1
}
if count == 1 {
return 1, 1
}
targetRatio := 16.0 / 9.0 // wir bevorzugen horizontale Spritesheets
bestCols, bestRows := 1, count
bestWaste := math.MaxInt
bestRatioScore := math.MaxFloat64
for c := 1; c <= count; c++ {
r := int(math.Ceil(float64(count) / float64(c)))
if r <= 0 {
r = 1
}
waste := c*r - count
ratio := float64(c) / float64(r)
ratioScore := math.Abs(ratio - targetRatio)
// Priorität:
// 1) weniger leere Zellen
// 2) näher an targetRatio
// 3) bei Gleichstand weniger Rows (lieber breiter als hoch)
if waste < bestWaste ||
(waste == bestWaste && ratioScore < bestRatioScore) ||
(waste == bestWaste && ratioScore == bestRatioScore && r < bestRows) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
}
return bestCols, bestRows
}
// generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video.
// ffmpeg muss im PATH verfügbar sein.
func generatePreviewSpriteWebP(
ctx context.Context,
videoPath string,
outPath string,
cols int,
rows int,
stepSec float64,
cellW int,
cellH int,
) error {
videoPath = strings.TrimSpace(videoPath)
outPath = strings.TrimSpace(outPath)
if videoPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty videoPath")
}
if outPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty outPath")
}
if cols <= 0 || rows <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid grid %dx%d", cols, rows)
}
if stepSec <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid stepSec %.3f", stepSec)
}
if cellW <= 0 || cellH <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH)
}
// Zielordner sicherstellen
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return fmt.Errorf("mkdir sprite dir: %w", err)
}
// Temp-Datei im gleichen Verzeichnis für atomaren Replace
tmpPath := outPath + ".tmp"
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
vf := fmt.Sprintf(
"fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,tile=%dx%d:margin=0:padding=0",
stepSec,
cellW, cellH,
cellW, cellH,
cols, rows,
)
// Statisches WebP-Spritesheet
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", videoPath,
"-an",
"-sn",
"-vf", vf,
"-frames:v", "1",
"-c:v", "libwebp",
"-lossless", "0",
"-compression_level", "6",
"-q:v", "80",
tmpPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg != "" {
return fmt.Errorf("ffmpeg sprite failed: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg sprite failed: %w", err)
}
fi, err := os.Stat(tmpPath)
if err != nil {
return fmt.Errorf("sprite temp stat failed: %w", err)
}
if fi.IsDir() || fi.Size() <= 0 {
_ = os.Remove(tmpPath)
return fmt.Errorf("sprite temp file invalid/empty")
}
// Windows: Ziel vorher löschen, damit Rename klappt
_ = os.Remove(outPath)
if err := os.Rename(tmpPath, outPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("sprite rename failed: %w", err)
}
return nil
}

View File

@ -917,132 +917,6 @@ func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// -------------------------
// Low disk space guard
// - pausiert Autostart
// - stoppt laufende Downloads
// -------------------------
const (
diskGuardInterval = 5 * time.Second
)
var diskEmergency int32 // 0=false, 1=true
type diskStatusResp struct {
Emergency bool `json:"emergency"`
PauseGB int `json:"pauseGB"`
ResumeGB int `json:"resumeGB"`
FreeBytes uint64 `json:"freeBytes"`
FreeBytesHuman string `json:"freeBytesHuman"`
RecordPath string `json:"recordPath"`
}
func diskStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
s := getSettings()
pauseGB, resumeGB, _, _, _ := computeDiskThresholds()
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
dir := strings.TrimSpace(recordDirAbs)
if dir == "" {
dir = strings.TrimSpace(s.RecordDir)
}
free := uint64(0)
if dir != "" {
if u, err := godisk.Usage(dir); err == nil && u != nil {
free = u.Free
}
}
resp := diskStatusResp{
Emergency: atomic.LoadInt32(&diskEmergency) == 1,
PauseGB: pauseGB,
ResumeGB: resumeGB,
FreeBytes: free,
FreeBytesHuman: formatBytesSI(int64(free)),
RecordPath: dir,
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, resp)
}
// stopJobsInternal markiert Jobs als "stopping" und cancelt sie (inkl. Preview-FFmpeg Kill).
// Nutzt 2 notify-Pushes, damit die UI Phase/Progress sofort sieht.
func stopJobsInternal(list []*RecordJob) {
if len(list) == 0 {
return
}
type payload struct {
cmd *exec.Cmd
cancel context.CancelFunc
}
pl := make([]payload, 0, len(list))
jobsMu.Lock()
for _, job := range list {
if job == nil {
continue
}
job.Phase = "stopping"
job.Progress = 10
pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel})
job.previewCmd = nil
}
jobsMu.Unlock()
notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress)
for _, p := range pl {
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
if p.cancel != nil {
p.cancel()
}
}
notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen
}
func stopAllStoppableJobs() int {
stoppable := make([]*RecordJob, 0, 16)
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
phase := strings.ToLower(strings.TrimSpace(j.Phase))
// ✅ Im Disk-Notfall ALLES stoppen, was noch schreibt.
// Wir skippen nur Jobs, die sowieso schon im "stopping" sind.
if phase == "stopping" {
continue
}
stoppable = append(stoppable, j)
}
jobsMu.Unlock()
stopJobsInternal(stoppable)
return len(stoppable)
}
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes) // returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -1081,177 +955,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
return false, size, thr return false, size, thr
} }
func sizeOfPathBestEffort(p string) uint64 {
p = strings.TrimSpace(p)
if p == "" {
return 0
}
// relativ -> absolut versuchen
if !filepath.IsAbs(p) {
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
p = abs
}
}
fi, err := os.Stat(p)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return 0
}
return uint64(fi.Size())
}
func inFlightBytesForJob(j *RecordJob) uint64 {
if j == nil {
return 0
}
// Prefer live-tracked bytes if available (accurate & cheap).
if j.SizeBytes > 0 {
return uint64(j.SizeBytes)
}
return sizeOfPathBestEffort(j.Output)
}
const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds:
// Pause = ceil( (2 * inFlightBytes) / GiB )
// Resume = Pause + 3 GB (Hysterese)
// Wenn inFlight==0 => Pause/Resume = 0
func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) {
inFlight = sumInFlightBytes()
if inFlight == 0 {
return 0, 0, 0, 0, 0
}
need := inFlight * 2
pauseGB = int((need + giB - 1) / giB) // ceil
// Safety cap (nur zur Sicherheit, falls irgendwas eskaliert)
if pauseGB > 10_000 {
pauseGB = 10_000
}
resumeGB = pauseGB + 3
if resumeGB > 10_000 {
resumeGB = 10_000
}
pauseNeed = uint64(pauseGB) * giB
resumeNeed = uint64(resumeGB) * giB
return
}
// ✅ Summe der "wachsenden" Daten (running + remuxing etc.)
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 {
var sum uint64
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
// Nimm die Datei, die gerade wächst.
// In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel).
// Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen.
sum += inFlightBytesForJob(j)
}
return sum
}
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz:
// - Autostart pausieren
// - laufende Jobs stoppen (nur Status=running und Phase leer)
func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval)
defer t.Stop()
for range t.C {
s := getSettings()
// Pfad bestimmen, auf dem wir freien Speicher prüfen
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
dir := strings.TrimSpace(recordDirAbs)
if dir == "" {
dir = strings.TrimSpace(s.RecordDir)
}
if dir == "" {
continue
}
u, err := godisk.Usage(dir)
if err != nil || u == nil {
continue
}
free := u.Free
// ✅ Dynamische Schwellen:
// Pause = ceil((2 * inFlight) / GiB)
// Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
// Wenn nichts läuft, gibt es nichts zu reservieren.
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.)
if inFlight == 0 {
if atomic.LoadInt32(&diskEmergency) == 1 {
atomic.StoreInt32(&diskEmergency, 0)
fmt.Printf(
"✅ [disk] No active jobs: emergency cleared (free=%s, path=%s)\n",
formatBytesSI(u64ToI64(free)),
dir,
)
}
continue
}
// ✅ Hysterese: erst ab resumeNeed wieder "bereit"
if atomic.LoadInt32(&diskEmergency) == 1 {
if free >= resumeNeed {
atomic.StoreInt32(&diskEmergency, 0)
fmt.Printf(
"✅ [disk] Recovered: free=%s (%dB) (>= %s, %dB) emergency cleared (pause=%dGB resume=%dGB inFlight=%s, %dB)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
)
}
continue
}
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
if free >= pauseNeed {
continue
}
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
atomic.StoreInt32(&diskEmergency, 1)
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
}
}
}
func setJobPhase(job *RecordJob, phase string, progress int) { func setJobPhase(job *RecordJob, phase string, progress int) {
if progress < 0 { if progress < 0 {
progress = 0 progress = 0

View File

@ -19,6 +19,16 @@ type previewClip struct {
DurationSeconds float64 `json:"durationSeconds"` DurationSeconds float64 `json:"durationSeconds"`
} }
type previewSpriteMeta struct {
Path string `json:"path"` // z.B. /api/preview-sprite/<id>
Count int `json:"count"` // Anzahl Frames im Sprite
Cols int `json:"cols"` // Spalten im Tile
Rows int `json:"rows"` // Zeilen im Tile
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames (z.B. 5)
CellWidth int `json:"cellWidth,omitempty"`
CellHeight int `json:"cellHeight,omitempty"`
}
type videoMeta struct { type videoMeta struct {
Version int `json:"version"` Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"` DurationSeconds float64 `json:"durationSeconds"`
@ -30,8 +40,9 @@ type videoMeta struct {
FPS float64 `json:"fps,omitempty"` FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080" Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
SourceURL string `json:"sourceUrl,omitempty"` SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"` PreviewClips []previewClip `json:"previewClips,omitempty"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"`
} }
@ -148,6 +159,44 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
return atomicWriteFile(metaPath, buf) return atomicWriteFile(metaPath, buf)
} }
func writeVideoMetaWithPreviewClipsAndSprite(
metaPath string,
fi os.FileInfo,
dur float64,
w int,
h int,
fps float64,
sourceURL string,
clips []previewClip,
sprite *previewSpriteMeta,
) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
PreviewClips: clips,
PreviewSprite: sprite,
UpdatedAtUnix: time.Now().Unix(),
}
buf, err := json.Marshal(m)
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props) // Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
@ -218,6 +267,14 @@ func generatedPreviewFile(id string) (string, error) {
return filepath.Join(dir, "preview.mp4"), nil return filepath.Join(dir, "preview.mp4"), nil
} }
func generatedPreviewSpriteFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "preview-sprite.webp"), nil
}
func ensureGeneratedDirs() error { func ensureGeneratedDirs() error {
root, err := generatedMetaRoot() root, err := generatedMetaRoot()
if err != nil { if err != nil {

View File

@ -16,14 +16,16 @@ import (
) )
type StoredModel struct { type StoredModel struct {
ID string `json:"id"` // unique (wir verwenden host:modelKey) ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key ModelKey string `json:"modelKey"` // Display/Key
Tags string `json:"tags,omitempty"` Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"` LastStream string `json:"lastStream,omitempty"`
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
Watching bool `json:"watching"` Watching bool `json:"watching"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
@ -356,6 +358,9 @@ CREATE TABLE IF NOT EXISTS models (
biocontext_json TEXT, biocontext_json TEXT,
biocontext_fetched_at TEXT, biocontext_fetched_at TEXT,
last_seen_online INTEGER NULL, -- NULL/0/1
last_seen_online_at TEXT,
watching INTEGER NOT NULL DEFAULT 0, watching INTEGER NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0,
hot INTEGER NOT NULL DEFAULT 0, hot INTEGER NOT NULL DEFAULT 0,
@ -418,6 +423,18 @@ func ensureModelsColumns(db *sql.DB) error {
} }
} }
// ✅ Last seen online/offline (persistente Presence-Infos)
if !cols["last_seen_online"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_seen_online INTEGER NULL;`); err != nil {
return err
}
}
if !cols["last_seen_online_at"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_seen_online_at TEXT;`); err != nil {
return err
}
}
return nil return nil
} }
@ -458,6 +475,24 @@ func ptrLikedFromNull(n sql.NullInt64) *bool {
return &v return &v
} }
func nullBoolToNullInt64(p *bool) sql.NullInt64 {
if p == nil {
return sql.NullInt64{Valid: false}
}
if *p {
return sql.NullInt64{Valid: true, Int64: 1}
}
return sql.NullInt64{Valid: true, Int64: 0}
}
func ptrBoolFromNullInt64(n sql.NullInt64) *bool {
if !n.Valid {
return nil
}
v := n.Int64 != 0
return &v
}
// --- Biocontext Cache (persistente Bio-Infos aus Chaturbate) --- // --- Biocontext Cache (persistente Bio-Infos aus Chaturbate) ---
// GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel). // GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel).
@ -556,6 +591,77 @@ func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) er
return err return err
} }
// SetLastSeenOnline speichert den zuletzt bekannten Online/Offline-Status (+ Zeit)
// dauerhaft in der DB. Legt das Model (host+modelKey) bei Bedarf minimal an.
func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
ts := strings.TrimSpace(seenAt)
if ts == "" {
ts = time.Now().UTC().Format(time.RFC3339Nano)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
var onlineArg any = int64(0)
if online {
onlineArg = int64(1)
}
s.mu.Lock()
defer s.mu.Unlock()
// Erst versuchen, vorhandenes Model zu aktualisieren
res, err := s.db.Exec(`
UPDATE models
SET last_seen_online=?, last_seen_online_at=?, updated_at=?
WHERE lower(trim(host)) = lower(trim(?))
AND lower(trim(model_key)) = lower(trim(?));
`, onlineArg, ts, now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Falls noch kein Model existiert: minimal anlegen
id := canonicalID(host, key)
input := "https://" + host + "/" + key + "/"
path := "/" + key + "/"
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
biocontext_json,biocontext_fetched_at,
last_seen_online,last_seen_online_at,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
last_seen_online=excluded.last_seen_online,
last_seen_online_at=excluded.last_seen_online_at,
updated_at=excluded.updated_at;
`,
id, input, int64(1), host, path, key,
"", "",
nil, nil,
onlineArg, ts,
int64(0), int64(0), int64(0), int64(0), nil,
now, now,
)
return err
}
func (s *ModelStore) migrateFromJSONIfEmpty() error { func (s *ModelStore) migrateFromJSONIfEmpty() error {
// DB leer? // DB leer?
var cnt int var cnt int
@ -829,6 +935,7 @@ func (s *ModelStore) List() []StoredModel {
SELECT SELECT
id,input,is_url,host,path,model_key, id,input,is_url,host,path,model_key,
tags, COALESCE(last_stream,''), tags, COALESCE(last_stream,''),
last_seen_online, COALESCE(last_seen_online_at,''),
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
FROM models FROM models
@ -846,10 +953,13 @@ func (s *ModelStore) List() []StoredModel {
id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64 isURL, watching, favorite, hot, keep int64
liked sql.NullInt64 liked sql.NullInt64
lastSeenOnline sql.NullInt64
lastSeenOnlineAt string
) )
if err := rows.Scan( if err := rows.Scan(
&id, &input, &isURL, &host, &path, &modelKey, &id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, &tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
); err != nil { ); err != nil {
@ -857,21 +967,23 @@ func (s *ModelStore) List() []StoredModel {
} }
out = append(out, StoredModel{ out = append(out, StoredModel{
ID: id, ID: id,
Input: input, Input: input,
IsURL: isURL != 0, IsURL: isURL != 0,
Host: host, Host: host,
Path: path, Path: path,
ModelKey: modelKey, ModelKey: modelKey,
Watching: watching != 0, Watching: watching != 0,
Tags: tags, LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline),
LastStream: lastStream, LastSeenOnlineAt: lastSeenOnlineAt,
Favorite: favorite != 0, Tags: tags,
Hot: hot != 0, LastStream: lastStream,
Keep: keep != 0, Favorite: favorite != 0,
Liked: ptrLikedFromNull(liked), Hot: hot != 0,
CreatedAt: createdAt, Keep: keep != 0,
UpdatedAt: updatedAt, Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}) })
} }
@ -1174,12 +1286,15 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64 isURL, watching, favorite, hot, keep int64
liked sql.NullInt64 liked sql.NullInt64
lastSeenOnlineAt string
lastSeenOnline sql.NullInt64
) )
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT SELECT
input,is_url,host,path,model_key, input,is_url,host,path,model_key,
tags, COALESCE(last_stream,''), tags, COALESCE(last_stream,''),
last_seen_online, COALESCE(last_seen_online_at,''),
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
FROM models FROM models
@ -1187,6 +1302,7 @@ WHERE id=?;
`, id).Scan( `, id).Scan(
&input, &isURL, &host, &path, &modelKey, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, &tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
@ -1198,20 +1314,22 @@ WHERE id=?;
} }
return StoredModel{ return StoredModel{
ID: id, ID: id,
Input: input, Input: input,
IsURL: isURL != 0, IsURL: isURL != 0,
Host: host, Host: host,
Path: path, Path: path,
ModelKey: modelKey, ModelKey: modelKey,
Tags: tags, Tags: tags,
LastStream: lastStream, LastStream: lastStream,
Watching: watching != 0, LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline),
Favorite: favorite != 0, LastSeenOnlineAt: lastSeenOnlineAt,
Hot: hot != 0, Watching: watching != 0,
Keep: keep != 0, Favorite: favorite != 0,
Liked: ptrLikedFromNull(liked), Hot: hot != 0,
CreatedAt: createdAt, Keep: keep != 0,
UpdatedAt: updatedAt, Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil }, nil
} }

Binary file not shown.

View File

@ -386,7 +386,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
jobsMu.Unlock() jobsMu.Unlock()
}() }()
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.webp regelmäßig neu) // ✅ Live thumb writer starten (schreibt generated/<assetID>/thumbs.webp regelmäßig neu)
startLiveThumbWebPLoop(ctx, job) startLiveThumbWebPLoop(ctx, job)
return nil return nil

View File

@ -9,9 +9,9 @@ import (
) )
func rewriteM3U8(raw []byte, id string) []byte { func rewriteM3U8(raw []byte, id string) []byte {
// Wir bauen alle URIs so um, dass sie wieder über /api/record/preview laufen. // Wir bauen alle URIs so um, dass sie wieder über /api/preview laufen.
// Wichtig: play=1 bleibt dran, damit Folge-Requests (segments, chunklists) auch ohne Hover gehen. // Wichtig: play=1 bleibt dran, damit Folge-Requests (segments, chunklists) auch ohne Hover gehen.
base := "/api/record/preview?id=" + url.QueryEscape(id) + "&file=" base := "/api/preview?id=" + url.QueryEscape(id) + "&file="
var out bytes.Buffer var out bytes.Buffer
sc := bufio.NewScanner(bytes.NewReader(raw)) sc := bufio.NewScanner(bytes.NewReader(raw))
@ -48,7 +48,7 @@ func rewriteM3U8(raw []byte, id string) []byte {
} }
// Wenn es schon unser API ist, lassen // Wenn es schon unser API ist, lassen
if strings.Contains(u, "/api/record/preview") { if strings.Contains(u, "/api/preview") {
out.WriteString(line) out.WriteString(line)
out.WriteByte('\n') out.WriteByte('\n')
continue continue
@ -89,7 +89,7 @@ func rewriteAttrURI(line, base string) string {
valTrim := strings.TrimSpace(val) valTrim := strings.TrimSpace(val)
// absolut oder schon preview => nix tun // absolut oder schon preview => nix tun
if strings.HasPrefix(valTrim, "http://") || strings.HasPrefix(valTrim, "https://") || strings.Contains(valTrim, "/api/record/preview") { if strings.HasPrefix(valTrim, "http://") || strings.HasPrefix(valTrim, "https://") || strings.Contains(valTrim, "/api/preview") {
return line return line
} }

View File

@ -617,23 +617,7 @@ func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
updateLiveThumbWebPOnce(ctx, job) updateLiveThumbWebPOnce(ctx, job)
for { for {
// dynamische Frequenz: je mehr aktive Jobs, desto langsamer (weniger Last) delay := 10 * time.Second
jobsMu.Lock()
nRunning := 0
for _, j := range jobs {
if j != nil && j.Status == JobRunning {
nRunning++
}
}
jobsMu.Unlock()
delay := 12 * time.Second
if nRunning >= 6 {
delay = 18 * time.Second
}
if nRunning >= 12 {
delay = 25 * time.Second
}
select { select {
case <-ctx.Done(): case <-ctx.Done():

View File

@ -6,14 +6,10 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
@ -22,7 +18,6 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
) )
@ -291,25 +286,18 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
return return
} }
// Versuche Meta aus dem Video zu extrahieren (FFprobe)
// -> du hast bereits ensureFFprobeAvailable(), getVideoHeightCached(), durationSecondsCached(), etc.
if err := ensureFFprobeAvailable(); err != nil {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren // kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second) pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel() defer cancel()
// Dauer // Dauer (best effort)
dur, derr := durationSecondsCached(pctx, videoPath) dur := 0.0
if derr != nil || dur <= 0 { if d, derr := durationSecondsCached(pctx, videoPath); derr == nil && d > 0 {
// best-effort: nicht blockieren dur = d
dur = 0
} }
// Height (und daraus evtl. Width) falls du schon Width helper hast, nimm den. // Height/Width optional nicht mehr berechnen (wenn helper entfernt wurde)
h, _ := getVideoHeightCached(pctx, videoPath) h := 0
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen. // FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
fps := 0.0 fps := 0.0
@ -384,7 +372,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
// Wichtig: Browser schicken bei Video-Range-Requests oft If-Range / If-Modified-Since / If-None-Match. // Wichtig: Browser schicken bei Video-Range-Requests oft If-Range / If-Modified-Since / If-None-Match.
// Wenn du die nicht erlaubst, schlägt der Preflight fehl -> VideoJS sieht "NETWORK error". // Wenn du die nicht erlaubst, schlägt der Preflight fehl -> VideoJS sieht "NETWORK error".
w.Header().Set("Access-Control-Allow-Headers", "Range, If-Range, If-Modified-Since, If-None-Match") w.Header().Set("Access-Control-Allow-Headers", "Range, If-Range, If-Modified-Since, If-None-Match")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified, X-Transcode-Offset-Seconds") w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified")
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
} }
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@ -392,107 +380,6 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ---- query normalize ----
// Neu: resolution=LOW|MEDIUM|HIGH|ORIGINAL
res := strings.TrimSpace(r.URL.Query().Get("resolution"))
// Backwards-Compat: falls altes Frontend noch quality nutzt
if res == "" {
res = strings.TrimSpace(r.URL.Query().Get("quality"))
}
// Normalize: auto/original => leer (== "ORIGINAL" Profil)
if strings.EqualFold(res, "auto") || strings.EqualFold(res, "original") {
res = ""
}
// Validieren (wenn gesetzt)
if res != "" {
if _, ok := profileFromResolution(res); !ok {
writeErr(http.StatusBadRequest, "ungültige resolution")
return
}
}
rawProgress := strings.TrimSpace(r.URL.Query().Get("progress"))
if rawProgress == "" {
rawProgress = strings.TrimSpace(r.URL.Query().Get("p"))
}
// ---- startSec parse (seek position in seconds) ----
startSec := 0
startFrac := -1.0 // wenn 0..1 => Progress-Fraction (currentProgress)
raw := strings.TrimSpace(r.URL.Query().Get("start"))
if raw == "" {
raw = strings.TrimSpace(r.URL.Query().Get("t"))
}
parseFracOrSeconds := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// allow "hh:mm:ss" / "mm:ss"
if strings.Contains(s, ":") {
parts := strings.Split(s, ":")
ok := true
vals := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
ok = false
break
}
vals = append(vals, n)
}
if ok {
if len(vals) == 2 {
startSec = vals[0]*60 + vals[1]
return
} else if len(vals) == 3 {
startSec = vals[0]*3600 + vals[1]*60 + vals[2]
return
}
}
return
}
// number: seconds OR fraction
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return
}
if f <= 0 {
return
}
// < 1.0 => treat as fraction (currentProgress)
if f > 0 && f < 1.0 {
startFrac = f
return
}
// >= 1.0 => treat as seconds (floor)
startSec = int(f)
}
parseFracOrSeconds(raw)
// optional explicit progress overrides fraction
if rawProgress != "" {
f, err := strconv.ParseFloat(strings.TrimSpace(rawProgress), 64)
if err == nil && f > 0 && f < 1.0 {
startFrac = f
}
}
if startSec < 0 {
startSec = 0
}
// ---- resolve outPath from file or id ---- // ---- resolve outPath from file or id ----
resolveOutPath := func() (string, bool) { resolveOutPath := func() (string, bool) {
// ✅ Wiedergabe über Dateiname (für doneDir / recordDir) // ✅ Wiedergabe über Dateiname (für doneDir / recordDir)
@ -603,24 +490,6 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ---- convert progress fraction to seconds (if needed) ----
if startSec == 0 && startFrac > 0 && startFrac < 1.0 {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// ✅ nutzt deinen zentralen Cache + (wenn du es wie empfohlen ergänzt hast) durSem-Limit
dur, derr := durationSecondsCached(pctx, outPath)
if derr == nil && dur > 0 {
startSec = int(startFrac * dur)
}
}
// sanitize + optional bucket align (wie bei GOP-ish seeking)
if startSec < 0 {
startSec = 0
}
startSec = (startSec / 2) * 2
// ---- TS -> MP4 (on-demand remux) ---- // ---- TS -> MP4 (on-demand remux) ----
if strings.ToLower(filepath.Ext(outPath)) == ".ts" { if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
newOut, err := maybeRemuxTS(outPath) newOut, err := maybeRemuxTS(outPath)
@ -658,228 +527,14 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
} }
} }
// ✅ NEU: meta.json sicherstellen (best effort), bevor wir ausliefern/transcoden // ✅ meta.json sicherstellen (best effort), bevor wir ausliefern
ensureMetaJSONForPlayback(r.Context(), outPath) ensureMetaJSONForPlayback(r.Context(), outPath)
// ---- Quality / Transcode handling ---- // ✅ immer Original-Datei ausliefern (Range-fähig via serveVideoFile)
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
stream := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("stream")))
wantStream := stream == "1" || stream == "true" || stream == "yes"
// ✅ Wenn quality gesetzt ist:
if res != "" {
prof, _ := profileFromResolution(res)
// ✅ wenn Quelle schon <= Zielhöhe: ORIGINAL liefern
// ABER NUR wenn wir NICHT seeken und NICHT streamen wollen.
if prof.Height > 0 && startSec == 0 && !wantStream {
if err := ensureFFprobeAvailable(); err == nil {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if srcH, err := getVideoHeightCached(pctx, outPath); err == nil && srcH > 0 {
if srcH <= prof.Height+8 {
serveVideoFile(w, r, outPath)
return
}
}
}
}
// ✅ 1) Seek (startSec>0): Standard = Segment-Datei transcodieren & dann normal ausliefern (Range-fähig)
// stream=1 kann weiterhin den "live pipe" erzwingen.
if startSec > 0 && !wantStream {
segPath, terr := maybeTranscodeForRequest(r.Context(), outPath, res, startSec)
if terr != nil {
writeErr(http.StatusInternalServerError, "transcode failed: "+terr.Error())
return
}
// ✅ Offset NUR setzen, wenn wir wirklich ab startSec ausliefern (Segment)
w.Header().Set("X-Transcode-Offset-Seconds", strconv.Itoa(startSec))
serveVideoFile(w, r, segPath)
return
}
// ✅ 2) stream=1 ODER startSec>0 mit stream=true: pipe-stream
if wantStream || startSec > 0 {
if startSec > 0 {
// ✅ Offset NUR setzen, wenn wir wirklich ab startSec ausliefern (Stream)
w.Header().Set("X-Transcode-Offset-Seconds", strconv.Itoa(startSec))
}
if err := serveTranscodedStreamAt(r.Context(), w, outPath, prof, startSec); err != nil {
if errors.Is(err, context.Canceled) {
return
}
writeErr(http.StatusInternalServerError, "transcode stream failed: "+err.Error())
return
}
return
}
// ✅ 3) startSec==0: Full-file Cache-Transcode (wie vorher)
if startSec == 0 {
segPath, terr := maybeTranscodeForRequest(r.Context(), outPath, res, 0)
if terr != nil {
writeErr(http.StatusInternalServerError, "transcode failed: "+terr.Error())
return
}
serveVideoFile(w, r, segPath)
return
}
}
// ✅ Full-file Cache-Transcode nur wenn startSec == 0
if res != "" && startSec == 0 {
var terr error
outPath, terr = maybeTranscodeForRequest(r.Context(), outPath, res, startSec)
if terr != nil {
writeErr(http.StatusInternalServerError, "transcode failed: "+terr.Error())
return
}
}
serveVideoFile(w, r, outPath) serveVideoFile(w, r, outPath)
} }
type flushWriter struct {
w http.ResponseWriter
f http.Flusher
}
func (fw flushWriter) Write(p []byte) (int, error) {
n, err := fw.w.Write(p)
if fw.f != nil {
fw.f.Flush()
}
return n, err
}
func isClientDisconnectErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe) {
return true
}
// Windows / net/http typische Fälle
var op *net.OpError
if errors.As(err, &op) {
// op.Err kann syscall.Errno(10054/10053/...) sein
if se, ok := op.Err.(syscall.Errno); ok {
switch int(se) {
case 10054, 10053, 10058: // WSAECONNRESET, WSAECONNABORTED, WSAESHUTDOWN
return true
}
}
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "forcibly closed") ||
strings.Contains(msg, "wsasend") ||
strings.Contains(msg, "wsarecv") {
return true
}
return false
}
func serveTranscodedStream(ctx context.Context, w http.ResponseWriter, inPath string, prof TranscodeProfile) error {
return serveTranscodedStreamAt(ctx, w, inPath, prof, 0)
}
func serveTranscodedStreamAt(ctx context.Context, w http.ResponseWriter, inPath string, prof TranscodeProfile, startSec int) error {
if err := ensureFFmpegAvailable(); err != nil {
return err
}
// ffmpeg args (mit -ss vor -i)
args := buildFFmpegStreamArgsAt(inPath, prof, startSec)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
// stderr MUSS gelesen werden, sonst kann ffmpeg blockieren
go func() {
_, _ = io.ReadAll(stderr)
_ = cmd.Wait()
}()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Accept-Ranges", "none")
w.WriteHeader(http.StatusOK)
// kontinuierlich flushen
var out io.Writer = w
if f, ok := w.(http.Flusher); ok {
out = flushWriter{w: w, f: f}
}
_, copyErr := io.Copy(out, stdout)
// Client abgebrochen -> kein Fehler
if copyErr != nil {
if isClientDisconnectErr(copyErr) {
return nil
}
}
// Wenn der Request context weg ist: ebenfalls ok (Quality-Wechsel, Seek, Tab zu)
if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return copyErr
}
func buildFFmpegStreamArgsAt(inPath string, prof TranscodeProfile, startSec int) []string {
args := buildFFmpegStreamArgs(inPath, prof)
if startSec <= 0 {
return args
}
// Insert "-ss <sec>" before "-i"
out := make([]string, 0, len(args)+2)
inserted := false
for i := 0; i < len(args); i++ {
if !inserted && args[i] == "-i" {
out = append(out, "-ss", strconv.Itoa(startSec))
inserted = true
}
out = append(out, args[i])
}
// Fallback: falls "-i" nicht gefunden wird, häng's vorne dran
if !inserted {
return append([]string{"-ss", strconv.Itoa(startSec)}, args...)
}
return out
}
func recordStatus(w http.ResponseWriter, r *http.Request) { func recordStatus(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
if id == "" { if id == "" {
@ -1862,16 +1517,6 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
} }
} }
// ✅ NEU: auch Transcode-Cache zum endgültig gelöschten Video entfernen
if prevCanonical != "" {
removeTranscodesForID(doneAbs, prevCanonical)
// Best-effort (falls irgendwo doch mal abweichende IDs genutzt wurden)
if prevBase != "" && prevBase != prevCanonical {
removeTranscodesForID(doneAbs, stripHotPrefix(prevBase))
}
}
if err := os.MkdirAll(trashDir, 0o755); err != nil { if err := os.MkdirAll(trashDir, 0o755); err != nil {
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
@ -2076,10 +1721,18 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ Restore soll im Card-Stack "oben" erscheinen (Sort: completed_desc)
// Dafür ModTime auf "jetzt" setzen, weil buildDoneIndex() endedAt aus fi.ModTime() nimmt.
now := time.Now()
_ = os.Chtimes(dst, now, now) // best-effort
// ✅ Optional: Trash leeren, damit Token danach definitiv tot ist // ✅ Optional: Trash leeren, damit Token danach definitiv tot ist
_ = os.RemoveAll(trashDir) _ = os.RemoveAll(trashDir)
_ = os.MkdirAll(trashDir, 0o755) _ = os.MkdirAll(trashDir, 0o755)
purgeDurationCacheForPath(src) // falls src noch irgendwo gecacht wäre (optional)
purgeDurationCacheForPath(dst) // optional
notifyDoneChanged() notifyDoneChanged()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -0,0 +1,122 @@
package main
import (
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"strings"
)
const defaultScrubberCount = 18
// /api/preview-scrubber/{index}?id=... (oder ?file=...)
func recordPreviewScrubberFrame(w http.ResponseWriter, r *http.Request) {
const prefix = "/api/preview-scrubber/"
if !strings.HasPrefix(r.URL.Path, prefix) {
http.NotFound(w, r)
return
}
idxPart := strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/")
if idxPart == "" {
http.Error(w, "missing scrubber frame index", http.StatusBadRequest)
return
}
idx, err := strconv.Atoi(idxPart)
if err != nil || idx < 0 {
http.Error(w, "invalid scrubber frame index", http.StatusBadRequest)
return
}
// id oder file muss vorhanden sein (wie bei recordPreview / recordDoneMeta)
q := r.URL.Query()
id := strings.TrimSpace(q.Get("id"))
file := strings.TrimSpace(q.Get("file"))
if id == "" && file == "" {
http.Error(w, "missing id or file", http.StatusBadRequest)
return
}
// Dauer aus Meta ermitteln (WICHTIG für gleichmäßige Verteilung)
durSec, err := lookupDurationForScrubber(r, id, file)
if err != nil || durSec <= 0 {
// Fallback: wir versuchen trotzdem was Sinnvolles
// (z. B. 60s annehmen) besser als gar kein Bild
durSec = 60
}
// Count: gleich wie im Frontend (oder dynamisch, aber dann auch im Payload liefern!)
count := defaultScrubberCount
if idx >= count {
// wenn Frontend mehr sendet als Backend erwartet -> clamp
idx = count - 1
}
if count < 1 {
count = 1
}
t := scrubberIndexToTime(idx, count, durSec)
// An bestehenden Preview-Handler delegieren via Redirect
// recordPreview unterstützt bei dir bereits ?id=...&t=...
targetQ := url.Values{}
if id != "" {
targetQ.Set("id", id)
}
if file != "" {
targetQ.Set("file", file)
}
targetQ.Set("t", fmt.Sprintf("%.3f", t))
// Cache freundlich (optional feinjustieren)
w.Header().Set("Cache-Control", "private, max-age=300")
http.Redirect(w, r, "/api/preview?"+targetQ.Encode(), http.StatusFound)
}
// Gleichmäßig über die Videolänge sampeln (Mitte des Segments)
func scrubberIndexToTime(index, count int, durationSec float64) float64 {
if count <= 1 {
return 0.1
}
if durationSec <= 0 {
return 0.1
}
// nicht exakt bei 0 / nicht exakt am Ende
maxT := math.Max(0.1, durationSec-0.1)
ratio := (float64(index) + 0.5) / float64(count)
t := ratio * maxT
if t < 0.1 {
t = 0.1
}
if t > maxT {
t = maxT
}
return t
}
// TODO: Hier deine bestehende Meta-Lookup-Logik aus recordDoneMeta wiederverwenden.
// Ziel: durationSeconds aus meta.json / job-meta lesen.
// Diese Funktion ist der einzige Teil, den du an dein Projekt anpassen musst.
func lookupDurationForScrubber(r *http.Request, id, file string) (float64, error) {
// ------------------------------------------------------------
// OPTION A (empfohlen): dieselbe interne Funktion nutzen wie recordDoneMeta
// Beispiel (PSEUDO):
//
// meta, err := loadDoneMetaByIDOrFile(id, file)
// if err != nil { return 0, err }
// if d := meta.DurationSeconds; d > 0 { return d, nil }
//
// ------------------------------------------------------------
// ------------------------------------------------------------
// OPTION B: Wenn du aktuell keine Helper-Funktion hast:
// erstmal Fehler zurückgeben und später konkret anschließen.
// ------------------------------------------------------------
return 0, fmt.Errorf("lookupDurationForScrubber not wired yet")
}

View File

@ -0,0 +1,67 @@
// backend\record_preview_sprite.go
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
)
func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD", http.StatusMethodNotAllowed)
return
}
// Unterstützt beide Prefixe (falls du mal testweise /api/preview-sprite/ nutzt)
id := strings.TrimPrefix(r.URL.Path, "/api/record/preview-sprite/")
if id == r.URL.Path {
id = strings.TrimPrefix(r.URL.Path, "/api/preview-sprite/")
}
id = strings.TrimSpace(id)
// Falls jemand versehentlich einen Slash am Ende schickt
id = strings.Trim(id, "/")
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, "ungültige id", http.StatusBadRequest)
return
}
dir, err := generatedDirForID(id)
if err != nil {
http.Error(w, "ungültige id", http.StatusBadRequest)
return
}
spritePath := filepath.Join(dir, "preview-sprite.webp")
fi, err := os.Stat(spritePath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
http.NotFound(w, r)
return
}
f, err := os.Open(spritePath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
// Cachebar (du hängst im Frontend ?v=updatedAtUnix dran)
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "preview-sprite.webp", fi.ModTime(), f)
}

View File

@ -44,7 +44,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record", startRecordingFromRequest) api.HandleFunc("/api/record", startRecordingFromRequest)
api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/status", recordStatus)
api.HandleFunc("/api/record/stop", recordStop) api.HandleFunc("/api/record/stop", recordStop)
api.HandleFunc("/api/record/preview", recordPreview) api.HandleFunc("/api/preview", recordPreview)
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
api.HandleFunc("/api/preview-sprite/", recordPreviewSprite)
api.HandleFunc("/api/record/list", recordList) api.HandleFunc("/api/record/list", recordList)
api.HandleFunc("/api/record/stream", recordStream) api.HandleFunc("/api/record/stream", recordStream)
api.HandleFunc("/api/record/done/meta", recordDoneMeta) api.HandleFunc("/api/record/done/meta", recordDoneMeta)

View File

@ -27,6 +27,7 @@ type AssetsTaskState struct {
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
} }
var assetsTaskMu sync.Mutex var assetsTaskMu sync.Mutex
@ -84,6 +85,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
StartedAt: now, StartedAt: now,
FinishedAt: nil, FinishedAt: nil,
Error: "", Error: "",
CurrentFile: "",
} }
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
@ -141,6 +143,7 @@ func runGenerateMissingAssets(ctx context.Context) {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Running = false st.Running = false
st.FinishedAt = &now st.FinishedAt = &now
st.CurrentFile = ""
if err == nil { if err == nil {
// Erfolg: Error leeren // Erfolg: Error leeren
@ -249,6 +252,11 @@ func runGenerateMissingAssets(ctx context.Context) {
return return
} }
// ✅ aktuellen Dateinamen für UI setzen
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
})
// 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)
@ -269,7 +277,7 @@ 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 {
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

View File

@ -1,619 +0,0 @@
// backend\transcode.go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// -------------------------
// Transcode config / globals
// -------------------------
// max parallel ffmpeg jobs
var transcodeSem = make(chan struct{}, 2)
// de-dupe concurrent requests for same output
var transcodeSF singleflight.Group
type heightCacheEntry struct {
mtime time.Time
size int64
height int
}
var heightCacheMu sync.Mutex
var heightCache = map[string]heightCacheEntry{}
type durationCacheEntry struct {
mtime time.Time
size int64
dur float64
}
var durationCacheMu sync.Mutex
var durationCache = map[string]durationCacheEntry{}
func probeVideoDurationSeconds(ctx context.Context, inPath string) (float64, error) {
// ffprobe -v error -show_entries format=duration -of csv=p=0 <file>
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
inPath,
)
out, err := cmd.Output()
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(out))
if s == "" {
return 0, fmt.Errorf("ffprobe returned empty duration")
}
d, err := strconv.ParseFloat(s, 64)
if err != nil || d <= 0 {
return 0, fmt.Errorf("bad duration %q", s)
}
return d, nil
}
func getVideoDurationSecondsCached(ctx context.Context, inPath string) (float64, error) {
fi, err := os.Stat(inPath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return 0, fmt.Errorf("input not usable")
}
durationCacheMu.Lock()
if e, ok := durationCache[inPath]; ok {
if e.size == fi.Size() && e.mtime.Equal(fi.ModTime()) && e.dur > 0 {
d := e.dur
durationCacheMu.Unlock()
return d, nil
}
}
durationCacheMu.Unlock()
d, err := probeVideoDurationSeconds(ctx, inPath)
if err != nil {
return 0, err
}
durationCacheMu.Lock()
durationCache[inPath] = durationCacheEntry{mtime: fi.ModTime(), size: fi.Size(), dur: d}
durationCacheMu.Unlock()
return d, nil
}
func probeVideoHeight(ctx context.Context, inPath string) (int, error) {
// ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 <file>
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=height",
"-of", "csv=p=0",
inPath,
)
out, err := cmd.Output()
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(out))
if s == "" {
return 0, fmt.Errorf("ffprobe returned empty height")
}
h, err := strconv.Atoi(s)
if err != nil || h <= 0 {
return 0, fmt.Errorf("bad height %q", s)
}
return h, nil
}
func getVideoHeightCached(ctx context.Context, inPath string) (int, error) {
fi, err := os.Stat(inPath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return 0, fmt.Errorf("input not usable")
}
heightCacheMu.Lock()
if e, ok := heightCache[inPath]; ok {
if e.size == fi.Size() && e.mtime.Equal(fi.ModTime()) && e.height > 0 {
h := e.height
heightCacheMu.Unlock()
return h, nil
}
}
heightCacheMu.Unlock()
h, err := probeVideoHeight(ctx, inPath)
if err != nil {
return 0, err
}
heightCacheMu.Lock()
heightCache[inPath] = heightCacheEntry{mtime: fi.ModTime(), size: fi.Size(), height: h}
heightCacheMu.Unlock()
return h, nil
}
type TranscodeProfile struct {
Name string // "1080p" | "720p" | "480p"
Height int
}
func profileFromResolution(res string) (TranscodeProfile, bool) {
// Stash-like: LOW | MEDIUM | HIGH | ORIGINAL (case-insensitive)
s := strings.ToUpper(strings.TrimSpace(res))
switch s {
case "", "ORIGINAL", "SOURCE", "AUTO":
return TranscodeProfile{Name: "ORIGINAL", Height: 0}, true
case "LOW":
return TranscodeProfile{Name: "LOW", Height: 480}, true
case "MEDIUM":
return TranscodeProfile{Name: "MEDIUM", Height: 720}, true
case "HIGH":
return TranscodeProfile{Name: "HIGH", Height: 1080}, true
}
// Backwards-Kompatibilität: "<height>p" (z.B. 720p)
s2 := strings.ToLower(strings.TrimSpace(res))
if m := regexp.MustCompile(`^(\d{3,4})p$`).FindStringSubmatch(s2); m != nil {
h, err := strconv.Atoi(m[1])
if err != nil || h <= 0 {
return TranscodeProfile{}, false
}
if h < 144 || h > 4320 {
return TranscodeProfile{}, false
}
return TranscodeProfile{Name: fmt.Sprintf("%dp", h), Height: h}, true
}
return TranscodeProfile{}, false
}
// Cache layout: <doneAbs>/.transcodes/<canonicalID>/<v>/<quality>/s<start>.mp4
func transcodeCachePath(doneAbs, canonicalID, quality string, startSec int) string {
const v = "v2"
return filepath.Join(doneAbs, ".transcodes", canonicalID, v, quality, fmt.Sprintf("s%d.mp4", startSec))
}
func ensureFFmpegAvailable() error {
_, err := exec.LookPath("ffmpeg")
if err != nil {
return fmt.Errorf("ffmpeg not found in PATH")
}
return nil
}
func ensureFFprobeAvailable() error {
_, err := exec.LookPath("ffprobe")
if err != nil {
return fmt.Errorf("ffprobe not found in PATH")
}
return nil
}
func fileUsable(p string) (os.FileInfo, bool) {
fi, err := os.Stat(p)
if err != nil {
return nil, false
}
if fi.IsDir() || fi.Size() <= 0 {
return nil, false
}
return fi, true
}
func isCacheFresh(inPath, outPath string) bool {
inFi, err := os.Stat(inPath)
if err != nil || inFi.IsDir() || inFi.Size() <= 0 {
return false
}
outFi, ok := fileUsable(outPath)
if !ok {
return false
}
// if out is not older than input -> ok
return !outFi.ModTime().Before(inFi.ModTime())
}
func acquireTranscodeSlot(ctx context.Context) error {
select {
case transcodeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func releaseTranscodeSlot() {
select {
case <-transcodeSem:
default:
}
}
func tailString(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[len(s)-max:]
}
func runFFmpeg(ctx context.Context, args []string) error {
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err == nil {
return nil
}
// Wenn ctx abgebrochen wurde (Timeout oder Cancel), gib Output trotzdem mit aus.
if ctx.Err() != nil {
return fmt.Errorf("ffmpeg aborted: %v (output=%s)", ctx.Err(), tailString(buf.String(), 4000))
}
return fmt.Errorf("ffmpeg failed: %w (output=%s)", err, tailString(buf.String(), 4000))
}
// -------------------------
// Public entry used by recordVideo
// -------------------------
// maybeTranscodeForRequest inspects "resolution" query param.
// If quality is "auto" (or empty), it returns original outPath unchanged.
// Otherwise it ensures cached transcode exists & is fresh, and returns the cached path.
func maybeTranscodeForRequest(rctx context.Context, originalPath string, resolution string, startSec int) (string, error) {
if startSec < 0 {
startSec = 0
}
// optional: auf 2 Sekunden runter runden, passt zu GOP=60 (~2s bei 30fps)
startSec = (startSec / 2) * 2
prof, ok := profileFromResolution(resolution)
if !ok {
return "", fmt.Errorf("bad resolution %q", resolution)
}
if strings.EqualFold(prof.Name, "ORIGINAL") || prof.Height <= 0 {
return originalPath, nil
}
// ensure ffmpeg is present
if err := ensureFFmpegAvailable(); err != nil {
return "", err
}
needScale := true
if prof.Height > 0 {
if err := ensureFFprobeAvailable(); err == nil {
pctx, cancel := context.WithTimeout(rctx, 5*time.Second)
defer cancel()
if srcH, err := getVideoHeightCached(pctx, originalPath); err == nil && srcH > 0 {
// Quelle <= Ziel => kein Downscale nötig
if srcH <= prof.Height+8 {
needScale = false
// ✅ WICHTIG: wenn startSec==0, liefern wir wirklich Original (keine Cache-Datei bauen)
if startSec == 0 {
return originalPath, nil
}
}
}
}
}
// Need doneAbs for cache root
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir missing or invalid")
}
// canonicalID = basename stem without ext and without "HOT "
base := filepath.Base(originalPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
canonicalID := stripHotPrefix(stem)
canonicalID = strings.TrimSpace(canonicalID)
if canonicalID == "" {
return "", fmt.Errorf("canonical id empty")
}
qualityKey := strings.ToLower(strings.TrimSpace(prof.Name))
cacheOut := transcodeCachePath(doneAbs, canonicalID, qualityKey, startSec)
// fast path: already exists & fresh
if isCacheFresh(originalPath, cacheOut) {
return cacheOut, nil
}
// singleflight key: input + cacheOut
key := originalPath + "|" + cacheOut
_, err, _ = transcodeSF.Do(key, func() (any, error) {
// check again inside singleflight
if isCacheFresh(originalPath, cacheOut) {
return nil, nil
}
// If stale exists, remove (best-effort)
_ = os.Remove(cacheOut)
// ensure dir
if err := os.MkdirAll(filepath.Dir(cacheOut), 0o755); err != nil {
return nil, err
}
// timeout for transcode
// ✅ NICHT an rctx hängen, sonst killt Client-Abbruch ffmpeg beim Quality-Wechsel
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
if err := acquireTranscodeSlot(ctx); err != nil {
return nil, err
}
defer releaseTranscodeSlot()
// ✅ Temp muss eine "echte" Video-Endung haben, sonst kann ffmpeg das Format nicht wählen
tmp := cacheOut + ".part.mp4"
_ = os.Remove(tmp)
// ffmpeg args
var args []string
if needScale {
args = buildFFmpegArgs(originalPath, tmp, prof, startSec)
} else {
// ✅ nativer Seek: schneiden ohne re-encode
args = buildFFmpegCopySegmentArgs(originalPath, tmp, startSec)
}
if err := runFFmpeg(ctx, args); err != nil {
_ = os.Remove(tmp)
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("transcode timeout: %w", err)
}
return nil, err
}
// validate tmp
if _, ok := fileUsable(tmp); !ok {
_ = os.Remove(tmp)
return nil, fmt.Errorf("transcode output invalid")
}
// atomic replace
_ = os.Remove(cacheOut)
if err := os.Rename(tmp, cacheOut); err != nil {
_ = os.Remove(tmp)
return nil, err
}
return nil, nil
})
if err != nil {
return "", err
}
// final validate
if _, ok := fileUsable(cacheOut); !ok {
return "", fmt.Errorf("transcode cache missing after build")
}
return cacheOut, nil
}
// -------------------------
// ffmpeg profiles
// -------------------------
func buildFFmpegArgs(inPath, outPath string, prof TranscodeProfile, startSec int) []string {
// You can tune these defaults:
// - CRF: lower => better quality, bigger file (1080p ~22, 720p ~23, 480p ~24/25)
// - preset: veryfast is good for on-demand
crf := "23"
h := prof.Height
switch {
case h >= 2160:
crf = "20"
case h >= 1440:
crf = "21"
case h >= 1080:
crf = "22"
case h >= 720:
crf = "23"
case h >= 480:
crf = "25"
case h >= 360:
crf = "27"
default:
crf = "29"
}
// Keyframes: choose a stable value; if you want dynamic based on fps you can extend later.
gop := "60"
// ✅ Für fertige MP4-Dateien: NICHT fragmentieren.
// faststart reicht, damit "moov" vorne liegt.
movflags := "+faststart"
// scale keeps aspect ratio, ensures even width
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
// sanitize start
if startSec < 0 {
startSec = 0
}
// optional: align to small buckets to reduce cache fragmentation (and match GOP-ish seeking)
// startSec = (startSec / 2) * 2
args := []string{
"-hide_banner",
"-loglevel", "error",
"-nostdin",
"-y",
}
// ✅ Startposition: VOR "-i" => schnelles Seek zum nächsten Keyframe (gut für on-demand)
// (Wenn du frame-genau willst: "-ss" NACH "-i", ist aber deutlich langsamer.)
if startSec > 0 {
args = append(args, "-ss", strconv.Itoa(startSec))
}
args = append(args,
"-i", inPath,
// ✅ robust: falls Audio fehlt, trotzdem kein Fehler
"-map", "0:v:0?",
"-map", "0:a:0?",
"-sn",
"-vf", vf,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", crf,
"-pix_fmt", "yuv420p",
"-max_muxing_queue_size", "1024",
"-g", gop,
"-keyint_min", gop,
"-sc_threshold", "0",
// Audio nur wenn vorhanden (wegen "-map 0:a:0?")
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-movflags", movflags,
outPath,
)
return args
}
func buildFFmpegCopySegmentArgs(inPath, outPath string, startSec int) []string {
args := []string{
"-hide_banner",
"-loglevel", "error",
"-nostdin",
"-y",
}
if startSec > 0 {
args = append(args, "-ss", strconv.Itoa(startSec))
}
args = append(args,
"-i", inPath,
"-map", "0:v:0?",
"-map", "0:a:0?",
"-sn",
// ✅ kein re-encode
"-c", "copy",
// ✅ fürs normale File: moov nach vorne
"-movflags", "+faststart",
outPath,
)
return args
}
func buildFFmpegStreamArgs(inPath string, prof TranscodeProfile) []string {
// Stash streamt MP4 als fragmented MP4 mit empty_moov
// (kein default_base_moof für "plain mp4 stream").
movflags := "frag_keyframe+empty_moov"
// Stash-ähnliche CRF-Werte
crf := "25"
switch strings.ToUpper(strings.TrimSpace(prof.Name)) {
case "HIGH", "1080P":
crf = "23"
case "MEDIUM", "720P":
crf = "25"
case "LOW", "480P":
crf = "27"
}
args := []string{
"-hide_banner",
"-loglevel", "error",
"-nostdin",
// "-y" ist bei pipe egal, kann aber bleiben ich lasse es weg wie im Beispiel
}
// Input
args = append(args, "-i", inPath)
// robust: Video/Audio optional
args = append(args,
"-map", "0:v:0?",
"-map", "0:a:0?",
"-sn",
)
// Scale nur wenn wir wirklich runterskalieren wollen
if prof.Height > 0 {
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
args = append(args, "-vf", vf)
}
// Video
args = append(args,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", crf,
"-pix_fmt", "yuv420p",
"-sc_threshold", "0",
"-max_muxing_queue_size", "1024",
)
// Audio (nur wenn vorhanden wegen map 0:a:0?)
args = append(args,
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
)
// MP4 stream flags
args = append(args,
"-movflags", movflags,
"-f", "mp4",
"pipe:", // wichtig: wie im Beispiel
)
return args
}
// -------------------------
// Cleanup helper
// -------------------------
func removeTranscodesForID(doneAbs, canonicalID string) {
_ = os.RemoveAll(filepath.Join(doneAbs, ".transcodes", canonicalID))
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -467,8 +467,26 @@ export default function App() {
}, []) }, [])
// ✅ sagt FinishedDownloads: "bitte ALL neu laden" // ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null)
const requestFinishedReload = useCallback(() => { const requestFinishedReload = useCallback(() => {
window.dispatchEvent(new CustomEvent('finished-downloads:reload')) if (finishedReloadTimerRef.current != null) {
window.clearTimeout(finishedReloadTimerRef.current)
}
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
}, 150)
}, [])
useEffect(() => {
return () => {
if (finishedReloadTimerRef.current != null) {
window.clearTimeout(finishedReloadTimerRef.current)
finishedReloadTimerRef.current = null
}
}
}, []) }, [])
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
@ -2794,7 +2812,7 @@ export default function App() {
</div> </div>
</div> </div>
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 space-y-6"> <main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 space-y-2">
{selectedTab === 'running' ? ( {selectedTab === 'running' ? (
<Downloads <Downloads
jobs={runningJobs} jobs={runningJobs}
@ -2834,7 +2852,7 @@ export default function App() {
setDoneSort(m) setDoneSort(m)
setDonePage(1) setDonePage(1)
}} }}
loadMode="all" loadMode="paged"
/> />
) : null} ) : null}

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\Button.tsx
import * as React from 'react' import * as React from 'react'
type Variant = 'primary' | 'secondary' | 'soft' type Variant = 'primary' | 'secondary' | 'soft'

View File

@ -12,6 +12,7 @@ import RecordJobActions from './RecordJobActions'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { subscribeSSE } from '../../lib/sseSingleton' import { subscribeSSE } from '../../lib/sseSingleton'
import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE' import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE'
import { useMediaQuery } from '../../lib/useMediaQuery'
type PendingWatchedRoom = WaitingModelRow & { type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown currentShow: string // public / private / hidden / away / unknown
@ -84,6 +85,35 @@ const toMs = (v: unknown): number => {
return 0 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 jobThumbsWebpCandidates = (job: RecordJob): string[] => {
const j = job as any
const direct = [
j.thumbsWebpUrl,
j.thumbsUrl,
j.previewThumbsUrl,
j.thumbnailSheetUrl,
]
const base = [
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/thumbs.webp` : '',
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/thumbs.webp` : '',
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/thumbs.webp` : '',
]
return [...direct, ...base]
.map((x) => absUrlMaybe(String(x ?? '')))
.filter(Boolean)
}
const addedAtMsOf = (r: DownloadRow): number => { const addedAtMsOf = (r: DownloadRow): number => {
if (r.kind === 'job') { if (r.kind === 'job') {
const j = r.job as any const j = r.job as any
@ -454,6 +484,7 @@ function DownloadsCardRow({
fastRetryMs={1000} fastRetryMs={1000}
fastRetryMax={25} fastRetryMax={25}
fastRetryWindowMs={60_000} fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</div> </div>
@ -689,6 +720,8 @@ export default function Downloads({
const jobsLive = useRecordJobsSSE(jobs) const jobsLive = useRecordJobsSSE(jobs)
const isDesktop = useMediaQuery('(min-width: 640px)', true)
const [stopAllBusy, setStopAllBusy] = useState(false) const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false)
@ -914,6 +947,7 @@ export default function Downloads({
fastRetryMs={1000} fastRetryMs={1000}
fastRetryMax={25} fastRetryMax={25}
fastRetryWindowMs={60_000} fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</div> </div>
@ -1212,9 +1246,41 @@ export default function Downloads({
}) })
.map((job) => ({ kind: 'job', job }) as const) .map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) const stateRank = (j: RecordJob): number => {
const pw = (j as any)?.postWork
const state = String(pw?.state ?? '').toLowerCase()
// running zuerst anzeigen, queued danach
if (state === 'running') return 0
if (state === 'queued') return 1
return 2
}
const queuePosOf = (j: RecordJob): number => {
const id = String((j as any)?.id ?? '')
const info = id ? postworkQueueInfoById.get(id) : undefined
return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER
}
list.sort((a, b) => {
const aj = a.job
const bj = b.job
// 1) running vor queued/sonst
const sr = stateRank(aj) - stateRank(bj)
if (sr !== 0) return sr
// 2) queued nach Position in der Queue sortieren (1, 2, 3, ...)
const pa = queuePosOf(aj)
const pb = queuePosOf(bj)
if (pa !== pb) return pa - pb
// 3) Fallback stabil nach enqueue/addedAt (älter zuerst)
return addedAtMsOf(a) - addedAtMsOf(b)
})
return list return list
}, [jobsLive]) }, [jobsLive, postworkQueueInfoById])
const pendingRows = useMemo<DownloadRow[]>(() => { const pendingRows = useMemo<DownloadRow[]>(() => {
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const) const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)
@ -1306,8 +1372,9 @@ export default function Downloads({
{/* ✅ Content abhängig von Jobs/Pending */} {/* ✅ Content abhängig von Jobs/Pending */}
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? ( {(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
<> <>
{/* Mobile: Cards */} {!isDesktop ? (
<div className="mt-3 grid gap-4 sm:hidden"> /* Mobile: Cards (wirklich nur mobile gemountet) */
<div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? ( {downloadJobRows.length > 0 ? (
<> <>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200"> <div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
@ -1383,9 +1450,9 @@ export default function Downloads({
</> </>
) : null} ) : null}
</div> </div>
) : (
{/* Desktop: Tabellen */} /* Desktop: Tabellen (wirklich nur desktop gemountet) */
<div className="mt-3 hidden sm:block space-y-4"> <div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? ( {downloadJobRows.length > 0 ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white"> <div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
@ -1446,6 +1513,7 @@ export default function Downloads({
</div> </div>
) : null} ) : null}
</div> </div>
)}
</> </>
) : ( ) : (
<Card grayBody> <Card grayBody>

View File

@ -195,7 +195,6 @@ const sizeBytesOf = (job: RecordJob): number | null => {
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
} }
export default function FinishedDownloads({ export default function FinishedDownloads({
jobs, jobs,
doneJobs, doneJobs,
@ -240,14 +239,63 @@ export default function FinishedDownloads({
const [isLoading, setIsLoading] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; from?: 'done' | 'keep' } | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string } | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
| { kind: 'hot'; currentFile: string } | { kind: 'hot'; currentFile: string }
const [lastAction, setLastAction] = React.useState<UndoAction | null>(null) type PersistedUndoState = {
v: 1
action: UndoAction
ts: number
}
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const [lastAction, setLastAction] = React.useState<UndoAction | null>(() => {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(LAST_UNDO_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedUndoState
if (!parsed || parsed.v !== 1 || !parsed.action) return null
// optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird
const ageMs = Date.now() - Number(parsed.ts || 0)
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
localStorage.removeItem(LAST_UNDO_KEY)
return null
}
return parsed.action
} catch {
return null
}
})
const [undoing, setUndoing] = React.useState(false) const [undoing, setUndoing] = React.useState(false)
useEffect(() => {
try {
if (!lastAction) {
localStorage.removeItem(LAST_UNDO_KEY)
return
}
const payload: PersistedUndoState = {
v: 1,
action: lastAction,
ts: Date.now(),
}
localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload))
} catch {
// ignore
}
}, [lastAction])
// 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({}) const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
@ -297,108 +345,6 @@ export default function FinishedDownloads({
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map()) const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
const unhide = (file: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
try {
if (lastAction.kind === 'delete') {
const res = await fetch(
`/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
unhide(lastAction.originalFile)
unhide(restoredFile)
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta)
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'keep') {
const res = await fetch(
`/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.newFile || lastAction.originalFile)
unhide(lastAction.originalFile)
unhide(restoredFile)
emitCountHint(+1)
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'hot') {
// HOT ist reversibel über denselben Toggle-Endpunkt
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// UI-optimistik (damit es sofort stimmt)
const data = (await res.json().catch(() => null)) as any
const oldFile = String(data?.oldFile || lastAction.currentFile)
const newFile = String(data?.newFile || '')
if (newFile) {
applyRename(oldFile, newFile)
}
queueRefill()
setLastAction(null)
return
}
} catch (e: any) {
notify.error('Undo fehlgeschlagen', String(e?.message || e))
} finally {
setUndoing(false)
}
}, [lastAction, undoing, notify, queueRefill])
// 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
const [tagFilter, setTagFilter] = React.useState<string[]>([]) const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
@ -510,20 +456,33 @@ export default function FinishedDownloads({
const ac = new AbortController() const ac = new AbortController()
let alive = true let alive = true
const finishRefill = () => {
refillInFlightRef.current = false
}
// ✅ Refill läuft
refillInFlightRef.current = true
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all" // ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) { if (effectiveAllMode) {
;(async () => { ;(async () => {
try { try {
// (Wenn fetchAllDoneJobs selbst setIsLoading macht: reicht das.) // fetchAllDoneJobs setzt isLoading selbst
await fetchAllDoneJobs(ac.signal) await fetchAllDoneJobs(ac.signal)
if (alive) {
refillRetryRef.current = 0
}
} catch { } catch {
// ignore // ignore (Abort/Netzwerk)
} finally {
if (alive) finishRefill()
} }
})() })()
return () => { return () => {
alive = false alive = false
ac.abort() ac.abort()
finishRefill()
} }
} }
@ -532,7 +491,7 @@ export default function FinishedDownloads({
;(async () => { ;(async () => {
try { try {
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt // 1) Liste + count holen
const [listRes, metaRes] = await Promise.all([ const [listRes, metaRes] = await Promise.all([
fetch( fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${ `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
@ -546,20 +505,27 @@ export default function FinishedDownloads({
), ),
]) ])
let okAll = true if (!alive || ac.signal.aborted) return
let okAll = listRes.ok && metaRes.ok
if (listRes.ok) { if (listRes.ok) {
const data = await listRes.json().catch(() => null) const data = await listRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items) const items = Array.isArray(data?.items)
? (data.items as RecordJob[]) ? (data.items as RecordJob[])
: Array.isArray(data) : Array.isArray(data)
? data ? data
: [] : []
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
} }
if (metaRes.ok) { if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null) const meta = await metaRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const countRaw = Number(meta?.count ?? 0) const countRaw = Number(meta?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
@ -575,22 +541,30 @@ export default function FinishedDownloads({
if (okAll) { if (okAll) {
refillRetryRef.current = 0 refillRetryRef.current = 0
} else if (alive && refillRetryRef.current < 2) { } else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
refillRetryRef.current++ refillRetryRef.current += 1
const retryNo = refillRetryRef.current
window.setTimeout(() => { window.setTimeout(() => {
if (!ac.signal.aborted) setRefillTick((n) => n + 1) if (!ac.signal.aborted) {
}, 400 * refillRetryRef.current) setRefillTick((n) => n + 1)
}
}, 400 * retryNo)
} }
} catch { } catch {
// Abort / Fehler ignorieren // Abort / Fehler ignorieren
} finally { } finally {
if (alive) setIsLoading(false) if (alive) {
setIsLoading(false)
finishRefill()
}
} }
})() })()
return () => { return () => {
alive = false alive = false
ac.abort() ac.abort()
finishRefill()
} }
}, [ }, [
refillTick, refillTick,
@ -813,6 +787,15 @@ export default function FinishedDownloads({
// ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können // ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
const removeTimersRef = React.useRef<Map<string, number>>(new Map()) const removeTimersRef = React.useRef<Map<string, number>>(new Map())
useEffect(() => {
return () => {
for (const t of removeTimersRef.current.values()) {
window.clearTimeout(t)
}
removeTimersRef.current.clear()
}
}, [])
const markRemoving = useCallback((key: string, value: boolean) => { const markRemoving = useCallback((key: string, value: boolean) => {
setRemovingKeys((prev) => { setRemovingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -862,9 +845,6 @@ export default function FinishedDownloads({
const animateRemove = useCallback( const animateRemove = useCallback(
(key: string) => { (key: string) => {
// ✅ Refill sofort starten (parallel zur Animation)
queueRefill()
markRemoving(key, true) markRemoving(key, true)
cancelRemoveTimer(key) cancelRemoveTimer(key)
@ -877,7 +857,7 @@ export default function FinishedDownloads({
removeTimersRef.current.set(key, t) removeTimersRef.current.set(key, t)
}, },
[markDeleted, markRemoving, queueRefill, cancelRemoveTimer] [markDeleted, markRemoving, cancelRemoveTimer]
) )
const releasePlayingFile = useCallback( const releasePlayingFile = useCallback(
@ -913,7 +893,7 @@ export default function FinishedDownloads({
const undoToken = (r as any)?.undoToken const undoToken = (r as any)?.undoToken
if (typeof undoToken === 'string' && undoToken) { if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file }) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else { } else {
setLastAction(null) setLastAction(null)
// optional: nicht als "error" melden, eher info/warn // optional: nicht als "error" melden, eher info/warn
@ -922,6 +902,7 @@ export default function FinishedDownloads({
// ✅ OPTIMISTIK + Pagination refill + count hint // ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key) animateRemove(key)
queueRefill()
emitCountHint(-1) emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok: // animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill() // queueRefill()
@ -941,10 +922,11 @@ export default function FinishedDownloads({
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep' const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, from }) if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
else setLastAction(null) else setLastAction(null)
animateRemove(key) animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf) // ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(-1) emitCountHint(-1)
@ -967,7 +949,9 @@ export default function FinishedDownloads({
onDeleteJob, onDeleteJob,
animateRemove, animateRemove,
notify, notify,
setLastAction, restoreRow,
queueRefill,
emitCountHint,
] ]
) )
@ -997,10 +981,11 @@ export default function FinishedDownloads({
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
// ✅ Undo-Info merken // ✅ Undo-Info merken
setLastAction({ kind: 'keep', keptFile, originalFile: file }) setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished // ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key) animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf) // ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1) emitCountHint(includeKeep ? 0 : -1)
@ -1020,7 +1005,9 @@ export default function FinishedDownloads({
releasePlayingFile, releasePlayingFile,
animateRemove, animateRemove,
notify, notify,
setLastAction, queueRefill,
emitCountHint,
includeKeep,
] ]
) )
@ -1036,6 +1023,120 @@ export default function FinishedDownloads({
}) })
}, []) }, [])
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
const unhideByToken = (token: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
}
try {
if (lastAction.kind === 'delete') {
const res = await fetch(
`/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
if (lastAction.rowKey) unhideByToken(lastAction.rowKey)
// Fallbacks (falls alte lastAction ohne rowKey existiert)
unhideByToken(lastAction.originalFile)
unhideByToken(restoredFile)
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta)
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'keep') {
const res = await fetch(
`/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.newFile || lastAction.originalFile)
if (lastAction.rowKey) unhideByToken(lastAction.rowKey)
// Fallbacks (für ältere Actions / Sonderfälle)
unhideByToken(lastAction.originalFile)
unhideByToken(restoredFile)
emitCountHint(+1)
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'hot') {
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = (await res.json().catch(() => null)) as any
const oldFile = String(data?.oldFile || lastAction.currentFile)
const newFile = String(data?.newFile || '')
if (newFile) {
applyRename(oldFile, newFile)
}
queueRefill()
setLastAction(null)
return
}
} catch (e: any) {
notify.error('Undo fehlgeschlagen', String(e?.message || e))
} finally {
setUndoing(false)
}
}, [
lastAction,
undoing,
notify,
queueRefill,
includeKeep,
emitCountHint,
applyRename,
])
const toggleHotVideo = useCallback( const toggleHotVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob) => {
const currentFile = baseName(job.output || '') const currentFile = baseName(job.output || '')
@ -1112,8 +1213,8 @@ export default function FinishedDownloads({
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e)) notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
} }
}, },
[baseName, notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, setLastAction] [notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, sortMode]
) )
const applyRenamedOutput = useCallback( const applyRenamedOutput = useCallback(
@ -1127,7 +1228,7 @@ export default function FinishedDownloads({
const dir = idx >= 0 ? out.slice(0, idx + 1) : '' const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
return { ...job, output: dir + override } return { ...job, output: dir + override }
}, },
[renamedFiles, baseName] [renamedFiles]
) )
const doneJobsPage = overrideDoneJobs ?? doneJobs const doneJobsPage = overrideDoneJobs ?? doneJobs
@ -1214,12 +1315,8 @@ export default function FinishedDownloads({
useEffect(() => { useEffect(() => {
const onReload = () => { const onReload = () => {
// ✅ wichtig: das soll "ALL neu laden" triggern if (refillInFlightRef.current) return
// Option A (wenn vorhanden):
queueRefill() queueRefill()
// Option B (falls du kein queueRefill hast):
// void fetchAllDoneJobs(new AbortController().signal)
} }
window.addEventListener('finished-downloads:reload', onReload as any) window.addEventListener('finished-downloads:reload', onReload as any)
@ -1424,6 +1521,20 @@ export default function FinishedDownloads({
// ✅ Hooks immer zuerst unabhängig von rows // ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)') const isSmall = useMediaQuery('(max-width: 639px)')
useEffect(() => {
if (!isSmall) return
if (view !== 'cards') return
const top = pageRows[0]
if (!top) {
setTeaserKey(null)
return
}
const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, [isSmall, view, pageRows, keyFor])
useEffect(() => { useEffect(() => {
if (!isSmall) { if (!isSmall) {
// dein Cleanup (z.B. swipeRefs reset) wie gehabt // dein Cleanup (z.B. swipeRefs reset) wie gehabt
@ -1739,7 +1850,7 @@ export default function FinishedDownloads({
Lade Downloads Lade Downloads
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-300"> <div className="text-xs text-gray-600 dark:text-gray-300">
Bitte einen Moment. Bitte warte einen Moment.
</div> </div>
</div> </div>
@ -1799,48 +1910,49 @@ export default function FinishedDownloads({
) : ( ) : (
<> <>
{view === 'cards' && ( {view === 'cards' && (
<FinishedDownloadsCardsView <div className={isSmall ? 'mt-8' : ''}>
rows={pageRows} <FinishedDownloadsCardsView
isSmall={isSmall} rows={pageRows}
isLoading={isLoading} isSmall={isSmall}
blurPreviews={blurPreviews} isLoading={isLoading}
durations={durations} blurPreviews={blurPreviews}
teaserKey={teaserKey} durations={durations}
teaserPlayback={teaserPlaybackMode} teaserKey={teaserKey}
teaserAudio={teaserAudio} teaserPlayback={teaserPlaybackMode}
hoverTeaserKey={hoverTeaserKey} teaserAudio={teaserAudio}
inlinePlay={inlinePlay} hoverTeaserKey={hoverTeaserKey}
setInlinePlay={setInlinePlay} inlinePlay={inlinePlay}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
swipeRefs={swipeRefs} swipeRefs={swipeRefs}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}
modelNameFromOutput={modelNameFromOutput} modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf} runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf} sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes} formatBytes={formatBytes}
lower={lower} lower={lower}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer} openPlayer={openPlayer}
startInline={startInline} startInline={startInline}
tryAutoplayInline={tryAutoplayInline} tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration} handleDuration={handleDuration}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
releasePlayingFile={releasePlayingFile} releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
onToggleHot={toggleHotVideo} onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
/> />
</div>
)} )}
{view === 'table' && ( {view === 'table' && (

View File

@ -30,7 +30,6 @@ type Props = {
durations: Record<string, number> durations: Record<string, number>
teaserKey: string | null teaserKey: string | null
inlinePlay: InlinePlayState inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string> deletingKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
@ -103,8 +102,6 @@ export default function FinishedDownloadsCardsView({
blurPreviews, blurPreviews,
teaserKey, teaserKey,
inlinePlay, inlinePlay,
setInlinePlay,
deletingKeys, deletingKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
@ -152,235 +149,432 @@ export default function FinishedDownloadsCardsView({
return null return null
}, []) }, [])
const metaChipCls = 'rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium backdrop-blur-[2px]' const renderCardItem = (
j: RecordJob,
opts?: {
forceStill?: boolean
disableInline?: boolean
disablePreviewHover?: boolean
isDecorative?: boolean
forceLoadStill?: boolean
mobileStackTopOnlyVideo?: boolean
}
) => {
const k = keyFor(j)
const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive
const allowSound =
!opts?.forceStill &&
Boolean(teaserAudio) &&
(inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const forceLoadStill = Boolean(opts?.forceLoadStill)
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
const allowTeaserAnimation =
opts?.forceStill
? false
: opts?.mobileStackTopOnlyVideo
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false))
: (teaserPlayback === 'all'
? true
: teaserPlayback === 'hover'
? teaserKey === k
: false)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
// ✅ Shell an Gallery angelehnt
const shellCls = [
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')
const cardInner = (
<div
role="button"
tabIndex={0}
className={shellCls}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
id={inlineDomId}
ref={
opts?.disableInline || opts?.isDecorative
? undefined
: registerTeaserHost(k)
}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}
onMouseLeave={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(null)
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall || opts?.disableInline) return
startInline(k)
}}
>
{/* media */}
<div className="absolute inset-0">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
{!inlineActive ? (
<div
className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
(inlineActive ? 'opacity-0' : 'opacity-100')
}
>
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[
dur,
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
size,
]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
) : null}
<FinishedVideoPreview
job={j}
getFileName={baseName}
className="h-full w-full"
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill || true}
teaserPreloadEnabled={
// ✅ im Mobile-Stack nur für Top-Card Teaser preloaden
opts?.mobileStackTopOnlyVideo ? true : !isSmall
}
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
/>
</div>
</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Meta + Actions (nicht im Video) */}
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Actions: volle Breite */}
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
return {
k,
j,
busy,
isHot,
inlineDomId,
cardInner,
}
}
const mobileStackDepth = 3
// Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile
const mobileAllRows = isSmall ? rows : []
// größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
const stackTopGapPx = 24
// weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) * stackPeekOffsetPx
return ( return (
<div className="relative"> <div className="relative">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {!isSmall ? (
{rows.map((j) => { <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
const k = keyFor(j) {rows.map((j) => {
const inlineActive = inlinePlay?.key === k const { k, cardInner } = renderCardItem(j)
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) return (
<React.Fragment key={k}>
{cardInner}
</React.Fragment>
)
})}
</div>
) : (
<div className="relative">
{rows.length === 0 ? null : (
<div className="relative mx-auto w-full max-w-[560px] overflow-visible">
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div
className="relative overflow-visible"
style={{
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
}}
>
{mobileVisibleStackRows
.map((j, idx) => {
const isTop = idx === 0
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(
j,
isTop
? {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
}
: {
forceStill: true,
disableInline: true,
disablePreviewHover: true,
isDecorative: true,
forceLoadStill: true,
}
)
const depth = idx // 0,1,2
const y = -(depth * stackPeekOffsetPx) // nach OBEN staffeln
const scale = 1 - depth * 0.03 // etwas stärkerer Tiefen-Effekt
const opacity = 1 - depth * 0.14
const model = modelNameFromOutput(j.output) // untere Karten nur Deko (keine Interaktion)
const fileRaw = baseName(j.output || '') if (!isTop) {
const isHot = isHotName(fileRaw) return (
<div
key={k}
className="absolute inset-x-0 top-0 pointer-events-none"
style={{
zIndex: 20 - depth,
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
opacity,
transformOrigin: 'top center',
}}
aria-hidden="true"
>
<div className="relative">
{cardInner}
{/* leichtes Frosting, damit klar ist: nur Vorschau */}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
}
const flags = modelsByKey[lower(model)] // oberste Karte: echte SwipeCard (wie bisher)
const isFav = Boolean(flags?.favorite) return (
const isLiked = flags?.liked === true <div
const isWatching = Boolean(flags?.watching) key={k}
className="absolute inset-x-0 top-0"
const tags = parseTags(flags?.tags) style={{
zIndex: 30,
const dur = runtimeOf(j) transform: `translateY(${y}px) scale(${scale})`,
const size = formatBytes(sizeBytesOf(j)) transformOrigin: 'top center',
}}
const resObj = resolutionObjOf(j) >
const resLabel = formatResolution(resObj) <SwipeCard
ref={(h) => {
const inlineDomId = `inline-prev-${encodeURIComponent(k)}` if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
// ✅ Shell an Gallery angelehnt }}
const shellCls = [ enabled
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', disabled={busy}
'bg-white dark:bg-gray-900/40', ignoreFromBottomPx={110}
'transition-all duration-200', doubleTapMs={360}
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none', doubleTapMaxMovePx={48}
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500', onDoubleTap={async () => {
busy && 'pointer-events-none opacity-70', if (isHot) return
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', await onToggleHot?.(j)
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', }}
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]', onTap={() => {
] startInline(k)
.filter(Boolean) requestAnimationFrame(() => {
.join(' ') if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
})
const cardInner = ( }}
<div onSwipeLeft={() => deleteVideo(j)}
role="button" onSwipeRight={() => keepVideo(j)}
tabIndex={0} >
className={shellCls} {cardInner}
onClick={isSmall ? undefined : () => openPlayer(j)} </SwipeCard>
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall) return
startInline(k)
}}
>
{/* media */}
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={baseName}
className="h-full w-full"
showPopover={false}
blur={isSmall ? false : inlineActive ? false : blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
{/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
<RecordJobActions
job={j}
variant="overlay"
busy={busy}
collapseToMenu
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
</div>
{/* Restart (wenn inline läuft) */}
{!isSmall && inlinePlay?.key === k ? (
<button
type="button"
className="absolute left-2 top-10 z-10 rounded-md bg-black/45 px-2 py-1 text-xs font-semibold text-white hover:bg-black/60"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setInlinePlay((prev) => ({ key: k, nonce: prev?.key === k ? prev.nonce + 1 : 1 }))
}}
title="Von vorne starten"
aria-label="Von vorne starten"
>
</button>
) : null}
{/* Bottom overlay (ohne Gradient) */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0
px-2 pb-2 pt-8 text-white
"
>
<div className="flex items-center justify-end gap-2">
<div className="shrink-0 flex items-center gap-1.5">
<span className={metaChipCls}>{dur}</span>
{resLabel ? (
<span className={metaChipCls} title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}>
{resLabel}
</span>
) : null}
<span className={metaChipCls}>{size}</span>
</div> </div>
</div> )
</div> })
</div> .reverse() /* zuerst hinten rendern, oben zuletzt */}
</div>
{/* Footer / Meta (wie Gallery strukturiert) */} {/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> {mobileAllRows.length > mobileStackDepth ? (
<div className="flex items-start justify-between gap-2"> <div className="sr-only" aria-hidden="true">
<div className="min-w-0"> {mobileAllRows.slice(mobileStackDepth).map((j) => {
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div> const k = keyFor(j)
<div className="mt-0.5 flex items-center gap-2 min-w-0"> return (
{isHot ? ( <div key={`preload-still-${k}`} className="relative aspect-video">
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300"> <FinishedVideoPreview
HOT job={j}
</span> getFileName={baseName}
) : null} className="h-full w-full"
showPopover={false}
blur={Boolean(blurPreviews)}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"> // ✅ Nur Previewbild laden kein Teaser-Video
{stripHotPrefix(fileRaw) || '—'} animated={false}
</span> animatedMode="teaser"
animatedTrigger="always"
inlineVideo={false}
inlineControls={false}
inlineLoop={false}
muted={true}
popoverMuted={true}
assetNonce={assetNonce ?? 0}
// ✅ Still immer laden
alwaysLoadStill
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
teaserPreloadEnabled={false}
/>
</div> </div>
</div> )
})}
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div> </div>
</Card> ) : null}
{/* optionaler Hinweis */}
{rows.length > 1 ? (
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
Wische nach links zum Löschen nach rechts zum Behalten
</div>
) : null}
</div> </div>
) )}
</div>
return isSmall ? ( )}
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}`
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) requestAnimationFrame(() => tryAutoplayInline(domId))
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
) : (
<React.Fragment key={k}>{cardInner}</React.Fragment>
)
})}
</div>
{isLoading && rows.length === 0 ? ( {isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40"> <div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">

View File

@ -13,6 +13,22 @@ import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import GalleryPreviewScrubber from './GalleryPreviewScrubber'
type ModelFlags = {
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ Optional für Model-Hover-Preview
image?: string
imageUrl?: string | null
// ✅ Optional für stashapp-artigen Preview-Scrubber
previewScrubberPath?: string
previewScrubberCount?: number
}
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -46,7 +62,7 @@ type Props = {
lower: (s: string) => string lower: (s: string) => string
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }> modelsByKey: Record<string, ModelFlags>
activeTagSet: Set<string> activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
@ -56,6 +72,41 @@ type Props = {
onToggleHot: (job: RecordJob) => void | Promise<void> onToggleHot: (job: RecordJob) => void | Promise<void>
} }
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const v of values) {
if (typeof v === 'string') {
const s = v.trim()
if (s) return s
}
}
return undefined
}
function parseJobMeta(metaRaw: unknown): any | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
return JSON.parse(metaRaw)
} catch {
return null
}
}
if (typeof metaRaw === 'object') return metaRaw
return null
}
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
// ms -> s Heuristik wie in FinishedVideoPreview
return value > 24 * 60 * 60 ? value / 1000 : value
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
const DEFAULT_SPRITE_STEP_SECONDS = 5
export default function FinishedDownloadsGalleryView({ export default function FinishedDownloadsGalleryView({
rows, rows,
isLoading, isLoading,
@ -65,8 +116,8 @@ export default function FinishedDownloadsGalleryView({
teaserAudio, teaserAudio,
hoverTeaserKey, hoverTeaserKey,
teaserKey, teaserKey,
handleDuration,
handleDuration,
keyFor, keyFor,
baseName, baseName,
modelNameFromOutput, modelNameFromOutput,
@ -94,7 +145,6 @@ export default function FinishedDownloadsGalleryView({
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
}: Props) { }: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all' const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
@ -132,179 +182,422 @@ export default function FinishedDownloadsGalleryView({
// ✅ Auflösung als {w,h} aus meta.json bevorzugen // ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w = const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) || (typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0) ? (j as any).meta.videoWidth
: 0) ||
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
? (j as any).videoWidth
: 0)
const h = const h =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) || (typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0) ? (j as any).meta.videoHeight
: 0) ||
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
? (j as any).videoHeight
: 0)
if (w > 0 && h > 0) return { w, h } if (w > 0 && h > 0) return { w, h }
return null return null
}, []) }, [])
// ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
// ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => {
if (index === undefined) {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
}
if (prev[key] === index) return prev
return { ...prev, [key]: index }
})
}, [])
const clearScrubIndex = React.useCallback((key: string) => {
setScrubIndexForKey(key, undefined)
}, [setScrubIndexForKey])
return ( return (
<div className="relative"> <div className="relative">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser // Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound const previewMuted = !allowSound
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const modelKey = lower(model) const modelKey = lower(model)
const flags = modelsByKey[modelKey] const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags) const tags = parseTags(flags?.tags)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j) const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj) const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k) const deleted = deletedKeys.has(k)
return ( // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
const modelImageSrc = firstNonEmptyString(
flags?.image,
flags?.imageUrl,
(j as any)?.meta?.modelImage,
(j as any)?.meta?.modelImageUrl
)
// Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
// meta robust lesen (Objekt oder JSON-String)
const meta = parseJobMeta((j as any)?.meta)
// ------------------------------------------------------------
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
// ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path,
// optional weitere Fallback-Felder, falls du sie so speicherst:
(meta as any)?.previewSpritePath,
// optional API-Fallback-Path (nur sinnvoll, wenn Backend existiert)
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteCountRaw = meta?.previewSprite?.count ?? (meta as any)?.previewSpriteCount
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
const spriteCount =
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
? Math.max(0, Math.floor(spriteCountRaw))
: 0
const spriteCols =
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
? Math.max(0, Math.floor(spriteColsRaw))
: 0
const spriteRows =
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
? Math.max(0, Math.floor(spriteRowsRaw))
: 0
const spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' &&
Number.isFinite(spriteStepSecondsRaw) &&
spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
// Optionaler Cache-Buster (wenn du sowas in meta hast)
const spriteVersion =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
? meta.updatedAtUnix
: undefined) ??
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
? (meta as any).fileModUnix
: undefined) ??
0
const spriteUrl =
spritePath && spriteVersion
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
: spritePath || undefined
const hasSpriteScrubber =
Boolean(spriteUrl) &&
spriteCount > 1 &&
spriteCols > 0 &&
spriteRows > 0
// Finales Scrubber-Setup (NUR Sprite)
const scrubberCount = hasSpriteScrubber ? spriteCount : 0
const scrubberStepSeconds = hasSpriteScrubber ? spriteStepSeconds : 0
const hasScrubber = hasSpriteScrubber
const activeScrubIndex = scrubIndexByKey[k]
// Sprite-Overlay-Frame (kein Request pro Move)
const spriteFrameStyle: React.CSSProperties | undefined =
hasSpriteScrubber && typeof activeScrubIndex === 'number'
? (() => {
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
const col = idx % spriteCols
const row = Math.floor(idx / spriteCols)
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
return {
backgroundImage: `url("${spriteUrl}")`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
backgroundPosition: `${posX}% ${posY}%`,
}
})()
: undefined
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay =
showModelPreviewInThumb || showScrubberSpriteInThumb
return (
<div
key={k}
role="button"
tabIndex={0}
className={[
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Thumb */}
<div <div
key={k} className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
role="button" ref={registerTeaserHostIfNeeded(k)}
tabIndex={0} onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
className={[ onMouseLeave={() => {
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', onHoverPreviewKeyChange?.(null)
'bg-white dark:bg-gray-900/40', clearScrubIndex(k)
'transition-all duration-200', setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}} }}
> >
{/* Thumb */} {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div <div className="absolute inset-0 overflow-hidden rounded-t-lg">
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" <div className="absolute inset-0">
ref={registerTeaserHostIfNeeded(k)} <FinishedVideoPreview
onMouseEnter={() => onHoverPreviewKeyChange?.(k)} job={j}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)} getFileName={(p) => stripHotPrefix(baseName(p))}
> durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */} onDuration={handleDuration}
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> variant="fill"
<div className="absolute inset-0"> showPopover={false}
<FinishedVideoPreview blur={blurPreviews}
job={j} animated={
getFileName={(p) => stripHotPrefix(baseName(p))} hideTeaserUnderOverlay
durationSeconds={durations[k] ?? (j as any)?.durationSeconds} ? false
onDuration={handleDuration} : teaserPlayback === 'all'
variant="fill" ? true
showPopover={false} : teaserPlayback === 'hover'
blur={blurPreviews} ? teaserKey === k
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false} : false
animatedMode="teaser" }
animatedTrigger="always" animatedMode="teaser"
clipSeconds={1} animatedTrigger="always"
thumbSamples={18} clipSeconds={1}
muted={previewMuted} thumbSamples={18}
popoverMuted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted}
/>
</div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-0 z-[5]">
<div
className="h-full w-full"
style={spriteFrameStyle}
aria-hidden="true"
/> />
</div> </div>
) : null}
{/* Bottom overlay meta */} {/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white"> {showModelPreviewInThumb && modelImageSrc ? (
<div className="flex items-end justify-end gap-2"> <div className="absolute inset-0 z-[6]">
{/* Right bottom: Duration + Resolution(label) + Size */} <img
<div className="shrink-0 flex items-center gap-1.5"> src={modelImageSrc}
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span> alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
{resLabel ? ( draggable={false}
<span />
className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium" <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'} <div className="text-[10px] font-semibold tracking-wide text-white/95">
> MODEL PREVIEW
{resLabel}
</span>
) : null}
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
</div> </div>
</div> </div>
</div> </div>
</div> ) : null}
{/* Actions (top-right) */} {/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}> {hasScrubber ? (
<RecordJobActions <div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100">
job={j} <GalleryPreviewScrubber
variant="overlay" className="pointer-events-auto px-1"
busy={busy} imageCount={scrubberCount}
collapseToMenu activeIndex={activeScrubIndex}
isHot={isHot} onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
isFavorite={isFav} stepSeconds={scrubberStepSeconds}
isLiked={isLiked} />
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
</div>
</div>
{/* Footer / Meta */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div> </div>
</div> ) : null}
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400"> {/* Meta-Overlay im Video: unten rechts */}
<span className="truncate">{stripHotPrefix(file) || '—'}</span> <div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover:opacity-0 group-focus-within:opacity-0">
<div
{isHot ? ( className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300"> title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
HOT .filter(Boolean)
</span> .join(' • ')}
) : null} >
</div> <span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{/* Tags */} {resLabel ? <span>{resLabel}</span> : null}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> <span aria-hidden="true"></span>
<TagOverflowRow <span>{size}</span>
rowKey={k} </div>
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div> </div>
</div> </div>
</div> </div>
)
})} {/* Footer / Meta */}
<div className="relative min-h-[118px] px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
{/* ✅ stashapp-like: Dateiname zuerst */}
<div className="min-w-0">
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"
title={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</div>
</div>
{/* ✅ Model darunter + Hover-Preview Trigger */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0">
<span
className={[
'block truncate text-xs text-gray-500 dark:text-gray-400',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
e.stopPropagation()
if (modelImageSrc) setHoveredModelPreviewKey(k)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{model || '—'}
</span>
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={j}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
)
})}
</div> </div>
{isLoading && rows.length === 0 ? ( {isLoading && rows.length === 0 ? (
@ -317,4 +610,4 @@ export default function FinishedDownloadsGalleryView({
) : null} ) : null}
</div> </div>
) )
} }

View File

@ -66,6 +66,15 @@ export type FinishedVideoPreviewProps = {
popoverMuted?: boolean popoverMuted?: boolean
noGenerateTeaser?: boolean noGenerateTeaser?: boolean
/** Still-Preview (Bild) unabhängig vom inView-Gating laden */
alwaysLoadStill?: boolean
/** Teaser-Datei vorladen, bevor das Element wirklich im Viewport ist */
teaserPreloadEnabled?: boolean
/** Vorlade-Zone für Teaser (IntersectionObserver rootMargin) */
teaserPreloadRootMargin?: string
} }
export default function FinishedVideoPreview({ export default function FinishedVideoPreview({
@ -101,6 +110,9 @@ export default function FinishedVideoPreview({
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser, noGenerateTeaser,
alwaysLoadStill = false,
teaserPreloadEnabled = false,
teaserPreloadRootMargin = '700px 0px',
}: FinishedVideoPreviewProps) { }: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
@ -275,9 +287,10 @@ export default function FinishedVideoPreview({
// inView (Viewport) // inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const [nearView, setNearView] = useState(false)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen) // ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [, setEverInView] = useState(false) const [everInView, setEverInView] = useState(false)
// Tick nur für frames-Mode // Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
@ -301,48 +314,6 @@ export default function FinishedVideoPreview({
return stripHot(base).trim() return stripHot(base).trim()
}, [job.output, getFileName]) }, [job.output, getFileName])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
useEffect(() => {
if (!previewId) return
if (!animated || animatedMode !== 'teaser') return
if (!(inView || (wantsHover && hovered))) return
const pcs = (meta as any)?.previewClips
const hasPcs =
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
if (hasPcs) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile
if (!aborted && j) setFetchedMeta(j)
})()
return () => {
aborted = true
ctrl.abort()
}
}, [previewId, file, animated, animatedMode, meta, inView, wantsHover, hovered])
// Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata) // Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata)
const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file]) const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file])
@ -470,7 +441,7 @@ export default function FinishedVideoPreview({
} }
}, [file]) }, [file])
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar // --- IntersectionObserver: echtes inView (für Playback)
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
if (!el) return if (!el) return
@ -483,7 +454,7 @@ export default function FinishedVideoPreview({
}, },
{ {
threshold: 0.01, threshold: 0.01,
rootMargin: '120px 0px', // oder '0px' wenn dus hart willst rootMargin: '0px',
} }
) )
@ -491,6 +462,40 @@ export default function FinishedVideoPreview({
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
// --- IntersectionObserver: nearView (für Teaser-Preload / Vorwärmen)
useEffect(() => {
const el = rootRef.current
if (!el) return
// Falls Preload deaktiviert ist: nearView folgt inView
if (!teaserPreloadEnabled) {
setNearView(inView)
return
}
let armed = true
const obs = new IntersectionObserver(
(entries) => {
const hit = Boolean(entries[0]?.isIntersecting)
if (hit) {
setNearView(true)
// einmal "armed" reicht (wir wollen nicht wieder entladen)
if (armed) {
armed = false
obs.disconnect()
}
}
},
{
threshold: 0,
rootMargin: teaserPreloadRootMargin,
}
)
obs.observe(el)
return () => obs.disconnect()
}, [teaserPreloadEnabled, teaserPreloadRootMargin, inView])
// --- Tick für "frames" // --- Tick für "frames"
useEffect(() => { useEffect(() => {
if (!animated) return if (!animated) return
@ -528,8 +533,8 @@ export default function FinishedVideoPreview({
const thumbSrc = useMemo(() => { const thumbSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}` if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}` return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
}, [previewId, thumbTimeSec, v]) }, [previewId, thumbTimeSec, v])
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
@ -538,6 +543,30 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser]) }, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
const teaserActive =
animated &&
inView &&
!document.hidden &&
videoOk &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered)
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig) // ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
useEffect(() => { useEffect(() => {
let did = false let did = false
@ -555,6 +584,49 @@ export default function FinishedVideoPreview({
if (did) setMetaLoaded(true) if (did) setMetaLoaded(true)
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH]) }, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
useEffect(() => {
if (!previewId) return
if (!animated || animatedMode !== 'teaser') return
if (!(shouldPreloadAnimatedAssets)) return
const pcs = (meta as any)?.previewClips
const hasPcs =
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
if (hasPcs) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile
if (!aborted && j) setFetchedMeta(j)
})()
return () => {
aborted = true
ctrl.abort()
}
}, [previewId, file, animated, animatedMode, meta, shouldPreloadAnimatedAssets])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen // ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true) setMetaLoaded(true)
@ -589,24 +661,16 @@ export default function FinishedVideoPreview({
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} /> return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
} }
// --- Inline Video sichtbar? // ✅ aktive Asset-Nutzung (z.B. poster etc.)
const showingInlineVideo = const shouldLoadAssets = shouldPreloadAnimatedAssets
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
const teaserCanPrewarm =
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
const teaserActive =
animated && animated &&
inView && animatedMode === 'teaser' &&
!document.hidden && teaserOk &&
videoOk && Boolean(teaserSrc) &&
!showingInlineVideo && !showingInlineVideo &&
(animatedTrigger === 'always' || hovered) && shouldPreloadAnimatedAssets
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
// ✅ Nur dann echte Asset-Requests auslösen, wenn wir sie brauchen
const shouldLoadAssets = inView || (wantsHover && hovered)
// ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig) // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef = const progressVideoRef =
@ -826,7 +890,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load? // ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta = const needHiddenMeta =
inView && (nearView || inView) &&
(onDuration || onResolution) && (onDuration || onResolution) &&
!metaLoaded && !metaLoaded &&
!showingInlineVideo && !showingInlineVideo &&
@ -846,10 +910,10 @@ export default function FinishedVideoPreview({
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined} data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
> >
{/* ✅ Thumb IMMER als Fallback/Background */} {/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadAssets && thumbSrc && thumbOk ? ( {shouldLoadStill && thumbSrc && thumbOk ? (
<img <img
src={thumbSrc} src={thumbSrc}
loading="lazy" loading={alwaysLoadStill ? 'eager' : 'lazy'}
decoding="async" decoding="async"
alt={file} alt={file}
className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')} className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
@ -886,6 +950,24 @@ export default function FinishedVideoPreview({
/> />
) : null} ) : null}
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
<video
key={`teaser-prewarm-${previewId}`}
src={teaserSrc}
className="hidden"
muted
playsInline
preload="auto"
onLoadedData={() => setTeaserReady(true)}
onCanPlay={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
}}
/>
) : null}
{/* ✅ Teaser MP4 */} {/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video <video
@ -907,7 +989,7 @@ export default function FinishedVideoPreview({
playsInline playsInline
autoPlay autoPlay
loop loop
preload="metadata" preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)} onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)} onPlaying={() => setTeaserReady(true)}
@ -941,7 +1023,7 @@ export default function FinishedVideoPreview({
<div <div
aria-hidden="true" aria-hidden="true"
className={[ className={[
'absolute left-0 right-0 bottom-0 z-10 pointer-events-none', 'absolute left-0 right-0 bottom-0 z-[2] pointer-events-none',
// etwas höher + bei hover deutlich // etwas höher + bei hover deutlich
'h-0.5 group-hover:h-1.5', 'h-0.5 group-hover:h-1.5',
'transition-[height] duration-150 ease-out', 'transition-[height] duration-150 ease-out',

View File

@ -0,0 +1,188 @@
// frontend\src\components\ui\GalleryPreviewScrubber.tsx
'use client'
import * as React from 'react'
type Props = {
imageCount: number
activeIndex?: number
onActiveIndexChange: (index: number | undefined) => void
onClickIndex?: (index: number) => void
className?: string
stepSeconds?: number
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function formatClock(totalSeconds: number) {
const s = Math.max(0, Math.floor(totalSeconds))
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${String(sec).padStart(2, '0')}`
}
export default function GalleryPreviewScrubber({
imageCount,
activeIndex,
onActiveIndexChange,
onClickIndex,
className,
stepSeconds = 0,
}: Props) {
const rootRef = React.useRef<HTMLDivElement | null>(null)
// rAF-Throttle für Pointer-Move (reduziert Re-Renders)
const rafRef = React.useRef<number | null>(null)
const pendingIndexRef = React.useRef<number | undefined>(undefined)
const flushPending = React.useCallback(() => {
rafRef.current = null
onActiveIndexChange(pendingIndexRef.current)
}, [onActiveIndexChange])
const setIndexThrottled = React.useCallback(
(index: number | undefined) => {
pendingIndexRef.current = index
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(flushPending)
},
[flushPending]
)
React.useEffect(() => {
return () => {
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
const indexFromClientX = React.useCallback(
(clientX: number) => {
if (!rootRef.current || imageCount < 1) return undefined
const rect = rootRef.current.getBoundingClientRect()
if (rect.width <= 0) return undefined
const x = clientX - rect.left
const ratio = clamp(x / rect.width, 0, 0.999999)
const idx = clamp(Math.floor(ratio * imageCount), 0, imageCount - 1)
return idx
},
[imageCount]
)
const handlePointerMove = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
},
[indexFromClientX, setIndexThrottled]
)
const handlePointerEnter = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
},
[indexFromClientX, setIndexThrottled]
)
const handlePointerLeave = React.useCallback(() => {
setIndexThrottled(undefined)
}, [setIndexThrottled])
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation()
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
try {
e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {}
},
[indexFromClientX, setIndexThrottled]
)
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (!onClickIndex) return
const idx = indexFromClientX(e.clientX)
if (typeof idx === 'number') onClickIndex(idx)
},
[indexFromClientX, onClickIndex]
)
if (!imageCount || imageCount < 1) return null
const markerLeftPct =
typeof activeIndex === 'number'
? ((activeIndex + 0.5) / imageCount) * 100
: undefined
const label =
typeof activeIndex === 'number'
? stepSeconds > 0
? formatClock(activeIndex * stepSeconds)
: `${activeIndex + 1}/${imageCount}`
: ''
const showLabel = typeof activeIndex === 'number'
return (
<div
ref={rootRef}
className={[
// große Hit-Area, sichtbarer Balken ist unten drin
'relative h-7 w-full select-none touch-none cursor-col-resize',
className,
]
.filter(Boolean)
.join(' ')}
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleClick}
title={showLabel ? label : `Preview scrubber (${imageCount})`}
role="slider"
aria-label="Preview scrubber"
aria-valuemin={1}
aria-valuemax={imageCount}
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
>
{/* sichtbarer Scrubbing-Bereich (durchgehend weiß) */}
<div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
{/* aktiver Marker */}
{typeof markerLeftPct === 'number' ? (
<div
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
style={{
left: `${markerLeftPct}%`,
transform: 'translateX(-50%)',
}}
/>
) : null}
</div>
{/* Zeitlabel (unten rechts / dezent) */}
<div
className={[
'pointer-events-none absolute right-1 bottom-1',
'text-[11px] leading-none text-white',
'transition-opacity duration-100',
showLabel ? 'opacity-100' : 'opacity-0',
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
].join(' ')}
>
{label}
</div>
</div>
)
}

View File

@ -16,9 +16,10 @@ type TaskState = {
startedAt?: string startedAt?: string
finishedAt?: string finishedAt?: string
error?: string error?: string
currentFile?: string
} }
type Progress = { done: number; total: number } type Progress = { done: number; total: number; currentFile?: string }
type Props = { type Props = {
onFinished?: () => void onFinished?: () => void
@ -152,11 +153,17 @@ export default function GenerateAssetsTask({
if (st?.running) { if (st?.running) {
const ac = ensureControllerCreated() const ac = ensureControllerCreated()
armTaskList(ac) armTaskList(ac)
onProgressRef.current?.({ done: st?.done ?? 0, total: st?.total ?? 0 }) onProgressRef.current?.({
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
} }
const errText = String(st?.error ?? '').trim() const errText = String(st?.error ?? '').trim()
if (errText && errText !== lastErrorRef.current) {
// ✅ Abbruch ist kein "Fehler"-Event für die UI
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText lastErrorRef.current = errText
onErrorRef.current?.(errText) onErrorRef.current?.(errText)
} }
@ -184,7 +191,11 @@ export default function GenerateAssetsTask({
armTaskList(ac) armTaskList(ac)
if (st?.running) { if (st?.running) {
onProgress?.({ done: st?.done ?? 0, total: st?.total ?? 0 }) onProgress?.({
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
} }
} catch (e: any) { } catch (e: any) {
// Start fehlgeschlagen -> Controller/Flags zurücksetzen // Start fehlgeschlagen -> Controller/Flags zurücksetzen

View File

@ -312,6 +312,8 @@ type StoredModel = {
id: string id: string
modelKey: string modelKey: string
tags?: string | null tags?: string | null
lastSeenOnline?: boolean | null
lastSeenOnlineAt?: string
favorite?: boolean favorite?: boolean
watching?: boolean watching?: boolean
liked?: boolean | null liked?: boolean | null
@ -699,6 +701,11 @@ export default function ModelDetails({
const about = stripHtmlToText(bio?.about_me) const about = stripHtmlToText(bio?.about_me)
const wish = stripHtmlToText(bio?.wish_list) const wish = stripHtmlToText(bio?.wish_list)
const storedPresenceLabel =
model?.lastSeenOnline == null ? '' : model.lastSeenOnline ? 'online' : 'offline'
const effectivePresenceLabel = (bioStatus || showLabel || storedPresenceLabel || '').trim()
const socials = Array.isArray(bio?.social_medias) ? bio!.social_medias! : [] const socials = Array.isArray(bio?.social_medias) ? bio!.social_medias! : []
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : [] const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : [] const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
@ -901,16 +908,16 @@ export default function ModelDetails({
{showPill} {showPill}
</span> </span>
) : null} ) : null}
{bioStatus ? ( {effectivePresenceLabel ? (
<span <span
className={pill( className={pill(
bioStatus.toLowerCase() === 'online' effectivePresenceLabel.toLowerCase() === 'online'
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20' ? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15' : 'bg-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15'
)} )}
> >
{bioStatus} {effectivePresenceLabel}
</span> </span>
) : null} ) : null}
@ -1201,7 +1208,7 @@ export default function ModelDetails({
aria-hidden aria-hidden
className={cn( className={cn(
'absolute bottom-1.5 right-1.5 size-2.5 rounded-full ring-2 ring-white/80 dark:ring-gray-900/60', 'absolute bottom-1.5 right-1.5 size-2.5 rounded-full ring-2 ring-white/80 dark:ring-gray-900/60',
(bioStatus || '').toLowerCase() === 'online' ? 'bg-emerald-400' : 'bg-gray-400' (effectivePresenceLabel || '').toLowerCase() === 'online' ? 'bg-emerald-400' : 'bg-gray-400'
)} )}
/> />
</button> </button>
@ -1499,6 +1506,19 @@ export default function ModelDetails({
· Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)} · Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)}
</span> </span>
) : null} ) : null}
{model?.lastSeenOnlineAt ? (
<span className="text-gray-500 dark:text-gray-400">
· Zuletzt gesehen:{' '}
<span className="font-medium">
{model.lastSeenOnline == null
? '—'
: model.lastSeenOnline
? 'online'
: 'offline'}
</span>{' '}
({fmtDateTime(model.lastSeenOnlineAt)})
</span>
) : null}
</div> </div>
) : ( ) : (
'—' '—'

View File

@ -21,6 +21,9 @@ type Props = {
fastRetryMs?: number fastRetryMs?: number
fastRetryMax?: number fastRetryMax?: number
fastRetryWindowMs?: number fastRetryWindowMs?: number
thumbsWebpUrl?: string | null
thumbsCandidates?: Array<string | null | undefined>
} }
export default function ModelPreview({ export default function ModelPreview({
@ -28,15 +31,18 @@ export default function ModelPreview({
thumbTick, thumbTick,
autoTickMs = 10_000, autoTickMs = 10_000,
blur = false, blur = false,
className,
alignStartAt, alignStartAt,
alignEndAt = null, alignEndAt = null,
alignEveryMs, alignEveryMs,
fastRetryMs, fastRetryMs,
fastRetryMax, fastRetryMax,
fastRetryWindowMs, fastRetryWindowMs,
className, thumbsWebpUrl,
thumbsCandidates,
}: Props) { }: Props) {
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
const CONTROLBAR_H = 30
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
@ -48,13 +54,16 @@ export default function ModelPreview({
const inViewRef = useRef(false) const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [directImgError, setDirectImgError] = useState(false)
const [apiImgError, setApiImgError] = useState(false)
const retryT = useRef<number | null>(null) const retryT = useRef<number | null>(null)
const fastTries = useRef(0) const fastTries = useRef(0)
const hadSuccess = useRef(false) const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false) const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const toMs = (v: any): number => { const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime() if (v instanceof Date) return v.getTime()
@ -62,12 +71,37 @@ export default function ModelPreview({
return Number.isFinite(ms) ? ms : NaN return Number.isFinite(ms) ? ms : NaN
} }
const normalizeUrl = (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 thumbsCandidatesKey = useMemo(() => {
const list = [
thumbsWebpUrl,
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
]
.map(normalizeUrl)
.filter(Boolean)
// Reihenfolge behalten, nur dedupe
return Array.from(new Set(list)).join('|')
}, [thumbsWebpUrl, thumbsCandidates])
// ✅ visibilitychange -> nur REF updaten // ✅ visibilitychange -> nur REF updaten
useEffect(() => { useEffect(() => {
const onVis = () => { const onVis = () => {
pageVisibleRef.current = !document.hidden const vis = !document.hidden
pageVisibleRef.current = vis
setPageVisible(vis) // ✅ sorgt dafür, dass Tick-Effect neu aufgebaut wird
} }
pageVisibleRef.current = !document.hidden const vis = !document.hidden
pageVisibleRef.current = vis
setPageVisible(vis)
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis)
}, []) }, [])
@ -161,7 +195,7 @@ export default function ModelPreview({
}, period) }, period)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs]) }, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
// ✅ tick Quelle // ✅ tick Quelle
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
@ -180,58 +214,82 @@ export default function ModelPreview({
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen // bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => { useEffect(() => {
setImgError(false) setDirectImgError(false)
setApiImgError(false)
}, [frozenTick]) }, [frozenTick])
// bei Job-Wechsel reset
useEffect(() => { useEffect(() => {
// bei Job-Wechsel reset
hadSuccess.current = false hadSuccess.current = false
fastTries.current = 0 fastTries.current = 0
enteredViewOnce.current = false enteredViewOnce.current = false
setImgError(false) setDirectImgError(false)
setApiImgError(false)
// ✅ sofort neuer Request, aber nur wenn wir auch wirklich sichtbar sind
if (inViewRef.current && pageVisibleRef.current) { if (inViewRef.current && pageVisibleRef.current) {
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
} }
}, [jobId]) }, [jobId, thumbsCandidatesKey])
const thumb = useMemo( const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`, () => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, frozenTick] [jobId, frozenTick]
) )
const hq = useMemo( const hq = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`, () => `/api/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
[jobId] [jobId]
) )
const directThumbCandidates = useMemo(() => {
if (!thumbsCandidatesKey) return []
return thumbsCandidatesKey.split('|')
}, [thumbsCandidatesKey])
const directThumb = directThumbCandidates[0] || ''
const useDirectThumb = Boolean(directThumb) && !directImgError
const currentImgSrc = useMemo(() => {
if (useDirectThumb) {
const sep = directThumb.includes('?') ? '&' : '?'
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}`
}
return thumb
}, [useDirectThumb, directThumb, frozenTick, thumb])
return ( return (
<HoverPopover <HoverPopover
content={(open, { close }) => content={(open, { close }) =>
open && ( open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black"> <div
<LiveHlsVideo src={hq} muted={false} className="w-full h-full relative z-0" /> className="relative rounded-lg overflow-hidden bg-black"
style={{
// 16:9 Videofläche + feste 30px Controlbar
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
}}
>
<div className="absolute inset-0">
<LiveHlsVideo src={hq} muted={false} className="w-full h-full object-contain object-bottom relative z-0" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm"> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" /> <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live Live
</div>
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
aria-label="Live-Vorschau schließen"
title="Vorschau schließen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
close()
}}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div> </div>
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
aria-label="Live-Vorschau schließen"
title="Vorschau schließen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
close()
}}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
) )
@ -244,9 +302,9 @@ export default function ModelPreview({
className || 'w-full h-full', className || 'w-full h-full',
].join(' ')} ].join(' ')}
> >
{!imgError ? ( {!apiImgError ? (
<img <img
src={thumb} src={currentImgSrc}
loading={inView ? 'eager' : 'lazy'} loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'} fetchPriority={inView ? 'high' : 'auto'}
decoding="async" decoding="async"
@ -256,10 +314,20 @@ export default function ModelPreview({
hadSuccess.current = true hadSuccess.current = true
fastTries.current = 0 fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
setImgError(false)
// nur den aktuell genutzten Pfad als "ok" markieren
if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false)
}} }}
onError={() => { onError={() => {
setImgError(true) // 1) Wenn direkte thumbs.webp fehlschlägt -> auf API-Fallback umschalten
if (useDirectThumb) {
setDirectImgError(true)
return
}
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
setApiImgError(true)
if (!fastRetryMs) return if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return if (!inViewRef.current || !pageVisibleRef.current) return
@ -276,6 +344,7 @@ export default function ModelPreview({
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => { retryT.current = window.setTimeout(() => {
fastTries.current += 1 fastTries.current += 1
setApiImgError(false) // API erneut probieren
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
}, fastRetryMs) }, fastRetryMs)
}} }}

File diff suppressed because it is too large Load Diff

View File

@ -435,7 +435,7 @@ export default function RecordJobActions({
delete: DeleteBtn, delete: DeleteBtn,
} }
const collapse = collapseToMenu && variant === 'overlay' const collapse = collapseToMenu
// Nur Keys, die wirklich einen Node haben // Nur Keys, die wirklich einen Node haben
const presentKeys = actionOrder.filter((k) => Boolean(byKey[k])) const presentKeys = actionOrder.filter((k) => Boolean(byKey[k]))

View File

@ -56,6 +56,13 @@ type Props = {
onAssetsGenerated?: () => void onAssetsGenerated?: () => void
} }
function shortTaskFilename(name?: string, max = 52) {
const s = String(name ?? '').trim()
if (!s) return ''
if (s.length <= max) return s
return '…' + s.slice(-(max - 1))
}
export default function RecorderSettings({ onAssetsGenerated }: Props) { export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -418,6 +425,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'running', status: 'running',
title: 'Assets generieren',
text: '', text: '',
done: 0, done: 0,
total: 0, total: 0,
@ -426,22 +434,30 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
}} }}
onProgress={(p) => { onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'running', status: 'running',
text: '', title: 'Assets generieren',
text: fn || '',
done: p.done, done: p.done,
total: p.total, total: p.total,
})) }))
}} }}
onDone={() => { onDone={() => {
assetsAbortRef.current = null assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done' })) setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
onCancelled={() => { onCancelled={() => {
assetsAbortRef.current = null assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' })) setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Assets generieren',
text: 'Abgebrochen.',
}))
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
onError={(message) => { onError={(message) => {
@ -449,6 +465,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'error', status: 'error',
title: 'Assets generieren',
text: 'Fehler beim Generieren.', text: 'Fehler beim Generieren.',
err: message, err: message,
})) }))

View File

@ -3,7 +3,6 @@
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid' import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
@ -118,24 +117,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onSwipeLeft, onSwipeLeft,
onSwipeRight, onSwipeRight,
className, className,
leftAction = {
label: (
<span className="inline-flex items-center gap-2 font-semibold">
<BookmarkSquareIcon className="h-6 w-6" />
<span className="hidden sm:inline">Behalten</span>
</span>
),
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
},
rightAction = {
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<TrashIcon className="h-6 w-6" aria-hidden="true" />
<span>Löschen</span>
</span>
),
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
},
thresholdPx = 140, thresholdPx = 140,
thresholdRatio = 0.28, thresholdRatio = 0.28,
ignoreFromBottomPx = 72, ignoreFromBottomPx = 72,
@ -400,54 +381,48 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[commit, reset] [commit, reset]
) )
const absDx = Math.abs(dx)
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
const activeThreshold =
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const reveal = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold)))
const revealSoft = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold * 1.35)))
// leichte Tinder-ähnliche Kippung beim Drag
const tiltDeg = Math.max(-6, Math.min(6, dx / 28))
const dragScale = dx === 0 ? 1 : 0.995
return ( return (
<div <div
ref={outerRef} ref={outerRef}
className={cn('relative isolate overflow-hidden rounded-lg', className)} className={cn('relative isolate overflow-visible rounded-lg', className)}
> >
{/* Background actions (100% je Richtung, animiert) */}
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
<div
className={cn(
'absolute inset-0 transition-opacity duration-200 ease-out',
dx === 0 ? 'opacity-0' : 'opacity-100',
dx > 0 ? leftAction.className : rightAction.className
)}
/>
<div
className="absolute inset-0 flex items-center"
style={{
opacity: dx === 0 ? 0 : 1,
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
paddingLeft: dx > 0 ? 16 : 0,
paddingRight: dx > 0 ? 0 : 16,
}}
>
<div
style={{
transform: armedDir ? 'scale(1.05)' : 'scale(1)',
transition: 'transform 180ms ease, opacity 180ms ease',
opacity: armedDir ? 1 : 0.9,
}}
>
{dx > 0 ? leftAction.label : rightAction.label}
</div>
</div>
</div>
{/* Foreground (moves) */} {/* Foreground (moves) */}
<div <div
ref={cardRef} ref={cardRef}
className="relative" className="relative"
style={{ style={{
// ✅ iOS Fix: kein transform im Idle-Zustand, sonst sind Video-Controls oft nicht tappbar // ✅ iOS Fix: im Idle kein transform
transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined, transform:
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
transition: animMs ? `transform ${animMs}ms ease` : undefined, transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined, willChange: dx !== 0 ? 'transform' : undefined,
boxShadow: dx !== 0 ? '0 10px 24px rgba(0,0,0,0.18)' : undefined, 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:
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
@ -457,10 +432,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// Tap ignorieren (SingleTap nicht auslösen), DoubleTap soll aber weiter gehen // 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 (wie vorher) // Harte Ignore-Zone: da wollen wir wirklich gar nichts
if (ignoreSelector && target?.closest?.(ignoreSelector)) return if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// NEW: wenn true -> wir lassen Tap/DoubleTap zu, aber starten niemals Swipe/Drag // 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
@ -477,14 +452,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
e.clientY <= vr.bottom e.clientY <= vr.bottom
if (inVideo) { if (inVideo) {
// unten frei für Timeline/Scrub
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 // SingleTap nicht in unsere Logik -> Video controls sollen gewinnen tapIgnored = true
} else { } else {
// Swipe nur aus den Seitenrändern
const edgeZonePx = 64 const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX const xFromRight = vr.right - e.clientX
@ -492,18 +465,16 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (!inEdge) { if (!inEdge) {
noSwipe = true noSwipe = true
tapIgnored = true // Video-Interaktion nicht stören tapIgnored = true
} }
} }
} }
} }
// Generelle Card-Bottom-Sperre: Swipe verhindern, aber Tap darf bleiben
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) { if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
noSwipe = true noSwipe = true
// tapIgnored NICHT automatisch setzen Footer-Taps sollen funktionieren
} }
pointer.current = { pointer.current = {
@ -524,33 +495,24 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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.x
const ddy = e.clientY - pointer.current.y const ddy = e.clientY - pointer.current.y
// Erst entscheiden ob wir überhaupt draggen
if (!pointer.current.dragging) { if (!pointer.current.dragging) {
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) { if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
pointer.current.id = null pointer.current.id = null
return return
} }
// "Dead zone" bis wirklich horizontal gedrückt wird
if (Math.abs(ddx) < 12) return if (Math.abs(ddx) < 12) return
// ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true pointer.current.dragging = true
// ✅ während horizontalem Drag keine Scroll-Gesten verhandeln
;(e.currentTarget as HTMLElement).style.touchAction = 'none' ;(e.currentTarget as HTMLElement).style.touchAction = 'none'
// ✅ Anim nur 1x beim Drag-Start deaktivieren
setAnimMs(0) setAnimMs(0)
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
try { try {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pointer.current.captured = true pointer.current.captured = true
@ -578,14 +540,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}) })
} }
// ✅ armedDir nur updaten wenn geändert
const threshold = thresholdRef.current const threshold = thresholdRef.current
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => { setArmedDir((prev) => {
if (prev === nextDir) return prev if (prev === nextDir) return prev
// mini feedback beim “arming”
if (nextDir) { if (nextDir) {
try { navigator.vibrate?.(10) } catch {} try {
navigator.vibrate?.(10)
} catch {}
} }
return nextDir return nextDir
}) })
@ -606,15 +568,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
pointer.current.dragging = false pointer.current.dragging = false
pointer.current.captured = false pointer.current.captured = false
// Capture sauber lösen (falls gesetzt)
if (wasCaptured) { if (wasCaptured) {
try { try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {} } catch {}
} }
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y' ;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) { if (!wasDragging) {
const now = Date.now() const now = Date.now()
const last = lastTapRef.current const last = lastTapRef.current
@ -626,26 +587,20 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const isDouble = const isDouble =
Boolean(onDoubleTap) && Boolean(onDoubleTap) &&
last && last &&
(now - last.t) <= doubleTapMs && now - last.t <= doubleTapMs &&
isNear isNear
if (isDouble) { if (isDouble) {
// 2nd tap: Single-Tap abbrechen + DoubleTap ausführen
lastTapRef.current = null lastTapRef.current = null
clearTapTimer() clearTapTimer()
if (doubleTapBusyRef.current) return if (doubleTapBusyRef.current) return
doubleTapBusyRef.current = true doubleTapBusyRef.current = true
// ✅ FX sofort anlegen
try { try {
runHotFx(e.clientX, e.clientY) runHotFx(e.clientX, e.clientY)
} catch (err) { } catch {}
// optional zum Debuggen:
// console.error('runHotFx failed', err)
}
// ✅ Toggle erst NACH dem nächsten Paint-Frame starten
requestAnimationFrame(() => { requestAnimationFrame(() => {
;(async () => { ;(async () => {
try { try {
@ -659,9 +614,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return return
} }
// ✅ nur SingleTap "blocken" wenn tapIgnored DoubleTap bleibt möglich
if (wasTapIgnored) { if (wasTapIgnored) {
// wichtig: lastTapRef trotzdem setzen, damit DoubleTap beim 2. Tap klappt
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY } lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer() clearTapTimer()
tapTimerRef.current = window.setTimeout(() => { tapTimerRef.current = window.setTimeout(() => {
@ -671,10 +624,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return return
} }
// ✅ NUR bei SingleTap soft resetten
softResetForTap() softResetForTap()
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY } lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer() clearTapTimer()
@ -689,7 +640,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const finalDx = dxRef.current const finalDx = dxRef.current
// rAF cleanup
if (rafRef.current != null) { if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null rafRef.current = null
@ -719,7 +669,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
dxRef.current = 0 dxRef.current = 0
try { (e.currentTarget as HTMLElement).style.touchAction = 'pan-y' } catch {} try {
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
} catch {}
reset() reset()
}} }}
@ -727,14 +679,32 @@ 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 liegt ÜBER dem Inhalt */} {/* Overlay über dem Inhalt (subtiler, progressiv) */}
<div <div
className={cn( className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg', style={{
armedDir === 'right' && 'bg-emerald-500/20 opacity-100', opacity: dx === 0 ? 0 : 0.12 + revealSoft * 0.18,
armedDir === 'left' && 'bg-red-500/20 opacity-100', background:
armedDir === null && 'opacity-0' 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%)'
: 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%)'
: 'transparent',
}}
/>
{/* Armed border glow */}
<div
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
style={{
opacity: armedDir ? 1 : 0,
boxShadow:
armedDir === 'right'
? 'inset 0 0 0 1px rgba(16,185,129,0.45), inset 0 0 32px rgba(16,185,129,0.10)'
: armedDir === 'left'
? 'inset 0 0 0 1px rgba(244,63,94,0.45), inset 0 0 32px rgba(244,63,94,0.10)'
: 'none',
}}
/> />
</div> </div>
</div> </div>

View File

@ -212,7 +212,7 @@ export default function Table<T>({
<div <div
className={cn( className={cn(
card && card &&
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10' 'overflow-hidden shadow-sm outline-1 outline-black/5 rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10'
)} )}
> >
<table className="relative min-w-full divide-y divide-gray-200 dark:divide-white/10"> <table className="relative min-w-full divide-y divide-gray-200 dark:divide-white/10">

View File

@ -54,8 +54,14 @@ export default function TagOverflowRow({
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
}, [maxVisible]) }, [maxVisible])
const sortedTags = React.useMemo(() => {
return [...tags].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
}, [tags])
const [visibleCount, setVisibleCount] = React.useState<number>(() => { const [visibleCount, setVisibleCount] = React.useState<number>(() => {
const total = Math.min(tags.length, cap) const total = Math.min(sortedTags.length, cap)
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
}) })
@ -67,7 +73,7 @@ export default function TagOverflowRow({
const rowW = Math.floor(rowWrap.getBoundingClientRect().width) const rowW = Math.floor(rowWrap.getBoundingClientRect().width)
if (rowW <= 0) return if (rowW <= 0) return
const totalTags = Math.min(tags.length, cap) const totalTags = Math.min(sortedTags.length, cap)
if (totalTags <= 0) { if (totalTags <= 0) {
setVisibleCount(0) setVisibleCount(0)
return return
@ -110,12 +116,12 @@ export default function TagOverflowRow({
} }
setVisibleCount(Math.max(0, Math.min(count, totalTags))) setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [tags, cap, gapPx]) }, [sortedTags, cap, gapPx])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
recalc() recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [recalc, tags.join('\u0000'), maxWidthClassName, cap]) }, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap])
React.useEffect(() => { React.useEffect(() => {
const el = rowWrapRef.current const el = rowWrapRef.current
@ -125,14 +131,14 @@ export default function TagOverflowRow({
return () => ro.disconnect() return () => ro.disconnect()
}, [recalc]) }, [recalc])
const totalTags = Math.min(tags.length, cap) const totalTags = Math.min(sortedTags.length, cap)
const visibleTags = tags.slice(0, Math.min(visibleCount, totalTags)) const visibleTags = sortedTags.slice(0, Math.min(visibleCount, totalTags))
const stop = (e: React.SyntheticEvent) => e.stopPropagation() const stop = (e: React.SyntheticEvent) => e.stopPropagation()
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped) // ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length // willst du immer gegen alle tags: rest = tags.length - visibleTags.length
const restAll = tags.length - visibleTags.length const restAll = sortedTags.length - visibleTags.length
return ( return (
<> <>
@ -224,7 +230,7 @@ export default function TagOverflowRow({
{/* volle Fläche für Tags */} {/* volle Fläche für Tags */}
<div className="h-full overflow-auto pr-1"> <div className="h-full overflow-auto pr-1">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{tags.map((t) => ( {sortedTags.map((t) => (
<TagBadge <TagBadge
key={t} key={t}
tag={t} tag={t}
@ -241,7 +247,7 @@ export default function TagOverflowRow({
{/* hidden measure area */} {/* hidden measure area */}
<div className="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none"> <div className="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none">
<div ref={measureRef} className="flex flex-nowrap items-center gap-1.5"> <div ref={measureRef} className="flex flex-nowrap items-center gap-1.5">
{tags.slice(0, totalTags).map((t) => ( {sortedTags.slice(0, totalTags).map((t) => (
<TagBadge <TagBadge
key={t} key={t}
tag={t} tag={t}

View File

@ -0,0 +1,33 @@
'use client'
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string, defaultValue = false): boolean {
const [matches, setMatches] = useState<boolean>(defaultValue)
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return
}
const mql = window.matchMedia(query)
const onChange = () => {
setMatches(mql.matches)
}
// Initial setzen (wichtig nach Mount)
onChange()
// Safari fallback
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}
mql.addListener(onChange)
return () => mql.removeListener(onChange)
}, [query])
return matches
}