updated
This commit is contained in:
parent
99837f0ed3
commit
d603dd2342
360
backend/main.go
360
backend/main.go
@ -47,8 +47,9 @@ type RecordJob struct {
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
PreviewDir string `json:"-"`
|
||||
previewCmd *exec.Cmd `json:"-"`
|
||||
PreviewDir string `json:"-"`
|
||||
PreviewImage string `json:"-"`
|
||||
previewCmd *exec.Cmd `json:"-"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"`
|
||||
}
|
||||
@ -58,16 +59,29 @@ var (
|
||||
jobsMu = sync.Mutex{}
|
||||
)
|
||||
|
||||
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
||||
var ffmpegPath = detectFFmpegPath()
|
||||
|
||||
// main.go
|
||||
|
||||
type RecorderSettings struct {
|
||||
RecordDir string `json:"recordDir"`
|
||||
DoneDir string `json:"doneDir"`
|
||||
RecordDir string `json:"recordDir"`
|
||||
DoneDir string `json:"doneDir"`
|
||||
FFmpegPath string `json:"ffmpegPath,omitempty"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList,omitempty"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
settingsMu sync.Mutex
|
||||
settings = RecorderSettings{
|
||||
RecordDir: "/records",
|
||||
DoneDir: "/records/done",
|
||||
RecordDir: "/records",
|
||||
DoneDir: "/records/done",
|
||||
FFmpegPath: "",
|
||||
|
||||
AutoAddToDownloadList: false,
|
||||
AutoStartAddedDownloads: false,
|
||||
}
|
||||
settingsFile = "recorder_settings.json"
|
||||
)
|
||||
@ -78,6 +92,53 @@ func getSettings() RecorderSettings {
|
||||
return settings
|
||||
}
|
||||
|
||||
func detectFFmpegPath() string {
|
||||
// 0. Settings-Override (ffmpegPath in recorder_settings.json / UI)
|
||||
s := getSettings()
|
||||
if p := strings.TrimSpace(s.FFmpegPath); p != "" {
|
||||
// Relativ zur EXE auflösen, falls nötig
|
||||
if !filepath.IsAbs(p) {
|
||||
if abs, err := resolvePathRelativeToExe(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// 1. Umgebungsvariable FFMPEG_PATH erlaubt Override
|
||||
if p := strings.TrimSpace(os.Getenv("FFMPEG_PATH")); p != "" {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// 2. ffmpeg / ffmpeg.exe im selben Ordner wie dein Go-Programm
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(exeDir, "ffmpeg"),
|
||||
filepath.Join(exeDir, "ffmpeg.exe"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ffmpeg über PATH suchen und absolut machen
|
||||
if lp, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
if abs, err2 := filepath.Abs(lp); err2 == nil {
|
||||
return abs
|
||||
}
|
||||
return lp
|
||||
}
|
||||
|
||||
// 4. Fallback: plain "ffmpeg" – kann dann immer noch fehlschlagen
|
||||
return "ffmpeg"
|
||||
}
|
||||
|
||||
func loadSettings() {
|
||||
b, err := os.ReadFile(settingsFile)
|
||||
if err == nil {
|
||||
@ -89,6 +150,10 @@ func loadSettings() {
|
||||
if strings.TrimSpace(s.DoneDir) != "" {
|
||||
s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir))
|
||||
}
|
||||
if strings.TrimSpace(s.FFmpegPath) != "" {
|
||||
s.FFmpegPath = strings.TrimSpace(s.FFmpegPath)
|
||||
}
|
||||
|
||||
settingsMu.Lock()
|
||||
settings = s
|
||||
settingsMu.Unlock()
|
||||
@ -99,6 +164,10 @@ func loadSettings() {
|
||||
s := getSettings()
|
||||
_ = os.MkdirAll(s.RecordDir, 0o755)
|
||||
_ = os.MkdirAll(s.DoneDir, 0o755)
|
||||
|
||||
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
|
||||
ffmpegPath = detectFFmpegPath()
|
||||
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
}
|
||||
|
||||
func saveSettingsToDisk() {
|
||||
@ -123,6 +192,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir))
|
||||
in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir))
|
||||
in.FFmpegPath = strings.TrimSpace(in.FFmpegPath)
|
||||
|
||||
if in.RecordDir == "" || in.DoneDir == "" {
|
||||
http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest)
|
||||
@ -144,6 +214,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
settingsMu.Unlock()
|
||||
saveSettingsToDisk()
|
||||
|
||||
// ffmpeg-Pfad nach Änderungen neu bestimmen
|
||||
ffmpegPath = detectFFmpegPath()
|
||||
fmt.Println("🔍 ffmpegPath (nach Save):", ffmpegPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(getSettings())
|
||||
return
|
||||
@ -156,19 +230,35 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.Query().Get("target")
|
||||
if target != "record" && target != "done" {
|
||||
http.Error(w, "target muss record oder done sein", http.StatusBadRequest)
|
||||
if target != "record" && target != "done" && target != "ffmpeg" {
|
||||
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := dialog.Directory().Title("Ordner auswählen").Browse()
|
||||
var (
|
||||
p string
|
||||
err error
|
||||
)
|
||||
|
||||
if target == "ffmpeg" {
|
||||
// Dateiauswahl für ffmpeg.exe
|
||||
p, err = dialog.File().
|
||||
Title("ffmpeg.exe auswählen").
|
||||
Load()
|
||||
} else {
|
||||
// Ordnerauswahl für record/done
|
||||
p, err = dialog.Directory().
|
||||
Title("Ordner auswählen").
|
||||
Browse()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// User cancelled → 204 No Content ist praktisch fürs Frontend
|
||||
if strings.Contains(strings.ToLower(err.Error()), "cancel") {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@ -301,7 +391,7 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri
|
||||
|
||||
func remuxTSToMP4(tsPath, mp4Path string) error {
|
||||
// ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4
|
||||
cmd := exec.Command("ffmpeg",
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-y",
|
||||
"-i", tsPath,
|
||||
"-c", "copy",
|
||||
@ -317,10 +407,8 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
|
||||
}
|
||||
|
||||
func extractLastFrameJPEG(path string) ([]byte, error) {
|
||||
// “Letzter Frame” über -sseof nahe Dateiende.
|
||||
// Bei laufenden Aufnahmen kann das manchmal fehlschlagen -> Fallback oben.
|
||||
cmd := exec.Command(
|
||||
"ffmpeg",
|
||||
ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-sseof", "-0.1",
|
||||
@ -343,6 +431,79 @@ func extractLastFrameJPEG(path string) ([]byte, error) {
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
seek := fmt.Sprintf("%.3f", seconds)
|
||||
|
||||
cmd := exec.Command(
|
||||
ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", seek,
|
||||
"-i", path,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "4",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg frame-at-time: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts)
|
||||
func latestPreviewSegment(previewDir string) (string, error) {
|
||||
entries, err := os.ReadDir(previewDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var best string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") {
|
||||
continue
|
||||
}
|
||||
if best == "" || name > best {
|
||||
best = name
|
||||
}
|
||||
}
|
||||
|
||||
if best == "" {
|
||||
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir)
|
||||
}
|
||||
return filepath.Join(previewDir, best), nil
|
||||
}
|
||||
|
||||
// erzeugt ein JPEG aus dem letzten Preview-Segment
|
||||
func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) {
|
||||
seg, err := latestPreviewSegment(previewDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Segment ist klein und "fertig" – hier reicht ein Last-Frame-Versuch,
|
||||
// mit Fallback auf First-Frame.
|
||||
img, err := extractLastFrameJPEG(seg)
|
||||
if err != nil {
|
||||
return extractFirstFrameJPEG(seg)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func recordPreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
@ -350,53 +511,132 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// HLS mode: ?file=index.m3u8 / seg_00001.ts / index_hq.m3u8 ...
|
||||
// HLS-Dateien (index.m3u8, seg_*.ts) wie bisher
|
||||
if file := r.URL.Query().Get("file"); file != "" {
|
||||
servePreviewHLSFile(w, r, id, file)
|
||||
return
|
||||
}
|
||||
|
||||
// sonst: JPEG Fallback
|
||||
// Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig)
|
||||
jobsMu.Lock()
|
||||
job, ok := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||||
if ok {
|
||||
// 1️⃣ Bevorzugt: aktuelles Bild aus HLS-Preview-Segmenten
|
||||
previewDir := strings.TrimSpace(job.PreviewDir)
|
||||
if previewDir != "" {
|
||||
if img, err := extractLastFrameFromPreviewDir(previewDir); err == nil {
|
||||
// dynamischer Snapshot – Frontend hängt ?v=... dran,
|
||||
// damit der Browser ihn neu lädt
|
||||
servePreviewJPEGBytes(w, img)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ Fallback: direkt aus der Ausgabedatei (TS/MP4), z.B. wenn Preview noch nicht läuft
|
||||
outPath := strings.TrimSpace(job.Output)
|
||||
if outPath == "" {
|
||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
outPath = filepath.Clean(outPath)
|
||||
|
||||
if !filepath.IsAbs(outPath) {
|
||||
if abs, err := resolvePathRelativeToExe(outPath); err == nil {
|
||||
outPath = abs
|
||||
}
|
||||
}
|
||||
|
||||
fi, err := os.Stat(outPath)
|
||||
if err != nil || fi.IsDir() || fi.Size() == 0 {
|
||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||
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
|
||||
}
|
||||
|
||||
servePreviewJPEGBytes(w, img)
|
||||
return
|
||||
}
|
||||
|
||||
outPath := strings.TrimSpace(job.Output)
|
||||
// 3️⃣ Kein Job im RAM → id als Dateistamm für fertige Downloads behandeln
|
||||
servePreviewForFinishedFile(w, r, id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
recordAbs, _ := resolvePathRelativeToExe(s.RecordDir)
|
||||
doneAbs, _ := resolvePathRelativeToExe(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 == "" {
|
||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
outPath = filepath.Clean(outPath)
|
||||
|
||||
// ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen.
|
||||
// (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.)
|
||||
if !filepath.IsAbs(outPath) {
|
||||
if outPath == "." || outPath == ".." ||
|
||||
strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) {
|
||||
http.Error(w, "ungültiger output-pfad", http.StatusBadRequest)
|
||||
return
|
||||
// 🔹 NEU: dynamischer Frame an Zeitposition t (z.B. für animierte Thumbnails)
|
||||
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
|
||||
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
||||
if img, err := extractFrameAtTimeJPEG(outPath, sec); err == nil {
|
||||
servePreviewJPEGBytes(w, img)
|
||||
return
|
||||
}
|
||||
// wenn ffmpeg hier scheitert, geht's unten mit statischem Preview weiter
|
||||
}
|
||||
}
|
||||
|
||||
fi, err := os.Stat(outPath)
|
||||
if err != nil {
|
||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||
// 🔸 ALT: einmaliges Preview cachen (preview.jpg) – Fallback
|
||||
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)
|
||||
return
|
||||
}
|
||||
if fi.IsDir() || fi.Size() == 0 {
|
||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||
|
||||
jpegPath := filepath.Join(previewDir, "preview.jpg")
|
||||
|
||||
if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
servePreviewJPEGFile(w, r, jpegPath)
|
||||
return
|
||||
}
|
||||
|
||||
img, err := extractLastFrameJPEG(outPath)
|
||||
if err != nil {
|
||||
// Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger
|
||||
img2, err2 := extractFirstFrameJPEG(outPath)
|
||||
if err2 != nil {
|
||||
http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError)
|
||||
@ -405,13 +645,25 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
|
||||
img = img2
|
||||
}
|
||||
|
||||
_ = os.WriteFile(jpegPath, img, 0o644)
|
||||
servePreviewJPEGBytes(w, img)
|
||||
}
|
||||
|
||||
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(img)
|
||||
}
|
||||
|
||||
func servePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func recordList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -506,6 +758,10 @@ func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string {
|
||||
}
|
||||
|
||||
func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie, userAgent string) error {
|
||||
if strings.TrimSpace(ffmpegPath) == "" {
|
||||
return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(previewDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -553,20 +809,20 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
|
||||
|
||||
// beide Prozesse starten (einfach & robust)
|
||||
go func(kind string, args []string) {
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil && ctx.Err() == nil {
|
||||
fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
|
||||
fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
}("low", lowArgs)
|
||||
|
||||
go func(kind string, args []string) {
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil && ctx.Err() == nil {
|
||||
fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
|
||||
fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
}("hq", hqArgs)
|
||||
|
||||
@ -575,7 +831,7 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
|
||||
|
||||
func extractFirstFrameJPEG(path string) ([]byte, error) {
|
||||
cmd := exec.Command(
|
||||
"ffmpeg",
|
||||
ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", path,
|
||||
@ -930,8 +1186,24 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn kein DoneDir gesetzt ist → einfach leere Liste zurückgeben
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode([]*RecordJob{})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(doneAbs)
|
||||
if err != nil {
|
||||
// Wenn Verzeichnis nicht existiert → leere Liste statt 500
|
||||
if os.IsNotExist(err) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode([]*RecordJob{})
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -953,10 +1225,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ID stabil aus Dateiname (ohne Extension) – reicht für Player/Key
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
|
||||
// best effort: Zeiten aus FileInfo
|
||||
t := fi.ModTime()
|
||||
|
||||
list = append(list, &RecordJob{
|
||||
@ -969,7 +1238,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// neueste zuerst
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].EndedAt.After(*list[j].EndedAt)
|
||||
})
|
||||
@ -1398,7 +1666,7 @@ func (p *Playlist) WatchSegments(
|
||||
) error {
|
||||
var lastSeq int64 = -1
|
||||
emptyRounds := 0
|
||||
const maxEmptyRounds = 5
|
||||
const maxEmptyRounds = 60 // statt 5
|
||||
|
||||
for {
|
||||
select {
|
||||
@ -1741,7 +2009,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error {
|
||||
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"ffmpeg",
|
||||
ffmpegPath,
|
||||
"-y",
|
||||
"-i", m3u8URL,
|
||||
"-c", "copy",
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
{
|
||||
"recordDir": "C:\\Users\\Rother\\Desktop\\test",
|
||||
"doneDir": "C:\\Users\\Rother\\Desktop\\test\\done"
|
||||
"recordDir": "C:\\test",
|
||||
"doneDir": "C:\\test\\done",
|
||||
"ffmpegPath": "C:\\ffmpeg\\ffmpeg.exe",
|
||||
"autoAddToDownloadList": true,
|
||||
"autoStartAddedDownloads": true
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import './App.css'
|
||||
import Button from './components/ui/Button'
|
||||
import Table, { type Column } from './components/ui/Table'
|
||||
@ -64,6 +64,35 @@ const runtimeOf = (j: RecordJob) => {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
type RecorderSettings = {
|
||||
recordDir: string
|
||||
doneDir: string
|
||||
ffmpegPath?: string
|
||||
autoAddToDownloadList?: boolean
|
||||
autoStartAddedDownloads?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
recordDir: 'records',
|
||||
doneDir: 'records/done',
|
||||
ffmpegPath: '',
|
||||
autoAddToDownloadList: false,
|
||||
autoStartAddedDownloads: false,
|
||||
}
|
||||
|
||||
function extractFirstHttpUrl(text: string): string | null {
|
||||
const t = (text ?? '').trim()
|
||||
if (!t) return null
|
||||
|
||||
// erstes Token, das wie eine URL aussieht
|
||||
for (const token of t.split(/\s+/g)) {
|
||||
if (!/^https?:\/\//i.test(token)) continue
|
||||
try {
|
||||
return new URL(token).toString()
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [sourceUrl, setSourceUrl] = useState('')
|
||||
@ -80,6 +109,46 @@ export default function App() {
|
||||
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
|
||||
const [playerExpanded, setPlayerExpanded] = useState(false)
|
||||
|
||||
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
|
||||
|
||||
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
|
||||
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
|
||||
|
||||
// "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
|
||||
const busyRef = useRef(false)
|
||||
const cookiesRef = useRef<Record<string, string>>({})
|
||||
const jobsRef = useRef<RecordJob[]>([])
|
||||
|
||||
useEffect(() => { busyRef.current = busy }, [busy])
|
||||
useEffect(() => { cookiesRef.current = cookies }, [cookies])
|
||||
useEffect(() => { jobsRef.current = jobs }, [jobs])
|
||||
|
||||
// pending start falls gerade busy
|
||||
const pendingStartUrlRef = useRef<string | null>(null)
|
||||
// um identische Clipboard-Werte nicht dauernd zu triggern
|
||||
const lastClipboardUrlRef = useRef<string>('')
|
||||
|
||||
// settings poll (damit Umschalten im Settings-Tab ohne Reload wirkt)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await apiJSON<RecorderSettings>('/api/settings', { cache: 'no-store' })
|
||||
if (!cancelled && s) setRecSettings((prev) => ({ ...prev, ...s }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const t = window.setInterval(load, 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initialCookies = useMemo(
|
||||
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
|
||||
[cookies]
|
||||
@ -146,31 +215,31 @@ export default function App() {
|
||||
}, [sourceUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setJobs((prev) => {
|
||||
prev.forEach((job) => {
|
||||
if (job.status !== 'running') return
|
||||
apiJSON<RecordJob>(`/api/record/status?id=${encodeURIComponent(job.id)}`)
|
||||
.then((updated) => {
|
||||
setJobs((curr) =>
|
||||
curr.map((j) => (j.id === updated.id ? updated : j))
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
return prev
|
||||
})
|
||||
}, 1000)
|
||||
let cancelled = false
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
const loadJobs = async () => {
|
||||
try {
|
||||
const list = await apiJSON<RecordJob[]>('/api/record/list')
|
||||
if (!cancelled) {
|
||||
setJobs(Array.isArray(list) ? list : [])
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
// optional: bei Fehler nicht alles leeren, sondern Zustand behalten
|
||||
// setJobs([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiJSON<RecordJob[]>('/api/record/list')
|
||||
.then((list) => setJobs(list))
|
||||
.catch(() => {
|
||||
// backend evtl. noch nicht da -> ignorieren
|
||||
})
|
||||
// direkt einmal laden
|
||||
loadJobs()
|
||||
// dann jede Sekunde
|
||||
const t = setInterval(loadJobs, 1000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(t)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -216,33 +285,38 @@ export default function App() {
|
||||
return Boolean(cf && sess)
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
const startUrl = useCallback(async (rawUrl: string) => {
|
||||
const url = rawUrl.trim()
|
||||
if (!url) return
|
||||
if (busyRef.current) return
|
||||
|
||||
setError(null)
|
||||
|
||||
const url = sourceUrl.trim()
|
||||
|
||||
// ❌ Chaturbate ohne Cookies blockieren
|
||||
if (isChaturbate(url) && !hasRequiredChaturbateCookies(cookies)) {
|
||||
setError(
|
||||
'Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.'
|
||||
)
|
||||
const currentCookies = cookiesRef.current
|
||||
if (isChaturbate(url) && !hasRequiredChaturbateCookies(currentCookies)) {
|
||||
setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
|
||||
return
|
||||
}
|
||||
|
||||
// Duplicate-running guard
|
||||
const alreadyRunning = jobsRef.current.some(
|
||||
(j) => j.status === 'running' && String(j.sourceUrl || '') === url
|
||||
)
|
||||
if (alreadyRunning) return
|
||||
|
||||
setBusy(true)
|
||||
busyRef.current = true
|
||||
|
||||
try {
|
||||
const cookieString = Object.entries(cookies)
|
||||
const cookieString = Object.entries(currentCookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ')
|
||||
|
||||
const created = await apiJSON<RecordJob>('/api/record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
cookie: cookieString,
|
||||
}),
|
||||
body: JSON.stringify({ url, cookie: cookieString }),
|
||||
})
|
||||
|
||||
setJobs((prev) => [created, ...prev])
|
||||
@ -250,9 +324,84 @@ export default function App() {
|
||||
setError(e?.message ?? String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
busyRef.current = false
|
||||
}
|
||||
}, []) // arbeitet über refs, daher keine deps nötig
|
||||
|
||||
async function onStart() {
|
||||
return startUrl(sourceUrl)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoAddEnabled && !autoStartEnabled) return
|
||||
if (!navigator.clipboard?.readText) return
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
let timer: number | null = null
|
||||
|
||||
const checkClipboard = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
const url = extractFirstHttpUrl(text)
|
||||
if (!url) return
|
||||
|
||||
if (url === lastClipboardUrlRef.current) return
|
||||
lastClipboardUrlRef.current = url
|
||||
|
||||
// Auto-Add: Input befüllen
|
||||
if (autoAddEnabled) setSourceUrl(url)
|
||||
|
||||
// Auto-Start: sofort starten oder merken, wenn busy
|
||||
if (autoStartEnabled) {
|
||||
if (busyRef.current) {
|
||||
pendingStartUrlRef.current = url
|
||||
} else {
|
||||
pendingStartUrlRef.current = null
|
||||
await startUrl(url)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// In Browsern kann readText im Hintergrund/ohne Permission failen -> ignorieren
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (ms: number) => {
|
||||
if (cancelled) return
|
||||
timer = window.setTimeout(async () => {
|
||||
await checkClipboard()
|
||||
// Hintergrund weniger aggressiv pollen
|
||||
schedule(document.hidden ? 5000 : 1500)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const kick = () => void checkClipboard()
|
||||
window.addEventListener('focus', kick)
|
||||
document.addEventListener('visibilitychange', kick)
|
||||
|
||||
schedule(0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) window.clearTimeout(timer)
|
||||
window.removeEventListener('focus', kick)
|
||||
document.removeEventListener('visibilitychange', kick)
|
||||
}
|
||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (busy) return
|
||||
if (!autoStartEnabled) return
|
||||
const pending = pendingStartUrlRef.current
|
||||
if (!pending) return
|
||||
pendingStartUrlRef.current = null
|
||||
void startUrl(pending)
|
||||
}, [busy, autoStartEnabled, startUrl])
|
||||
|
||||
async function stopJob(id: string) {
|
||||
try {
|
||||
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, {
|
||||
@ -261,65 +410,6 @@ export default function App() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const columns: Column<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) =>
|
||||
j.status === 'running'
|
||||
? <ModelPreview jobId={j.id} />
|
||||
: <img src={`/api/record/preview?id=${j.id}`} />
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modelname',
|
||||
cell: (j) => (
|
||||
<span className="truncate" title={modelNameFromOutput(j.output)}>
|
||||
{modelNameFromOutput(j.output)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sourceUrl',
|
||||
header: 'Source',
|
||||
cell: (j) => (
|
||||
<a
|
||||
href={j.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{j.sourceUrl}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
header: 'Datei',
|
||||
cell: (j) => baseName(j.output || ''),
|
||||
},
|
||||
{ key: 'status', header: 'Status' },
|
||||
{
|
||||
key: 'runtime',
|
||||
header: 'Dauer',
|
||||
cell: (j) => runtimeOf(j),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktion',
|
||||
srOnlyHeader: true,
|
||||
align: 'right',
|
||||
cell: (j) =>
|
||||
j.status === 'running' ? (
|
||||
<Button size="md" variant="primary" onClick={() => stopJob(j.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto py-4 max-w-7xl sm:px-6 lg:px-8 space-y-6">
|
||||
<Card
|
||||
@ -362,6 +452,7 @@ export default function App() {
|
||||
value={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
ariaLabel="Tabs"
|
||||
variant="pillsBrand"
|
||||
|
||||
/>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// FinishedDownloads.tsx
|
||||
// frontend/src/components/ui/FinishedDownloads.tsx
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -35,7 +35,8 @@ function formatDuration(ms: number): string {
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
function runtimeOf(job: RecordJob): string {
|
||||
// Fallback: reine Aufnahmezeit aus startedAt/endedAt
|
||||
function runtimeFromTimestamps(job: RecordJob): string {
|
||||
const start = Date.parse(String(job.startedAt || ''))
|
||||
const end = Date.parse(String(job.endedAt || ''))
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return '—'
|
||||
@ -60,6 +61,20 @@ const modelNameFromOutput = (output?: string) => {
|
||||
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
||||
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
||||
|
||||
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos
|
||||
const [thumbTick, setThumbTick] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setThumbTick((t) => t + 1)
|
||||
}, 3000) // alle 3 Sekunden
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||
|
||||
const openCtx = (job: RecordJob, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -106,8 +121,10 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
const rows = useMemo(() => {
|
||||
const map = new Map<string, RecordJob>()
|
||||
|
||||
// Basis: Files aus dem Done-Ordner
|
||||
for (const j of doneJobs) map.set(keyFor(j), j)
|
||||
|
||||
// Jobs aus /list drübermergen (z.B. frisch fertiggewordene)
|
||||
for (const j of jobs) {
|
||||
const k = keyFor(j)
|
||||
if (map.has(k)) map.set(k, { ...map.get(k)!, ...j })
|
||||
@ -121,11 +138,42 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
return list
|
||||
}, [jobs, doneJobs])
|
||||
|
||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||
const runtimeOf = (job: RecordJob): string => {
|
||||
const k = keyFor(job)
|
||||
const sec = durations[k]
|
||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
||||
return formatDuration(sec * 1000)
|
||||
}
|
||||
return runtimeFromTimestamps(job)
|
||||
}
|
||||
|
||||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||||
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||||
const k = keyFor(job)
|
||||
setDurations((prev) => {
|
||||
const old = prev[k]
|
||||
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) {
|
||||
return prev // keine unnötigen Re-Renders
|
||||
}
|
||||
return { ...prev, [k]: seconds }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const columns: Column<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <FinishedVideoPreview job={j} getFileName={baseName} />,
|
||||
cell: (j) => (
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[keyFor(j)]}
|
||||
onDuration={handleDuration}
|
||||
thumbTick={thumbTick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
@ -252,7 +300,12 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
openCtx(j, e)
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview job={j} getFileName={baseName} />
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[keyFor(j)]}
|
||||
onDuration={handleDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@ -1,55 +1,148 @@
|
||||
// frontend/src/components/ui/FinishedVideoPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, type SyntheticEvent } from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import HoverPopover from './HoverPopover'
|
||||
|
||||
type Props = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
// 🔹 optional: bereits bekannte Dauer (Sekunden)
|
||||
durationSeconds?: number
|
||||
// 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben
|
||||
onDuration?: (job: RecordJob, seconds: number) => void
|
||||
|
||||
thumbTick?: number
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({ job, getFileName }: Props) {
|
||||
export default function FinishedVideoPreview({
|
||||
job,
|
||||
getFileName,
|
||||
durationSeconds,
|
||||
onDuration,
|
||||
thumbTick
|
||||
}: Props) {
|
||||
const file = getFileName(job.output || '')
|
||||
const src = file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''
|
||||
|
||||
if (!src) {
|
||||
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
const [thumbOk, setThumbOk] = useState(true)
|
||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||
|
||||
// id für /api/record/preview: Dateiname ohne Extension
|
||||
const previewId = useMemo(() => {
|
||||
if (!file) return ''
|
||||
const dot = file.lastIndexOf('.')
|
||||
return dot > 0 ? file.slice(0, dot) : file
|
||||
}, [file])
|
||||
|
||||
const videoSrc = useMemo(
|
||||
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
|
||||
[file]
|
||||
)
|
||||
|
||||
const hasDuration =
|
||||
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(() => {
|
||||
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
// Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben
|
||||
return 0
|
||||
}
|
||||
const step = 3 // Sekunden pro Schritt
|
||||
const steps = Math.max(0, Math.floor(tick))
|
||||
// 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)
|
||||
const thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
|
||||
const params: string[] = []
|
||||
|
||||
// ⬅️ 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))}`)
|
||||
}
|
||||
|
||||
const qs = params.length ? `&${params.join('&')}` : ''
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}`
|
||||
}, [previewId, thumbTimeSec, thumbTick])
|
||||
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
setMetaLoaded(true)
|
||||
if (!onDuration) return
|
||||
|
||||
const secs = e.currentTarget.duration
|
||||
if (Number.isFinite(secs) && secs > 0) {
|
||||
onDuration(job, secs)
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoSrc) {
|
||||
return (
|
||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<video
|
||||
src={src}
|
||||
className="w-full h-full bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
// ⚠️ Großes Video nur rendern, wenn Popover offen ist
|
||||
content={(open) =>
|
||||
open && (
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<video
|
||||
src={videoSrc}
|
||||
className="w-full h-full bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Mini in Tabelle */}
|
||||
<video
|
||||
src={src}
|
||||
className="w-20 h-16 object-cover rounded bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
loop
|
||||
autoPlay
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* 🔹 Inline nur Thumbnail / Platzhalter */}
|
||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
||||
{thumbSrc && thumbOk ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
loading="lazy"
|
||||
alt={file}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setThumbOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* 🔍 Unsichtbares Video nur zum Metadaten-Laden (Dauer),
|
||||
wird genau EINMAL pro Datei geladen */}
|
||||
{onDuration && !hasDuration && !metaLoaded && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="hidden"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// HoverPopover.tsx
|
||||
'use client'
|
||||
|
||||
import {
|
||||
@ -14,16 +13,46 @@ import Card from './Card'
|
||||
|
||||
type Pos = { left: number; top: number }
|
||||
|
||||
export default function HoverPopover({
|
||||
children,
|
||||
content,
|
||||
}: PropsWithChildren<{ content: ReactNode }>) {
|
||||
type HoverPopoverProps = PropsWithChildren<{
|
||||
// Entweder direkt ein ReactNode
|
||||
// oder eine Renderfunktion, die den Open-Status bekommt
|
||||
content: ReactNode | ((open: boolean) => ReactNode)
|
||||
}>
|
||||
|
||||
export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<Pos | null>(null)
|
||||
|
||||
// Timeout-Ref für verzögertes Schließen
|
||||
const closeTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const clearCloseTimeout = () => {
|
||||
if (closeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeTimeoutRef.current)
|
||||
closeTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearCloseTimeout()
|
||||
closeTimeoutRef.current = window.setTimeout(() => {
|
||||
setOpen(false)
|
||||
closeTimeoutRef.current = null
|
||||
}, 150) // 150ms „Gnadenzeit“ zum Rüberfahren
|
||||
}
|
||||
|
||||
const handleEnter = () => {
|
||||
clearCloseTimeout()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const computePos = () => {
|
||||
const trigger = triggerRef.current
|
||||
const pop = popoverRef.current
|
||||
@ -61,7 +90,6 @@ export default function HoverPopover({
|
||||
// Beim Öffnen: erst rendern, dann messen/positionieren
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
// rAF sorgt dafür, dass DOM wirklich steht bevor wir messen
|
||||
const id = requestAnimationFrame(() => computePos())
|
||||
return () => cancelAnimationFrame(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -72,7 +100,7 @@ export default function HoverPopover({
|
||||
if (!open) return
|
||||
const onMove = () => requestAnimationFrame(() => computePos())
|
||||
window.addEventListener('resize', onMove)
|
||||
window.addEventListener('scroll', onMove, true) // capture: auch in scroll-containern
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onMove)
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
@ -80,13 +108,24 @@ export default function HoverPopover({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// Cleanup für Timeout
|
||||
useEffect(() => {
|
||||
return () => clearCloseTimeout()
|
||||
}, [])
|
||||
|
||||
// Hilfsfunktion: content normalisieren
|
||||
const renderContent = () =>
|
||||
typeof content === 'function'
|
||||
? (content as (open: boolean) => ReactNode)(open)
|
||||
: content
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="inline-flex"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@ -101,14 +140,14 @@ export default function HoverPopover({
|
||||
top: pos?.top ?? -9999,
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
<Card
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]"
|
||||
noBodyPadding
|
||||
>
|
||||
{content}
|
||||
{renderContent()}
|
||||
</Card>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
78
frontend/src/components/ui/LabeledSwitch.tsx
Normal file
78
frontend/src/components/ui/LabeledSwitch.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
// components/ui/LabeledSwitch.tsx
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Switch, { type SwitchProps } from './Switch'
|
||||
|
||||
type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & {
|
||||
label: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
|
||||
/** "left" = Label links / Switch rechts (wie Beispiel) */
|
||||
labelPosition?: 'left' | 'right'
|
||||
/** Layout wrapper classes */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LabeledSwitch({
|
||||
label,
|
||||
description,
|
||||
labelPosition = 'left',
|
||||
id,
|
||||
className,
|
||||
...switchProps
|
||||
}: Props) {
|
||||
const reactId = React.useId()
|
||||
const switchId = id ?? `sw-${reactId}`
|
||||
const labelId = `${switchId}-label`
|
||||
const descId = `${switchId}-desc`
|
||||
|
||||
if (labelPosition === 'right') {
|
||||
// With right label Beispiel
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between gap-3', className)}>
|
||||
<Switch
|
||||
{...switchProps}
|
||||
id={switchId}
|
||||
ariaLabelledby={labelId}
|
||||
ariaDescribedby={description ? descId : undefined}
|
||||
/>
|
||||
|
||||
<div className="text-sm">
|
||||
<label id={labelId} htmlFor={switchId} className="font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</label>{' '}
|
||||
{description ? (
|
||||
<span id={descId} className="text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// With left label and description Beispiel
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between', className)}>
|
||||
<span className="flex grow flex-col">
|
||||
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
{description ? (
|
||||
<span id={descId} className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<Switch
|
||||
{...switchProps}
|
||||
id={switchId}
|
||||
ariaLabelledby={labelId}
|
||||
ariaDescribedby={description ? descId : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,39 +1,54 @@
|
||||
// frontend\src\components\ui\ModelPreview.tsx
|
||||
// frontend/src/components/ui/ModelPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import HoverPopover from './HoverPopover'
|
||||
import LiveHlsVideo from './LiveHlsVideo'
|
||||
|
||||
export default function ModelPreview({ jobId }: { jobId: string }) {
|
||||
const low = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index.m3u8`,
|
||||
[jobId]
|
||||
type Props = {
|
||||
jobId: string
|
||||
// wird von außen hochgezählt (z.B. alle 5s)
|
||||
thumbTick: number
|
||||
}
|
||||
|
||||
export default function ModelPreview({ jobId, thumbTick }: Props) {
|
||||
// Thumbnail mit Cache-Buster (?v=...)
|
||||
const thumb = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${thumbTick}`,
|
||||
[jobId, thumbTick]
|
||||
)
|
||||
|
||||
// HLS nur für große Vorschau im Popover
|
||||
const hq = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
|
||||
() =>
|
||||
`/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
|
||||
[jobId]
|
||||
)
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<LiveHlsVideo
|
||||
src={hq}
|
||||
muted={false}
|
||||
className="w-full h-full bg-black"
|
||||
/>
|
||||
content={(open) =>
|
||||
open && (
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<LiveHlsVideo
|
||||
src={hq}
|
||||
muted={false}
|
||||
className="w-full h-full bg-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<LiveHlsVideo
|
||||
src={low}
|
||||
muted
|
||||
className="w-20 h-16 object-cover rounded bg-gray-100 dark:bg-white/5"
|
||||
/>
|
||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden">
|
||||
<img
|
||||
src={thumb}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,19 +52,18 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
|
||||
containerRef.current.appendChild(videoEl)
|
||||
videoNodeRef.current = videoEl
|
||||
|
||||
const p = (playerRef.current = videojs(videoEl, {
|
||||
playerRef.current = videojs(videoEl, {
|
||||
autoplay: true,
|
||||
controls: true,
|
||||
preload: 'auto',
|
||||
playsinline: true,
|
||||
responsive: true,
|
||||
fluid: false, // ✅ besser für flex-layouts
|
||||
fill: true, // ✅ füllt Container sauber
|
||||
fluid: false,
|
||||
fill: true,
|
||||
controlBar: {
|
||||
skipButtons: { backward: 10, forward: 10 },
|
||||
children: [
|
||||
'playToggle',
|
||||
'rewindToggle',
|
||||
'forwardToggle',
|
||||
'progressControl',
|
||||
'currentTimeDisplay',
|
||||
'timeDivider',
|
||||
@ -75,7 +74,7 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
|
||||
],
|
||||
},
|
||||
playbackRates: [0.5, 1, 1.25, 1.5, 2],
|
||||
}))
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
|
||||
@ -1,24 +1,36 @@
|
||||
// RecorderSettings.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Button from './Button'
|
||||
import Card from './Card'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
|
||||
type RecorderSettings = {
|
||||
recordDir: string
|
||||
doneDir: string
|
||||
ffmpegPath?: string
|
||||
|
||||
// ✅ neue Optionen
|
||||
autoAddToDownloadList?: boolean
|
||||
autoStartAddedDownloads?: boolean
|
||||
}
|
||||
|
||||
const DEFAULTS: RecorderSettings = {
|
||||
// ✅ relativ zur .exe (Backend löst das auf)
|
||||
recordDir: 'records',
|
||||
doneDir: 'records/done',
|
||||
ffmpegPath: '',
|
||||
|
||||
// ✅ defaults für switches
|
||||
autoAddToDownloadList: true,
|
||||
autoStartAddedDownloads: true,
|
||||
}
|
||||
|
||||
export default function RecorderSettings() {
|
||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | null>(null)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
@ -34,6 +46,11 @@ export default function RecorderSettings() {
|
||||
setValue({
|
||||
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
||||
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
|
||||
ffmpegPath: (data.ffmpegPath ?? DEFAULTS.ffmpegPath).toString(),
|
||||
|
||||
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
|
||||
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
|
||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@ -44,7 +61,7 @@ export default function RecorderSettings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function browse(target: 'record' | 'done') {
|
||||
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
setBrowsing(target)
|
||||
@ -62,9 +79,11 @@ export default function RecorderSettings() {
|
||||
const p = (data.path ?? '').trim()
|
||||
if (!p) return
|
||||
|
||||
setValue((v) =>
|
||||
target === 'record' ? { ...v, recordDir: p } : { ...v, doneDir: p }
|
||||
)
|
||||
setValue((v) => {
|
||||
if (target === 'record') return { ...v, recordDir: p }
|
||||
if (target === 'done') return { ...v, doneDir: p }
|
||||
return { ...v, ffmpegPath: p }
|
||||
})
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
} finally {
|
||||
@ -78,18 +97,29 @@ export default function RecorderSettings() {
|
||||
|
||||
const recordDir = value.recordDir.trim()
|
||||
const doneDir = value.doneDir.trim()
|
||||
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
||||
|
||||
if (!recordDir || !doneDir) {
|
||||
setErr('Bitte beide Pfade angeben.')
|
||||
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recordDir, doneDir }),
|
||||
body: JSON.stringify({
|
||||
recordDir,
|
||||
doneDir,
|
||||
ffmpegPath,
|
||||
autoAddToDownloadList: value.autoAddToDownloadList,
|
||||
autoStartAddedDownloads: value.autoStartAddedDownloads,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '')
|
||||
@ -108,9 +138,7 @@ export default function RecorderSettings() {
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Einstellungen
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={save} disabled={saving}>
|
||||
Speichern
|
||||
@ -131,10 +159,9 @@ export default function RecorderSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aufnahme-Ordner */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Aufnahme-Ordner
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Aufnahme-Ordner</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.recordDir}
|
||||
@ -143,16 +170,13 @@ export default function RecorderSettings() {
|
||||
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900
|
||||
dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => browse('record')}
|
||||
disabled={saving || browsing !== null}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fertige Downloads */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Fertige Downloads nach
|
||||
@ -165,15 +189,55 @@ export default function RecorderSettings() {
|
||||
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900
|
||||
dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => browse('done')}
|
||||
disabled={saving || browsing !== null}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ffmpeg.exe */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">ffmpeg.exe</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.ffmpegPath ?? ''}
|
||||
onChange={(e) => setValue((v) => ({ ...v, ffmpegPath: e.target.value }))}
|
||||
placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)"
|
||||
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900
|
||||
dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Automatisierung */}
|
||||
<div className="mt-2 border-t border-gray-200 pt-4 dark:border-white/10">
|
||||
<div className="space-y-3">
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoAddToDownloadList}
|
||||
onChange={(checked) =>
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
autoAddToDownloadList: checked,
|
||||
// wenn aus, Autostart gleich mit aus
|
||||
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
||||
}))
|
||||
}
|
||||
label="Automatisch zur Downloadliste hinzufügen"
|
||||
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoStartAddedDownloads}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, autoStartAddedDownloads: checked }))}
|
||||
disabled={!value.autoAddToDownloadList}
|
||||
label="Hinzugefügte Downloads automatisch starten"
|
||||
description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// RunningDownloads.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Table, { type Column } from './Table'
|
||||
import Card from './Card'
|
||||
import Button from './Button'
|
||||
@ -50,12 +50,23 @@ const runtimeOf = (j: RecordJob) => {
|
||||
}
|
||||
|
||||
export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Props) {
|
||||
// globaler Tick für alle Thumbnails
|
||||
const [thumbTick, setThumbTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setThumbTick((t) => t + 1)
|
||||
}, 5000) // alle 5s
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const columns = useMemo<Column<RecordJob>[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <ModelPreview jobId={j.id} />,
|
||||
cell: (j) => <ModelPreview jobId={j.id} thumbTick={thumbTick} />,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
@ -114,7 +125,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [onStopJob])
|
||||
}, [onStopJob, thumbTick])
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
@ -173,7 +184,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<ModelPreview jobId={j.id} />
|
||||
<ModelPreview jobId={j.id} thumbTick={thumbTick} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
174
frontend/src/components/ui/Switch.tsx
Normal file
174
frontend/src/components/ui/Switch.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
// components/ui/Switch.tsx
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SwitchSize = 'default' | 'short'
|
||||
type SwitchVariant = 'simple' | 'icon'
|
||||
|
||||
export type SwitchProps = {
|
||||
/** Controlled */
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
|
||||
/** Optional wiring */
|
||||
id?: string
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
|
||||
/** Labeling / a11y */
|
||||
ariaLabel?: string
|
||||
ariaLabelledby?: string
|
||||
ariaDescribedby?: string
|
||||
|
||||
/** UI */
|
||||
size?: SwitchSize
|
||||
variant?: SwitchVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch / Toggle (Tailwind, ohne Headless UI)
|
||||
* - size="default" (w-11) wie Simple toggle
|
||||
* - size="short" (h-5 w-10) wie Short toggle
|
||||
* - variant="icon" zeigt X/Check Icon im Thumb
|
||||
*/
|
||||
export default function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
required,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
ariaDescribedby,
|
||||
size = 'default',
|
||||
variant = 'simple',
|
||||
className,
|
||||
}: SwitchProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
onChange(e.target.checked)
|
||||
}
|
||||
|
||||
const baseInput = clsx(
|
||||
'absolute inset-0 size-full appearance-none focus:outline-hidden',
|
||||
disabled && 'cursor-not-allowed'
|
||||
)
|
||||
|
||||
if (size === 'short') {
|
||||
// Short toggle Beispiel
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group relative inline-flex h-5 w-10 shrink-0 items-center justify-center rounded-full outline-offset-2 outline-indigo-600 has-focus-visible:outline-2 dark:outline-indigo-500',
|
||||
disabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute mx-auto h-4 w-9 rounded-full bg-gray-200 inset-ring inset-ring-gray-900/5 transition-colors duration-200 ease-in-out dark:bg-gray-800/50 dark:inset-ring-white/10',
|
||||
checked && 'bg-indigo-600 dark:bg-indigo-500'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute left-0 size-5 rounded-full border border-gray-300 bg-white shadow-xs transition-transform duration-200 ease-in-out dark:shadow-none',
|
||||
checked && 'translate-x-5'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-describedby={ariaDescribedby}
|
||||
className={baseInput}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default size (simple / icon) Beispiele
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500',
|
||||
checked && 'bg-indigo-600 dark:bg-indigo-500',
|
||||
disabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{variant === 'icon' ? (
|
||||
<span
|
||||
className={clsx(
|
||||
'relative size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out',
|
||||
checked && 'translate-x-5'
|
||||
)}
|
||||
>
|
||||
{/* Off icon */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'absolute inset-0 flex size-full items-center justify-center transition-opacity ease-in',
|
||||
checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200'
|
||||
)}
|
||||
>
|
||||
<svg fill="none" viewBox="0 0 12 12" className="size-3 text-gray-400 dark:text-gray-600">
|
||||
<path
|
||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* On icon */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'absolute inset-0 flex size-full items-center justify-center transition-opacity ease-out',
|
||||
checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100'
|
||||
)}
|
||||
>
|
||||
<svg fill="currentColor" viewBox="0 0 12 12" className="size-3 text-indigo-600 dark:text-indigo-500">
|
||||
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
'size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out',
|
||||
checked && 'translate-x-5'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-describedby={ariaDescribedby}
|
||||
className={baseInput}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,20 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type TabIcon = React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
|
||||
export type TabItem = {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
count?: number | string
|
||||
icon?: TabIcon
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type TabsVariant =
|
||||
| 'underline'
|
||||
| 'underlineIcons'
|
||||
| 'pills'
|
||||
| 'pillsGray'
|
||||
| 'pillsBrand'
|
||||
| 'fullWidthUnderline'
|
||||
| 'barUnderline'
|
||||
| 'simple'
|
||||
|
||||
type TabsProps = {
|
||||
tabs: TabItem[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
|
||||
/** Siehe Variants aus pasted.txt */
|
||||
variant?: TabsVariant
|
||||
|
||||
/**
|
||||
* Optional: In der pasted.txt sind Badges teils erst ab md sichtbar.
|
||||
* Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop).
|
||||
*/
|
||||
hideCountUntilMd?: boolean
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
@ -23,19 +47,258 @@ export default function Tabs({
|
||||
onChange,
|
||||
className,
|
||||
ariaLabel = 'Ansicht auswählen',
|
||||
variant = 'underline',
|
||||
hideCountUntilMd = false,
|
||||
}: TabsProps) {
|
||||
if (!tabs?.length) return null
|
||||
|
||||
const current = tabs.find((t) => t.id === value) ?? tabs[0]
|
||||
|
||||
const mobileSelectClass = clsx(
|
||||
// entspricht den Beispielen (outline-1 + -outline-offset-1)
|
||||
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
|
||||
variant === 'pillsBrand' ? 'dark:bg-gray-800/50' : 'dark:bg-white/5'
|
||||
)
|
||||
|
||||
const countPillClass = (selected: boolean) =>
|
||||
clsx(
|
||||
selected
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
hideCountUntilMd ? 'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block' : 'ml-3 rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
)
|
||||
|
||||
const renderCount = (selected: boolean, tab: TabItem) => {
|
||||
if (tab.count === undefined) return null
|
||||
return <span className={countPillClass(selected)}>{tab.count}</span>
|
||||
}
|
||||
|
||||
const renderDesktop = () => {
|
||||
switch (variant) {
|
||||
case 'underline':
|
||||
case 'underlineIcons': {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
const disabled = !!tab.disabled
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(tab.id)}
|
||||
disabled={disabled}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={clsx(
|
||||
selected
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
variant === 'underlineIcons'
|
||||
? 'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium'
|
||||
: 'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:border-transparent hover:text-gray-500 dark:hover:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{variant === 'underlineIcons' && tab.icon ? (
|
||||
<tab.icon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
selected
|
||||
? 'text-indigo-500 dark:text-indigo-400'
|
||||
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
|
||||
'mr-2 -ml-0.5 size-5'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span>{tab.label}</span>
|
||||
{renderCount(selected, tab)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'pills':
|
||||
case 'pillsGray':
|
||||
case 'pillsBrand': {
|
||||
const active =
|
||||
variant === 'pills'
|
||||
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
|
||||
: variant === 'pillsGray'
|
||||
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
|
||||
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
|
||||
const inactive =
|
||||
variant === 'pills'
|
||||
? 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
: variant === 'pillsGray'
|
||||
? 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="flex space-x-4">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
const disabled = !!tab.disabled
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(tab.id)}
|
||||
disabled={disabled}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={clsx(
|
||||
selected ? active : inactive,
|
||||
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:text-inherit'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined ? (
|
||||
<span
|
||||
className={clsx(
|
||||
selected
|
||||
? 'ml-2 bg-white/70 text-gray-900 dark:bg-white/10 dark:text-white'
|
||||
: 'ml-2 bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium'
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
case 'fullWidthUnderline': {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className="-mb-px flex">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
const disabled = !!tab.disabled
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(tab.id)}
|
||||
disabled={disabled}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={clsx(
|
||||
selected
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||
'flex-1 border-b-2 px-1 py-4 text-center text-sm font-medium',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:border-transparent hover:text-gray-500 dark:hover:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'barUnderline': {
|
||||
return (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className="isolate flex divide-x divide-gray-200 rounded-lg bg-white shadow-sm dark:divide-white/10 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
{tabs.map((tab, idx) => {
|
||||
const selected = tab.id === current.id
|
||||
const disabled = !!tab.disabled
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(tab.id)}
|
||||
disabled={disabled}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={clsx(
|
||||
selected
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
|
||||
idx === 0 ? 'rounded-l-lg' : '',
|
||||
idx === tabs.length - 1 ? 'rounded-r-lg' : '',
|
||||
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center">
|
||||
{tab.label}
|
||||
{tab.count !== undefined ? (
|
||||
<span className="ml-2 rounded-full bg-white/70 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
selected ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
|
||||
'absolute inset-x-0 bottom-0 h-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
case 'simple': {
|
||||
return (
|
||||
<nav className="flex border-b border-gray-200 py-4 dark:border-white/10" aria-label={ariaLabel}>
|
||||
<ul role="list" className="flex min-w-full flex-none gap-x-8 px-2 text-sm/6 font-semibold text-gray-500 dark:text-gray-400">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
const disabled = !!tab.disabled
|
||||
return (
|
||||
<li key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(tab.id)}
|
||||
disabled={disabled}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={clsx(
|
||||
selected ? 'text-indigo-600 dark:text-indigo-400' : 'hover:text-gray-700 dark:hover:text-white',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:text-inherit'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Mobile: Dropdown */}
|
||||
{/* Mobile: Select + Chevron (wie Beispiele) */}
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<select
|
||||
value={current.id}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline outline-1 outline-gray-300 focus:outline-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:focus:outline-indigo-500"
|
||||
>
|
||||
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
@ -48,44 +311,8 @@ export default function Tabs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Horizontal Tabs */}
|
||||
<div className="hidden sm:block">
|
||||
<nav className="border-b border-gray-200 dark:border-white/10" aria-label={ariaLabel}>
|
||||
<ul className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
return (
|
||||
<li key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={clsx(
|
||||
selected
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-white',
|
||||
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={clsx(
|
||||
selected
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-3 rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{/* Desktop */}
|
||||
<div className="hidden sm:block">{renderDesktop()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user