Compare commits

...

2 Commits

Author SHA1 Message Date
Linrador
c751430af5 updated ui 2025-12-31 18:27:30 +01:00
unknown
821fe0fef1 updated finished download 2025-12-30 23:35:00 +01:00
21 changed files with 2277 additions and 988 deletions

View File

@ -0,0 +1,219 @@
package main
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
}
func normUser(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func chaturbateUserFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
host := strings.ToLower(u.Hostname())
if !strings.Contains(host, "chaturbate.com") {
return ""
}
parts := strings.Split(u.Path, "/")
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
return normUser(p)
}
}
return ""
}
func cookieHeaderFromSettings(s RecorderSettings) string {
m, err := decryptCookieMap(s.EncryptedCookies)
if err != nil || len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
v := strings.TrimSpace(m[k])
if k == "" || v == "" {
continue
}
if i > 0 {
b.WriteString("; ")
}
b.WriteString(k)
b.WriteString("=")
b.WriteString(v)
}
return b.String()
}
func resolveChaturbateURL(m WatchedModelLite) string {
in := strings.TrimSpace(m.Input)
if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
return in
}
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
if key == "" {
return ""
}
return fmt.Sprintf("https://chaturbate.com/%s/", key)
}
// Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil {
fmt.Println("⚠️ [autostart] model store is nil")
return
}
const pollInterval = 5 * time.Second
const startGap = 5 * time.Second
const retryCooldown = 25 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastTry := map[string]time.Time{}
var lastStart time.Time
for {
s := getSettings()
// ✅ Autostart nur wenn Feature aktiviert ist
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)
if !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || !s.AutoAddToDownloadList {
queue = queue[:0]
queued = map[string]bool{}
time.Sleep(2 * time.Second)
continue
}
cookieHdr := cookieHeaderFromSettings(s)
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
if !hasChaturbateCookies(cookieHdr) {
time.Sleep(5 * time.Second)
continue
}
// online snapshot aus cache
cbMu.RLock()
rooms := append([]ChaturbateRoom(nil), cb.Rooms...)
cbMu.RUnlock()
showByUser := map[string]string{}
for _, r := range rooms {
showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
}
// running users (damit wir nicht doppelt starten)
running := map[string]bool{}
jobsMu.Lock()
for _, j := range jobs {
if j == nil || j.Status != JobRunning {
continue
}
u := chaturbateUserFromURL(j.SourceURL)
if u != "" {
running[u] = true
}
}
jobsMu.Unlock()
// watched list aus DB
watched := store.ListWatchedLite("chaturbate.com")
watchedByUser := map[string]WatchedModelLite{}
for _, m := range watched {
key := normUser(m.ModelKey)
if key != "" && m.Watching {
watchedByUser[key] = m
}
}
// queue prune
nextQueue := queue[:0]
nextQueued := map[string]bool{}
for _, it := range queue {
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
if showByUser[it.userKey] == "" {
continue
}
if running[it.userKey] {
continue
}
it.url = resolveChaturbateURL(m)
if it.url == "" {
continue
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
// enqueue new public watched
now := time.Now()
for user, m := range watchedByUser {
if showByUser[user] != "public" {
continue
}
if running[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
continue
}
u := resolveChaturbateURL(m)
if u == "" {
continue
}
queue = append(queue, autoStartItem{userKey: user, url: u})
queued[user] = true
}
// starte max. einen Job pro Loop (mit Abstand)
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
it := queue[0]
queue = queue[1:]
delete(queued, it.userKey)
lastTry[it.userKey] = time.Now()
_, err := startRecordingInternal(RecordRequest{
URL: it.url,
Cookie: cookieHdr,
})
if err != nil {
fmt.Println("❌ [autostart] start failed:", it.url, err)
} else {
fmt.Println("▶️ [autostart] started:", it.url)
lastStart = time.Now()
}
}
time.Sleep(pollInterval)
}
}

View File

