This commit is contained in:
Linrador 2025-12-26 21:47:48 +01:00
parent 05c9d04db9
commit 82cd87c92e
14 changed files with 1522 additions and 458 deletions

View File

@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -12,12 +13,15 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
@ -38,14 +42,15 @@ const (
) )
type RecordJob struct { type RecordJob struct {
ID string `json:"id"` ID string `json:"id"`
model string `json:"model"` model string `json:"model"`
SourceURL string `json:"sourceUrl"` SourceURL string `json:"sourceUrl"`
Output string `json:"output"` Output string `json:"output"`
Status JobStatus `json:"status"` Status JobStatus `json:"status"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"` EndedAt *time.Time `json:"endedAt,omitempty"`
Error string `json:"error,omitempty"` DurationSeconds float64 `json:"durationSeconds,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"` PreviewDir string `json:"-"`
PreviewImage string `json:"-"` PreviewImage string `json:"-"`
@ -68,6 +73,55 @@ var (
// ffmpeg-Binary suchen (env, neben EXE, oder PATH) // ffmpeg-Binary suchen (env, neben EXE, oder PATH)
var ffmpegPath = detectFFmpegPath() var ffmpegPath = detectFFmpegPath()
type durEntry struct {
size int64
mod time.Time
sec float64
}
var durCache = struct {
mu sync.Mutex
m map[string]durEntry
}{m: map[string]durEntry{}}
func durationSecondsCached(path string) (float64, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
}
durCache.mu.Lock()
if e, ok := durCache.m[path]; ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
durCache.mu.Unlock()
return e.sec, nil
}
durCache.mu.Unlock()
// ffprobe (oder notfalls ffmpeg -i parsen)
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
)
out, err := cmd.Output()
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(out))
sec, err := strconv.ParseFloat(s, 64)
if err != nil || sec <= 0 {
return 0, fmt.Errorf("invalid duration: %q", s)
}
durCache.mu.Lock()
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
durCache.mu.Unlock()
return sec, nil
}
// main.go // main.go
type RecorderSettings struct { type RecorderSettings struct {
@ -423,6 +477,112 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
return nil return nil
} }
// --- MP4 Streaming Optimierung (Fast Start) ---
// "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen)
func isFastStartMP4(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
for i := 0; i < 256; i++ {
var hdr [8]byte
if _, err := io.ReadFull(f, hdr[:]); err != nil {
// unklar/kurz -> nicht anfassen
return true, nil
}
sz32 := binary.BigEndian.Uint32(hdr[0:4])
typ := string(hdr[4:8])
var boxSize int64
headerSize := int64(8)
if sz32 == 0 {
return true, nil
}
if sz32 == 1 {
var ext [8]byte
if _, err := io.ReadFull(f, ext[:]); err != nil {
return true, nil
}
boxSize = int64(binary.BigEndian.Uint64(ext[:]))
headerSize = 16
} else {
boxSize = int64(sz32)
}
if boxSize < headerSize {
return true, nil
}
switch typ {
case "moov":
return true, nil
case "mdat":
return false, nil
}
if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil {
return true, nil
}
}
return true, nil
}
func ensureFastStartMP4(path string) error {
path = strings.TrimSpace(path)
if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") {
return nil
}
if strings.TrimSpace(ffmpegPath) == "" {
return nil
}
ok, err := isFastStartMP4(path)
if err == nil && ok {
return nil
}
dir := filepath.Dir(path)
base := filepath.Base(path)
tmp := filepath.Join(dir, ".__faststart__"+base+".tmp")
bak := filepath.Join(dir, ".__faststart__"+base+".bak")
_ = os.Remove(tmp)
_ = os.Remove(bak)
cmd := exec.Command(ffmpegPath,
"-y",
"-i", path,
"-c", "copy",
"-movflags", "+faststart",
tmp,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
// atomar austauschen
if err := os.Rename(path, bak); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename original to bak failed: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Rename(bak, path)
_ = os.Remove(tmp)
return fmt.Errorf("rename tmp to original failed: %w", err)
}
_ = os.Remove(bak)
return nil
}
func extractLastFrameJPEG(path string) ([]byte, error) { func extractLastFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command( cmd := exec.Command(
ffmpegPath, ffmpegPath,
@ -905,6 +1065,79 @@ func resolvePathRelativeToApp(p string) (string, error) {
return filepath.Join(wd, p), nil return filepath.Join(wd, p), nil
} }
// Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html
func registerFrontend(mux *http.ServeMux) {
// Kandidaten: zuerst ENV, dann typische Ordner
candidates := []string{
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
"web/dist",
"dist",
}
var distAbs string
for _, c := range candidates {
if c == "" {
continue
}
abs, err := resolvePathRelativeToApp(c)
if err != nil {
continue
}
if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() {
distAbs = abs
break
}
}
if distAbs == "" {
fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, frontend/dist, dist) API läuft trotzdem.")
return
}
fmt.Println("🖼️ Frontend dist:", distAbs)
fileServer := http.FileServer(http.Dir(distAbs))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// /api bleibt bei deinen API-Routen (längeres Pattern gewinnt),
// aber falls mal was durchrutscht:
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// 1) Wenn echte Datei existiert -> ausliefern
reqPath := r.URL.Path
if reqPath == "" || reqPath == "/" {
// index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
return
}
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
clean := path.Clean("/" + reqPath) // path.Clean (für URL-Slashes)
rel := strings.TrimPrefix(clean, "/")
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
// Statische Assets ruhig cachen (Vite hashed assets)
ext := strings.ToLower(filepath.Ext(onDisk))
if ext != "" && ext != ".html" {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store")
}
fileServer.ServeHTTP(w, r)
return
}
// 2) SPA-Fallback: alle "Routen" ohne Datei -> index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
})
}
// routes.go (package main) // routes.go (package main)
func registerRoutes(mux *http.ServeMux) { func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/settings", recordSettingsHandler) mux.HandleFunc("/api/settings", recordSettingsHandler)
@ -919,6 +1152,7 @@ func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/record/done", recordDoneList) mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/delete", recordDeleteVideo) mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot) mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/record/keep", recordKeepVideo)
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
@ -932,6 +1166,9 @@ func registerRoutes(mux *http.ServeMux) {
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete // ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
RegisterModelAPI(mux, store) RegisterModelAPI(mux, store)
// ✅ Frontend (SPA) ausliefern
registerFrontend(mux)
} }
// --- main --- // --- main ---
@ -1313,14 +1550,17 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
base := strings.TrimSuffix(name, filepath.Ext(name)) base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime() t := fi.ModTime()
dur, _ := durationSecondsCached(full)
list = append(list, &RecordJob{ list = append(list, &RecordJob{
ID: base, ID: base,
SourceURL: "", Output: full,
Output: full, Status: JobFinished,
Status: JobFinished, StartedAt: t,
StartedAt: t, EndedAt: &t,
EndedAt: &t, DurationSeconds: dur,
}) })
} }
sort.Slice(list, func(i, j int) bool { sort.Slice(list, func(i, j int) bool {
@ -1395,7 +1635,11 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := os.Remove(target); err != nil { if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
@ -1408,6 +1652,99 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
}) })
} }
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
raw := strings.TrimSpace(r.URL.Query().Get("file"))
if raw == "" {
http.Error(w, "file fehlt", http.StatusBadRequest)
return
}
file, err := url.QueryUnescape(raw)
if err != nil {
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 {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(file))
if ext != ".mp4" && ext != ".ts" {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
src := filepath.Join(doneAbs, file)
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
keepDir := filepath.Join(doneAbs, "keep")
if err := os.MkdirAll(keepDir, 0o755); err != nil {
http.Error(w, "keep dir anlegen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
dst := filepath.Join(keepDir, file)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "ziel existiert bereits", http.StatusConflict)
return
} else if !os.IsNotExist(err) {
http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
// rename mit retry (Windows file-lock)
if err := renameWithRetry(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
}
http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"file": file,
})
}
func recordToggleHot(w http.ResponseWriter, r *http.Request) { func recordToggleHot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Nur POST", http.StatusMethodNotAllowed) http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
@ -1484,7 +1821,11 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := os.Rename(src, dst); err != nil { if err := renameWithRetry(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
@ -1546,6 +1887,60 @@ func moveFile(src, dst string) error {
} }
} }
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
func isSharingViolation(err error) bool {
var pe *os.PathError
if errors.As(err, &pe) {
if errno, ok := pe.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
}
return errors.Is(pe.Err, windowsSharingViolation)
}
var le *os.LinkError
if errors.As(err, &le) {
if errno, ok := le.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
}
return errors.Is(le.Err, windowsSharingViolation)
}
return errors.Is(err, windowsSharingViolation)
}
func renameWithRetry(src, dst string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
return err
}
return err
}
func removeWithRetry(path string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Remove(path)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
return err
}
return err
}
func moveToDoneDir(outputPath string) (string, error) { func moveToDoneDir(outputPath string) (string, error) {
outputPath = strings.TrimSpace(outputPath) outputPath = strings.TrimSpace(outputPath)
if outputPath == "" { if outputPath == "" {
@ -1567,7 +1962,15 @@ func moveToDoneDir(outputPath string) (string, error) {
if err := moveFile(outputPath, dst); err != nil { if err := moveFile(outputPath, dst); err != nil {
return "", err return "", err
} }
// ✅ Streaming-Optimierung
if strings.EqualFold(filepath.Ext(dst), ".mp4") {
if err := ensureFastStartMP4(dst); err != nil {
fmt.Println("⚠️ faststart:", err)
}
}
return dst, nil return dst, nil
} }
func recordStatus(w http.ResponseWriter, r *http.Request) { func recordStatus(w http.ResponseWriter, r *http.Request) {

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<script type="module" crossorigin src="/assets/index-iDPthw87.js"></script> <script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WtXLd9dH.css"> <link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -541,23 +541,41 @@ export default function App() {
return startUrl(sourceUrl) return startUrl(sourceUrl)
} }
const handlePlayerDelete = useCallback(async (job: RecordJob) => { const handleDeleteJob = useCallback(async (job: RecordJob) => {
// running => stop (macht mp4 remux etc)
if (job.status === 'running') {
await stopJob(job.id)
setPlayerJob(null)
return
}
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' }) // 1) Animation START im FinishedDownloads triggern
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'start' as const },
})
)
// UI sofort aktualisieren try {
setDoneJobs(prev => prev.filter(j => baseName(j.output || '') !== file)) await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
setJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
setPlayerJob(null) // 2) Animation SUCCESS triggern (FinishedDownloads startet fade-out)
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'success' as const },
})
)
// 3) erst NACH der Animation wirklich aus den Arrays entfernen
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
} catch (e) {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'error' as const },
})
)
throw e
}
}, []) }, [])
const handleToggleHot = useCallback(async (job: RecordJob) => { const handleToggleHot = useCallback(async (job: RecordJob) => {
@ -966,7 +984,7 @@ export default function App() {
isFavorite={Boolean(playerModel?.favorite)} isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true} isLiked={playerModel?.liked === true}
onDelete={handlePlayerDelete} onDelete={handleDeleteJob}
onToggleHot={handleToggleHot} onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}

View File

@ -0,0 +1,94 @@
// components/ui/ButtonGroup.tsx
'use client'
import * as React from 'react'
type Size = 'sm' | 'md'
export type ButtonGroupItem = {
id: string
label?: React.ReactNode // optional (für icon-only)
icon?: React.ReactNode
srLabel?: string // für icon-only (Screenreader)
disabled?: boolean
}
export type ButtonGroupProps = {
items: ButtonGroupItem[]
value: string
onChange: (id: string) => void
size?: Size
className?: string
ariaLabel?: string
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const sizeMap: Record<Size, { btn: string; icon: string }> = {
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5' },
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5' },
}
export default function ButtonGroup({
items,
value,
onChange,
size = 'md',
className,
ariaLabel = 'Optionen',
}: ButtonGroupProps) {
const s = sizeMap[size]
return (
<span className={cn('isolate inline-flex rounded-md shadow-xs dark:shadow-none', className)} role="group" aria-label={ariaLabel}>
{items.map((it, idx) => {
const active = it.id === value
const isFirst = idx === 0
const isLast = idx === items.length - 1
const iconOnly = !it.label && !!it.icon
return (
<button
key={it.id}
type="button"
disabled={it.disabled}
onClick={() => onChange(it.id)}
aria-pressed={active}
className={cn(
'relative inline-flex items-center font-semibold focus:z-10',
!isFirst && '-ml-px',
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md',
// Base (wie im TailwindUI Beispiel)
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50',
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
// Active-Style (dezente Hervorhebung)
active && 'bg-gray-50 dark:bg-white/20',
// Disabled
'disabled:opacity-50 disabled:cursor-not-allowed',
// Padding / Größe
iconOnly ? 'px-2 py-2 text-gray-400 dark:text-gray-300' : s.btn
)}
title={typeof it.label === 'string' ? it.label : it.srLabel}
>
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
{it.icon ? (
<span className={cn('shrink-0', iconOnly ? '' : '-ml-0.5 text-gray-400 dark:text-gray-500')}>
{it.icon}
</span>
) : null}
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
</button>
)
})}
</span>
)
}

View File

@ -10,6 +10,9 @@ import FinishedVideoPreview from './FinishedVideoPreview'
import ContextMenu, { type ContextMenuItem } from './ContextMenu' import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu' import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button' import Button from './Button'
import ButtonGroup from './ButtonGroup'
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
@ -49,16 +52,22 @@ const httpCodeFromError = (err?: string) => {
return m ? `HTTP ${m[1]}` : null return m ? `HTTP ${m[1]}` : null
} }
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const modelNameFromOutput = (output?: string) => { const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—' if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '') const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1] if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_') const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem return i > 0 ? stem.slice(0, i) : stem
} }
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) { export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
const PAGE_SIZE = 50 const PAGE_SIZE = 50
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE) const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
@ -70,17 +79,33 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
const [sort, setSort] = React.useState<SortState>(null) const [sort, setSort] = React.useState<SortState>(null)
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos type ViewMode = 'table' | 'cards' | 'gallery'
const [thumbTick, setThumbTick] = React.useState(0) const VIEW_KEY = 'finishedDownloads_view'
const [view, setView] = React.useState<ViewMode>('table')
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
React.useEffect(() => { React.useEffect(() => {
const id = window.setInterval(() => { try {
setThumbTick((t) => t + 1) const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
}, 3000) // alle 3 Sekunden if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
setView(saved)
return () => window.clearInterval(id) } else {
// Default: Mobile -> Cards, sonst Tabelle
setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table')
}
} catch {
setView('table')
}
}, []) }, [])
React.useEffect(() => {
try {
localStorage.setItem(VIEW_KEY, view)
} catch {}
}, [view])
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden) // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({}) const [durations, setDurations] = React.useState<Record<string, number>>({})
@ -111,33 +136,100 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}) })
}, []) }, [])
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
const markKeeping = React.useCallback((key: string, value: boolean) => {
setKeepingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
// neben deletedKeys / deletingKeys
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
const markRemoving = React.useCallback((key: string, value: boolean) => {
setRemovingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const animateRemove = React.useCallback((key: string) => {
// 1) rot + fade-out starten
markRemoving(key, true)
// 2) nach der Animation wirklich ausblenden
window.setTimeout(() => {
markDeleted(key)
markRemoving(key, false)
}, 320)
}, [markDeleted, markRemoving])
const deleteVideo = React.useCallback( const deleteVideo = React.useCallback(
async (job: RecordJob) => { async (job: RecordJob): Promise<boolean> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
const key = keyFor(job) const key = keyFor(job)
if (!file) { if (!file) {
window.alert('Kein Dateiname gefunden kann nicht löschen.') window.alert('Kein Dateiname gefunden kann nicht löschen.')
return return false
} }
if (deletingKeys.has(key)) return if (deletingKeys.has(key)) return false
markDeleting(key, true) markDeleting(key, true)
try { try {
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
method: 'POST',
})
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`) throw new Error(text || `HTTP ${res.status}`)
} }
markDeleted(key) animateRemove(key)
return true
} catch (e: any) { } catch (e: any) {
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`) window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
return false
} finally { } finally {
markDeleting(key, false) markDeleting(key, false)
} }
}, },
[deletingKeys, markDeleted, markDeleting] [deletingKeys, markDeleting, animateRemove]
)
const keepVideo = React.useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
window.alert('Kein Dateiname gefunden kann nicht behalten.')
return false
}
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
markKeeping(key, true)
try {
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key)
return true
} catch (e: any) {
window.alert(`Behalten fehlgeschlagen: ${String(e?.message || e)}`)
return false
} finally {
markKeeping(key, false)
}
},
[keepingKeys, deletingKeys, markKeeping, animateRemove]
) )
const items = React.useMemo<ContextMenuItem[]>(() => { const items = React.useMemo<ContextMenuItem[]>(() => {
@ -175,10 +267,14 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}, },
}) })
}, [ctx, deleteVideo, onOpenPlayer]) }, [ctx, deleteVideo, onOpenPlayer])
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => { const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
const k = keyFor(job) const k = keyFor(job)
const sec = durations[k] const sec =
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
? (job as any).durationSeconds
: durations[k]
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
const start = Date.parse(String(job.startedAt || '')) const start = Date.parse(String(job.startedAt || ''))
@ -187,6 +283,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return (end - start) / 1000 return (end - start) / 1000
}, [durations]) }, [durations])
const rows = useMemo(() => { const rows = useMemo(() => {
const map = new Map<string, RecordJob>() const map = new Map<string, RecordJob>()
@ -212,18 +309,61 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
setVisibleCount(PAGE_SIZE) setVisibleCount(PAGE_SIZE)
}, [rows.length]) }, [rows.length])
React.useEffect(() => {
const onExternalDelete = (ev: Event) => {
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
if (!detail?.file) return
const key = detail.file
if (detail.phase === 'start') {
markDeleting(key, true)
// ✅ wenn Cards-View: Swipe schon beim Start raus (ohne Aktion, weil App die API schon macht)
if (view === 'cards') {
swipeRefs.current.get(key)?.swipeLeft({ runAction: false })
}
} else if (detail.phase === 'success') {
markDeleting(key, false)
if (view === 'cards') {
// ✅ nach Swipe-Animation wirklich aus der Liste entfernen
window.setTimeout(() => markDeleted(key), 320)
} else {
// table/gallery: wie bisher ausblenden
animateRemove(key)
}
} else if (detail.phase === 'error') {
markDeleting(key, false)
// ✅ Swipe zurück, falls Delete fehlgeschlagen
if (view === 'cards') {
swipeRefs.current.get(key)?.reset()
}
}
}
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view])
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount]) const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt // 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => { const runtimeOf = (job: RecordJob): string => {
const k = keyFor(job) const k = keyFor(job)
const sec = durations[k] const sec =
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
? (job as any).durationSeconds
: durations[k]
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) { if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
return formatDuration(sec * 1000) return formatDuration(sec * 1000)
} }
return runtimeFromTimestamps(job) return runtimeFromTimestamps(job)
} }
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind // Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => { const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
if (!Number.isFinite(seconds) || seconds <= 0) return if (!Number.isFinite(seconds) || seconds <= 0) return
@ -247,7 +387,6 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
getFileName={baseName} getFileName={baseName}
durationSeconds={durations[keyFor(j)]} durationSeconds={durations[keyFor(j)]}
onDuration={handleDuration} onDuration={handleDuration}
thumbTick={thumbTick}
/> />
), ),
}, },
@ -335,61 +474,224 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return ( return (
<> <>
{/* ✅ Mobile: Cards */} {/* Toolbar */}
<div className="sm:hidden space-y-3"> <div className="mb-3 flex items-center justify-end">
{visibleRows.map((j) => { <ButtonGroup
const model = modelNameFromOutput(j.output) value={view}
const file = baseName(j.output || '') onChange={(id) => setView(id as ViewMode)}
const dur = runtimeOf(j) size="sm"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: <span className="hidden sm:inline">Tabelle</span>,
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: <span className="hidden sm:inline">Cards</span>,
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: <span className="hidden sm:inline">Galerie</span>,
srLabel: 'Galerie',
},
]}
/>
</div>
const statusNode = {/* ✅ Cards */}
j.status === 'failed' ? ( {view === 'cards' && (
<span className="text-red-700 dark:text-red-300" title={j.error || ''}> <div className="space-y-3">
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''} {visibleRows.map((j) => {
</span> const k = keyFor(j)
) : ( const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
<span className="font-medium">{j.status}</span>
)
return ( const model = modelNameFromOutput(j.output)
<div const file = baseName(j.output || '')
key={keyFor(j)} const dur = runtimeOf(j)
role="button"
tabIndex={0} const statusNode =
className="cursor-pointer" j.status === 'failed' ? (
onClick={() => onOpenPlayer(j)} <span className="text-red-700 dark:text-red-300" title={j.error || ''}>
onKeyDown={(e) => { failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) </span>
}} ) : (
onContextMenu={(e) => openCtx(j, e)} <span className="font-medium">{j.status}</span>
> )
<Card
header={ return (
<div className="flex items-start justify-between gap-3"> <SwipeCard
<div className="min-w-0"> ref={(h) => {
<div className="truncate text-sm font-medium text-gray-900 dark:text-white"> if (h) swipeRefs.current.set(k, h)
{model} else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
onTap={() => onOpenPlayer(j)}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
<div
role="button"
tabIndex={0}
className={[
'transition-all duration-300 ease-in-out',
busy && 'pointer-events-none',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
keepingKeys.has(k) &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
onContextMenu={(e) => openCtx(j, e)}
>
<Card
header={
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
</div>
<div className="shrink-0 flex items-center gap-1">
<Button
aria-label="Video löschen"
title="Video löschen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) {
void h.swipeLeft() // ✅ führt Swipe + deleteVideo aus
} else {
void deleteVideo(j)
}
}}
>
🗑
</Button>
<button
type="button"
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
</div>
</div> </div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300"> }
{file || '—'} >
</div> <div className="flex gap-3">
</div> <div
className="shrink-0"
<div className="shrink-0 flex items-center gap-1"> onClick={(e) => e.stopPropagation()}
{/* 🗑️ Direkt-Löschen */} onMouseDown={(e) => e.stopPropagation()}
<Button onContextMenu={(e) => {
aria-label="Video löschen"
title="Video löschen"
onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
void deleteVideo(j) openCtx(j, e)
}} }}
> >
🗑 <FinishedVideoPreview
</Button> job={j}
getFileName={baseName}
{/* ✅ Menü-Button für Touch/Small Devices */} durationSeconds={durations[k]}
onDuration={handleDuration}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
Status: {statusNode}
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.output ? (
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">{j.output}</div>
) : null}
</div>
</div>
</Card>
</div>
</SwipeCard>
)
})}
</div>
)}
{/* ✅ Tabelle */}
{view === 'table' && (
<Table
rows={visibleRows}
columns={columns}
getRowKey={(j) => keyFor(j)}
striped
fullWidth
stickyHeader
sort={sort}
onSortChange={setSort}
onRowClick={onOpenPlayer}
onRowContextMenu={(job, e) => openCtx(job, e)}
rowClassName={(j) => {
const k = keyFor(j)
return [
'transition-opacity duration-300',
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse',
removingKeys.has(k) && 'opacity-0',
]
.filter(Boolean)
.join(' ')
}}
/>
)}
{/* ✅ Galerie */}
{view === 'gallery' && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{visibleRows.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
return (
<div
key={keyFor(j)}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
onContextMenu={(e) => openCtx(j, e)}
>
<Card
header={
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
</div>
<button <button
type="button" type="button"
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10" className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
@ -404,12 +706,9 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
</button> </button>
</div> </div>
</div> }
} >
>
<div className="flex gap-3">
<div <div
className="shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => { onContextMenu={(e) => {
@ -426,40 +725,17 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
<div className="text-xs text-gray-600 dark:text-gray-300"> Status: <span className="font-medium">{j.status}</span>
Status: {statusNode} <span className="mx-2 opacity-60"></span>
<span className="mx-2 opacity-60"></span> Dauer: <span className="font-medium">{runtimeOf(j)}</span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.output ? (
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
{j.output}
</div>
) : null}
</div> </div>
</div> </Card>
</Card> </div>
</div> )
) })}
})} </div>
</div> )}
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={visibleRows}
columns={columns}
getRowKey={(j) => keyFor(j)}
striped
fullWidth
sort={sort}
onSortChange={setSort}
onRowClick={onOpenPlayer}
onRowContextMenu={(job, e) => openCtx(job, e)}
/>
</div>
<ContextMenu <ContextMenu
open={!!ctx} open={!!ctx}
@ -471,15 +747,15 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{rows.length > visibleCount ? ( {rows.length > visibleCount ? (
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">
<button <Button
type="button"
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15" className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))} onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
> >
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount}) Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
</button> </Button>
</div> </div>
) : null} ) : null}
</> </>
) )
} }

