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
import (

View File

@ -618,6 +618,20 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
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:
// - 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
import (
@ -57,27 +56,28 @@ func assetIDFromVideoPath(videoPath string) string {
return strings.TrimSpace(id)
}
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, metaPath string, err error) {
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / preview-sprite.webp / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) {
id = strings.TrimSpace(id)
if id == "" {
return "", "", "", "", fmt.Errorf("empty id")
return "", "", "", "", "", fmt.Errorf("empty id")
}
assetDir, err = ensureGeneratedDir(id)
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")
previewPath = filepath.Join(assetDir, "preview.mp4")
spritePath = filepath.Join(assetDir, "preview-sprite.webp")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json")
}
return assetDir, thumbPath, previewPath, metaPath, nil
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
}
type ensuredMeta struct {
@ -145,6 +145,7 @@ type EnsureAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
SpriteGenerated bool
MetaOK bool
}
@ -172,7 +173,7 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
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) {
var out EnsureAssetsResult
@ -191,7 +192,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return out, nil
}
_, thumbPath, previewPath, metaPath, perr := assetPathsForID(id)
_, thumbPath, previewPath, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
@ -216,6 +217,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
}
return false
}()
previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
@ -223,12 +225,19 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
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, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
// Wenn alles da ist: skipped
if thumbBefore && previewBefore && meta.ok {
if thumbBefore && previewBefore && spriteBefore && meta.ok {
out.Skipped = true
progress(1)
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 {
progress(1)
return out, nil
}
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)
// Preview ist schon da -> nicht returnen, weil Sprite evtl. noch fehlt
progress(thumbsW + previewW)
} else {
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) {
return
}
@ -355,16 +363,121 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000, // 3 decimals wie ffmpeg arg
DurationSeconds: math.Round(segDur*1000) / 1000, // 3 decimals
StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
// Originalvideo-fi (nicht preview-fi!), damit Validierung konsistent bleibt
_ = writeVideoMetaWithPreviewClips(metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, clips)
// ✅ merken für finalen Meta-Write
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)
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) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -1081,177 +955,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
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) {
if progress < 0 {
progress = 0

View File

@ -19,6 +19,16 @@ type previewClip struct {
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 {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
@ -30,8 +40,9 @@ type videoMeta struct {
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
@ -148,6 +159,44 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
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)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
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
}
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 {
root, err := generatedMetaRoot()
if err != nil {

View File

@ -16,14 +16,16 @@ import (
)
type StoredModel struct {
ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"`
ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"`
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
Watching bool `json:"watching"`
Favorite bool `json:"favorite"`
@ -356,6 +358,9 @@ CREATE TABLE IF NOT EXISTS models (
biocontext_json TEXT,
biocontext_fetched_at TEXT,
last_seen_online INTEGER NULL, -- NULL/0/1
last_seen_online_at TEXT,
watching INTEGER NOT NULL DEFAULT 0,
favorite 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
}
@ -458,6 +475,24 @@ func ptrLikedFromNull(n sql.NullInt64) *bool {
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) ---
// GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel).
@ -556,6 +591,77 @@ func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) er
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 {
// DB leer?
var cnt int
@ -829,6 +935,7 @@ func (s *ModelStore) List() []StoredModel {
SELECT
id,input,is_url,host,path,model_key,
tags, COALESCE(last_stream,''),
last_seen_online, COALESCE(last_seen_online_at,''),
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
@ -846,10 +953,13 @@ func (s *ModelStore) List() []StoredModel {
id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
lastSeenOnline sql.NullInt64
lastSeenOnlineAt string
)
if err := rows.Scan(
&id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
); err != nil {
@ -857,21 +967,23 @@ func (s *ModelStore) List() []StoredModel {
}
out = append(out, StoredModel{
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Watching: watching != 0,
Tags: tags,
LastStream: lastStream,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Watching: watching != 0,
LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline),
LastSeenOnlineAt: lastSeenOnlineAt,
Tags: tags,
LastStream: lastStream,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
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
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
lastSeenOnlineAt string
lastSeenOnline sql.NullInt64
)
err := s.db.QueryRow(`
SELECT
input,is_url,host,path,model_key,
tags, COALESCE(last_stream,''),
last_seen_online, COALESCE(last_seen_online_at,''),
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
@ -1187,6 +1302,7 @@ WHERE id=?;
`, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
@ -1198,20 +1314,22 @@ WHERE id=?;
}
return StoredModel{
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Tags: tags,
LastStream: lastStream,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Tags: tags,
LastStream: lastStream,
LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline),
LastSeenOnlineAt: lastSeenOnlineAt,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}

Binary file not shown.

View File

@ -386,7 +386,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
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)
return nil

View File

@ -9,9 +9,9 @@ import (
)
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.
base := "/api/record/preview?id=" + url.QueryEscape(id) + "&file="
base := "/api/preview?id=" + url.QueryEscape(id) + "&file="
var out bytes.Buffer
sc := bufio.NewScanner(bytes.NewReader(raw))
@ -48,7 +48,7 @@ func rewriteM3U8(raw []byte, id string) []byte {
}
// Wenn es schon unser API ist, lassen
if strings.Contains(u, "/api/record/preview") {
if strings.Contains(u, "/api/preview") {
out.WriteString(line)
out.WriteByte('\n')
continue
@ -89,7 +89,7 @@ func rewriteAttrURI(line, base string) string {
valTrim := strings.TrimSpace(val)
// 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
}

View File

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

View File

@ -6,14 +6,10 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
@ -22,7 +18,6 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
@ -291,25 +286,18 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
return
}
// Versuche Meta aus dem Video zu extrahieren (FFprobe)
// -> du hast bereits ensureFFprobeAvailable(), getVideoHeightCached(), durationSecondsCached(), etc.
if err := ensureFFprobeAvailable(); err != nil {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
// Dauer
dur, derr := durationSecondsCached(pctx, videoPath)
if derr != nil || dur <= 0 {
// best-effort: nicht blockieren
dur = 0
// Dauer (best effort)
dur := 0.0
if d, derr := durationSecondsCached(pctx, videoPath); derr == nil && d > 0 {
dur = d
}
// Height (und daraus evtl. Width) falls du schon Width helper hast, nimm den.
h, _ := getVideoHeightCached(pctx, videoPath)
// Height/Width optional nicht mehr berechnen (wenn helper entfernt wurde)
h := 0
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
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.
// 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-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")
}
if r.Method == http.MethodOptions {
@ -392,107 +380,6 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
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 ----
resolveOutPath := func() (string, bool) {
// ✅ Wiedergabe über Dateiname (für doneDir / recordDir)
@ -603,24 +490,6 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
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) ----
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
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)
// ---- Quality / Transcode handling ----
// ✅ immer Original-Datei ausliefern (Range-fähig via serveVideoFile)
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)
}
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) {
id := r.URL.Query().Get("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 {
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
@ -2076,10 +1721,18 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
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
_ = os.RemoveAll(trashDir)
_ = os.MkdirAll(trashDir, 0o755)
purgeDurationCacheForPath(src) // falls src noch irgendwo gecacht wäre (optional)
purgeDurationCacheForPath(dst) // optional
notifyDoneChanged()
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/status", recordStatus)
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/stream", recordStream)
api.HandleFunc("/api/record/done/meta", recordDoneMeta)

View File

@ -27,6 +27,7 @@ type AssetsTaskState struct {
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
}
var assetsTaskMu sync.Mutex
@ -84,6 +85,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
StartedAt: now,
FinishedAt: nil,
Error: "",
CurrentFile: "",
}
st := assetsTaskState
assetsTaskMu.Unlock()
@ -141,6 +143,7 @@ func runGenerateMissingAssets(ctx context.Context) {
updateAssetsState(func(st *AssetsTaskState) {
st.Running = false
st.FinishedAt = &now
st.CurrentFile = ""
if err == nil {
// Erfolg: Error leeren
@ -249,6 +252,11 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
// ✅ aktuellen Dateinamen für UI setzen
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
})
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
@ -269,7 +277,7 @@ func runGenerateMissingAssets(ctx context.Context) {
}
// Pfade einmalig über zentralen Helper
_, _, _, metaPath, perr := assetPathsForID(id)
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
updateAssetsState(func(st *AssetsTaskState) {
// 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" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-rrLyu52u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cd67oQ3U.css">
<script type="module" crossorigin src="/assets/index-BjA9ZqZd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZTD4GKM.css">
</head>
<body>
<div id="root"></div>

View File

@ -467,8 +467,26 @@ export default function App() {
}, [])
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null)
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 () => {
@ -2794,7 +2812,7 @@ export default function App() {
</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' ? (
<Downloads
jobs={runningJobs}
@ -2834,7 +2852,7 @@ export default function App() {
setDoneSort(m)
setDonePage(1)
}}
loadMode="all"
loadMode="paged"
/>
) : null}

View File

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

View File

@ -12,6 +12,7 @@ import RecordJobActions from './RecordJobActions'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { subscribeSSE } from '../../lib/sseSingleton'
import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE'
import { useMediaQuery } from '../../lib/useMediaQuery'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
@ -84,6 +85,35 @@ const toMs = (v: unknown): number => {
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 => {
if (r.kind === 'job') {
const j = r.job as any
@ -454,6 +484,7 @@ function DownloadsCardRow({
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full"
/>
</div>
@ -689,6 +720,8 @@ export default function Downloads({
const jobsLive = useRecordJobsSSE(jobs)
const isDesktop = useMediaQuery('(min-width: 640px)', true)
const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false)
@ -914,6 +947,7 @@ export default function Downloads({
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full"
/>
</div>
@ -1212,9 +1246,41 @@ export default function Downloads({
})
.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
}, [jobsLive])
}, [jobsLive, postworkQueueInfoById])
const pendingRows = useMemo<DownloadRow[]>(() => {
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 */}
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
<>
{/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden">
{!isDesktop ? (
/* Mobile: Cards (wirklich nur mobile gemountet) */
<div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? (
<>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
@ -1383,9 +1450,9 @@ export default function Downloads({
</>
) : null}
</div>
{/* Desktop: Tabellen */}
<div className="mt-3 hidden sm:block space-y-4">
) : (
/* Desktop: Tabellen (wirklich nur desktop gemountet) */
<div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
@ -1446,6 +1513,7 @@ export default function Downloads({
</div>
) : null}
</div>
)}
</>
) : (
<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
}
export default function FinishedDownloads({
jobs,
doneJobs,
@ -240,14 +239,63 @@ export default function FinishedDownloads({
const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false)
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string }
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: 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)
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
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 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)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
@ -510,20 +456,33 @@ export default function FinishedDownloads({
const ac = new AbortController()
let alive = true
const finishRefill = () => {
refillInFlightRef.current = false
}
// ✅ Refill läuft
refillInFlightRef.current = true
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) {
;(async () => {
try {
// (Wenn fetchAllDoneJobs selbst setIsLoading macht: reicht das.)
// fetchAllDoneJobs setzt isLoading selbst
await fetchAllDoneJobs(ac.signal)
if (alive) {
refillRetryRef.current = 0
}
} catch {
// ignore
// ignore (Abort/Netzwerk)
} finally {
if (alive) finishRefill()
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}
@ -532,7 +491,7 @@ export default function FinishedDownloads({
;(async () => {
try {
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
// 1) Liste + count holen
const [listRes, metaRes] = await Promise.all([
fetch(
`/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) {
const data = await listRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? data
: []
setOverrideDoneJobs(items)
}
if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const countRaw = Number(meta?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
@ -575,22 +541,30 @@ export default function FinishedDownloads({
if (okAll) {
refillRetryRef.current = 0
} else if (alive && refillRetryRef.current < 2) {
refillRetryRef.current++
} else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
refillRetryRef.current += 1
const retryNo = refillRetryRef.current
window.setTimeout(() => {
if (!ac.signal.aborted) setRefillTick((n) => n + 1)
}, 400 * refillRetryRef.current)
if (!ac.signal.aborted) {
setRefillTick((n) => n + 1)
}
}, 400 * retryNo)
}
} catch {
// Abort / Fehler ignorieren
} finally {
if (alive) setIsLoading(false)
if (alive) {
setIsLoading(false)
finishRefill()
}
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}, [
refillTick,
@ -813,6 +787,15 @@ export default function FinishedDownloads({
// ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
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) => {
setRemovingKeys((prev) => {
const next = new Set(prev)
@ -862,9 +845,6 @@ export default function FinishedDownloads({
const animateRemove = useCallback(
(key: string) => {
// ✅ Refill sofort starten (parallel zur Animation)
queueRefill()
markRemoving(key, true)
cancelRemoveTimer(key)
@ -877,7 +857,7 @@ export default function FinishedDownloads({
removeTimersRef.current.set(key, t)
},
[markDeleted, markRemoving, queueRefill, cancelRemoveTimer]
[markDeleted, markRemoving, cancelRemoveTimer]
)
const releasePlayingFile = useCallback(
@ -913,7 +893,7 @@ export default function FinishedDownloads({
const undoToken = (r as any)?.undoToken
if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file })
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
// optional: nicht als "error" melden, eher info/warn
@ -922,6 +902,7 @@ export default function FinishedDownloads({
// ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key)
queueRefill()
emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill()
@ -941,10 +922,11 @@ export default function FinishedDownloads({
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, from })
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
else setLastAction(null)
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(-1)
@ -967,7 +949,9 @@ export default function FinishedDownloads({
onDeleteJob,
animateRemove,
notify,
setLastAction,
restoreRow,
queueRefill,
emitCountHint,
]
)
@ -997,10 +981,11 @@ export default function FinishedDownloads({
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
// ✅ 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
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1)
@ -1020,7 +1005,9 @@ export default function FinishedDownloads({
releasePlayingFile,
animateRemove,
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(
async (job: RecordJob) => {
const currentFile = baseName(job.output || '')
@ -1112,8 +1213,8 @@ export default function FinishedDownloads({
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(
@ -1127,7 +1228,7 @@ export default function FinishedDownloads({
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
return { ...job, output: dir + override }
},
[renamedFiles, baseName]
[renamedFiles]
)
const doneJobsPage = overrideDoneJobs ?? doneJobs
@ -1214,12 +1315,8 @@ export default function FinishedDownloads({
useEffect(() => {
const onReload = () => {
// ✅ wichtig: das soll "ALL neu laden" triggern
// Option A (wenn vorhanden):
if (refillInFlightRef.current) return
queueRefill()
// Option B (falls du kein queueRefill hast):
// void fetchAllDoneJobs(new AbortController().signal)
}
window.addEventListener('finished-downloads:reload', onReload as any)
@ -1424,6 +1521,20 @@ export default function FinishedDownloads({
// ✅ Hooks immer zuerst unabhängig von rows
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(() => {
if (!isSmall) {
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
@ -1739,7 +1850,7 @@ export default function FinishedDownloads({
Lade Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte einen Moment.
Bitte warte einen Moment.
</div>
</div>
@ -1799,48 +1910,49 @@ export default function FinishedDownloads({
) : (
<>
{view === 'cards' && (
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
inlinePlay={inlinePlay}
setInlinePlay={setInlinePlay}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
swipeRefs={swipeRefs}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
/>
<div className={isSmall ? 'mt-8' : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
inlinePlay={inlinePlay}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
swipeRefs={swipeRefs}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
/>
</div>
)}
{view === 'table' && (

View File

@ -30,7 +30,6 @@ type Props = {
durations: Record<string, number>
teaserKey: string | null
inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string>
keepingKeys: Set<string>
@ -103,8 +102,6 @@ export default function FinishedDownloadsCardsView({
blurPreviews,
teaserKey,
inlinePlay,
setInlinePlay,
deletingKeys,
keepingKeys,
removingKeys,
@ -152,235 +149,432 @@ export default function FinishedDownloadsCardsView({
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 (
<div className="relative">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const k = keyFor(j)
const inlineActive = inlinePlay?.key === k
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
{!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const { k, cardInner } = renderCardItem(j)
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)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
// untere Karten nur Deko (keine Interaktion)
if (!isTop) {
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)]
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={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>
// oberste Karte: echte SwipeCard (wie bisher)
return (
<div
key={k}
className="absolute inset-x-0 top-0"
style={{
zIndex: 30,
transform: `translateY(${y}px) scale(${scale})`,
transformOrigin: 'top center',
}}
>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
</div>
</div>
</div>
</div>
)
})
.reverse() /* zuerst hinten rendern, oben zuletzt */}
</div>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */}
{mobileAllRows.length > mobileStackDepth ? (
<div className="sr-only" aria-hidden="true">
{mobileAllRows.slice(mobileStackDepth).map((j) => {
const k = keyFor(j)
<div className="mt-0.5 flex items-center gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
return (
<div key={`preload-still-${k}`} className="relative aspect-video">
<FinishedVideoPreview
job={j}
getFileName={baseName}
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">
{stripHotPrefix(fileRaw) || '—'}
</span>
// ✅ Nur Previewbild laden kein Teaser-Video
animated={false}
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 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>
</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>
)
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>
)}
</div>
)}
{isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">

View File

@ -13,6 +13,22 @@ import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
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 = {
rows: RecordJob[]
@ -46,7 +62,7 @@ type Props = {
lower: (s: string) => string
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
modelsByKey: Record<string, ModelFlags>
activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
@ -56,6 +72,41 @@ type Props = {
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({
rows,
isLoading,
@ -65,8 +116,8 @@ export default function FinishedDownloadsGalleryView({
teaserAudio,
hoverTeaserKey,
teaserKey,
handleDuration,
handleDuration,
keyFor,
baseName,
modelNameFromOutput,
@ -94,7 +145,6 @@ export default function FinishedDownloadsGalleryView({
onToggleLike,
onToggleWatch,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
@ -132,179 +182,422 @@ export default function FinishedDownloadsGalleryView({
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
? (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 =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
? (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 }
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 (
<div className="relative">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => {
const k = keyFor(j)
{rows.map((j) => {
const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const tags = parseTags(flags?.tags)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.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
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)
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
onHoverPreviewKeyChange?.(null)
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* Thumb */}
<div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
muted={previewMuted}
popoverMuted={previewMuted}
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={
hideTeaserUnderOverlay
? false
: teaserPlayback === 'all'
? true
: teaserPlayback === 'hover'
? teaserKey === k
: false
}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
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>
) : null}
{/* Bottom overlay meta */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
<div className="flex items-end justify-end gap-2">
{/* Right bottom: Duration + Resolution(label) + Size */}
<div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span>
{resLabel ? (
<span
className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium"
title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}
>
{resLabel}
</span>
) : null}
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<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">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
</div>
) : null}
{/* Actions (top-right) */}
<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>
</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}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber ? (
<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">
<GalleryPreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
stepSeconds={scrubberStepSeconds}
/>
</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">
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
{/* Meta-Overlay im Video: unten rechts */}
<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
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>
</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>
{isLoading && rows.length === 0 ? (
@ -317,4 +610,4 @@ export default function FinishedDownloadsGalleryView({
) : null}
</div>
)
}
}

View File

@ -66,6 +66,15 @@ export type FinishedVideoPreviewProps = {
popoverMuted?: 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({
@ -101,6 +110,9 @@ export default function FinishedVideoPreview({
muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
alwaysLoadStill = false,
teaserPreloadEnabled = false,
teaserPreloadRootMargin = '700px 0px',
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
@ -275,9 +287,10 @@ export default function FinishedVideoPreview({
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [nearView, setNearView] = useState(false)
// ✅ 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
const [localTick, setLocalTick] = useState(0)
@ -301,48 +314,6 @@ export default function FinishedVideoPreview({
return stripHot(base).trim()
}, [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)
const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file])
@ -470,7 +441,7 @@ export default function FinishedVideoPreview({
}
}, [file])
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
// --- IntersectionObserver: echtes inView (für Playback)
useEffect(() => {
const el = rootRef.current
if (!el) return
@ -483,7 +454,7 @@ export default function FinishedVideoPreview({
},
{
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()
}, [])
// --- 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"
useEffect(() => {
if (!animated) return
@ -528,8 +533,8 @@ export default function FinishedVideoPreview({
const thumbSrc = useMemo(() => {
if (!previewId) return ''
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
}, [previewId, thumbTimeSec, v])
const teaserSrc = useMemo(() => {
@ -538,6 +543,30 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [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)
useEffect(() => {
let did = false
@ -555,6 +584,49 @@ export default function FinishedVideoPreview({
if (did) setMetaLoaded(true)
}, [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
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
@ -589,24 +661,16 @@ export default function FinishedVideoPreview({
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// --- 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 =
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
const shouldLoadAssets = shouldPreloadAnimatedAssets
const teaserCanPrewarm =
animated &&
inView &&
!document.hidden &&
videoOk &&
animatedMode === 'teaser' &&
teaserOk &&
Boolean(teaserSrc) &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
((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)
shouldPreloadAnimatedAssets
// ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef =
@ -826,7 +890,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
inView &&
(nearView || inView) &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
@ -846,10 +910,10 @@ export default function FinishedVideoPreview({
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
>
{/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadAssets && thumbSrc && thumbOk ? (
{shouldLoadStill && thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading="lazy"
loading={alwaysLoadStill ? 'eager' : 'lazy'}
decoding="async"
alt={file}
className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
@ -886,6 +950,24 @@ export default function FinishedVideoPreview({
/>
) : 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 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video
@ -907,7 +989,7 @@ export default function FinishedVideoPreview({
playsInline
autoPlay
loop
preload="metadata"
preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
@ -941,7 +1023,7 @@ export default function FinishedVideoPreview({
<div
aria-hidden="true"
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
'h-0.5 group-hover:h-1.5',
'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
finishedAt?: string
error?: string
currentFile?: string
}
type Progress = { done: number; total: number }
type Progress = { done: number; total: number; currentFile?: string }
type Props = {
onFinished?: () => void
@ -152,11 +153,17 @@ export default function GenerateAssetsTask({
if (st?.running) {
const ac = ensureControllerCreated()
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()
if (errText && errText !== lastErrorRef.current) {
// ✅ Abbruch ist kein "Fehler"-Event für die UI
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)
}
@ -184,7 +191,11 @@ export default function GenerateAssetsTask({
armTaskList(ac)
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) {
// Start fehlgeschlagen -> Controller/Flags zurücksetzen

View File

@ -312,6 +312,8 @@ type StoredModel = {
id: string
modelKey: string
tags?: string | null
lastSeenOnline?: boolean | null
lastSeenOnlineAt?: string
favorite?: boolean
watching?: boolean
liked?: boolean | null
@ -699,6 +701,11 @@ export default function ModelDetails({
const about = stripHtmlToText(bio?.about_me)
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 photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
@ -901,16 +908,16 @@ export default function ModelDetails({
{showPill}
</span>
) : null}
{bioStatus ? (
{effectivePresenceLabel ? (
<span
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-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15'
)}
>
{bioStatus}
{effectivePresenceLabel}
</span>
) : null}
@ -1201,7 +1208,7 @@ export default function ModelDetails({
aria-hidden
className={cn(
'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>
@ -1499,6 +1506,19 @@ export default function ModelDetails({
· Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)}
</span>
) : 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>
) : (
'—'

View File

@ -21,6 +21,9 @@ type Props = {
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
thumbsWebpUrl?: string | null
thumbsCandidates?: Array<string | null | undefined>
}
export default function ModelPreview({
@ -28,15 +31,18 @@ export default function ModelPreview({
thumbTick,
autoTickMs = 10_000,
blur = false,
className,
alignStartAt,
alignEndAt = null,
alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
className,
thumbsWebpUrl,
thumbsCandidates,
}: Props) {
const blurCls = blur ? 'blur-md' : ''
const CONTROLBAR_H = 30
const rootRef = useRef<HTMLDivElement | null>(null)
@ -48,13 +54,16 @@ export default function ModelPreview({
const inViewRef = useRef(false)
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 fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
@ -62,12 +71,37 @@ export default function ModelPreview({
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
useEffect(() => {
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)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
@ -161,7 +195,7 @@ export default function ModelPreview({
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
// ✅ tick Quelle
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
@ -180,58 +214,82 @@ export default function ModelPreview({
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => {
setImgError(false)
setDirectImgError(false)
setApiImgError(false)
}, [frozenTick])
// bei Job-Wechsel reset
useEffect(() => {
// bei Job-Wechsel reset
hadSuccess.current = false
fastTries.current = 0
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) {
setLocalTick((x) => x + 1)
}
}, [jobId])
}, [jobId, thumbsCandidatesKey])
const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, frozenTick]
)
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]
)
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 (
<HoverPopover
content={(open, { close }) =>
open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className="w-full h-full relative z-0" />
<div
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">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
<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>
<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>
)
@ -244,9 +302,9 @@ export default function ModelPreview({
className || 'w-full h-full',
].join(' ')}
>
{!imgError ? (
{!apiImgError ? (
<img
src={thumb}
src={currentImgSrc}
loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'}
decoding="async"
@ -256,10 +314,20 @@ export default function ModelPreview({
hadSuccess.current = true
fastTries.current = 0
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={() => {
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 (!inViewRef.current || !pageVisibleRef.current) return
@ -276,6 +344,7 @@ export default function ModelPreview({
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setApiImgError(false) // API erneut probieren
setLocalTick((x) => x + 1)
}, fastRetryMs)
}}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -3,7 +3,6 @@
'use client'
import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client'
@ -118,24 +117,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onSwipeLeft,
onSwipeRight,
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,
thresholdRatio = 0.28,
ignoreFromBottomPx = 72,
@ -400,54 +381,48 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[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 (
<div
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) */}
<div
ref={cardRef}
className="relative"
style={{
// ✅ iOS Fix: kein transform im Idle-Zustand, sonst sind Video-Controls oft nicht tappbar
transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,
// ✅ iOS Fix: im Idle kein transform
transform:
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y',
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,
filter:
dx !== 0
? `saturate(${1 + reveal * 0.08}) brightness(${1 + reveal * 0.02})`
: undefined,
}}
onPointerDown={(e) => {
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
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
// NEW: wenn true -> wir lassen Tap/DoubleTap zu, aber starten niemals Swipe/Drag
// Tap/DoubleTap erlauben, aber niemals swipe starten
let noSwipe = false
const root = e.currentTarget as HTMLElement
@ -477,14 +452,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
e.clientY <= vr.bottom
if (inVideo) {
// unten frei für Timeline/Scrub
const fromBottomVideo = vr.bottom - e.clientY
const scrubZonePx = 72
if (fromBottomVideo <= scrubZonePx) {
noSwipe = true
tapIgnored = true // SingleTap nicht in unsere Logik -> Video controls sollen gewinnen
tapIgnored = true
} else {
// Swipe nur aus den Seitenrändern
const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX
@ -492,18 +465,16 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (!inEdge) {
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 fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
noSwipe = true
// tapIgnored NICHT automatisch setzen Footer-Taps sollen funktionieren
}
pointer.current = {
@ -524,33 +495,24 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return
const ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y
// Erst entscheiden ob wir überhaupt draggen
if (!pointer.current.dragging) {
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
pointer.current.id = null
return
}
// "Dead zone" bis wirklich horizontal gedrückt wird
if (Math.abs(ddx) < 12) return
// ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true
// ✅ während horizontalem Drag keine Scroll-Gesten verhandeln
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
// ✅ Anim nur 1x beim Drag-Start deaktivieren
setAnimMs(0)
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
try {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
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 nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => {
if (prev === nextDir) return prev
// mini feedback beim “arming”
if (nextDir) {
try { navigator.vibrate?.(10) } catch {}
try {
navigator.vibrate?.(10)
} catch {}
}
return nextDir
})
@ -606,15 +568,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
pointer.current.dragging = false
pointer.current.captured = false
// Capture sauber lösen (falls gesetzt)
if (wasCaptured) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {}
}
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) {
const now = Date.now()
const last = lastTapRef.current
@ -626,26 +587,20 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const isDouble =
Boolean(onDoubleTap) &&
last &&
(now - last.t) <= doubleTapMs &&
now - last.t <= doubleTapMs &&
isNear
if (isDouble) {
// 2nd tap: Single-Tap abbrechen + DoubleTap ausführen
lastTapRef.current = null
clearTapTimer()
if (doubleTapBusyRef.current) return
doubleTapBusyRef.current = true
// ✅ FX sofort anlegen
try {
runHotFx(e.clientX, e.clientY)
} catch (err) {
// optional zum Debuggen:
// console.error('runHotFx failed', err)
}
} catch {}
// ✅ Toggle erst NACH dem nächsten Paint-Frame starten
requestAnimationFrame(() => {
;(async () => {
try {
@ -659,9 +614,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return
}
// ✅ nur SingleTap "blocken" wenn tapIgnored DoubleTap bleibt möglich
if (wasTapIgnored) {
// wichtig: lastTapRef trotzdem setzen, damit DoubleTap beim 2. Tap klappt
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
@ -671,10 +624,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return
}
// ✅ NUR bei SingleTap soft resetten
softResetForTap()
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
@ -689,7 +640,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const finalDx = dxRef.current
// rAF cleanup
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
@ -719,7 +669,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
dxRef.current = 0
try { (e.currentTarget as HTMLElement).style.touchAction = 'pan-y' } catch {}
try {
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
} catch {}
reset()
}}
@ -727,14 +679,32 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
<div className="relative">
<div className="relative z-10">{children}</div>
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
{/* Overlay über dem Inhalt (subtiler, progressiv) */}
<div
className={cn(
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg',
armedDir === 'right' && 'bg-emerald-500/20 opacity-100',
armedDir === 'left' && 'bg-red-500/20 opacity-100',
armedDir === null && 'opacity-0'
)}
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
style={{
opacity: dx === 0 ? 0 : 0.12 + revealSoft * 0.18,
background:
swipeDir === 'right'
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.04) 45%, rgba(0,0,0,0) 100%)'
: 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>

View File

@ -212,7 +212,7 @@ export default function Table<T>({
<div
className={cn(
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">

View File

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