@ -93,7 +93,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller() {
const interval = 5 * time.Second
const interval = 10 * time.Second
// nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s)
lastLoggedCount := -1
@ -143,10 +143,6 @@ func startChaturbateOnlinePoller() {
cb.FetchedAt = time.Now()
cbMu.Unlock()
cb.LastErr = ""
cb.Rooms = rooms
cbMu.Unlock()
// success logging only on changes
if lastLoggedErr != "" {
fmt.Println("✅ [chaturbate] online rooms fetch recovered")

View File

@ -50,7 +50,9 @@ type RecordJob struct {
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Error string `json:"error,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
PreviewImage string `json:"-"`
@ -84,7 +86,11 @@ var durCache = struct {
m map[string]durEntry
}{m: map[string]durEntry{}}
func durationSecondsCached(path string) (float64, error) {
var startedAtFromFilenameRe = regexp.MustCompile(
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
)
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
@ -98,7 +104,7 @@ func durationSecondsCached(path string) (float64, error) {
durCache.mu.Unlock()
// ffprobe (oder notfalls ffmpeg -i parsen)
cmd := exec.Command("ffprobe",
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
@ -133,6 +139,7 @@ type RecorderSettings struct {
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
BlurPreviews bool `json:"blurPreviews,omitempty"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies,omitempty"`
@ -149,6 +156,7 @@ var (
AutoStartAddedDownloads: false,
UseChaturbateAPI: false,
BlurPreviews: false,
EncryptedCookies: "",
}
settingsFile = "recorder_settings.json"
@ -591,7 +599,8 @@ func extractLastFrameJPEG(path string) ([]byte, error) {
"-sseof", "-0.1",
"-i", path,
"-frames:v", "1",
"-q:v", "4",
"-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
@ -621,7 +630,8 @@ func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) {
"-ss", seek,
"-i", path,
"-frames:v", "1",
"-q:v", "4",
"-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
@ -777,7 +787,6 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
if strings.ContainsAny(id, `/\`) {
http.Error(w, "ungültige id", http.StatusBadRequest)
return
@ -802,32 +811,55 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
break
}
}
if outPath == "" {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
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
}
}
// 🔸 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
}
jpegPath := filepath.Join(previewDir, "preview.jpg")
// ✅ Cleanup: hält Cache klein + entfernt .part
// Empfehlung: 250 Frames pro Video, max 14 Tage behalten
const maxFrames = 250
const maxAge = 14 * 24 * time.Hour
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
// ✅ Frame bei Zeitposition t + Disk-Cache
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
key := int(sec*10 + 0.5) // 0.1s Raster, gerundet
if key < 0 {
key = 0
}
cachedFramePath := filepath.Join(previewDir, fmt.Sprintf("t_%09d.jpg", key))
if fi, err := os.Stat(cachedFramePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, cachedFramePath)
return
}
actualSec := float64(key) / 10.0
if img, err := extractFrameAtTimeJPEG(outPath, actualSec); err == nil && len(img) > 0 {
tmp := cachedFramePath + ".part"
_ = os.WriteFile(tmp, img, 0o644)
_ = os.Rename(tmp, cachedFramePath)
// nach neuem Write einmal kurz pruning (optional, aber hält hartes Limit)
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
servePreviewJPEGBytes(w, img)
return
}
// wenn ffmpeg scheitert -> unten statisches preview
}
}
// Statisches preview.jpg (Fallback, gecached)
jpegPath := filepath.Join(previewDir, "preview.jpg")
if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, jpegPath)
return
@ -843,10 +875,75 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
img = img2
}
_ = os.WriteFile(jpegPath, img, 0o644)
tmp := jpegPath + ".part"
_ = os.WriteFile(tmp, img, 0o644)
_ = os.Rename(tmp, jpegPath)
servePreviewJPEGBytes(w, img)
}
func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return
}
type frame struct {
path string
mt time.Time
}
now := time.Now()
var frames []frame
for _, e := range entries {
name := e.Name()
path := filepath.Join(previewDir, name)
// .part Dateien immer weg
if strings.HasSuffix(name, ".part") {
_ = os.Remove(path)
continue
}
// optional: preview.jpg neu erzeugen lassen, wenn uralt
if name == "preview.jpg" {
if info, err := e.Info(); err == nil {
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
}
}
continue
}
// Nur t_*.jpg verwalten
if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") {
info, err := e.Info()
if err != nil {
continue
}
// alte Frames löschen
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
continue
}
frames = append(frames, frame{path: path, mt: info.ModTime()})
}
}
// Anzahl begrenzen: älteste zuerst löschen
if maxFrames > 0 && len(frames) > maxFrames {
sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) })
toDelete := len(frames) - maxFrames
for i := 0; i < toDelete; i++ {
_ = os.Remove(frames[i].path)
}
}
}
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
@ -1011,7 +1108,8 @@ func extractFirstFrameJPEG(path string) ([]byte, error) {
"-loglevel", "error",
"-i", path,
"-frames:v", "1",
"-q:v", "4",
"-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
@ -1139,7 +1237,9 @@ func registerFrontend(mux *http.ServeMux) {
}
// routes.go (package main)
func registerRoutes(mux *http.ServeMux) {
func registerRoutes(mux *http.ServeMux) *ModelStore {
mux.HandleFunc("/api/cookies", cookiesHandler)
mux.HandleFunc("/api/settings", recordSettingsHandler)
mux.HandleFunc("/api/settings/browse", settingsBrowse)
@ -1150,6 +1250,7 @@ func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/record/list", recordList)
mux.HandleFunc("/api/record/video", recordVideo)
mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/record/keep", recordKeepVideo)
@ -1169,6 +1270,8 @@ func registerRoutes(mux *http.ServeMux) {
// ✅ Frontend (SPA) ausliefern
registerFrontend(mux)
return store
}
// --- main ---
@ -1176,7 +1279,10 @@ func main() {
loadSettings()
mux := http.NewServeMux()
registerRoutes(mux)
store := registerRoutes(mux)
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil {
@ -1191,6 +1297,40 @@ type RecordRequest struct {
UserAgent string `json:"userAgent,omitempty"`
}
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL)
if url == "" {
return nil, errors.New("url fehlt")
}
// Duplicate-running guard (identische URL)
jobsMu.Lock()
for _, j := range jobs {
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
jobsMu.Unlock()
return j, nil
}
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: url,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req)
return job, nil
}
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
@ -1203,30 +1343,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
return
}
if req.URL == "" {
http.Error(w, "url fehlt", http.StatusBadRequest)
job, err := startRecordingInternal(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: req.URL,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobsMu.Lock()
jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
_ = json.NewEncoder(w).Encode(job)
}
func parseCookieString(cookieStr string) map[string]string {
@ -1258,7 +1382,18 @@ func hasChaturbateCookies(cookieStr string) bool {
func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
defer func() {
now := time.Now()
jobsMu.Lock()
defer jobsMu.Unlock()
job.EndedAt = &now
// ✅ "Dauer" = Laufzeit (Recording Runtime), nicht ffprobe
if job.StartedAt.After(time.Time{}) {
sec := now.Sub(job.StartedAt).Seconds()
if sec > 0 {
job.DurationSeconds = sec
}
}
}()
hc := NewHTTPClient(req.UserAgent)
@ -1429,8 +1564,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
serveVideoFile(w, r, outPath)
return
}
@ -1490,9 +1624,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
serveVideoFile(w, r, outPath)
}
func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
durCache.mu.Lock()
e, ok := durCache.m[path]
durCache.mu.Unlock()
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
return e.sec
}
return 0
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
@ -1550,15 +1693,34 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime()
dur, _ := durationSecondsCached(full)
start := t
stem := base
if strings.HasPrefix(stem, "HOT ") {
stem = strings.TrimPrefix(stem, "HOT ")
}
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
mm, _ := strconv.Atoi(m[2])
dd, _ := strconv.Atoi(m[3])
yy, _ := strconv.Atoi(m[4])
hh, _ := strconv.Atoi(m[5])
mi, _ := strconv.Atoi(m[6])
ss, _ := strconv.Atoi(m[7])
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
}
dur := 0.0
if t.After(start) {
dur = t.Sub(start).Seconds()
}
list = append(list, &RecordJob{
ID: base,
Output: full,
Status: JobFinished,
StartedAt: t,
StartedAt: start,
EndedAt: &t,
DurationSeconds: dur,
DurationSeconds: dur, // ✅ Runtime
SizeBytes: fi.Size(),
})
}
@ -1572,6 +1734,64 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(list)
}
type doneMetaResp struct {
Count int `json:"count"`
}
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
entries, err := os.ReadDir(doneAbs)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
cnt := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
if ext != ".mp4" && ext != ".ts" {
continue
}
cnt++
}
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
}
type durationReq struct {
Files []string `json:"files"`
}
type durationItem struct {
File string `json:"file"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Error string `json:"error,omitempty"`
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
@ -1636,8 +1856,8 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
}
if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
if isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
@ -1652,6 +1872,27 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
})
}
func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
// ServeContent unterstützt Range Requests (wichtig für Video)
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
@ -1890,33 +2131,31 @@ func moveFile(src, dst string) error {
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
func isSharingViolation(err error) bool {
if runtime.GOOS != "windows" {
return false
}
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
var pe *os.PathError
if errors.As(err, &pe) {
if errno, ok := pe.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
}
return errors.Is(pe.Err, windowsSharingViolation)
}
var le *os.LinkError
if errors.As(err, &le) {
if errno, ok := le.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
}
return errors.Is(le.Err, windowsSharingViolation)
}
return errors.Is(err, windowsSharingViolation)
// Fallback über Text
s := strings.ToLower(err.Error())
return strings.Contains(s, "sharing violation") ||
strings.Contains(s, "used by another process") ||
strings.Contains(s, "wird von einem anderen prozess verwendet")
}
func renameWithRetry(src, dst string) error {
func removeWithRetry(path string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Rename(src, dst)
for i := 0; i < 40; i++ { // ~4s bei 100ms
err = os.Remove(path)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
@ -1925,14 +2164,14 @@ func renameWithRetry(src, dst string) error {
return err
}
func removeWithRetry(path string) error {
func renameWithRetry(oldPath, newPath string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Remove(path)
for i := 0; i < 40; i++ {
err = os.Rename(oldPath, newPath)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
@ -2029,8 +2268,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
}
}
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
w.Write([]byte(`{"ok":"stopped"}`))
}
@ -2076,8 +2313,6 @@ func RecordStream(
}
}
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
@ -2087,8 +2322,6 @@ func RecordStream(
_ = file.Close()
}()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben

Binary file not shown.

View File

@ -0,0 +1,9 @@
//go:build !windows
package main
import "os"
func openForReadShareDelete(path string) (*os.File, error) {
return os.Open(path)
}

View File

@ -0,0 +1,31 @@
//go:build windows
package main
import (
"os"
"syscall"
)
func openForReadShareDelete(path string) (*os.File, error) {
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
// Wichtig: FILE_SHARE_DELETE erlaubt Rename/Move/Delete während Lesen/Streaming
h, err := syscall.CreateFile(
p,
syscall.GENERIC_READ,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(h), path), nil
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -38,6 +38,7 @@ type RecorderSettings = {
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
blurPreviews?: boolean
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -47,6 +48,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
useChaturbateApi: false,
blurPreviews: false,
}
type StoredModel = {
@ -59,29 +61,6 @@ type StoredModel = {
liked?: boolean | null
}
type ChaturbateRoom = {
username: string
current_show?: 'public' | 'private' | 'hidden' | 'away' | string
}
type ChaturbateOnlineResponse = {
enabled: boolean
fetchedAt?: string
lastError?: string
count?: number
rooms: ChaturbateRoom[]
}
type PendingWatchedRoom = {
id: string
modelKey: string
url: string
currentShow: string
}
const sleep = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms))
function extractFirstHttpUrl(text: string): string | null {
const t = (text ?? '').trim()
if (!t) return null
@ -132,11 +111,11 @@ export default function App() {
const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const watchedModelsRef = useRef<StoredModel[]>([])
const [, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false)
@ -148,12 +127,6 @@ export default function App() {
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
// ✅ Watched+Online (wartend) + Autostart-Queue
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
const autoStartQueueRef = useRef<Array<{ userKey: string; url: string }>>([])
const autoStartQueuedUsersRef = useRef<Set<string>>(new Set())
const autoStartWorkerRef = useRef(false)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
@ -223,39 +196,6 @@ export default function App() {
}
}, [])
// ✅ 2) Watched-Chaturbate-Models (kleine Payload) nur für den Online-Abgleich/Autostart
useEffect(() => {
if (!recSettings.useChaturbateApi) {
watchedModelsRef.current = []
return
}
let cancelled = false
let inFlight = false
const load = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<StoredModel[]>('/api/models/watched?host=chaturbate.com', { cache: 'no-store' })
if (cancelled) return
watchedModelsRef.current = Array.isArray(list) ? list : []
} catch {
if (!cancelled) watchedModelsRef.current = []
} finally {
inFlight = false
}
}
load()
const t = window.setInterval(load, document.hidden ? 30000 : 10000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
const initialCookies = useMemo(
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
[cookies]
@ -269,8 +209,8 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running')
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length },
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount },
{ id: 'settings', label: 'Einstellungen' },
]
@ -332,6 +272,41 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded])
useEffect(() => {
let cancelled = false
let t: number | undefined
const loadDoneMeta = async () => {
try {
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
if (!res.ok) return
const meta = (await res.json()) as { count?: number }
if (!cancelled) setDoneCount(meta.count ?? 0)
} catch {
// ignore
} finally {
if (!cancelled) {
// wenn Tab nicht aktiv/Seite im Hintergrund: weniger oft
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneMeta, ms)
}
}
}
const onVis = () => {
if (!document.hidden) void loadDoneMeta()
}
document.addEventListener('visibilitychange', onVis)
void loadDoneMeta()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [])
useEffect(() => {
if (sourceUrl.trim() === '') {
setParsed(null)
@ -386,19 +361,47 @@ export default function App() {
}, [])
useEffect(() => {
// ✅ nur pollen, wenn Finished-Tab aktiv ist
if (selectedTab !== 'finished') return
let cancelled = false
let inFlight = false
const loadDone = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/done')
setDoneJobs(Array.isArray(list) ? list : [])
const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
} catch {
setDoneJobs([])
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
if (!cancelled) setDoneJobs([])
} finally {
inFlight = false
}
}
// beim Betreten des Tabs einmal sofort laden
loadDone()
const t = setInterval(loadDone, 5000)
return () => clearInterval(t)
}, [])
// ✅ weniger aggressiv pollen
const baseMs = 20000 // 20s
const tickMs = document.hidden ? 60000 : baseMs
const t = window.setInterval(loadDone, tickMs)
// ✅ wenn Tab wieder sichtbar wird: direkt refresh
const onVis = () => {
if (!document.hidden) void loadDone()
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab])
function isChaturbate(url: string): boolean {
try {
@ -602,209 +605,48 @@ export default function App() {
})
}
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
if (!m) return
const handleToggleFavorite = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
const next = !Boolean(m.favorite)
const updated = await patchModelFlags({ id: m.id, favorite: next })
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return
setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const next = !Boolean(m.favorite)
const handleToggleLike = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
if (!m) return
const updated = await patchModelFlags({
id: m.id,
favorite: next,
...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
})
const next = !(m.liked === true)
const updated = await patchModelFlags({ id: m.id, liked: next })
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
},
[playerJob, playerModel]
)
setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const handleToggleLike = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return
const normUser = (s: string) => (s || '').trim().toLowerCase()
const curLiked = m.liked === true
const updated = curLiked
? await patchModelFlags({ id: m.id, clearLiked: true }) // ✅ aus
: await patchModelFlags({ id: m.id, liked: true, favorite: false }) // ✅ an + fav aus
const chaturbateUserFromUrl = (u: string): string | null => {
try {
const url = new URL(u)
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null
const parts = url.pathname.split('/').filter(Boolean)
return parts[0] ? normUser(parts[0]) : null
} catch {
return null
}
}
// ✅ 1) Poll: alle watched+online Models als "wartend" anzeigen (public/private/hidden/away)
// und public-Models in eine Start-Queue legen
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setPendingWatchedRooms([])
autoStartQueueRef.current = []
autoStartQueuedUsersRef.current = new Set()
return
}
let cancelled = false
let inFlight = false
const poll = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const canAutoStart = hasRequiredChaturbateCookies(cookiesRef.current)
const modelsList = watchedModelsRef.current
const online = await apiJSON<ChaturbateOnlineResponse>('/api/chaturbate/online', { cache: 'no-store' })
if (!online?.enabled) return
// online username -> show
const showByUser = new Map<string, string>()
for (const r of online.rooms ?? []) {
showByUser.set(normUser(r.username), String(r.current_show || 'unknown').toLowerCase())
}
// running username set (damit wir nichts doppelt starten/anzeigen)
const runningUsers = new Set(
jobsRef.current
.filter((j) => j.status === 'running')
.map((j) => chaturbateUserFromUrl(String(j.sourceUrl || '')))
.filter(Boolean) as string[]
)
// watched username set
const watchedModels = (modelsList ?? []).filter(
(m) => Boolean(m?.watching) && (
String(m?.host || '').toLowerCase().includes('chaturbate.com') || isChaturbate(String(m?.input || ''))
)
)
const watchedUsers = new Set(watchedModels.map((m) => normUser(m.modelKey)).filter(Boolean))
// ✅ Queue aufräumen: raus, wenn nicht mehr watched, offline oder schon running
{
const nextQueue: Array<{ userKey: string; url: string }> = []
for (const q of autoStartQueueRef.current) {
if (!watchedUsers.has(q.userKey)) continue
if (!showByUser.has(q.userKey)) continue
if (runningUsers.has(q.userKey)) continue
nextQueue.push(q)
}
autoStartQueueRef.current = nextQueue
autoStartQueuedUsersRef.current = new Set(nextQueue.map((q) => q.userKey))
}
// ✅ Pending Map: alle watched+online, die NICHT running sind
const pendingMap = new Map<string, PendingWatchedRoom>()
for (const m of watchedModels) {
const key = normUser(m.modelKey)
if (!key) continue
const currentShow = showByUser.get(key)
if (!currentShow) continue // offline -> nicht pending
// running -> nicht pending (steht ja in Jobs)
if (runningUsers.has(key)) continue
const url = /^https?:\/\//i.test(m.input || '')
? String(m.input).trim()
: `https://chaturbate.com/${m.modelKey}/`
// ✅ erst mal ALLE watched+online als wartend anzeigen (auch public)
if (!pendingMap.has(key)) {
pendingMap.set(key, { id: m.id, modelKey: m.modelKey, url, currentShow })
}
// ✅ public in Queue (wenn Cookies da), aber ohne Duplikate
if (currentShow === 'public' && canAutoStart && !autoStartQueuedUsersRef.current.has(key)) {
autoStartQueueRef.current.push({ userKey: key, url })
autoStartQueuedUsersRef.current.add(key)
}
}
if (!cancelled) setPendingWatchedRooms([...pendingMap.values()])
} catch {
// silent
} finally {
inFlight = false
}
}
poll()
const t = window.setInterval(poll, document.hidden ? 15000 : 5000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
// ✅ 2) Worker: startet Queue nacheinander (5s Pause nach jedem Start)
useEffect(() => {
if (!recSettings.useChaturbateApi) return
let cancelled = false
const loop = async () => {
if (autoStartWorkerRef.current) return
autoStartWorkerRef.current = true
try {
while (!cancelled) {
// wenn UI gerade manuell startet -> warten
if (busyRef.current) {
await sleep(500)
continue
}
const next = autoStartQueueRef.current.shift()
if (!next) {
await sleep(1000)
continue
}
// aus queued-set entfernen (damit Poll ggf. neu einreihen kann, falls Start nicht klappt)
autoStartQueuedUsersRef.current.delete(next.userKey)
// start attempt (silent)
const ok = await startUrl(next.url, { silent: true })
if (ok) {
// pending sofort rausnehmen, damit UI direkt "running" zeigt
setPendingWatchedRooms((prev) => prev.filter((p) => normUser(p.modelKey) !== next.userKey))
}
// ✅ 5s Abstand nach (erfolgreichem) Starten ich warte auch bei failure,
// damit wir nicht in eine schnelle Retry-Schleife laufen.
if (ok) {
await sleep(5000)
} else {
await sleep(5000)
}
}
} finally {
autoStartWorkerRef.current = false
}
}
void loop()
return () => {
cancelled = true
}
}, [recSettings.useChaturbateApi, startUrl])
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
},
[playerJob, playerModel]
)
useEffect(() => {
if (!autoAddEnabled && !autoStartEnabled) return
@ -933,9 +775,9 @@ export default function App() {
{selectedTab === 'running' && (
<RunningDownloads
jobs={runningJobs}
pending={pendingWatchedRooms}
onOpenPlayer={openPlayer}
onStopJob={stopJob}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
)}
@ -944,6 +786,11 @@ export default function App() {
jobs={jobs}
doneJobs={doneJobs}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
)}
@ -977,13 +824,11 @@ export default function App() {
<Player
job={playerJob}
expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((v) => !v)}
onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => setPlayerJob(null)}
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true}
onDelete={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}