View File

@ -1,19 +1,17 @@
// frontend/src/components/ui/FinishedVideoPreview.tsx // FinishedVideoPreview.tsx
'use client' 'use client'
import { useMemo, useState, type SyntheticEvent } from 'react' import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
type Props = { type Props = {
job: RecordJob job: RecordJob
getFileName: (path: string) => string getFileName: (path: string) => string
// 🔹 optional: bereits bekannte Dauer (Sekunden)
durationSeconds?: number durationSeconds?: number
// 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben
onDuration?: (job: RecordJob, seconds: number) => void onDuration?: (job: RecordJob, seconds: number) => void
animated?: boolean // ✅ neu
thumbTick?: number autoTickMs?: number // ✅ neu
} }
export default function FinishedVideoPreview({ export default function FinishedVideoPreview({
@ -21,14 +19,39 @@ export default function FinishedVideoPreview({
getFileName, getFileName,
durationSeconds, durationSeconds,
onDuration, onDuration,
thumbTick animated = false,
autoTickMs = 15000,
}: Props) { }: Props) {
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const [thumbOk, setThumbOk] = useState(true) const [thumbOk, setThumbOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false) const [metaLoaded, setMetaLoaded] = useState(false)
// id für /api/record/preview: Dateiname ohne Extension // ✅ nur animieren, wenn sichtbar (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [localTick, setLocalTick] = useState(0)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!animated) return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, inView, autoTickMs])
const previewId = useMemo(() => { const previewId = useMemo(() => {
if (!file) return '' if (!file) return ''
const dot = file.lastIndexOf('.') const dot = file.lastIndexOf('.')
@ -43,58 +66,39 @@ export default function FinishedVideoPreview({
const hasDuration = const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const tick = thumbTick ?? 0
// Zeitposition im Video: alle 3s ein Schritt, modulo Videolänge
const thumbTimeSec = useMemo(() => { const thumbTimeSec = useMemo(() => {
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { if (!animated) return null
// Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben if (!hasDuration) return null
return 0 const step = 3
} const total = Math.max(durationSeconds! - 0.1, step)
const step = 3 // Sekunden pro Schritt return (localTick * step) % total
const steps = Math.max(0, Math.floor(tick)) }, [animated, hasDuration, durationSeconds, localTick])
// kleine Reserve, damit wir nicht exakt auf das letzte Frame springen
const total = Math.max(durationSeconds - 0.1, step)
return (steps * step) % total
}, [durationSeconds, tick])
// Thumbnail (immer mit t=..., auch wenn t=0 → erster Frame) // ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
const thumbSrc = useMemo(() => { const thumbSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
if (thumbTimeSec == null) {
const params: string[] = [] // statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
// ⬅️ immer Zeitposition mitgeben, auch bei 0
params.push(`t=${encodeURIComponent(thumbTimeSec.toFixed(2))}`)
// Versionierung für den Browser-Cache / Animation
if (typeof thumbTick === 'number') {
params.push(`v=${encodeURIComponent(String(thumbTick))}`)
} }
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
const qs = params.length ? `&${params.join('&')}` : '' thumbTimeSec.toFixed(2)
return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}` )}`
}, [previewId, thumbTimeSec, thumbTick]) }, [previewId, thumbTimeSec])
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true) setMetaLoaded(true)
if (!onDuration) return if (!onDuration) return
const secs = e.currentTarget.duration const secs = e.currentTarget.duration
if (Number.isFinite(secs) && secs > 0) { if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
onDuration(job, secs)
}
} }
if (!videoSrc) { if (!videoSrc) {
return ( return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
)
} }
return ( return (
<HoverPopover <HoverPopover
// ⚠️ Großes Video nur rendern, wenn Popover offen ist
content={(open) => content={(open) =>
open && ( open && (
<div className="w-[420px]"> <div className="w-[420px]">
@ -116,8 +120,7 @@ export default function FinishedVideoPreview({
) )
} }
> >
{/* 🔹 Inline nur Thumbnail / Platzhalter */} <div ref={rootRef} className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
{thumbSrc && thumbOk ? ( {thumbSrc && thumbOk ? (
<img <img
src={thumbSrc} src={thumbSrc}
@ -130,9 +133,8 @@ export default function FinishedVideoPreview({
<div className="w-full h-full bg-black" /> <div className="w-full h-full bg-black" />
)} )}
{/* 🔍 Unsichtbares Video nur zum Metadaten-Laden (Dauer), {/* ✅ Metadaten nur laden, wenn sichtbar (inView) */}
wird genau EINMAL pro Datei geladen */} {inView && onDuration && !hasDuration && !metaLoaded && (
{onDuration && !hasDuration && !metaLoaded && (
<video <video
src={videoSrc} src={videoSrc}
preload="metadata" preload="metadata"

View File

@ -112,7 +112,7 @@ export default function Player({
autoplay: true, autoplay: true,
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
controls: true, controls: true,
preload: 'auto', preload: 'metadata',
playsinline: true, playsinline: true,
responsive: true, responsive: true,
fluid: false, fluid: false,
@ -195,6 +195,18 @@ export default function Player({
queueMicrotask(() => p.trigger('resize')) queueMicrotask(() => p.trigger('resize'))
}, [expanded]) }, [expanded])
const releaseMedia = React.useCallback(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
try {
p.pause()
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
if (!mounted) return null if (!mounted) return null
const mini = !expanded const mini = !expanded
@ -203,14 +215,33 @@ export default function Player({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant={isHot ? 'soft' : 'secondary'} variant={isHot ? 'soft' : 'secondary'}
size="xs" size="md"
className={cn( className={cn(
'px-2 py-1', 'px-2 py-1',
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10' !isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)} )}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'} title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'} aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
onClick={() => onToggleHot?.(job)} onClick={async () => {
// 1) Stream freigeben (wichtig für Windows Rename)
releaseMedia()
// 2) kurz warten, bis Browser/HTTP wirklich zu ist
await new Promise((r) => setTimeout(r, 150))
// 3) Rename (App aktualisiert danach job.output -> media.src ändert sich)
await onToggleHot?.(job)
// 4) Optional: nach Rename wieder starten (falls du willst)
await new Promise((r) => setTimeout(r, 0))
const p = playerRef.current
if (p && !(p as any).isDisposed?.()) {
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') {
;(ret as Promise<void>).catch(() => {})
}
}
}}
disabled={!onToggleHot} disabled={!onToggleHot}
> >
<FireIcon className="h-4 w-4" /> <FireIcon className="h-4 w-4" />
@ -218,7 +249,7 @@ export default function Player({
<Button <Button
variant={isFavorite ? 'soft' : 'secondary'} variant={isFavorite ? 'soft' : 'secondary'}
size="xs" size="md"
className={cn( className={cn(
'px-2 py-1', 'px-2 py-1',
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10' !isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
@ -233,7 +264,7 @@ export default function Player({
<Button <Button
variant={isLiked ? 'soft' : 'secondary'} variant={isLiked ? 'soft' : 'secondary'}
size="xs" size="md"
className={cn( className={cn(
'px-2 py-1', 'px-2 py-1',
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10' !isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
@ -248,11 +279,20 @@ export default function Player({
<Button <Button
variant="secondary" variant="secondary"
size="xs" size="md"
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400" className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
title="Löschen" title="Löschen"
aria-label="Löschen" aria-label="Löschen"
onClick={() => onDelete?.(job)} onClick={async () => {
releaseMedia()
// optional: Player schließen -> dispose() läuft im Cleanup und gibt endgültig frei
onClose()
// kurzer Moment, bis der Browser den Stream wirklich abbricht
await new Promise((r) => setTimeout(r, 150))
await onDelete?.(job)
}}
disabled={!onDelete} disabled={!onDelete}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
@ -273,7 +313,9 @@ export default function Player({
<Card <Card
className={cn( className={cn(
'fixed z-50 shadow-xl border flex flex-col', 'fixed z-50 shadow-xl border flex flex-col',
expanded ? 'inset-6' : 'bottom-4 right-4 w-[380px]', expanded
? 'inset-6'
: 'left-0 right-0 bottom-0 w-full rounded-none sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[380px]',
className ?? '' className ?? ''
)} )}
noBodyPadding noBodyPadding
@ -288,7 +330,7 @@ export default function Player({
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Button <Button
variant="secondary" variant="secondary"
size="xs" size="md"
className="px-2 py-1" className="px-2 py-1"
onClick={onToggleExpand} onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'} aria-label={expanded ? 'Minimieren' : 'Maximieren'}
@ -303,7 +345,8 @@ export default function Player({
<Button <Button
variant="secondary" variant="secondary"
size="xs" size="md"
color='red'
className="px-2 py-1" className="px-2 py-1"
onClick={onClose} onClick={onClose}
title="Schließen" title="Schließen"

View File

@ -0,0 +1,224 @@
'use client'
import * as React from 'react'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export type SwipeAction = {
label: React.ReactNode
className?: string
}
export type SwipeCardProps = {
children: React.ReactNode
/** Swipe an/aus (z.B. nur mobile view) */
enabled?: boolean
/** blockiert Swipe + Tap */
disabled?: boolean
/** Tap ohne Swipe (z.B. Player öffnen) */
onTap?: () => void
/**
* Rückgabe:
* - true/void => Aktion erfolgreich, Karte fliegt raus (translate offscreen)
* - false => Aktion fehlgeschlagen => Karte snappt zurück
*/
onSwipeLeft: () => boolean | void | Promise<boolean | void>
onSwipeRight: () => boolean | void | Promise<boolean | void>
/** optionales Styling am äußeren Wrapper */
className?: string
/** Action-Bereiche */
leftAction?: SwipeAction // standard: Behalten
rightAction?: SwipeAction // standard: Löschen
/** Ab welcher Strecke wird ausgelöst? */
thresholdPx?: number
thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35
/** Animation timings */
snapMs?: number
commitMs?: number
}
export type SwipeCardHandle = {
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
reset: () => void
}
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
{
children,
enabled = true,
disabled = false,
onTap,
onSwipeLeft,
onSwipeRight,
className,
leftAction = {
label: <span className="inline-flex items-center gap-2 font-semibold"> Behalten</span>,
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
},
rightAction = {
label: <span className="inline-flex items-center gap-2 font-semibold"> Löschen</span>,
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
},
thresholdPx = 120,
thresholdRatio = 0.35,
snapMs = 180,
commitMs = 180,
},
ref
) {
const cardRef = React.useRef<HTMLDivElement | null>(null)
const pointer = React.useRef<{
id: number | null
x: number
y: number
dragging: boolean
}>({ id: null, x: 0, y: 0, dragging: false })
const [dx, setDx] = React.useState(0)
const [animMs, setAnimMs] = React.useState<number>(0)
const reset = React.useCallback(() => {
setAnimMs(snapMs)
setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs)
}, [snapMs])
const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => {
const el = cardRef.current
const w = el?.offsetWidth || 360
// rausfliegen lassen
setAnimMs(commitMs)
setDx(dir === 'right' ? w + 40 : -(w + 40))
let ok: boolean | void = true
if (runAction) {
try {
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
} catch {
ok = false
}
}
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
setAnimMs(snapMs)
setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs)
return false
}
return true
},
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
)
React.useImperativeHandle(
ref,
() => ({
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
reset: () => reset(),
}),
[commit, reset]
)
return (
<div className={cn('relative overflow-hidden rounded-lg', className)}>
{/* Background actions (100% je Richtung) */}
<div className="absolute inset-0 pointer-events-none">
{dx !== 0 ? (
<div
className={cn(
'h-full w-full flex items-center',
dx > 0 ? leftAction.className : rightAction.className,
dx > 0 ? 'justify-start pl-4' : 'justify-end pr-4'
)}
>
{dx > 0 ? leftAction.label : rightAction.label}
</div>
) : null}
</div>
{/* Foreground (moves) */}
<div
ref={cardRef}
className="relative"
style={{
transform: `translateX(${dx}px)`,
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) 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, nicht hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
if (Math.abs(ddx) < 6) return
pointer.current.dragging = true
}
setAnimMs(0)
setDx(ddx)
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging
pointer.current.id = null
if (!wasDragging) {
reset()
onTap?.()
return
}
if (dx > threshold) {
void commit('right', true) // keep
} else if (dx < -threshold) {
void commit('left', true) // delete
} else {
reset()
}
}}
onPointerCancel={() => {
if (!enabled || disabled) return
reset()
}}
>
{children}
</div>
</div>
)
})
export default SwipeCard

View File

@ -57,6 +57,8 @@ export type TableProps<T> = {
className?: string className?: string
rowClassName?: (row: T, rowIndex: number) => string | undefined
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
@ -116,6 +118,7 @@ export default function Table<T>({
isLoading = false, isLoading = false,
emptyLabel = 'Keine Daten vorhanden.', emptyLabel = 'Keine Daten vorhanden.',
className, className,
rowClassName,
onRowClick, onRowClick,
onRowContextMenu, onRowContextMenu,
sort, sort,
@ -309,7 +312,8 @@ export default function Table<T>({
key={key} key={key}
className={cn( className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50', striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && 'cursor-pointer' onRowClick && 'cursor-pointer',
rowClassName?.(row, rowIndex)
)} )}
onClick={() => onRowClick?.(row)} onClick={() => onRowClick?.(row)}
onContextMenu={ onContextMenu={