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"`
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",

View File

@ -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
}

View File

@ -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"
/>

View File

@ -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">

View File

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

View File

@ -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

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

View File

@ -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) {

View File

@ -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>
)

View File

@ -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">

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