This commit is contained in:
Chris 2025-12-19 23:06:40 +01:00
parent 99837f0ed3
commit d603dd2342
13 changed files with 1405 additions and 290 deletions

View File

@ -47,8 +47,9 @@ type RecordJob struct {
EndedAt *time.Time `json:"endedAt,omitempty"` EndedAt *time.Time `json:"endedAt,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
PreviewDir string `json:"-"` PreviewDir string `json:"-"`
previewCmd *exec.Cmd `json:"-"` PreviewImage string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
cancel context.CancelFunc `json:"-"` cancel context.CancelFunc `json:"-"`
} }
@ -58,16 +59,29 @@ var (
jobsMu = sync.Mutex{} jobsMu = sync.Mutex{}
) )
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
var ffmpegPath = detectFFmpegPath()
// main.go
type RecorderSettings struct { type RecorderSettings struct {
RecordDir string `json:"recordDir"` RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"` DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList,omitempty"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
} }
var ( var (
settingsMu sync.Mutex settingsMu sync.Mutex
settings = RecorderSettings{ settings = RecorderSettings{
RecordDir: "/records", RecordDir: "/records",
DoneDir: "/records/done", DoneDir: "/records/done",
FFmpegPath: "",
AutoAddToDownloadList: false,
AutoStartAddedDownloads: false,
} }
settingsFile = "recorder_settings.json" settingsFile = "recorder_settings.json"
) )
@ -78,6 +92,53 @@ func getSettings() RecorderSettings {
return settings 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() { func loadSettings() {
b, err := os.ReadFile(settingsFile) b, err := os.ReadFile(settingsFile)
if err == nil { if err == nil {
@ -89,6 +150,10 @@ func loadSettings() {
if strings.TrimSpace(s.DoneDir) != "" { if strings.TrimSpace(s.DoneDir) != "" {
s.DoneDir = filepath.Clean(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() settingsMu.Lock()
settings = s settings = s
settingsMu.Unlock() settingsMu.Unlock()
@ -99,6 +164,10 @@ func loadSettings() {
s := getSettings() s := getSettings()
_ = os.MkdirAll(s.RecordDir, 0o755) _ = os.MkdirAll(s.RecordDir, 0o755)
_ = os.MkdirAll(s.DoneDir, 0o755) _ = os.MkdirAll(s.DoneDir, 0o755)
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
} }
func saveSettingsToDisk() { func saveSettingsToDisk() {
@ -123,6 +192,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir)) in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir))
in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir)) in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir))
in.FFmpegPath = strings.TrimSpace(in.FFmpegPath)
if in.RecordDir == "" || in.DoneDir == "" { if in.RecordDir == "" || in.DoneDir == "" {
http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest) 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() settingsMu.Unlock()
saveSettingsToDisk() saveSettingsToDisk()
// ffmpeg-Pfad nach Änderungen neu bestimmen
ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath (nach Save):", ffmpegPath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(getSettings()) _ = json.NewEncoder(w).Encode(getSettings())
return return
@ -156,19 +230,35 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
func settingsBrowse(w http.ResponseWriter, r *http.Request) { func settingsBrowse(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target") target := r.URL.Query().Get("target")
if target != "record" && target != "done" { if target != "record" && target != "done" && target != "ffmpeg" {
http.Error(w, "target muss record oder done sein", http.StatusBadRequest) http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
return 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 { if err != nil {
// User cancelled → 204 No Content ist praktisch fürs Frontend // User cancelled → 204 No Content ist praktisch fürs Frontend
if strings.Contains(strings.ToLower(err.Error()), "cancel") { if strings.Contains(strings.ToLower(err.Error()), "cancel") {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
@ -301,7 +391,7 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri
func remuxTSToMP4(tsPath, mp4Path string) error { func remuxTSToMP4(tsPath, mp4Path string) error {
// ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4 // ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4
cmd := exec.Command("ffmpeg", cmd := exec.Command(ffmpegPath,
"-y", "-y",
"-i", tsPath, "-i", tsPath,
"-c", "copy", "-c", "copy",
@ -317,10 +407,8 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
} }
func extractLastFrameJPEG(path string) ([]byte, 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( cmd := exec.Command(
"ffmpeg", ffmpegPath,
"-hide_banner", "-hide_banner",
"-loglevel", "error", "-loglevel", "error",
"-sseof", "-0.1", "-sseof", "-0.1",
@ -343,6 +431,79 @@ func extractLastFrameJPEG(path string) ([]byte, error) {
return out.Bytes(), nil 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) { func recordPreview(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
if id == "" { if id == "" {
@ -350,53 +511,132 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
return 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 != "" { if file := r.URL.Query().Get("file"); file != "" {
servePreviewHLSFile(w, r, id, file) servePreviewHLSFile(w, r, id, file)
return return
} }
// sonst: JPEG Fallback // Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig)
jobsMu.Lock() jobsMu.Lock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.Unlock()
if !ok { if ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound) // 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 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 == "" { if outPath == "" {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound) http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return return
} }
outPath = filepath.Clean(outPath) // 🔹 NEU: dynamischer Frame an Zeitposition t (z.B. für animierte Thumbnails)
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
// ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen. if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
// (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.) if img, err := extractFrameAtTimeJPEG(outPath, sec); err == nil {
if !filepath.IsAbs(outPath) { servePreviewJPEGBytes(w, img)
if outPath == "." || outPath == ".." || return
strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) { }
http.Error(w, "ungültiger output-pfad", http.StatusBadRequest) // wenn ffmpeg hier scheitert, geht's unten mit statischem Preview weiter
return
} }
} }
fi, err := os.Stat(outPath) // 🔸 ALT: einmaliges Preview cachen (preview.jpg) Fallback
if err != nil { previewDir := filepath.Join(os.TempDir(), "rec_preview", id)
http.Error(w, "preview nicht verfügbar", http.StatusNotFound) if err := os.MkdirAll(previewDir, 0o755); err != nil {
http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError)
return 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 return
} }
img, err := extractLastFrameJPEG(outPath) img, err := extractLastFrameJPEG(outPath)
if err != nil { if err != nil {
// Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger
img2, err2 := extractFirstFrameJPEG(outPath) img2, err2 := extractFirstFrameJPEG(outPath)
if err2 != nil { if err2 != nil {
http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError) 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 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("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.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(img) _, _ = 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) { func recordList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) 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 { 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 { if err := os.MkdirAll(previewDir, 0755); err != nil {
return err return err
} }
@ -553,20 +809,20 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
// beide Prozesse starten (einfach & robust) // beide Prozesse starten (einfach & robust)
go func(kind string, args []string) { go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil && ctx.Err() == nil { 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) }("low", lowArgs)
go func(kind string, args []string) { go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil && ctx.Err() == nil { 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) }("hq", hqArgs)
@ -575,7 +831,7 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
func extractFirstFrameJPEG(path string) ([]byte, error) { func extractFirstFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command( cmd := exec.Command(
"ffmpeg", ffmpegPath,
"-hide_banner", "-hide_banner",
"-loglevel", "error", "-loglevel", "error",
"-i", path, "-i", path,
@ -930,8 +1186,24 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return 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) entries, err := os.ReadDir(doneAbs)
if err != nil { 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) http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
@ -953,10 +1225,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// ID stabil aus Dateiname (ohne Extension) reicht für Player/Key
base := strings.TrimSuffix(name, filepath.Ext(name)) base := strings.TrimSuffix(name, filepath.Ext(name))
// best effort: Zeiten aus FileInfo
t := fi.ModTime() t := fi.ModTime()
list = append(list, &RecordJob{ 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 { sort.Slice(list, func(i, j int) bool {
return list[i].EndedAt.After(*list[j].EndedAt) return list[i].EndedAt.After(*list[j].EndedAt)
}) })
@ -1398,7 +1666,7 @@ func (p *Playlist) WatchSegments(
) error { ) error {
var lastSeq int64 = -1 var lastSeq int64 = -1
emptyRounds := 0 emptyRounds := 0
const maxEmptyRounds = 5 const maxEmptyRounds = 60 // statt 5
for { for {
select { select {
@ -1741,7 +2009,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error {
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!) // ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
cmd := exec.CommandContext( cmd := exec.CommandContext(
ctx, ctx,
"ffmpeg", ffmpegPath,
"-y", "-y",
"-i", m3u8URL, "-i", m3u8URL,
"-c", "copy", "-c", "copy",

View File

@ -1,4 +1,7 @@
{ {
"recordDir": "C:\\Users\\Rother\\Desktop\\test", "recordDir": "C:\\test",
"doneDir": "C:\\Users\\Rother\\Desktop\\test\\done" "doneDir": "C:\\test\\done",
"ffmpegPath": "C:\\ffmpeg\\ffmpeg.exe",
"autoAddToDownloadList": true,
"autoStartAddedDownloads": true
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import './App.css' import './App.css'
import Button from './components/ui/Button' import Button from './components/ui/Button'
import Table, { type Column } from './components/ui/Table' import Table, { type Column } from './components/ui/Table'
@ -64,6 +64,35 @@ const runtimeOf = (j: RecordJob) => {
return formatDuration(end - start) 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() { export default function App() {
const [sourceUrl, setSourceUrl] = useState('') const [sourceUrl, setSourceUrl] = useState('')
@ -80,6 +109,46 @@ export default function App() {
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null) const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false) 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( const initialCookies = useMemo(
() => Object.entries(cookies).map(([name, value]) => ({ name, value })), () => Object.entries(cookies).map(([name, value]) => ({ name, value })),
[cookies] [cookies]
@ -146,31 +215,31 @@ export default function App() {
}, [sourceUrl]) }, [sourceUrl])
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { let cancelled = false
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)
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(() => { // direkt einmal laden
apiJSON<RecordJob[]>('/api/record/list') loadJobs()
.then((list) => setJobs(list)) // dann jede Sekunde
.catch(() => { const t = setInterval(loadJobs, 1000)
// backend evtl. noch nicht da -> ignorieren
}) return () => {
cancelled = true
clearInterval(t)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -216,33 +285,38 @@ export default function App() {
return Boolean(cf && sess) 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) setError(null)
const url = sourceUrl.trim()
// ❌ Chaturbate ohne Cookies blockieren // ❌ Chaturbate ohne Cookies blockieren
if (isChaturbate(url) && !hasRequiredChaturbateCookies(cookies)) { const currentCookies = cookiesRef.current
setError( if (isChaturbate(url) && !hasRequiredChaturbateCookies(currentCookies)) {
'Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.' setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
)
return return
} }
// Duplicate-running guard
const alreadyRunning = jobsRef.current.some(
(j) => j.status === 'running' && String(j.sourceUrl || '') === url
)
if (alreadyRunning) return
setBusy(true) setBusy(true)
busyRef.current = true
try { try {
const cookieString = Object.entries(cookies) const cookieString = Object.entries(currentCookies)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join('; ') .join('; ')
const created = await apiJSON<RecordJob>('/api/record', { const created = await apiJSON<RecordJob>('/api/record', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ url, cookie: cookieString }),
url,
cookie: cookieString,
}),
}) })
setJobs((prev) => [created, ...prev]) setJobs((prev) => [created, ...prev])
@ -250,9 +324,84 @@ export default function App() {
setError(e?.message ?? String(e)) setError(e?.message ?? String(e))
} finally { } finally {
setBusy(false) 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) { async function stopJob(id: string) {
try { try {
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, {
@ -261,65 +410,6 @@ export default function App() {
} catch {} } 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 ( return (
<div className="mx-auto py-4 max-w-7xl sm:px-6 lg:px-8 space-y-6"> <div className="mx-auto py-4 max-w-7xl sm:px-6 lg:px-8 space-y-6">
<Card <Card
@ -362,6 +452,7 @@ export default function App() {
value={selectedTab} value={selectedTab}
onChange={setSelectedTab} onChange={setSelectedTab}
ariaLabel="Tabs" ariaLabel="Tabs"
variant="pillsBrand"
/> />

View File

@ -1,4 +1,4 @@
// FinishedDownloads.tsx // frontend/src/components/ui/FinishedDownloads.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -35,7 +35,8 @@ function formatDuration(ms: number): string {
return `${s}s` 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 start = Date.parse(String(job.startedAt || ''))
const end = Date.parse(String(job.endedAt || '')) const end = Date.parse(String(job.endedAt || ''))
if (!Number.isFinite(start) || !Number.isFinite(end)) return '—' if (!Number.isFinite(start) || !Number.isFinite(end)) return '—'
@ -60,6 +61,20 @@ const modelNameFromOutput = (output?: string) => {
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) { export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null) 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) => { const openCtx = (job: RecordJob, e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -106,8 +121,10 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
const rows = useMemo(() => { const rows = useMemo(() => {
const map = new Map<string, RecordJob>() const map = new Map<string, RecordJob>()
// Basis: Files aus dem Done-Ordner
for (const j of doneJobs) map.set(keyFor(j), j) for (const j of doneJobs) map.set(keyFor(j), j)
// Jobs aus /list drübermergen (z.B. frisch fertiggewordene)
for (const j of jobs) { for (const j of jobs) {
const k = keyFor(j) const k = keyFor(j)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...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 return list
}, [jobs, doneJobs]) }, [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>[] = [ const columns: Column<RecordJob>[] = [
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', 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', key: 'model',
@ -252,7 +300,12 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
openCtx(j, e) openCtx(j, e)
}} }}
> >
<FinishedVideoPreview job={j} getFileName={baseName} /> <FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[keyFor(j)]}
onDuration={handleDuration}
/>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@ -1,55 +1,148 @@
// frontend/src/components/ui/FinishedVideoPreview.tsx // frontend/src/components/ui/FinishedVideoPreview.tsx
'use client' 'use client'
import { useMemo, 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
// 🔹 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 file = getFileName(job.output || '')
const src = file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''
if (!src) { const [thumbOk, setThumbOk] = useState(true)
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" /> 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 ( return (
<HoverPopover <HoverPopover
content={ // ⚠️ Großes Video nur rendern, wenn Popover offen ist
<div className="w-[420px]"> content={(open) =>
<div className="aspect-video"> open && (
<video <div className="w-[420px]">
src={src} <div className="aspect-video">
className="w-full h-full bg-black" <video
muted src={videoSrc}
playsInline className="w-full h-full bg-black"
preload="metadata" muted
controls playsInline
autoPlay preload="metadata"
loop controls
onClick={(e) => e.stopPropagation()} autoPlay
onMouseDown={(e) => e.stopPropagation()} loop
/> onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</div>
</div> </div>
</div> )
} }
> >
{/* Mini in Tabelle */} {/* 🔹 Inline nur Thumbnail / Platzhalter */}
<video <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
src={src} {thumbSrc && thumbOk ? (
className="w-20 h-16 object-cover rounded bg-black" <img
muted src={thumbSrc}
playsInline loading="lazy"
preload="metadata" alt={file}
loop className="w-full h-full object-cover"
autoPlay onError={() => setThumbOk(false)}
onClick={(e) => e.stopPropagation()} />
onMouseDown={(e) => e.stopPropagation()} ) : (
/> <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> </HoverPopover>
) )
} }

View File

@ -1,4 +1,3 @@
// HoverPopover.tsx
'use client' 'use client'
import { import {
@ -14,16 +13,46 @@ import Card from './Card'
type Pos = { left: number; top: number } type Pos = { left: number; top: number }
export default function HoverPopover({ type HoverPopoverProps = PropsWithChildren<{
children, // Entweder direkt ein ReactNode
content, // oder eine Renderfunktion, die den Open-Status bekommt
}: PropsWithChildren<{ content: ReactNode }>) { content: ReactNode | ((open: boolean) => ReactNode)
}>
export default function HoverPopover({ children, content }: HoverPopoverProps) {
const triggerRef = useRef<HTMLDivElement>(null) const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null) const popoverRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pos, setPos] = useState<Pos | null>(null) 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 computePos = () => {
const trigger = triggerRef.current const trigger = triggerRef.current
const pop = popoverRef.current const pop = popoverRef.current
@ -61,7 +90,6 @@ export default function HoverPopover({
// Beim Öffnen: erst rendern, dann messen/positionieren // Beim Öffnen: erst rendern, dann messen/positionieren
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return if (!open) return
// rAF sorgt dafür, dass DOM wirklich steht bevor wir messen
const id = requestAnimationFrame(() => computePos()) const id = requestAnimationFrame(() => computePos())
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -72,7 +100,7 @@ export default function HoverPopover({
if (!open) return if (!open) return
const onMove = () => requestAnimationFrame(() => computePos()) const onMove = () => requestAnimationFrame(() => computePos())
window.addEventListener('resize', onMove) window.addEventListener('resize', onMove)
window.addEventListener('scroll', onMove, true) // capture: auch in scroll-containern window.addEventListener('scroll', onMove, true)
return () => { return () => {
window.removeEventListener('resize', onMove) window.removeEventListener('resize', onMove)
window.removeEventListener('scroll', onMove, true) window.removeEventListener('scroll', onMove, true)
@ -80,13 +108,24 @@ export default function HoverPopover({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]) }, [open])
// Cleanup für Timeout
useEffect(() => {
return () => clearCloseTimeout()
}, [])
// Hilfsfunktion: content normalisieren
const renderContent = () =>
typeof content === 'function'
? (content as (open: boolean) => ReactNode)(open)
: content
return ( return (
<> <>
<div <div
ref={triggerRef} ref={triggerRef}
className="inline-flex" className="inline-flex"
onMouseEnter={() => setOpen(true)} onMouseEnter={handleEnter}
onMouseLeave={() => setOpen(false)} onMouseLeave={handleLeave}
> >
{children} {children}
</div> </div>
@ -101,14 +140,14 @@ export default function HoverPopover({
top: pos?.top ?? -9999, top: pos?.top ?? -9999,
visibility: pos ? 'visible' : 'hidden', visibility: pos ? 'visible' : 'hidden',
}} }}
onMouseEnter={() => setOpen(true)} onMouseEnter={handleEnter}
onMouseLeave={() => setOpen(false)} onMouseLeave={handleLeave}
> >
<Card <Card
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]" className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]"
noBodyPadding noBodyPadding
> >
{content} {renderContent()}
</Card> </Card>
</div>, </div>,
document.body document.body

View 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>
)
}

View File

@ -1,39 +1,54 @@
// frontend\src\components\ui\ModelPreview.tsx // frontend/src/components/ui/ModelPreview.tsx
'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo' import LiveHlsVideo from './LiveHlsVideo'
export default function ModelPreview({ jobId }: { jobId: string }) { type Props = {
const low = useMemo( jobId: string
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index.m3u8`, // wird von außen hochgezählt (z.B. alle 5s)
[jobId] 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( const hq = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`, () =>
`/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
[jobId] [jobId]
) )
return ( return (
<HoverPopover <HoverPopover
content={ content={(open) =>
<div className="w-[420px]"> open && (
<div className="aspect-video"> <div className="w-[420px]">
<LiveHlsVideo <div className="aspect-video">
src={hq} <LiveHlsVideo
muted={false} src={hq}
className="w-full h-full bg-black" muted={false}
/> className="w-full h-full bg-black"
/>
</div>
</div> </div>
</div> )
} }
> >
<LiveHlsVideo <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden">
src={low} <img
muted src={thumb}
className="w-20 h-16 object-cover rounded bg-gray-100 dark:bg-white/5" loading="lazy"
/> alt=""
className="w-full h-full object-cover"
/>
</div>
</HoverPopover> </HoverPopover>
) )
} }

View File

@ -52,19 +52,18 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
containerRef.current.appendChild(videoEl) containerRef.current.appendChild(videoEl)
videoNodeRef.current = videoEl videoNodeRef.current = videoEl
const p = (playerRef.current = videojs(videoEl, { playerRef.current = videojs(videoEl, {
autoplay: true, autoplay: true,
controls: true, controls: true,
preload: 'auto', preload: 'auto',
playsinline: true, playsinline: true,
responsive: true, responsive: true,
fluid: false, // ✅ besser für flex-layouts fluid: false,
fill: true, // ✅ füllt Container sauber fill: true,
controlBar: { controlBar: {
skipButtons: { backward: 10, forward: 10 },
children: [ children: [
'playToggle', 'playToggle',
'rewindToggle',
'forwardToggle',
'progressControl', 'progressControl',
'currentTimeDisplay', 'currentTimeDisplay',
'timeDivider', 'timeDivider',
@ -75,7 +74,7 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
], ],
}, },
playbackRates: [0.5, 1, 1.25, 1.5, 2], playbackRates: [0.5, 1, 1.25, 1.5, 2],
})) })
return () => { return () => {
if (playerRef.current) { if (playerRef.current) {

View File

@ -1,24 +1,36 @@
// RecorderSettings.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Button from './Button' import Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
type RecorderSettings = { type RecorderSettings = {
recordDir: string recordDir: string
doneDir: string doneDir: string
ffmpegPath?: string
// ✅ neue Optionen
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
} }
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
// ✅ relativ zur .exe (Backend löst das auf) // ✅ relativ zur .exe (Backend löst das auf)
recordDir: 'records', recordDir: 'records',
doneDir: 'records/done', doneDir: 'records/done',
ffmpegPath: '',
// ✅ defaults für switches
autoAddToDownloadList: true,
autoStartAddedDownloads: true,
} }
export default function RecorderSettings() { export default function RecorderSettings() {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) 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 [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
@ -34,6 +46,11 @@ export default function RecorderSettings() {
setValue({ setValue({
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(), recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
doneDir: (data.doneDir || DEFAULTS.doneDir).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(() => { .catch(() => {
@ -44,7 +61,7 @@ export default function RecorderSettings() {
} }
}, []) }, [])
async function browse(target: 'record' | 'done') { async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
setBrowsing(target) setBrowsing(target)
@ -62,9 +79,11 @@ export default function RecorderSettings() {
const p = (data.path ?? '').trim() const p = (data.path ?? '').trim()
if (!p) return if (!p) return
setValue((v) => setValue((v) => {
target === 'record' ? { ...v, recordDir: p } : { ...v, doneDir: p } if (target === 'record') return { ...v, recordDir: p }
) if (target === 'done') return { ...v, doneDir: p }
return { ...v, ffmpegPath: p }
})
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
} finally { } finally {
@ -78,18 +97,29 @@ export default function RecorderSettings() {
const recordDir = value.recordDir.trim() const recordDir = value.recordDir.trim()
const doneDir = value.doneDir.trim() const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim()
if (!recordDir || !doneDir) { if (!recordDir || !doneDir) {
setErr('Bitte beide Pfade angeben.') setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
return return
} }
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
setSaving(true) setSaving(true)
try { try {
const res = await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!res.ok) {
const t = await res.text().catch(() => '') const t = await res.text().catch(() => '')
@ -108,9 +138,7 @@ export default function RecorderSettings() {
header={ header={
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-base font-semibold text-gray-900 dark:text-white"> <div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
Einstellungen
</div>
</div> </div>
<Button variant="primary" onClick={save} disabled={saving}> <Button variant="primary" onClick={save} disabled={saving}>
Speichern Speichern
@ -131,10 +159,9 @@ export default function RecorderSettings() {
</div> </div>
)} )}
{/* Aufnahme-Ordner */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center"> <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"> <label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Aufnahme-Ordner</label>
Aufnahme-Ordner
</label>
<div className="sm:col-span-9 flex gap-2"> <div className="sm:col-span-9 flex gap-2">
<input <input
value={value.recordDir} 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 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" dark:bg-white/10 dark:text-white"
/> />
<Button <Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
variant="secondary"
onClick={() => browse('record')}
disabled={saving || browsing !== null}
>
Durchsuchen... Durchsuchen...
</Button> </Button>
</div> </div>
</div> </div>
{/* Fertige Downloads */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center"> <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"> <label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Fertige Downloads nach 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 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" dark:bg-white/10 dark:text-white"
/> />
<Button <Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
variant="secondary"
onClick={() => browse('done')}
disabled={saving || browsing !== null}
>
Durchsuchen... Durchsuchen...
</Button> </Button>
</div> </div>
</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> </div>
</Card> </Card>
) )

View File

@ -1,7 +1,7 @@
// RunningDownloads.tsx // RunningDownloads.tsx
'use client' 'use client'
import { useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import Table, { type Column } from './Table' import Table, { type Column } from './Table'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
@ -50,12 +50,23 @@ const runtimeOf = (j: RecordJob) => {
} }
export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Props) { 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>[]>(() => { const columns = useMemo<Column<RecordJob>[]>(() => {
return [ return [
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', header: 'Vorschau',
cell: (j) => <ModelPreview jobId={j.id} />, cell: (j) => <ModelPreview jobId={j.id} thumbTick={thumbTick} />,
}, },
{ {
key: 'model', key: 'model',
@ -114,7 +125,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop
), ),
}, },
] ]
}, [onStopJob]) }, [onStopJob, thumbTick])
if (jobs.length === 0) { if (jobs.length === 0) {
return ( return (
@ -173,7 +184,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}> <div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} /> <ModelPreview jobId={j.id} thumbTick={thumbTick} />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View 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>
)
}

View File

@ -1,20 +1,44 @@
'use client' 'use client'
import * as React from 'react'
import { ChevronDownIcon } from '@heroicons/react/16/solid' import { ChevronDownIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx' import clsx from 'clsx'
export type TabIcon = React.ComponentType<React.SVGProps<SVGSVGElement>>
export type TabItem = { export type TabItem = {
id: string id: string
label: 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 = { type TabsProps = {
tabs: TabItem[] tabs: TabItem[]
value: string value: string
onChange: (id: string) => void onChange: (id: string) => void
className?: string className?: string
ariaLabel?: 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({ export default function Tabs({
@ -23,19 +47,258 @@ export default function Tabs({
onChange, onChange,
className, className,
ariaLabel = 'Ansicht auswählen', ariaLabel = 'Ansicht auswählen',
variant = 'underline',
hideCountUntilMd = false,
}: TabsProps) { }: TabsProps) {
if (!tabs?.length) return null
const current = tabs.find((t) => t.id === value) ?? tabs[0] 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 ( return (
<div className={className}> <div className={className}>
{/* Mobile: Dropdown */} {/* Mobile: Select + Chevron (wie Beispiele) */}
<div className="grid grid-cols-1 sm:hidden"> <div className="grid grid-cols-1 sm:hidden">
<select <select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
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"
>
{tabs.map((tab) => ( {tabs.map((tab) => (
<option key={tab.id} value={tab.id}> <option key={tab.id} value={tab.id}>
{tab.label} {tab.label}
@ -48,44 +311,8 @@ export default function Tabs({
/> />
</div> </div>
{/* Desktop: Horizontal Tabs */} {/* Desktop */}
<div className="hidden sm:block"> <div className="hidden sm:block">{renderDesktop()}</div>
<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>
</div> </div>
) )
} }