File diff suppressed because it is too large Load Diff

View File

@ -6,30 +6,49 @@ import HoverPopover from './HoverPopover'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips'
type AnimatedTrigger = 'always' | 'hover'
type Props = {
export type FinishedVideoPreviewProps = {
job: RecordJob
getFileName: (path: string) => string
durationSeconds?: number
onDuration?: (job: RecordJob, seconds: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
animated?: boolean
animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger
/** nur für frames */
autoTickMs?: number
thumbStepSec?: number
thumbSpread?: boolean
thumbSamples?: number
/** nur für clips */
clipSeconds?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant
/** optionales Zusatz-Styling */
className?: string
showPopover?: boolean
blur?: boolean
/**
* inline video:
* - false: nur Bild
* - false: nur Bild/Teaser
* - true/'always': immer inline abspielen (wenn inView)
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
* - 'hover': nur bei Hover/Focus abspielen, sonst Bild
*/
inlineVideo?: InlineVideoMode
/** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */
inlineNonce?: number
/** Inline-Playback: Controls anzeigen? */
inlineControls?: boolean
/** Inline-Playback: loopen? */
inlineLoop?: boolean
}
export default function FinishedVideoPreview({
@ -37,46 +56,51 @@ export default function FinishedVideoPreview({
getFileName,
durationSeconds,
onDuration,
animated = false,
animatedMode = 'frames',
animatedTrigger = 'always',
autoTickMs = 15000,
thumbStepSec,
thumbSpread,
thumbSamples,
clipSeconds = 1,
variant = 'thumb',
className,
showPopover = true,
blur = false,
inlineVideo = false,
}: Props) {
inlineNonce = 0,
inlineControls = false,
inlineLoop = true,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
// ✅ nur animieren, wenn sichtbar (Viewport)
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
// Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0)
// ✅ für hover-play
// Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!animated) return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, inView, autoTickMs])
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
const previewId = useMemo(() => {
if (!file) return ''
@ -92,25 +116,62 @@ export default function FinishedVideoPreview({
const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// --- Tick für "frames"
useEffect(() => {
if (!animated) return
if (animatedMode !== 'frames') return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs])
// --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => {
if (!animated) return null
if (animatedMode !== 'frames') return null
if (!hasDuration) return null
const step = 3
const total = Math.max(durationSeconds! - 0.1, step)
return (localTick * step) % total
}, [animated, hasDuration, durationSeconds, localTick])
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
const dur = durationSeconds!
const step = Math.max(0.25, thumbStepSec ?? 3)
if (thumbSpread) {
const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur)))
const idx = localTick % count
const span = Math.max(0.1, dur - step)
const base = Math.min(0.25, span * 0.02)
const t = (idx / count) * span + base
return Math.min(dur - 0.05, Math.max(0.05, t))
}
const total = Math.max(dur - 0.1, step)
const t = (localTick * step) % total
return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
const thumbSrc = useMemo(() => {
if (!previewId) return ''
if (thumbTimeSec == null) {
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
}
// static thumb (oder frames: mit t=...)
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
thumbTimeSec.toFixed(2)
)}`
}, [previewId, thumbTimeSec])
)}&v=${encodeURIComponent(String(localTick))}`
}, [previewId, thumbTimeSec, localTick])
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
@ -120,24 +181,105 @@ export default function FinishedVideoPreview({
}
if (!videoSrc) {
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' &&
inView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
// --- Teaser Clip Zeiten (nur clips)
const clipTimes = useMemo(() => {
if (!animated) return []
if (animatedMode !== 'clips') return []
if (!hasDuration) return []
const dur = durationSeconds!
const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
const span = Math.max(0.1, dur - clipLen)
const base = Math.min(0.25, span * 0.02)
const times: number[] = []
for (let i = 0; i < count; i++) {
const t = (i / count) * span + base
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
}
return times
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
// --- Teaser aktiv? (nur inView, nicht inline, optional nur hover)
const teaserActive =
animated &&
animatedMode === 'clips' &&
inView &&
!document.hidden &&
videoOk &&
clipTimes.length > 0 &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered)
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover')
// --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop)
const teaserRef = useRef<HTMLVideoElement | null>(null)
const clipIdxRef = useRef(0)
const clipStartRef = useRef(0)
useEffect(() => {
const v = teaserRef.current
if (!v) return
if (!teaserActive) {
v.pause()
return
}
if (!clipTimes.length) return
clipIdxRef.current = clipIdxRef.current % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
const start = () => {
try {
v.currentTime = clipStartRef.current
} catch {}
const p = v.play()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}
const onLoaded = () => start()
const onTimeUpdate = () => {
if (!clipTimes.length) return
if (v.currentTime - clipStartRef.current >= clipSeconds) {
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
try {
v.currentTime = clipStartRef.current + 0.01
} catch {}
}
}
v.addEventListener('loadedmetadata', onLoaded)
v.addEventListener('timeupdate', onTimeUpdate)
// Wenn metadata schon da ist:
if (v.readyState >= 1) start()
return () => {
v.removeEventListener('loadedmetadata', onLoaded)
v.removeEventListener('timeupdate', onTimeUpdate)
v.pause()
}
}, [teaserActive, clipTimesKey, clipSeconds])
const previewNode = (
<div
@ -147,39 +289,59 @@ export default function FinishedVideoPreview({
sizeClass,
className ?? '',
].join(' ')}
// ✅ hover only relevant for inlineMode==='hover'
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined}
>
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
{/* 1) Inline Full Video (mit Controls) */}
{showingInlineVideo ? (
<video
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc}
className="w-full h-full object-cover bg-black pointer-events-none"
className={[
'w-full h-full object-cover bg-black',
blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"
autoPlay
loop
controls={inlineControls}
loop={inlineLoop}
poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : teaserActive ? (
/* 2) Teaser Clips (1s Segmente) */
<video
ref={teaserRef}
key={`teaser-${previewId}-${clipTimesKey}`}
src={videoSrc}
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"
poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : thumbSrc && thumbOk ? (
/* 3) Statisches Bild / Frames */
<img
src={thumbSrc}
loading="lazy"
alt={file}
className="w-full h-full object-cover"
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video
src={videoSrc}
@ -193,7 +355,7 @@ export default function FinishedVideoPreview({
</div>
)
// Gallery: kein HoverPopover
// Gallery: kein HoverPopover
if (!showPopover) return previewNode
return (
@ -204,7 +366,7 @@ export default function FinishedVideoPreview({
<div className="aspect-video">
<video
src={videoSrc}
className="w-full h-full bg-black"
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"

View File

@ -13,10 +13,13 @@ import Card from './Card'
type Pos = { left: number; top: number }
type HoverPopoverAPI = { close: () => void }
type HoverPopoverProps = PropsWithChildren<{
// Entweder direkt ein ReactNode
// oder eine Renderfunktion, die den Open-Status bekommt
content: ReactNode | ((open: boolean) => ReactNode)
// (2. Param erlaubt z.B. Close-Button im Popover)
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
}>
export default function HoverPopover({ children, content }: HoverPopoverProps) {
@ -53,6 +56,11 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
scheduleClose()
}
const close = () => {
clearCloseTimeout()
setOpen(false)
}
const computePos = () => {
const trigger = triggerRef.current
const pop = popoverRef.current
@ -116,7 +124,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
// Hilfsfunktion: content normalisieren
const renderContent = () =>
typeof content === 'function'
? (content as (open: boolean) => ReactNode)(open)
? (content as any)(open, { close })
: content
return (
@ -144,7 +152,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
onMouseLeave={handleLeave}
>
<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 max-w-[calc(100vw-16px)]"
noBodyPadding
>
{renderContent()}

View File

@ -4,6 +4,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
jobId: string
@ -12,9 +13,11 @@ type Props = {
thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number
blur?: boolean
}
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: Props) {
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blur = false }: Props) {
const blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
@ -75,15 +78,32 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
return (
<HoverPopover
content={(open) =>
content={(open, { close }) =>
open && (
<div className="w-[420px]">
<div className="aspect-video">
<LiveHlsVideo
src={hq}
muted={false}
className="w-full h-full bg-black"
/>
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
{/* LIVE badge */}
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
{/* Close */}
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
aria-label="Live-Vorschau schließen"
title="Vorschau schließen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
close()
}}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
)
@ -98,7 +118,7 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
src={thumb}
loading="lazy"
alt=""
className="w-full h-full object-cover"
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setImgError(true)}
onLoad={() => setImgError(false)}
/>

View File

@ -12,14 +12,65 @@ import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
FireIcon,
HeartIcon,
HandThumbUpIcon,
TrashIcon,
XMarkIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
} from '@heroicons/react/24/outline'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
} from '@heroicons/react/24/solid'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
function formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
@ -59,6 +110,24 @@ export default function Player({
}: PlayerProps) {
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id])
const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => modelNameFromOutput(job.output), [job.output])
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
const runtimeLabel = React.useMemo(() => {
const start = Date.parse(String((job as any).startedAt || ''))
const end = Date.parse(String((job as any).endedAt || ''))
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
return formatDuration(end - start)
}
const sec = (job as any).durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return formatDuration(sec * 1000)
return '—'
}, [job])
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKeyDown)
@ -91,6 +160,41 @@ export default function Player({
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
const [mounted, setMounted] = React.useState(false)
const [controlBarH, setControlBarH] = React.useState(56)
React.useEffect(() => {
if (!mounted) return
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const root = p.el() as HTMLElement | null
if (!root) return
const bar = root.querySelector('.vjs-control-bar') as HTMLElement | null
if (!bar) return
const update = () => {
const h = Math.round(bar.getBoundingClientRect().height || 0)
if (h > 0) setControlBarH(h)
}
update()
// live nachziehen, falls Video.js/Fullscreen/Responsive die Höhe ändert
let ro: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(update)
ro.observe(bar)
}
window.addEventListener('resize', update)
return () => {
window.removeEventListener('resize', update)
ro?.disconnect()
}
}, [mounted, expanded])
React.useEffect(() => setMounted(true), [])
// ✅ 1x initialisieren
@ -199,18 +303,84 @@ export default function Player({
try {
p.pause()
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
// video.js hat reset() -> stoppt Tech + Requests oft zuverlässiger
;(p as any).reset?.()
} catch {}
try {
// Source leeren, damit Browser/Tech die HTTP-Verbindung abbricht
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => {
const onRelease = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim()
if (!file) return
const current = baseName(job.output?.trim() || '')
if (current && current === file) {
releaseMedia()
}
}
window.addEventListener('player:release', onRelease as EventListener)
return () => window.removeEventListener('player:release', onRelease as EventListener)
}, [job.output, releaseMedia])
React.useEffect(() => {
const onCloseIfFile = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim()
if (!file) return
const current = baseName(job.output?.trim() || '')
if (current && current === file) {
releaseMedia()
onClose()
}
}
window.addEventListener('player:close', onCloseIfFile as EventListener)
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
}, [job.output, releaseMedia, onClose])
const mini = !expanded
const [miniHover, setMiniHover] = React.useState(false)
const [canHover, setCanHover] = React.useState(false)
const [progressActive, setProgressActive] = React.useState(false)
const [isPaused, setIsPaused] = React.useState(false)
React.useEffect(() => {
if (!mounted) return
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const update = () => {
try {
setIsPaused(Boolean((p as any).paused?.()))
} catch {
setIsPaused(false)
}
}
update()
p.on('play', update)
p.on('pause', update)
return () => {
try {
p.off('play', update)
p.off('pause', update)
} catch {}
}
}, [mounted])
const progressTimerRef = React.useRef<number | null>(null)
const stopProgress = React.useCallback(() => {
@ -298,29 +468,35 @@ export default function Player({
<button
type="button"
className={overlayBtn}
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
e.stopPropagation()
onToggleFavorite?.(job)
}}
disabled={!onToggleFavorite}
>
<HeartIcon className={cn('h-5 w-5', isFavorite ? 'text-pink-300' : 'text-white')} />
{(() => {
const Icon = isFavorite ? StarSolidIcon : StarOutlineIcon
return <Icon className={cn('h-5 w-5', isFavorite ? 'text-amber-300' : 'text-white/90')} />
})()}
</button>
<button
type="button"
className={overlayBtn}
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
onClick={(e) => {
e.stopPropagation()
onToggleLike?.(job)
}}
disabled={!onToggleLike}
>
<HandThumbUpIcon className={cn('h-5 w-5', isLiked ? 'text-indigo-200' : 'text-white')} />
{(() => {
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
return <Icon className={cn('h-5 w-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
})()}
</button>
<button
@ -342,6 +518,12 @@ export default function Player({
</div>
)
const controlsVisible = !canHover || progressActive || isPaused
const controlsInsetPx = expanded ? (controlsVisible ? controlBarH : 0) : 0
const expandedGradientBottom = `calc(${controlsInsetPx}px + env(safe-area-inset-bottom))`
const expandedMetaBottom = `calc(${controlsInsetPx + 8}px + env(safe-area-inset-bottom))`
return createPortal(
<>
{expanded && (
@ -402,15 +584,29 @@ export default function Player({
>
<div ref={containerRef} className="absolute inset-0" />
{/* Top overlay: title + window controls */}
{/* Top overlay: inline-like header + actions + window controls */}
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="player-ui max-w-[70vw] sm:max-w-[320px] truncate rounded-md bg-black/45 px-2.5 py-1.5 text-xs font-semibold text-white backdrop-blur">
{title}
<div className="player-ui max-w-[70vw] sm:max-w-[360px] rounded-md bg-black/45 px-2.5 py-1.5 text-white backdrop-blur">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate text-sm font-semibold">{model}</div>
{isHotFile ? (
<span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
HOT
</span>
) : null}
</div>
<div className="truncate text-[11px] text-white/80">
{file || title}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 pointer-events-auto">
{/* Inline-like actions (Hot/Fav/Like/Delete) */}
{footerRight}
{/* Window controls */}
<button
type="button"
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
@ -418,11 +614,7 @@ export default function Player({
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
{expanded ? <ArrowsPointingInIcon className="h-5 w-5" /> : <ArrowsPointingOutIcon className="h-5 w-5" />}
</button>
<button
@ -437,33 +629,37 @@ export default function Player({
</div>
</div>
{/* Bottom overlay: mini actions + status */}
{!expanded && (
<>
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20'
)}
/>
{/* Bottom overlay: inline-like meta */}
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
'transition-all duration-200 ease-out',
expanded ? 'h-28' : (liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20')
)}
style={expanded ? { bottom: expandedGradientBottom } : undefined}
/>
<div
className={cn(
'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2',
'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7' : 'bottom-2'
)}
>
<div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur">
<span className="font-semibold text-white">{job.status}</span>
{job.output ? <span className="ml-2 opacity-80"> {job.output}</span> : null}
</div>
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-2 z-20 flex items-end justify-between gap-2',
'transition-all duration-200 ease-out',
expanded ? '' : (liftMiniOverlay ? 'bottom-7' : 'bottom-2')
)}
style={expanded ? { bottom: expandedMetaBottom } : undefined}
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{model}</div>
<div className="truncate text-[11px] text-white/80">{file || title}</div>
</div>
{footerRight}
</div>
</>
)}
<div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-semibold">{job.status}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span>
{sizeLabel !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span>
) : null}
</div>
</div>
</div>
</div>
</Card>

View File

@ -17,6 +17,7 @@ type RecorderSettings = {
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean
blurPreviews?: boolean
}
const DEFAULTS: RecorderSettings = {
@ -30,6 +31,7 @@ const DEFAULTS: RecorderSettings = {
autoStartAddedDownloads: true,
useChaturbateApi: false,
blurPreviews: false,
}
export default function RecorderSettings() {
@ -59,6 +61,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
})
})
.catch(() => {
@ -117,6 +120,7 @@ export default function RecorderSettings() {
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi
const blurPreviews = !!value.blurPreviews
setSaving(true)
try {
@ -131,6 +135,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads,
useChaturbateApi,
blurPreviews,
}),
})
if (!res.ok) {
@ -256,6 +261,13 @@ export default function RecorderSettings() {
label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/>
<LabeledSwitch
checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
/>
</div>
</div>
</div>

View File

@ -18,6 +18,7 @@ type Props = {
pending?: PendingWatchedRoom[]
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
blurPreviews?: boolean
}
const baseName = (p: string) =>
@ -55,13 +56,13 @@ const runtimeOf = (j: RecordJob) => {
return formatDuration(end - start)
}
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob }: Props) {
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
const columns = useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => <ModelPreview jobId={j.id} />,
cell: (j) => <ModelPreview jobId={j.id} blur={blurPreviews} />,
},
{
key: 'model',
@ -199,7 +200,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} />
<ModelPreview jobId={j.id} blur={blurPreviews} />
</div>
<div className="min-w-0 flex-1">

View File

@ -45,6 +45,19 @@ export type SwipeCardProps = {
/** Animation timings */
snapMs?: number
commitMs?: number
/**
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
*/
ignoreFromBottomPx?: number
/**
* Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird.
* (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben)
*/
ignoreSelector?: string
}
export type SwipeCardHandle = {
@ -82,6 +95,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
},
thresholdPx = 120,
thresholdRatio = 0.35,
ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]',
snapMs = 180,
commitMs = 180,
},
@ -95,7 +110,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
x: number
y: number
dragging: boolean
}>({ id: null, x: 0, y: 0, dragging: false })
captured: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false })
const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
@ -190,9 +206,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
const target = e.target as HTMLElement | null
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// ✅ 2) Ignoriere Start im unteren Bereich (z.B. Video-Controls/Progressbar)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
pointer.current = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
dragging: false,
captured: false,
}
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
@ -200,12 +232,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y
// Erst entscheiden ob wir überhaupt "draggen"
// Erst entscheiden ob wir überhaupt draggen
if (!pointer.current.dragging) {
// wenn Nutzer vertikal scrollt, nicht hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
if (Math.abs(ddx) < 6) return
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
pointer.current.id = null
return
}
// "Dead zone" bis wirklich horizontal gedrückt wird
if (Math.abs(ddx) < 12) return
// ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
try {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pointer.current.captured = true
} catch {
pointer.current.captured = false
}
}
setAnimMs(0)
@ -214,9 +261,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
@ -226,7 +273,18 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
pointer.current.id = null
pointer.current.dragging = false
pointer.current.captured = false
// Capture sauber lösen (falls gesetzt)
if (wasCaptured) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {}
}
if (!wasDragging) {
reset()
@ -235,15 +293,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
if (dx > threshold) {
void commit('right', true) // keep
void commit('right', true)
} else if (dx < -threshold) {
void commit('left', true) // delete
void commit('left', true)
} else {
reset()
}
}}
onPointerCancel={() => {
onPointerCancel={(e) => {
if (!enabled || disabled) return
if (pointer.current.captured && pointer.current.id != null) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {}
}
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false }
reset()
}}
>

View File

@ -15,58 +15,6 @@
-moz-osx-font-smoothing: grayscale;
}
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
.vjs-mini .video-js .vjs-control-bar{
z-index: 40; /* über Overlays */
background: rgba(0,0,0,.65);
backdrop-filter: blur(8px);
}
/* Progressbar deutlich höher */
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
height: 10px;
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
.vjs-mini .video-js .vjs-play-progress{
border-radius: 9999px;
background: rgba(99,102,241,.95);
}
.vjs-mini .video-js .vjs-load-progress{
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
.vjs-controls-on-activity .video-js .vjs-control-bar{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
.vjs-controls-on-activity .player-ui{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .player-ui,
.vjs-controls-on-activity:focus-within .player-ui{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;