From ca237ef2daa82c216ac48eef2ac1ad67f4ebb4d5 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:22:11 +0100 Subject: [PATCH] updated --- .gitignore | 1 + backend/main.go | 1160 ++++++++++++++--- frontend/src/App.tsx | 20 +- .../src/components/ui/FinishedDownloads.tsx | 628 ++------- .../ui/FinishedDownloadsCardsView.tsx | 420 ++++++ .../ui/FinishedDownloadsGalleryView.tsx | 231 ++++ .../ui/FinishedDownloadsTableView.tsx | 44 + .../components/ui/FinishedVideoPreview.tsx | 117 +- frontend/src/components/ui/Player.tsx | 26 +- .../src/components/ui/RunningDownloads.tsx | 75 +- 10 files changed, 1969 insertions(+), 753 deletions(-) create mode 100644 frontend/src/components/ui/FinishedDownloadsCardsView.tsx create mode 100644 frontend/src/components/ui/FinishedDownloadsGalleryView.tsx create mode 100644 frontend/src/components/ui/FinishedDownloadsTableView.tsx diff --git a/.gitignore b/.gitignore index bed31ff..3fd8728 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ backend/recorder_settings.json records .DS_Store +backend/generated diff --git a/backend/main.go b/backend/main.go index 26745e8..35af620 100644 --- a/backend/main.go +++ b/backend/main.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "net/url" "os" @@ -43,7 +44,6 @@ const ( type RecordJob struct { ID string `json:"id"` - model string `json:"model"` SourceURL string `json:"sourceUrl"` Output string `json:"output"` Status JobStatus `json:"status"` @@ -64,9 +64,26 @@ type RecordJob struct { previewJpegAt time.Time `json:"-"` previewGen bool `json:"-"` + // ✅ Frontend Progress beim Stop/Finalize + Phase string `json:"phase,omitempty"` // stopping | remuxing | moving | finalizing + Progress int `json:"progress,omitempty"` // 0..100 + cancel context.CancelFunc `json:"-"` } +type dummyResponseWriter struct { + h http.Header +} + +func (d *dummyResponseWriter) Header() http.Header { + if d.h == nil { + d.h = make(http.Header) + } + return d.h +} +func (d *dummyResponseWriter) Write(b []byte) (int, error) { return len(b), nil } +func (d *dummyResponseWriter) WriteHeader(statusCode int) {} + var ( jobs = map[string]*RecordJob{} jobsMu = sync.Mutex{} @@ -75,6 +92,59 @@ var ( // ffmpeg-Binary suchen (env, neben EXE, oder PATH) var ffmpegPath = detectFFmpegPath() +var ffprobePath = detectFFprobePath() + +func detectFFprobePath() string { + // 1) Env-Override + if p := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); p != "" { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p + } + + // 2) Neben ffmpeg.exe (gleicher Ordner) + fp := strings.TrimSpace(ffmpegPath) + if fp != "" && fp != "ffmpeg" { + dir := filepath.Dir(fp) + ext := "" + if strings.HasSuffix(strings.ToLower(fp), ".exe") { + ext = ".exe" + } + c := filepath.Join(dir, "ffprobe"+ext) + if fi, err := os.Stat(c); err == nil && !fi.IsDir() { + return c + } + } + + // 3) Im EXE-Ordner + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(exeDir, "ffprobe"), + filepath.Join(exeDir, "ffprobe.exe"), + } + for _, c := range candidates { + if fi, err := os.Stat(c); err == nil && !fi.IsDir() { + return c + } + } + } + + // 4) PATH + if lp, err := exec.LookPath("ffprobe"); err == nil { + if abs, err2 := filepath.Abs(lp); err2 == nil { + return abs + } + return lp + } + + return "ffprobe" +} + +// Preview/Teaser-Generierung nicht unendlich parallel +var genSem = make(chan struct{}, 2) + type durEntry struct { size int64 mod time.Time @@ -90,6 +160,20 @@ var startedAtFromFilenameRe = regexp.MustCompile( `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, ) +func setJobPhase(job *RecordJob, phase string, progress int) { + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + + jobsMu.Lock() + job.Phase = phase + job.Progress = progress + jobsMu.Unlock() +} + func durationSecondsCached(ctx context.Context, path string) (float64, error) { fi, err := os.Stat(path) if err != nil { @@ -103,28 +187,46 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) { } durCache.mu.Unlock() - // ffprobe (oder notfalls ffmpeg -i parsen) - cmd := exec.CommandContext(ctx, "ffprobe", + // 1) ffprobe (bevorzugt) + cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, ) out, err := cmd.Output() - if err != nil { - return 0, err + if err == nil { + s := strings.TrimSpace(string(out)) + sec, err2 := strconv.ParseFloat(s, 64) + if err2 == nil && sec > 0 { + durCache.mu.Lock() + durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec} + durCache.mu.Unlock() + return sec, nil + } } - s := strings.TrimSpace(string(out)) - sec, err := strconv.ParseFloat(s, 64) - if err != nil || sec <= 0 { - return 0, fmt.Errorf("invalid duration: %q", s) + // 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen + cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path) + b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da + text := string(b) + + re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) + m := re.FindStringSubmatch(text) + if len(m) != 4 { + return 0, fmt.Errorf("duration not found") + } + hh, _ := strconv.ParseFloat(m[1], 64) + mm, _ := strconv.ParseFloat(m[2], 64) + ss, _ := strconv.ParseFloat(m[3], 64) + sec := hh*3600 + mm*60 + ss + if sec <= 0 { + return 0, fmt.Errorf("invalid duration") } durCache.mu.Lock() durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec} durCache.mu.Unlock() - return sec, nil } @@ -215,6 +317,39 @@ func detectFFmpegPath() string { return "ffmpeg" } +func removeGeneratedForID(id string) { + thumbsRoot, _ := generatedThumbsRoot() + teaserRoot, _ := generatedTeaserRoot() + + _ = os.RemoveAll(filepath.Join(thumbsRoot, id)) + _ = os.Remove(filepath.Join(teaserRoot, id+".mp4")) +} + +func renameGenerated(oldID, newID string) { + thumbsRoot, _ := generatedThumbsRoot() + teaserRoot, _ := generatedTeaserRoot() + + oldThumb := filepath.Join(thumbsRoot, oldID) + newThumb := filepath.Join(thumbsRoot, newID) + if _, err := os.Stat(oldThumb); err == nil { + if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) { + _ = os.Rename(oldThumb, newThumb) + } else { + _ = os.RemoveAll(oldThumb) + } + } + + oldTeaser := filepath.Join(teaserRoot, oldID+".mp4") + newTeaser := filepath.Join(teaserRoot, newID+".mp4") + if _, err := os.Stat(oldTeaser); err == nil { + if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) { + _ = os.Rename(oldTeaser, newTeaser) + } else { + _ = os.Remove(oldTeaser) + } + } +} + func loadSettings() { b, err := os.ReadFile(settingsFile) if err == nil { @@ -244,6 +379,10 @@ func loadSettings() { // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen ffmpegPath = detectFFmpegPath() fmt.Println("🔍 ffmpegPath:", ffmpegPath) + + ffprobePath = detectFFprobePath() + fmt.Println("🔍 ffprobePath:", ffprobePath) + } func saveSettingsToDisk() { @@ -297,6 +436,9 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { ffmpegPath = detectFFmpegPath() fmt.Println("🔍 ffmpegPath (nach Save):", ffmpegPath) + ffprobePath = detectFFprobePath() + fmt.Println("🔍 ffprobePath (nach Save):", ffprobePath) + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(getSettings()) return @@ -599,8 +741,8 @@ func extractLastFrameJPEG(path string) ([]byte, error) { "-sseof", "-0.1", "-i", path, "-frames:v", "1", - "-vf", "scale=320:-2", - "-q:v", "7", + "-vf", "scale=720:-2", + "-q:v", "10", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", @@ -630,8 +772,8 @@ func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) { "-ss", seek, "-i", path, "-frames:v", "1", - "-vf", "scale=320:-2", - "-q:v", "7", + "-vf", "scale=720:-2", + "-q:v", "10", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", @@ -691,6 +833,94 @@ func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) { return img, nil } +func generatedThumbsRoot() (string, error) { + return resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) +} +func generatedTeaserRoot() (string, error) { + return resolvePathRelativeToApp(filepath.Join("generated", "teaser")) +} + +func ensureGeneratedDirs() error { + thumbs, err := generatedThumbsRoot() + if err != nil { + return err + } + teaser, err := generatedTeaserRoot() + if err != nil { + return err + } + if err := os.MkdirAll(thumbs, 0o755); err != nil { + return err + } + if err := os.MkdirAll(teaser, 0o755); err != nil { + return err + } + return nil +} + +func sanitizeID(id string) (string, error) { + id = strings.TrimSpace(id) + if id == "" { + return "", fmt.Errorf("id fehlt") + } + if strings.ContainsAny(id, `/\`) { + return "", fmt.Errorf("ungültige id") + } + return id, nil +} + +func idFromVideoPath(videoPath string) string { + name := filepath.Base(strings.TrimSpace(videoPath)) + return strings.TrimSuffix(name, filepath.Ext(name)) +} + +func atomicWriteFile(dst string, data []byte) error { + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + _ = tmp.Chmod(0o644) + + _, werr := tmp.Write(data) + cerr := tmp.Close() + + if werr != nil { + _ = os.Remove(tmpName) + return werr + } + if cerr != nil { + _ = os.Remove(tmpName) + return cerr + } + return os.Rename(tmpName, dst) +} + +func findFinishedFileByID(id string) (string, error) { + s := getSettings() + recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) + doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) + + candidates := []string{ + filepath.Join(doneAbs, id+".mp4"), + filepath.Join(doneAbs, id+".ts"), + filepath.Join(recordAbs, id+".mp4"), + filepath.Join(recordAbs, id+".ts"), + } + + for _, p := range candidates { + fi, err := os.Stat(p) + if err == nil && !fi.IsDir() && fi.Size() > 0 { + return p, nil + } + } + return "", fmt.Errorf("not found") +} + func recordPreview(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { @@ -782,106 +1012,296 @@ func recordPreview(w http.ResponseWriter, r *http.Request) { // Fallback: Preview für fertige Dateien nur anhand des Dateistamms (id) func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) { - id = strings.TrimSpace(id) - if id == "" { - http.Error(w, "id fehlt", http.StatusBadRequest) - return - } - if strings.ContainsAny(id, `/\`) { - http.Error(w, "ungültige id", http.StatusBadRequest) + var err error + id, err = sanitizeID(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - s := getSettings() - recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) - doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) - - candidates := []string{ - filepath.Join(doneAbs, id+".mp4"), - filepath.Join(doneAbs, id+".ts"), - filepath.Join(recordAbs, id+".mp4"), - filepath.Join(recordAbs, id+".ts"), - } - - var outPath string - for _, p := range candidates { - fi, err := os.Stat(p) - if err == nil && !fi.IsDir() && fi.Size() > 0 { - outPath = p - break - } - } - if outPath == "" { + outPath, err := findFinishedFileByID(id) + if err != nil { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } - previewDir := filepath.Join(os.TempDir(), "rec_preview", id) - if err := os.MkdirAll(previewDir, 0o755); err != nil { - http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError) + if err := ensureGeneratedDirs(); err != nil { + http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } - // ✅ Cleanup: hält Cache klein + entfernt .part - // Empfehlung: 250 Frames pro Video, max 14 Tage behalten - const maxFrames = 250 - const maxAge = 14 * 24 * time.Hour - prunePreviewCacheDir(previewDir, maxFrames, maxAge) + thumbsRoot, _ := generatedThumbsRoot() + thumbDir := filepath.Join(thumbsRoot, id) + _ = os.MkdirAll(thumbDir, 0o755) - // ✅ Frame bei Zeitposition t + Disk-Cache + // ✅ Frame-Caching für t=... (für alte "clips" Logik) if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" { if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 { - key := int(sec*10 + 0.5) // 0.1s Raster, gerundet - if key < 0 { - key = 0 + secI := int64(sec + 0.5) // auf ~Sekunden runden + if secI < 0 { + secI = 0 } - cachedFramePath := filepath.Join(previewDir, fmt.Sprintf("t_%09d.jpg", key)) - - if fi, err := os.Stat(cachedFramePath); err == nil && !fi.IsDir() && fi.Size() > 0 { - servePreviewJPEGFile(w, r, cachedFramePath) + framePath := filepath.Join(thumbDir, fmt.Sprintf("t_%d.jpg", secI)) + if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 { + servePreviewJPEGFile(w, r, framePath) return } - actualSec := float64(key) / 10.0 - if img, err := extractFrameAtTimeJPEG(outPath, actualSec); err == nil && len(img) > 0 { - tmp := cachedFramePath + ".part" - _ = os.WriteFile(tmp, img, 0o644) - _ = os.Rename(tmp, cachedFramePath) - - // nach neuem Write einmal kurz pruning (optional, aber hält hartes Limit) - prunePreviewCacheDir(previewDir, maxFrames, maxAge) - + img, err := extractFrameAtTimeJPEG(outPath, float64(secI)) + if err == nil && len(img) > 0 { + _ = atomicWriteFile(framePath, img) servePreviewJPEGBytes(w, img) return } - // wenn ffmpeg scheitert -> unten statisches preview + // wenn das scheitert, unten weiter mit preview.jpg } } - // Statisches preview.jpg (Fallback, gecached) - jpegPath := filepath.Join(previewDir, "preview.jpg") - if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 { - servePreviewJPEGFile(w, r, jpegPath) + // ✅ Statisches Preview (einmalig) -> generated/thumbs//preview.jpg + previewJpg := filepath.Join(thumbDir, "preview.jpg") + if fi, err := os.Stat(previewJpg); err == nil && !fi.IsDir() && fi.Size() > 0 { + servePreviewJPEGFile(w, r, previewJpg) return } - img, err := extractLastFrameJPEG(outPath) - if err != nil { - img2, err2 := extractFirstFrameJPEG(outPath) - if err2 != nil { - http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError) - return - } - img = img2 + // Besseres Preview: wenn Duration bekannt, nimm Mitte; sonst fallback + genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + var t float64 = 0 + if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 { + t = dur * 0.5 } - tmp := jpegPath + ".part" - _ = os.WriteFile(tmp, img, 0o644) - _ = os.Rename(tmp, jpegPath) + img, err := extractFrameAtTimeJPEG(outPath, t) + if err != nil || len(img) == 0 { + img, err = extractLastFrameJPEG(outPath) + if err != nil || len(img) == 0 { + img, err = extractFirstFrameJPEG(outPath) + if err != nil || len(img) == 0 { + http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError) + return + } + } + } + _ = atomicWriteFile(previewJpg, img) servePreviewJPEGBytes(w, img) } +func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { + f, err := openForReadShareDelete(path) + if err != nil { + http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil || fi.IsDir() || fi.Size() == 0 { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + + w.Header().Set("Cache-Control", "public, max-age=31536000") + w.Header().Set("Content-Type", "video/mp4") + http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) +} + +func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { + if durSec <= 0 { + durSec = 8 + } + if startSec < 0 { + startSec = 0 + } + + // temp schreiben -> rename + tmp := outPath + ".tmp.mp4" + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", startSec), + "-i", srcPath, + "-t", fmt.Sprintf("%.3f", durSec), + "-vf", "scale=720:-2", + "-an", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "28", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + + // ✅ WICHTIG: Output-Format festnageln, weil tmp auf ".part" endet + "-f", "mp4", + + tmp, + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + + _ = os.Remove(outPath) + return os.Rename(tmp, outPath) +} + +func generatedTeaser(w http.ResponseWriter, r *http.Request) { + id, err := sanitizeID(r.URL.Query().Get("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := ensureGeneratedDirs(); err != nil { + http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) + return + } + + teaserRoot, _ := generatedTeaserRoot() + + // ✅ neuer Name + teaserPath := filepath.Join(teaserRoot, id+"_teaser.mp4") + + // ✅ optional: Legacy-Name unterstützen (falls bereits welche existieren) + legacyPath := filepath.Join(teaserRoot, id+".mp4") + + // Cache hit (neu) + if fi, err := os.Stat(teaserPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + serveTeaserFile(w, r, teaserPath) + return + } + + // Cache hit (legacy) + if fi, err := os.Stat(legacyPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + serveTeaserFile(w, r, legacyPath) + return + } + + // Quelle finden + srcPath, err := findFinishedFileByID(id) + if err != nil { + http.Error(w, "teaser nicht verfügbar", http.StatusNotFound) + return + } + + // Generieren (limitiert parallel) + genSem <- struct{}{} + defer func() { <-genSem }() + + genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + if err := os.MkdirAll(filepath.Dir(teaserPath), 0o755); err != nil { + http.Error(w, "teaser dir error: "+err.Error(), http.StatusInternalServerError) + return + } + + // ✅ NEU: Teaser aus mehreren 1s-Clips erzeugen + if err := generateTeaserClipsMP4(genCtx, srcPath, teaserPath, 1.0, 18); err != nil { + http.Error(w, "teaser erzeugen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + serveTeaserFile(w, r, teaserPath) +} + +func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error { + if clipLenSec <= 0 { + clipLenSec = 1 + } + if maxClips <= 0 { + maxClips = 18 + } + + // Dauer holen (einmalig; wird gecached) + dur, _ := durationSecondsCached(ctx, srcPath) + + // Wenn Dauer unbekannt/zu klein: einfach ab 0 ein kurzes Stück + if !(dur > 0) || dur <= clipLenSec+0.2 { + return generateTeaserMP4(ctx, srcPath, outPath, 0, math.Min(8, math.Max(clipLenSec, dur))) + } + + // Anzahl Clips ähnlich wie deine Frontend-"clips"-Logik: + // mind. 8, max. maxClips, aber nicht absurd groß + count := int(math.Floor(dur)) + if count < 8 { + count = 8 + } + if count > maxClips { + count = maxClips + } + + span := math.Max(0.1, dur-clipLenSec) + base := math.Min(0.25, span*0.02) + + starts := make([]float64, 0, count) + for i := 0; i < count; i++ { + t := (float64(i)/float64(count))*span + base + if t < 0.05 { + t = 0.05 + } + if t > dur-0.05-clipLenSec { + t = math.Max(0, dur-0.05-clipLenSec) + } + starts = append(starts, t) + } + + // temp schreiben -> rename (WICHTIG: temp endet auf .mp4, sonst Muxer-Error) + tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4" + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + } + + // Mehrere Inputs: gleiche Datei, aber je Clip mit eigenem -ss/-t + for _, t := range starts { + args = append(args, + "-ss", fmt.Sprintf("%.3f", t), + "-t", fmt.Sprintf("%.3f", clipLenSec), + "-i", srcPath, + ) + } + + // filter_complex: jedes Segment angleichen + concat + var fc strings.Builder + for i := range starts { + // setpts: jedes Segment startet bei 0 + fmt.Fprintf(&fc, "[%d:v]scale=720:-2,setsar=1,setpts=PTS-STARTPTS,format=yuv420p[v%d];", i, i) + } + for i := range starts { + fmt.Fprintf(&fc, "[v%d]", i) + } + fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts)) + + args = append(args, + "-filter_complex", fc.String(), + "-map", "[v]", + "-an", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "28", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + tmp, + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg teaser clips failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + + _ = os.Remove(outPath) + return os.Rename(tmp, outPath) +} + func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) { entries, err := os.ReadDir(previewDir) if err != nil { @@ -1108,8 +1528,8 @@ func extractFirstFrameJPEG(path string) ([]byte, error) { "-loglevel", "error", "-i", path, "-frames:v", "1", - "-vf", "scale=320:-2", - "-q:v", "7", + "-vf", "scale=720:-2", + "-q:v", "10", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", @@ -1257,6 +1677,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore { mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) + mux.HandleFunc("/api/generated/teaser", generatedTeaser) + modelsPath, _ := resolvePathRelativeToApp("data/models_store.db") fmt.Println("📦 Models DB:", modelsPath) @@ -1380,28 +1802,13 @@ func hasChaturbateCookies(cookieStr string) bool { } func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { - defer func() { - now := time.Now() - jobsMu.Lock() - defer jobsMu.Unlock() - - job.EndedAt = &now - - // ✅ "Dauer" = Laufzeit (Recording Runtime), nicht ffprobe - if job.StartedAt.After(time.Time{}) { - sec := now.Sub(job.StartedAt).Seconds() - if sec > 0 { - job.DurationSeconds = sec - } - } - }() - hc := NewHTTPClient(req.UserAgent) provider := detectProvider(req.URL) var err error now := time.Now() + // ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ---- switch provider { case "chaturbate": if !hasChaturbateCookies(req.Cookie) { @@ -1410,70 +1817,196 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } s := getSettings() + recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) + if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { + err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) + break + } + _ = os.MkdirAll(recordDirAbs, 0o755) username := extractUsername(req.URL) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) - outPath := filepath.Join(s.RecordDir, filename) + outPath := filepath.Join(recordDirAbs, filename) + // Output setzen (kurz locken) + jobsMu.Lock() job.Output = outPath + jobsMu.Unlock() + err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) case "mfc": s := getSettings() + recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir) + if rerr != nil || strings.TrimSpace(recordDirAbs) == "" { + err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr) + break + } + _ = os.MkdirAll(recordDirAbs, 0o755) username := extractMFCUsername(req.URL) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) - outPath := filepath.Join(s.RecordDir, filename) + outPath := filepath.Join(recordDirAbs, filename) + jobsMu.Lock() job.Output = outPath + jobsMu.Unlock() + err = RecordStreamMFC(ctx, hc, username, outPath, job) default: err = errors.New("unsupported provider") } - jobsMu.Lock() - defer jobsMu.Unlock() + // ---- Finalisieren (EndedAt/Error setzen, dann remux/move OHNE global-lock) ---- + end := time.Now() + // Zielstatus bestimmen (Status erst am Ende setzen, damit Progress sichtbar bleibt) + target := JobFinished + var errText string if err != nil { if errors.Is(err, context.Canceled) { - job.Status = JobStopped - - // ✅ Auch bei STOP: .ts -> .mp4 remuxen (falls möglich) - if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { - job.Output = newOut - } - - // ✅ und danach nach "done" verschieben - if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { - job.Output = moved - } + target = JobStopped } else { - job.Status = JobFailed - job.Error = err.Error() - - // ✅ best effort: trotzdem remuxen und nach done verschieben (falls Datei existiert) - if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { - job.Output = newOut - } - if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { - job.Output = moved - } - } - } else { - job.Status = JobFinished - - // ✅ Erst remuxen (damit in /done direkt die .mp4 landet) - if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { - job.Output = newOut - } - - // ✅ nach "done" verschieben (robust) - if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { - job.Output = moved + target = JobFailed + errText = err.Error() } } + // EndedAt + Error speichern (kurz locken) + jobsMu.Lock() + job.EndedAt = &end + if errText != "" { + job.Error = errText + } + // Output lokal kopieren, damit wir ohne lock weiterarbeiten können + out := strings.TrimSpace(job.Output) + jobsMu.Unlock() + + // Falls Output fehlt (z.B. provider error), direkt final status setzen + if out == "" { + setJobPhase(job, "finalizing", 95) + jobsMu.Lock() + job.Status = target + job.Phase = "" + job.Progress = 100 + jobsMu.Unlock() + return + } + + // 1) Remux (auch bei STOP/FAILED best-effort) + setJobPhase(job, "remuxing", 45) + if newOut, err2 := maybeRemuxTS(out); err2 == nil && strings.TrimSpace(newOut) != "" { + out = strings.TrimSpace(newOut) + jobsMu.Lock() + job.Output = out + jobsMu.Unlock() + } + + // 2) Move to done (best-effort) + setJobPhase(job, "moving", 80) + if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" { + out = strings.TrimSpace(moved) + jobsMu.Lock() + job.Output = out + jobsMu.Unlock() + } + + // 3) Finalize + setJobPhase(job, "finalizing", 95) + + // Jetzt erst finalen Status setzen + jobsMu.Lock() + job.Status = target + job.Phase = "" + job.Progress = 100 + finalOut := strings.TrimSpace(job.Output) + finalStatus := job.Status + jobsMu.Unlock() + + // ---- Nach Abschluss Assets erzeugen (Preview + Teaser) ---- + // nur bei Finished/Stopped, und nur wenn die Datei existiert + if finalOut != "" && (finalStatus == JobFinished || finalStatus == JobStopped) { + go func(videoPath string) { + fi, statErr := os.Stat(videoPath) + if statErr != nil || fi.IsDir() || fi.Size() <= 0 { + return + } + + // generated-Ordner im EXE-Pfad + genRoot, gerr := resolvePathRelativeToApp(filepath.Join("generated")) + if gerr != nil || strings.TrimSpace(genRoot) == "" { + fmt.Println("⚠️ generated root:", gerr) + return + } + thumbsDir := filepath.Join(genRoot, "thumbs") + teaserDir := filepath.Join(genRoot, "teaser") + _ = os.MkdirAll(thumbsDir, 0o755) + _ = os.MkdirAll(teaserDir, 0o755) + + // ID = Dateiname ohne Endung + base := filepath.Base(videoPath) + id := strings.TrimSuffix(base, filepath.Ext(base)) + if id == "" { + return + } + + // --- Atomic writer (lokal) --- + writeAtomic := func(dst string, data []byte) error { + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp := dst + ".part" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + _ = os.Remove(tmp) + return err + } + _ = os.Remove(dst) + return os.Rename(tmp, dst) + } + + // --- 1) Thumb (ein Frame) --- + thumbPath := filepath.Join(thumbsDir, id+".jpg") + if tfi, err := os.Stat(thumbPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 { + genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + t := 0.0 + if dur, derr := durationSecondsCached(genCtx, videoPath); derr == nil && dur > 0 { + t = dur * 0.5 + } + + img, e1 := extractFrameAtTimeJPEG(videoPath, t) + if e1 != nil || len(img) == 0 { + img, e1 = extractLastFrameJPEG(videoPath) + if e1 != nil || len(img) == 0 { + img, e1 = extractFirstFrameJPEG(videoPath) + } + } + + if e1 == nil && len(img) > 0 { + if err := writeAtomic(thumbPath, img); err != nil { + fmt.Println("⚠️ thumb write:", err) + } + } + } + + // --- 2) Teaser (mp4 aus 1s-clips) --- + teaserPath := filepath.Join(teaserDir, id+"_teaser.mp4") + if tfi, err := os.Stat(teaserPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 { + genSem <- struct{}{} + defer func() { <-genSem }() + + genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if err := generateTeaserClipsMP4(genCtx, videoPath, teaserPath, 1.0, 18); err != nil { + fmt.Println("⚠️ teaser clips:", err) + } + } + }(finalOut) + } } func recordVideo(w http.ResponseWriter, r *http.Request) { @@ -1644,6 +2177,27 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { return } + // optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste + page := 0 + pageSize := 0 + if v := strings.TrimSpace(r.URL.Query().Get("page")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + page = n + } + } + if v := strings.TrimSpace(r.URL.Query().Get("pageSize")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + pageSize = n + } + } + + // optional: Sort (für später) + // supported: completed_(asc|desc), model_(asc|desc), file_(asc|desc), duration_(asc|desc), size_(asc|desc) + sortMode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort"))) + if sortMode == "" { + sortMode = "completed_desc" + } + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -1668,11 +2222,40 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode([]*RecordJob{}) return } - http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } + // helpers (Sort) + fileForSort := func(j *RecordJob) string { + f := strings.ToLower(filepath.Base(j.Output)) + // HOT Prefix aus Sortierung rausnehmen + f = strings.TrimPrefix(f, "hot ") + return f + } + stemForSort := func(j *RecordJob) string { + // ohne ext und ohne HOT Prefix + f := fileForSort(j) + return strings.TrimSuffix(f, filepath.Ext(f)) + } + modelForSort := func(j *RecordJob) string { + stem := stemForSort(j) + if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { + return strings.ToLower(strings.TrimSpace(m[1])) + } + // fallback: alles vor letztem "_" (oder kompletter stem) + if i := strings.LastIndex(stem, "_"); i > 0 { + return strings.ToLower(strings.TrimSpace(stem[:i])) + } + return strings.ToLower(strings.TrimSpace(stem)) + } + durationForSort := func(j *RecordJob) (sec float64, ok bool) { + if j.DurationSeconds > 0 { + return j.DurationSeconds, true + } + return 0, false + } + list := make([]*RecordJob, 0, len(entries)) for _, e := range entries { if e.IsDir() { @@ -1693,6 +2276,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { base := strings.TrimSuffix(name, filepath.Ext(name)) t := fi.ModTime() + // ✅ StartedAt aus Dateiname (Fallback: ModTime) start := t stem := base if strings.HasPrefix(stem, "HOT ") { @@ -1708,10 +2292,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local) } - dur := 0.0 - if t.After(start) { - dur = t.Sub(start).Seconds() - } + dur := durationSecondsCacheOnly(full, fi) list = append(list, &RecordJob{ ID: base, @@ -1719,16 +2300,140 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { Status: JobFinished, StartedAt: start, EndedAt: &t, - DurationSeconds: dur, // ✅ Runtime + DurationSeconds: dur, SizeBytes: fi.Size(), }) - } + // Sortierung sort.Slice(list, func(i, j int) bool { - return list[i].EndedAt.After(*list[j].EndedAt) + a, b := list[i], list[j] + ta, tb := time.Time{}, time.Time{} + if a.EndedAt != nil { + ta = *a.EndedAt + } + if b.EndedAt != nil { + tb = *b.EndedAt + } + + switch sortMode { + case "completed_asc": + if !ta.Equal(tb) { + return ta.Before(tb) + } + return fileForSort(a) < fileForSort(b) + case "completed_desc": + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + + case "model_asc": + ma, mb := modelForSort(a), modelForSort(b) + if ma != mb { + return ma < mb + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + case "model_desc": + ma, mb := modelForSort(a), modelForSort(b) + if ma != mb { + return ma > mb + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + + case "file_asc": + fa, fb := fileForSort(a), fileForSort(b) + if fa != fb { + return fa < fb + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + case "file_desc": + fa, fb := fileForSort(a), fileForSort(b) + if fa != fb { + return fa > fb + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + + case "duration_asc": + da, okA := durationForSort(a) + db, okB := durationForSort(b) + if okA != okB { + return okA // unbekannt nach hinten + } + if okA && okB && da != db { + return da < db + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + case "duration_desc": + da, okA := durationForSort(a) + db, okB := durationForSort(b) + if okA != okB { + return okA + } + if okA && okB && da != db { + return da > db + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + + case "size_asc": + if a.SizeBytes != b.SizeBytes { + return a.SizeBytes < b.SizeBytes + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + case "size_desc": + if a.SizeBytes != b.SizeBytes { + return a.SizeBytes > b.SizeBytes + } + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + default: + if !ta.Equal(tb) { + return ta.After(tb) + } + return fileForSort(a) < fileForSort(b) + } }) + // Pagination (nach Sort!) + if pageSize > 0 { + if page <= 0 { + page = 1 + } + startIdx := (page - 1) * pageSize + if startIdx >= len(list) { + list = []*RecordJob{} + } else { + endIdx := startIdx + pageSize + if endIdx > len(list) { + endIdx = len(list) + } + list = list[startIdx:endIdx] + } + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(list) @@ -1811,16 +2516,20 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { http.Error(w, "ungültiger file", http.StatusBadRequest) return } - file = strings.TrimSpace(file) - // kein Pfad, keine Backslashes, kein Traversal - if file == "" || - strings.Contains(file, "/") || - strings.Contains(file, "\\") || - filepath.Base(file) != file { + file = strings.TrimSpace(file) + if file == "" { + http.Error(w, "file leer", http.StatusBadRequest) + return + } + + // Pfad absichern: nur Dateiname, keine Unterordner/Traversal + clean := filepath.Clean(file) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) { http.Error(w, "ungültiger file", http.StatusBadRequest) return } + file = clean ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { @@ -1855,15 +2564,46 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { return } + // löschen mit retry (Windows file-lock) if err := removeWithRetry(target); err != nil { - if isSharingViolation(err) { - http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict) - return + if runtime.GOOS == "windows" { + if isSharingViolation(err) { + http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict) + return + } } http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } + // ✅ generated Assets löschen (best effort) + base := strings.TrimSuffix(file, filepath.Ext(file)) + + thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) + teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) + + if strings.TrimSpace(thumbsAbs) != "" { + // Falls du thumbs als Ordner pro Video nutzt (thumbs//...) + _ = os.RemoveAll(filepath.Join(thumbsAbs, base)) + + // Falls du thumbs als Datei nutzt (thumbs/.jpg) + _ = os.Remove(filepath.Join(thumbsAbs, base+".jpg")) + + // Falls du zusätzlich frame-files im Root ablegst (thumbs/_*.jpg) + if entries, err := os.ReadDir(thumbsAbs); err == nil { + prefix := base + "_" + for _, e := range entries { + if strings.HasPrefix(e.Name(), prefix) { + _ = os.Remove(filepath.Join(thumbsAbs, e.Name())) + } + } + } + } + + if strings.TrimSpace(teaserAbs) != "" { + _ = os.Remove(filepath.Join(teaserAbs, base+"_teaser.mp4")) + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ @@ -1910,16 +2650,20 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { http.Error(w, "ungültiger file", http.StatusBadRequest) return } - file = strings.TrimSpace(file) - // kein Pfad, keine Backslashes, kein Traversal - if file == "" || - strings.Contains(file, "/") || - strings.Contains(file, "\\") || - filepath.Base(file) != file { + file = strings.TrimSpace(file) + if file == "" { + http.Error(w, "file leer", http.StatusBadRequest) + return + } + + // Pfad absichern + clean := filepath.Clean(file) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) { http.Error(w, "ungültiger file", http.StatusBadRequest) return } + file = clean ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { @@ -1939,6 +2683,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { } src := filepath.Join(doneAbs, file) + fi, err := os.Stat(src) if err != nil { if os.IsNotExist(err) { @@ -1955,11 +2700,13 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { keepDir := filepath.Join(doneAbs, "keep") if err := os.MkdirAll(keepDir, 0o755); err != nil { - http.Error(w, "keep dir anlegen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } dst := filepath.Join(keepDir, file) + + // falls schon vorhanden => Fehler if _, err := os.Stat(dst); err == nil { http.Error(w, "ziel existiert bereits", http.StatusConflict) return @@ -1978,6 +2725,29 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ generated Assets löschen (best effort) + base := strings.TrimSuffix(file, filepath.Ext(file)) + + thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) + teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) + + if strings.TrimSpace(thumbsAbs) != "" { + _ = os.RemoveAll(filepath.Join(thumbsAbs, base)) + _ = os.Remove(filepath.Join(thumbsAbs, base+".jpg")) + if entries, err := os.ReadDir(thumbsAbs); err == nil { + prefix := base + "_" + for _, e := range entries { + if strings.HasPrefix(e.Name(), prefix) { + _ = os.Remove(filepath.Join(thumbsAbs, e.Name())) + } + } + } + } + + if strings.TrimSpace(teaserAbs) != "" { + _ = os.Remove(filepath.Join(teaserAbs, base+".mp4")) + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ @@ -2071,6 +2841,53 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } + // ✅ NEU: generated Assets umbenennen (best effort) + oldBase := strings.TrimSuffix(file, filepath.Ext(file)) + newBase := strings.TrimSuffix(newFile, filepath.Ext(newFile)) + + thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) + teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) + + // thumbs/.jpg + if strings.TrimSpace(thumbsAbs) != "" { + oldThumb := filepath.Join(thumbsAbs, oldBase+".jpg") + newThumb := filepath.Join(thumbsAbs, newBase+".jpg") + + if _, err := os.Stat(oldThumb); err == nil { + if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) { + _ = renameWithRetry(oldThumb, newThumb) + } else { + // wenn Ziel existiert, alten löschen (keine Duplikate) + _ = os.Remove(oldThumb) + } + } + } + + // teaser/_teaser.mp4 + if strings.TrimSpace(teaserAbs) != "" { + oldTeaser := filepath.Join(teaserAbs, oldBase+"_teaser.mp4") + newTeaser := filepath.Join(teaserAbs, newBase+"_teaser.mp4") + + if _, err := os.Stat(oldTeaser); err == nil { + if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) { + _ = renameWithRetry(oldTeaser, newTeaser) + } else { + _ = os.Remove(oldTeaser) + } + } + + // optional: legacy teaser name ohne Suffix (falls noch vorhanden) + oldLegacy := filepath.Join(teaserAbs, oldBase+".mp4") + newLegacy := filepath.Join(teaserAbs, newBase+".mp4") + if _, err := os.Stat(oldLegacy); err == nil { + if _, err2 := os.Stat(newLegacy); os.IsNotExist(err2) { + _ = renameWithRetry(oldLegacy, newLegacy) + } else { + _ = os.Remove(oldLegacy) + } + } + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ @@ -2238,8 +3055,13 @@ func recordStop(w http.ResponseWriter, r *http.Request) { } id := r.URL.Query().Get("id") + jobsMu.Lock() job, ok := jobs[id] + if ok { + job.Phase = "stopping" + job.Progress = 10 + } jobsMu.Unlock() if !ok { @@ -2247,6 +3069,7 @@ func recordStop(w http.ResponseWriter, r *http.Request) { return } + // Preview wird bei dir über ctx beendet – kill kann bleiben, ist aber oft nil. if job.previewCmd != nil && job.previewCmd.Process != nil { _ = job.previewCmd.Process.Kill() job.previewCmd = nil @@ -2256,19 +3079,8 @@ func recordStop(w http.ResponseWriter, r *http.Request) { job.cancel() } - out := job.Output - if strings.EqualFold(filepath.Ext(out), ".ts") { - mp4 := strings.TrimSuffix(out, filepath.Ext(out)) + ".mp4" - - if err := remuxTSToMP4(out, mp4); err == nil { - _ = os.Remove(out) // optional: TS löschen - job.Output = mp4 // wichtig: Output umstellen - } else { - // optional: loggen, TS behalten - } - } - - w.Write([]byte(`{"ok":"stopped"}`)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(job) } // --- DVR-ähnlicher Recorder-Ablauf --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3785a88..f5aa966 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -106,11 +106,15 @@ function modelKeyFromFilename(fileOrPath: string): string | null { export default function App() { + + const DONE_PAGE_SIZE = 8 + const [sourceUrl, setSourceUrl] = useState('') const [, setParsed] = useState(null) const [, setParseError] = useState(null) const [jobs, setJobs] = useState([]) const [doneJobs, setDoneJobs] = useState([]) + const [donePage, setDonePage] = useState(1) const [doneCount, setDoneCount] = useState(0) const [modelsCount, setModelsCount] = useState(0) @@ -307,6 +311,11 @@ export default function App() { } }, []) + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE)) + if (donePage > maxPage) setDonePage(maxPage) + }, [doneCount, donePage]) + useEffect(() => { if (sourceUrl.trim() === '') { setParsed(null) @@ -371,7 +380,10 @@ export default function App() { if (cancelled || inFlight) return inFlight = true try { - const list = await apiJSON('/api/record/done', { cache: 'no-store' as any }) + const list = await apiJSON( + `/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}`, + { cache: 'no-store' as any } + ) if (!cancelled) setDoneJobs(Array.isArray(list) ? list : []) } catch { // optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst @@ -400,7 +412,7 @@ export default function App() { window.clearInterval(t) document.removeEventListener('visibilitychange', onVis) } - }, [selectedTab]) + }, [selectedTab, donePage]) function isChaturbate(url: string): boolean { @@ -785,6 +797,10 @@ export default function App() { void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise + doneTotal: number + page: number + pageSize: number + onPageChange: (page: number) => void } const norm = (p: string) => (p || '').replaceAll('\\', '/').trim() @@ -148,9 +154,11 @@ export default function FinishedDownloads({ onToggleHot, onToggleFavorite, onToggleLike, + doneTotal, + page, + pageSize, + onPageChange }: Props) { - const PAGE_SIZE = 50 - const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE) const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null) const teaserHostsRef = React.useRef>(new Map()) @@ -572,10 +580,6 @@ export default function FinishedDownloads({ return arr }, [rows, sortMode, durations]) - useEffect(() => { - setVisibleCount(PAGE_SIZE) - }, [rows.length]) - useEffect(() => { const onExternalDelete = (ev: Event) => { const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail @@ -616,12 +620,10 @@ export default function FinishedDownloads({ const viewRows = view === 'table' ? rows : sortedNonTableRows - const visibleRows = viewRows - .filter((j) => !deletedKeys.has(keyFor(j))) - .slice(0, visibleCount) + const visibleRows = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) useEffect(() => { - const active = view === 'cards' || view === 'gallery' + const active = view === 'cards' if (!active) { setTeaserKey(null); return } // in Cards: wenn Inline-Player aktiv ist, diesen festhalten @@ -726,6 +728,9 @@ export default function FinishedDownloads({ className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10" showPopover={false} blur={blurPreviews} + animated={true} + animatedMode="teaser" + animatedTrigger="always" /> ) @@ -993,337 +998,50 @@ export default function FinishedDownloads({ )} - {/* ✅ Cards */} {view === 'cards' && ( -
- {visibleRows.map((j) => { - const k = keyFor(j) - const inlineActive = inlinePlay?.key === k - const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 - - const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) - - const model = modelNameFromOutput(j.output) - const file = baseName(j.output || '') - const dur = runtimeOf(j) - const size = formatBytes(sizeBytesOf(j)) - - const statusNode = - j.status === 'failed' ? ( - - failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''} - - ) : ( - {j.status} - ) - - const inlineDomId = `inline-prev-${encodeURIComponent(k)}` - - const cardInner = ( -
openPlayer(j)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) - }} - onContextMenu={(e) => openCtx(j, e)} - > - - {/* Preview */} -
{ - e.preventDefault() - e.stopPropagation() - if (isSmall) return // ✅ Mobile: SwipeCard-onTap macht das - startInline(k) // ✅ Desktop: Click startet inline - }} - > - - - {/* Gradient overlay bottom */} -
- - {/* Overlay bottom */} -
-
-
{model}
-
{stripHotPrefix(file) || '—'}
-
- -
- {file.startsWith('HOT ') ? ( - - HOT - - ) : null} -
-
- - {!isSmall && inlinePlay?.key === k && ( - - )} - - {/* Actions top-right */} -
- {(() => { - const iconBtn = - 'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + - 'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500' - - const fileRaw = baseName(j.output || '') - const isHot = fileRaw.startsWith('HOT ') - const modelKey = modelNameFromOutput(j.output) - const flags = modelsByKey[lower(modelKey)] - const isFav = Boolean(flags?.favorite) - const isLiked = flags?.liked === true - - - return ( - <> - {!isSmall && ( - <> - {/* Keep */} - - - {/* Delete */} - - - )} - - {/* HOT */} - - - {/* Favorite */} - - - {/* Like */} - - - {/* Menu */} - - - ) - })()} -
-
- - {/* Meta */} -
-
-
- Status: {statusNode} - - Dauer: {dur} - - Größe: {size} -
-
- - {j.output ? ( -
- {j.output} -
- ) : null} -
- -
- ) - - // ✅ Mobile: SwipeCard, Desktop: normale Card - return isSmall ? ( - { - if (h) swipeRefs.current.set(k, h) - else swipeRefs.current.delete(k) - }} - key={k} - enabled - disabled={busy} - ignoreFromBottomPx={110} - onTap={() => { - const domId = `inline-prev-${encodeURIComponent(k)}` - - // ✅ State sofort committen (damit Video direkt im DOM ist) - flushSync(() => startInline(k)) - - // ✅ direkt versuchen (innerhalb des Tap-Tasks) - if (!tryAutoplayInline(domId)) { - // Fallback: nächster Frame (falls Video erst im Commit auftaucht) - requestAnimationFrame(() => tryAutoplayInline(domId)) - } - }} - onSwipeLeft={() => deleteVideo(j)} - onSwipeRight={() => keepVideo(j)} - > - {cardInner} - - ) : ( - {cardInner} - ) - - })} -
+ )} - {/* ✅ Tabelle */} {view === 'table' && ( - keyFor(j)} - striped - fullWidth - stickyHeader - compact sort={sort} onSortChange={setSort} onRowClick={onOpenPlayer} @@ -1342,182 +1060,30 @@ export default function FinishedDownloads({ /> )} - {/* ✅ Galerie */} {view === 'gallery' && ( -
- {visibleRows.map((j) => { - const k = keyFor(j) - const model = modelNameFromOutput(j.output) - const file = baseName(j.output || '') - const dur = runtimeOf(j) - const size = formatBytes(sizeBytesOf(j)) - - const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) - const deleted = deletedKeys.has(k) - - return ( -
onOpenPlayer(j)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) - }} - onContextMenu={(e) => openCtx(j, e)} - > - {/* Thumb */} -
{ - e.preventDefault() - e.stopPropagation() - openCtx(j, e) - }} - > - - - {/* Gradient overlay bottom */} -
- - {/* Bottom text */} -
-
{model}
-
- {stripHotPrefix(file) || '—'} - -
- {dur} - {size} -
-
-
- - {/* Quick keep */} - - - {/* Quick delete */} - - - {/* More / Context */} - -
- - {/* Optional: status line (below thumb) */} -
-
- - Status:{' '} - {j.status === 'failed' ? ( - - failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''} - - ) : ( - {j.status} - )} - - {/* kurzer Hinweis: Hot Prefix */} - {baseName(j.output || '').startsWith('HOT ') ? ( - - HOT - - ) : null} -
-
-
- ) - })} -
+ )} setCtx(null)} /> - {rows.length > visibleCount ? ( -
- -
- ) : null} + { + // 1) Inline-Playback + aktiven Teaser sofort stoppen + flushSync(() => { + setInlinePlay(null) + setTeaserKey(null) + }) + + // 2) alle aktuell sichtbaren Previews "freigeben" (damit keine Datei mehr offen ist) + for (const j of visibleRows) { + const f = baseName(j.output || '') + if (!f) continue + window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } })) + window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } })) + } + + // optional: zurück nach oben, damit neue Seite "sauber" startet + window.scrollTo({ top: 0, behavior: 'auto' }) + + // 3) Seite wechseln (App lädt dann neue doneJobs) + onPageChange(p) + }} + showSummary={false} + prevLabel="Zurück" + nextLabel="Weiter" + className="mt-4" + /> ) diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx new file mode 100644 index 0000000..a558797 --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -0,0 +1,420 @@ +'use client' + +import * as React from 'react' +import Card from './Card' +import type { RecordJob } from '../../types' +import FinishedVideoPreview from './FinishedVideoPreview' +import SwipeCard, { type SwipeCardHandle } from './SwipeCard' +import { flushSync } from 'react-dom' +import { + TrashIcon, + FireIcon, + EllipsisVerticalIcon, + BookmarkSquareIcon, + StarIcon as StarOutlineIcon, + HeartIcon as HeartOutlineIcon, +} from '@heroicons/react/24/outline' +import { StarIcon as StarSolidIcon, HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid' + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +type InlinePlayState = { key: string; nonce: number } | null + +type Props = { + rows: RecordJob[] + isSmall: boolean + + blurPreviews?: boolean + durations: Record + teaserKey: string | null + inlinePlay: InlinePlayState + setInlinePlay: React.Dispatch> + + deletingKeys: Set + keepingKeys: Set + removingKeys: Set + + swipeRefs: React.MutableRefObject> + + // helpers + keyFor: (j: RecordJob) => string + baseName: (p: string) => string + stripHotPrefix: (s: string) => string + modelNameFromOutput: (output?: string) => string + runtimeOf: (job: RecordJob) => string + sizeBytesOf: (job: RecordJob) => number | null + formatBytes: (bytes?: number | null) => string + lower: (s: string) => string + + // callbacks/actions + onOpenPlayer: (job: RecordJob) => void + openPlayer: (job: RecordJob) => void + startInline: (key: string) => void + tryAutoplayInline: (domId: string) => boolean + registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void + + handleDuration: (job: RecordJob, seconds: number) => void + + deleteVideo: (job: RecordJob) => Promise + keepVideo: (job: RecordJob) => Promise + + openCtx: (job: RecordJob, e: React.MouseEvent) => void + openCtxAt: (job: RecordJob, x: number, y: number) => void + + releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise + + modelsByKey: Record + + onToggleHot?: (job: RecordJob) => void | Promise + onToggleFavorite?: (job: RecordJob) => void | Promise + onToggleLike?: (job: RecordJob) => void | Promise +} + +export default function FinishedDownloadsCardsView({ + rows, + isSmall, + + blurPreviews, + durations, + teaserKey, + inlinePlay, + setInlinePlay, + + deletingKeys, + keepingKeys, + removingKeys, + + swipeRefs, + + keyFor, + baseName, + stripHotPrefix, + modelNameFromOutput, + runtimeOf, + sizeBytesOf, + formatBytes, + lower, + + onOpenPlayer, + openPlayer, + startInline, + tryAutoplayInline, + registerTeaserHost, + + handleDuration, + + deleteVideo, + keepVideo, + + openCtx, + openCtxAt, + + releasePlayingFile, + + modelsByKey, + + onToggleHot, + onToggleFavorite, + onToggleLike, +}: Props) { + return ( +
+ {rows.map((j) => { + const k = keyFor(j) + const inlineActive = inlinePlay?.key === k + const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 + + const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + + const model = modelNameFromOutput(j.output) + const fileRaw = baseName(j.output || '') + const dur = runtimeOf(j) + const size = formatBytes(sizeBytesOf(j)) + + const inlineDomId = `inline-prev-${encodeURIComponent(k)}` + + const cardInner = ( +
openPlayer(j)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + }} + onContextMenu={(e) => openCtx(j, e)} + > + + {/* Preview */} +
{ + e.preventDefault() + e.stopPropagation() + if (isSmall) return + startInline(k) + }} + > + + + {/* Gradient overlay bottom */} +
+ + {/* Overlay bottom */} +
+
+
{model}
+
{stripHotPrefix(fileRaw) || '—'}
+
+ +
+ {fileRaw.startsWith('HOT ') ? ( + + HOT + + ) : null} +
+
+ + {!isSmall && inlinePlay?.key === k && ( + + )} + + {/* Actions top-right */} +
+ {(() => { + const iconBtn = + 'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + + 'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500' + + const isHot = fileRaw.startsWith('HOT ') + const modelKey = modelNameFromOutput(j.output) + const flags = modelsByKey[lower(modelKey)] + const isFav = Boolean(flags?.favorite) + const isLiked = flags?.liked === true + + return ( + <> + {!isSmall && ( + <> + {/* Keep */} + + + {/* Delete */} + + + )} + + {/* HOT */} + + + {/* Favorite */} + + + {/* Like */} + + + {/* Menu */} + + + ) + })()} +
+
+ + {/* Meta */} +
+
+
+ Dauer: {dur} + + Größe: {size} +
+
+ + {j.output ? ( +
+ {j.output} +
+ ) : null} +
+ +
+ ) + + // ✅ Mobile: SwipeCard, Desktop: normale Card + return isSmall ? ( + { + if (h) swipeRefs.current.set(k, h) + else swipeRefs.current.delete(k) + }} + key={k} + enabled + disabled={busy} + ignoreFromBottomPx={110} + onTap={() => { + const domId = `inline-prev-${encodeURIComponent(k)}` + flushSync(() => startInline(k)) + if (!tryAutoplayInline(domId)) { + requestAnimationFrame(() => tryAutoplayInline(domId)) + } + }} + onSwipeLeft={() => deleteVideo(j)} + onSwipeRight={() => keepVideo(j)} + > + {cardInner} + + ) : ( + {cardInner} + ) + })} +
+ ) +} diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx new file mode 100644 index 0000000..25780f7 --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -0,0 +1,231 @@ +'use client' + +import * as React from 'react' +import type { RecordJob } from '../../types' +import FinishedVideoPreview from './FinishedVideoPreview' +import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline' + +type Props = { + rows: RecordJob[] + blurPreviews?: boolean + durations: Record + handleDuration: (job: RecordJob, seconds: number) => void + + keyFor: (j: RecordJob) => string + baseName: (p: string) => string + stripHotPrefix: (s: string) => string + modelNameFromOutput: (output?: string) => string + runtimeOf: (job: RecordJob) => string + sizeBytesOf: (job: RecordJob) => number | null + formatBytes: (bytes?: number | null) => string + + deletingKeys: Set + keepingKeys: Set + removingKeys: Set + deletedKeys: Set + + registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void + + onOpenPlayer: (job: RecordJob) => void + openCtx: (job: RecordJob, e: React.MouseEvent) => void + openCtxAt: (job: RecordJob, x: number, y: number) => void + deleteVideo: (job: RecordJob) => Promise + keepVideo: (job: RecordJob) => Promise +} + +export default function FinishedDownloadsGalleryView({ + rows, + blurPreviews, + durations, + handleDuration, + + keyFor, + baseName, + stripHotPrefix, + modelNameFromOutput, + runtimeOf, + sizeBytesOf, + formatBytes, + + deletingKeys, + keepingKeys, + removingKeys, + deletedKeys, + + registerTeaserHost, + + onOpenPlayer, + openCtx, + openCtxAt, + deleteVideo, + keepVideo, +}: Props) { + return ( +
+ {rows.map((j) => { + const k = keyFor(j) + const model = modelNameFromOutput(j.output) + const file = baseName(j.output || '') + const dur = runtimeOf(j) + const size = formatBytes(sizeBytesOf(j)) + + const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + const deleted = deletedKeys.has(k) + + return ( +
onOpenPlayer(j)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + }} + onContextMenu={(e) => openCtx(j, e)} + > + {/* Thumb */} +
{ + e.preventDefault() + e.stopPropagation() + openCtx(j, e) + }} + > + + + {/* Gradient overlay bottom */} +
+ + {/* Bottom text */} +
+
{model}
+
+ {stripHotPrefix(file) || '—'} + +
+ {dur} + {size} +
+
+
+ + {/* Quick keep */} + + + {/* Quick delete */} + + + {/* More / Context */} + +
+ + {/* status line */} +
+
+ + Status: {j.status} + + {baseName(j.output || '').startsWith('HOT ') ? ( + + HOT + + ) : null} +
+
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx new file mode 100644 index 0000000..04a37e4 --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -0,0 +1,44 @@ +'use client' + +import * as React from 'react' +import Table, { type Column, type SortState } from './Table' +import type { RecordJob } from '../../types' + +type Props = { + rows: RecordJob[] + columns: Column[] + getRowKey: (j: RecordJob) => string + sort: SortState + onSortChange: (s: SortState) => void + onRowClick: (job: RecordJob) => void + onRowContextMenu: (job: RecordJob, e: React.MouseEvent) => void + rowClassName?: (job: RecordJob) => string +} + +export default function FinishedDownloadsTableView({ + rows, + columns, + getRowKey, + sort, + onSortChange, + onRowClick, + onRowContextMenu, + rowClassName, +}: Props) { + return ( +
+ ) +} diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 7ebc56d..81d93d4 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -6,7 +6,7 @@ import HoverPopover from './HoverPopover' type Variant = 'thumb' | 'fill' type InlineVideoMode = false | true | 'always' | 'hover' -type AnimatedMode = 'frames' | 'clips' +type AnimatedMode = 'frames' | 'clips' | 'teaser' type AnimatedTrigger = 'always' | 'hover' export type FinishedVideoPreviewProps = { @@ -15,10 +15,11 @@ export type FinishedVideoPreviewProps = { durationSeconds?: number onDuration?: (job: RecordJob, seconds: number) => void - /** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */ + /** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */ animated?: boolean animatedMode?: AnimatedMode animatedTrigger?: AnimatedTrigger + active?: boolean /** nur für frames */ autoTickMs?: number @@ -60,6 +61,7 @@ export default function FinishedVideoPreview({ animated = false, animatedMode = 'frames', animatedTrigger = 'always', + active, autoTickMs = 15000, thumbStepSec, @@ -102,22 +104,63 @@ export default function FinishedVideoPreview({ ? 'hover' : 'never' + // ✅ id = Dateiname ohne Endung (genau wie du willst) const previewId = useMemo(() => { if (!file) return '' const dot = file.lastIndexOf('.') return dot > 0 ? file.slice(0, dot) : file }, [file]) + // Vollvideo (für Inline-Playback + Duration-Metadaten) const videoSrc = useMemo( () => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file] ) + // ✅ Teaser-Video (vorgerendert) + const isActive = active !== undefined ? Boolean(active) : true + + const teaserSrc = useMemo( + () => (previewId ? `/api/generated/teaser?id=${encodeURIComponent(previewId)}` : ''), + [previewId] + ) + const hasDuration = typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16' + const inlineRef = useRef(null) + const teaserMp4Ref = useRef(null) + const clipsRef = useRef(null) + + const hardStop = (v: HTMLVideoElement | null) => { + if (!v) return + try { v.pause() } catch {} + try { + v.removeAttribute('src') + // @ts-ignore + v.src = '' + v.load() + } catch {} + } + + useEffect(() => { + const onRelease = (ev: any) => { + const f = String(ev?.detail?.file ?? '') + if (!f || f !== file) return + hardStop(inlineRef.current) + hardStop(teaserMp4Ref.current) + hardStop(clipsRef.current) + } + window.addEventListener('player:release', onRelease as EventListener) + window.addEventListener('player:close', onRelease as EventListener) + return () => { + window.removeEventListener('player:release', onRelease as EventListener) + window.removeEventListener('player:close', onRelease as EventListener) + } + }, [file]) + // --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar useEffect(() => { const el = rootRef.current @@ -173,6 +216,7 @@ export default function FinishedVideoPreview({ )}&v=${encodeURIComponent(String(localTick))}` }, [previewId, thumbTimeSec, localTick]) + // ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!) const handleLoadedMetadata = (e: SyntheticEvent) => { setMetaLoaded(true) if (!onDuration) return @@ -191,7 +235,27 @@ export default function FinishedVideoPreview({ videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered)) - // --- Teaser Clip Zeiten (nur clips) + // --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover) + const teaserActive = + animated && + inView && + !document.hidden && + videoOk && + !showingInlineVideo && + (animatedTrigger === 'always' || hovered) && + ( + // ✅ neuer schneller Modus + (animatedMode === 'teaser' && Boolean(teaserSrc)) || + // Legacy: clips nur wenn Duration bekannt + (animatedMode === 'clips' && hasDuration) + ) + + // --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover + const wantsHover = + inlineMode === 'hover' || + (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover') + + // --- Legacy "clips" Logik (wenn du es noch nutzt) const clipTimes = useMemo(() => { if (!animated) return [] if (animatedMode !== 'clips') return [] @@ -214,31 +278,18 @@ export default function FinishedVideoPreview({ const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes]) - // --- Teaser aktiv? (nur inView, nicht inline, optional nur hover) - const teaserActive = - animated && - animatedMode === 'clips' && - inView && - !document.hidden && - videoOk && - clipTimes.length > 0 && - !showingInlineVideo && - (animatedTrigger === 'always' || hovered) - - // --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover - const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover') - - // --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop) const teaserRef = useRef(null) const clipIdxRef = useRef(0) const clipStartRef = useRef(0) + // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek useEffect(() => { const v = teaserRef.current if (!v) return - if (!teaserActive) { - v.pause() + if (!(teaserActive && animatedMode === 'clips')) { + // bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv + if (!teaserActive) v.pause() return } @@ -271,7 +322,6 @@ export default function FinishedVideoPreview({ v.addEventListener('loadedmetadata', onLoaded) v.addEventListener('timeupdate', onTimeUpdate) - // Wenn metadata schon da ist: if (v.readyState >= 1) start() return () => { @@ -279,7 +329,7 @@ export default function FinishedVideoPreview({ v.removeEventListener('timeupdate', onTimeUpdate) v.pause() } - }, [teaserActive, clipTimesKey, clipSeconds]) + }, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes]) const previewNode = (
setVideoOk(false)} /> - ) : teaserActive ? ( - /* 2) Teaser Clips (1s Segmente) */ + ) : teaserActive && animatedMode === 'teaser' ? ( + /* 2a) ✅ Teaser MP4 (vorgerendert) */