updated
This commit is contained in:
parent
478e2696da
commit
e8bd9e9d68
@ -1,3 +1,5 @@
|
||||
// backend\chaturbate_biocontext.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@ -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
299
backend/disk_guard.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
152
backend/generate_sprite.go
Normal 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
|
||||
}
|
||||
297
backend/main.go
297
backend/main.go
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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")
|
||||
|
||||
122
backend/record_preview_scrubber.go
Normal file
122
backend/record_preview_scrubber.go
Normal 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")
|
||||
}
|
||||
67
backend/record_preview_sprite.go
Normal file
67
backend/record_preview_sprite.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
1
backend/web/dist/assets/index-BZTD4GKM.css
vendored
Normal file
1
backend/web/dist/assets/index-BZTD4GKM.css
vendored
Normal file
File diff suppressed because one or more lines are too long
416
backend/web/dist/assets/index-BjA9ZqZd.js
vendored
Normal file
416
backend/web/dist/assets/index-BjA9ZqZd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-Cd67oQ3U.css
vendored
1
backend/web/dist/assets/index-Cd67oQ3U.css
vendored
File diff suppressed because one or more lines are too long
413
backend/web/dist/assets/index-rrLyu52u.js
vendored
413
backend/web/dist/assets/index-rrLyu52u.js
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-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>
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\Button.tsx
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'soft'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' && (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 du’s 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',
|
||||
|
||||
188
frontend/src/components/ui/GalleryPreviewScrubber.tsx
Normal file
188
frontend/src/components/ui/GalleryPreviewScrubber.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
'—'
|
||||
|
||||
@ -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
@ -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]))
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
33
frontend/src/lib/useMediaQuery.ts
Normal file
33
frontend/src/lib/useMediaQuery.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user