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

View File

@ -50,6 +50,8 @@ type RecordJob struct {
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"` EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"` DurationSeconds float64 `json:"durationSeconds,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
PreviewDir string `json:"-"` PreviewDir string `json:"-"`
@ -84,7 +86,11 @@ var durCache = struct {
m map[string]durEntry m map[string]durEntry
}{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) fi, err := os.Stat(path)
if err != nil { if err != nil {
return 0, err return 0, err
@ -98,7 +104,7 @@ func durationSecondsCached(path string) (float64, error) {
durCache.mu.Unlock() durCache.mu.Unlock()
// ffprobe (oder notfalls ffmpeg -i parsen) // ffprobe (oder notfalls ffmpeg -i parsen)
cmd := exec.Command("ffprobe", cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error", "-v", "error",
"-show_entries", "format=duration", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", "-of", "default=noprint_wrappers=1:nokey=1",
@ -133,6 +139,7 @@ type RecorderSettings struct {
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"` UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
BlurPreviews bool `json:"blurPreviews,omitempty"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies,omitempty"` EncryptedCookies string `json:"encryptedCookies,omitempty"`
@ -149,6 +156,7 @@ var (
AutoStartAddedDownloads: false, AutoStartAddedDownloads: false,
UseChaturbateAPI: false, UseChaturbateAPI: false,
BlurPreviews: false,
EncryptedCookies: "", EncryptedCookies: "",
} }
settingsFile = "recorder_settings.json" settingsFile = "recorder_settings.json"
@ -591,7 +599,8 @@ func extractLastFrameJPEG(path string) ([]byte, error) {
"-sseof", "-0.1", "-sseof", "-0.1",
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-q:v", "4", "-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "mjpeg", "-vcodec", "mjpeg",
"pipe:1", "pipe:1",
@ -621,7 +630,8 @@ func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) {
"-ss", seek, "-ss", seek,
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-q:v", "4", "-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "mjpeg", "-vcodec", "mjpeg",
"pipe:1", "pipe:1",
@ -777,7 +787,6 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
http.Error(w, "id fehlt", http.StatusBadRequest) http.Error(w, "id fehlt", http.StatusBadRequest)
return return
} }
if strings.ContainsAny(id, `/\`) { if strings.ContainsAny(id, `/\`) {
http.Error(w, "ungültige id", http.StatusBadRequest) http.Error(w, "ungültige id", http.StatusBadRequest)
return return
@ -802,32 +811,55 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
break break
} }
} }
if outPath == "" { if outPath == "" {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound) http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return return
} }
// 🔹 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) previewDir := filepath.Join(os.TempDir(), "rec_preview", id)
if err := os.MkdirAll(previewDir, 0o755); err != nil { if err := os.MkdirAll(previewDir, 0o755); err != nil {
http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError) http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError)
return 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 { if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, jpegPath) servePreviewJPEGFile(w, r, jpegPath)
return return
@ -843,10 +875,75 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
img = img2 img = img2
} }
_ = os.WriteFile(jpegPath, img, 0o644) tmp := jpegPath + ".part"
_ = os.WriteFile(tmp, img, 0o644)
_ = os.Rename(tmp, jpegPath)
servePreviewJPEGBytes(w, img) 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) { func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("Cache-Control", "public, max-age=31536000")
@ -1011,7 +1108,8 @@ func extractFirstFrameJPEG(path string) ([]byte, error) {
"-loglevel", "error", "-loglevel", "error",
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-q:v", "4", "-vf", "scale=320:-2",
"-q:v", "7",
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "mjpeg", "-vcodec", "mjpeg",
"pipe:1", "pipe:1",
@ -1139,7 +1237,9 @@ func registerFrontend(mux *http.ServeMux) {
} }
// routes.go (package main) // 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", recordSettingsHandler)
mux.HandleFunc("/api/settings/browse", settingsBrowse) 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/list", recordList)
mux.HandleFunc("/api/record/video", recordVideo) mux.HandleFunc("/api/record/video", recordVideo)
mux.HandleFunc("/api/record/done", recordDoneList) mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
mux.HandleFunc("/api/record/delete", recordDeleteVideo) mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot) mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/record/keep", recordKeepVideo) mux.HandleFunc("/api/record/keep", recordKeepVideo)
@ -1169,6 +1270,8 @@ func registerRoutes(mux *http.ServeMux) {
// ✅ Frontend (SPA) ausliefern // ✅ Frontend (SPA) ausliefern
registerFrontend(mux) registerFrontend(mux)
return store
} }
// --- main --- // --- main ---
@ -1176,7 +1279,10 @@ func main() {
loadSettings() loadSettings()
mux := http.NewServeMux() 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") fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil { if err := http.ListenAndServe(":9999", mux); err != nil {
@ -1191,6 +1297,40 @@ type RecordRequest struct {
UserAgent string `json:"userAgent,omitempty"` 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) { func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
@ -1203,30 +1343,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.URL == "" { job, err := startRecordingInternal(req)
http.Error(w, "url fehlt", http.StatusBadRequest) if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job) _ = json.NewEncoder(w).Encode(job)
} }
func parseCookieString(cookieStr string) map[string]string { 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) { func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
defer func() { defer func() {
now := time.Now() now := time.Now()
jobsMu.Lock()
defer jobsMu.Unlock()
job.EndedAt = &now 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) 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("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4") serveVideoFile(w, r, outPath)
http.ServeFile(w, r, outPath)
return return
} }
@ -1490,9 +1624,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
} }
} }
w.Header().Set("Cache-Control", "no-store") serveVideoFile(w, r, outPath)
w.Header().Set("Content-Type", "video/mp4") }
http.ServeFile(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) { 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)) base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime() 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{ list = append(list, &RecordJob{
ID: base, ID: base,
Output: full, Output: full,
Status: JobFinished, Status: JobFinished,
StartedAt: t, StartedAt: start,
EndedAt: &t, 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) _ = 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) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE // Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete { 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 err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if isSharingViolation(err) {
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict) http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return return
} }
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) 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) { func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) 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 const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
func isSharingViolation(err error) bool { func isSharingViolation(err error) bool {
if runtime.GOOS != "windows" {
return false
}
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
var pe *os.PathError var pe *os.PathError
if errors.As(err, &pe) { if errors.As(err, &pe) {
if errno, ok := pe.Err.(syscall.Errno); ok { 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) }
// 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")
} }
var le *os.LinkError func removeWithRetry(path string) error {
if errors.As(err, &le) {
if errno, ok := le.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
}
return errors.Is(le.Err, windowsSharingViolation)
}
return errors.Is(err, windowsSharingViolation)
}
func renameWithRetry(src, dst string) error {
var err error var err error
for i := 0; i < 15; i++ { // ~1.5s for i := 0; i < 40; i++ { // ~4s bei 100ms
err = os.Rename(src, dst) err = os.Remove(path)
if err == nil { if err == nil {
return nil return nil
} }
if runtime.GOOS == "windows" && isSharingViolation(err) { if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue continue
} }
@ -1925,14 +2164,14 @@ func renameWithRetry(src, dst string) error {
return err return err
} }
func removeWithRetry(path string) error { func renameWithRetry(oldPath, newPath string) error {
var err error var err error
for i := 0; i < 15; i++ { // ~1.5s for i := 0; i < 40; i++ {
err = os.Remove(path) err = os.Rename(oldPath, newPath)
if err == nil { if err == nil {
return nil return nil
} }
if runtime.GOOS == "windows" && isSharingViolation(err) { if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue continue
} }
@ -2029,8 +2268,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
} }
} }
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
w.Write([]byte(`{"ok":"stopped"}`)) 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 // 4) Datei öffnen
file, err := os.Create(outputPath) file, err := os.Create(outputPath)
if err != nil { if err != nil {
@ -2087,8 +2322,6 @@ func RecordStream(
_ = file.Close() _ = file.Close()
}() }()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR // 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { 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 // 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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script> <script type="module" crossorigin src="/assets/index-zKk-xTZ_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css"> <link rel="stylesheet" crossorigin href="/assets/index-ZZZa38Qs.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -38,6 +38,7 @@ type RecorderSettings = {
autoAddToDownloadList?: boolean autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean useChaturbateApi?: boolean
blurPreviews?: boolean
} }
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = { const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -47,6 +48,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
autoAddToDownloadList: false, autoAddToDownloadList: false,
autoStartAddedDownloads: false, autoStartAddedDownloads: false,
useChaturbateApi: false, useChaturbateApi: false,
blurPreviews: false,
} }
type StoredModel = { type StoredModel = {
@ -59,29 +61,6 @@ type StoredModel = {
liked?: boolean | null 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 { function extractFirstHttpUrl(text: string): string | null {
const t = (text ?? '').trim() const t = (text ?? '').trim()
if (!t) return null if (!t) return null
@ -132,11 +111,11 @@ export default function App() {
const [, setParseError] = useState<string | null>(null) const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([]) const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([]) const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0) const [modelsCount, setModelsCount] = useState(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null) const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const watchedModelsRef = useRef<StoredModel[]>([])
const [, setError] = useState<string | null>(null) const [, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false) const [cookieModalOpen, setCookieModalOpen] = useState(false)
@ -148,12 +127,6 @@ export default function App() {
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS) 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 autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads) 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( const initialCookies = useMemo(
() => Object.entries(cookies).map(([name, value]) => ({ name, value })), () => Object.entries(cookies).map(([name, value]) => ({ name, value })),
[cookies] [cookies]
@ -269,8 +209,8 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running') const runningJobs = jobs.filter((j) => j.status === 'running')
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length }, { id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length }, { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount }, { id: 'models', label: 'Models', count: modelsCount },
{ id: 'settings', label: 'Einstellungen' }, { id: 'settings', label: 'Einstellungen' },
] ]
@ -332,6 +272,41 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded]) }, [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(() => { useEffect(() => {
if (sourceUrl.trim() === '') { if (sourceUrl.trim() === '') {
setParsed(null) setParsed(null)
@ -386,19 +361,47 @@ export default function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
// ✅ nur pollen, wenn Finished-Tab aktiv ist
if (selectedTab !== 'finished') return
let cancelled = false
let inFlight = false
const loadDone = async () => { const loadDone = async () => {
if (cancelled || inFlight) return
inFlight = true
try { try {
const list = await apiJSON<RecordJob[]>('/api/record/done') const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
setDoneJobs(Array.isArray(list) ? list : []) if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
} catch { } 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() 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 { function isChaturbate(url: string): boolean {
try { try {
@ -602,210 +605,49 @@ export default function App() {
}) })
} }
const handleToggleFavorite = useCallback(async (job: RecordJob) => { const handleToggleFavorite = useCallback(
let m = playerModel async (job: RecordJob) => {
if (!m) { const file = baseName(job.output || '')
m = await resolveModelForJob(job) const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
setPlayerModel(m)
} let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return if (!m) return
const next = !Boolean(m.favorite) const next = !Boolean(m.favorite)
const updated = await patchModelFlags({ id: m.id, favorite: next })
setPlayerModel(updated) const updated = await patchModelFlags({
id: m.id,
favorite: next,
...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
})
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed')) window.dispatchEvent(new Event('models-changed'))
}, [playerModel]) },
[playerJob, playerModel]
)
const handleToggleLike = useCallback(async (job: RecordJob) => { const handleToggleLike = useCallback(
let m = playerModel async (job: RecordJob) => {
if (!m) { const file = baseName(job.output || '')
m = await resolveModelForJob(job) const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
setPlayerModel(m)
} let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return if (!m) return
const next = !(m.liked === true) const curLiked = m.liked === true
const updated = await patchModelFlags({ id: m.id, liked: next }) const updated = curLiked
? await patchModelFlags({ id: m.id, clearLiked: true }) // ✅ aus
: await patchModelFlags({ id: m.id, liked: true, favorite: false }) // ✅ an + fav aus
setPlayerModel(updated) if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed')) window.dispatchEvent(new Event('models-changed'))
}, [playerModel]) },
[playerJob, playerModel]
const normUser = (s: string) => (s || '').trim().toLowerCase()
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])
useEffect(() => { useEffect(() => {
if (!autoAddEnabled && !autoStartEnabled) return if (!autoAddEnabled && !autoStartEnabled) return
if (!navigator.clipboard?.readText) return if (!navigator.clipboard?.readText) return
@ -933,9 +775,9 @@ export default function App() {
{selectedTab === 'running' && ( {selectedTab === 'running' && (
<RunningDownloads <RunningDownloads
jobs={runningJobs} jobs={runningJobs}
pending={pendingWatchedRooms}
onOpenPlayer={openPlayer} onOpenPlayer={openPlayer}
onStopJob={stopJob} onStopJob={stopJob}
blurPreviews={Boolean(recSettings.blurPreviews)}
/> />
)} )}
@ -944,6 +786,11 @@ export default function App() {
jobs={jobs} jobs={jobs}
doneJobs={doneJobs} doneJobs={doneJobs}
onOpenPlayer={openPlayer} onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
blurPreviews={Boolean(recSettings.blurPreviews)}
/> />
)} )}
@ -977,13 +824,11 @@ export default function App() {
<Player <Player
job={playerJob} job={playerJob}
expanded={playerExpanded} expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((v) => !v)} onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => setPlayerJob(null)} onClose={() => setPlayerJob(null)}
isHot={baseName(playerJob.output || '').startsWith('HOT ')} isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)} isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true} isLiked={playerModel?.liked === true}
onDelete={handleDeleteJob} onDelete={handleDeleteJob}
onToggleHot={handleToggleHot} onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite} 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 Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover' type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips'
type AnimatedTrigger = 'always' | 'hover'
type Props = { export type FinishedVideoPreviewProps = {
job: RecordJob job: RecordJob
getFileName: (path: string) => string getFileName: (path: string) => string
durationSeconds?: number durationSeconds?: number
onDuration?: (job: RecordJob, seconds: number) => void onDuration?: (job: RecordJob, seconds: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
animated?: boolean animated?: boolean
animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger
/** nur für frames */
autoTickMs?: number 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 */ /** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant variant?: Variant
/** optionales Zusatz-Styling */
className?: string className?: string
showPopover?: boolean showPopover?: boolean
blur?: boolean
/** /**
* inline video: * inline video:
* - false: nur Bild * - false: nur Bild/Teaser
* - true/'always': immer inline abspielen (wenn inView) * - 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 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({ export default function FinishedVideoPreview({
@ -37,46 +56,51 @@ export default function FinishedVideoPreview({
getFileName, getFileName,
durationSeconds, durationSeconds,
onDuration, onDuration,
animated = false, animated = false,
animatedMode = 'frames',
animatedTrigger = 'always',
autoTickMs = 15000, autoTickMs = 15000,
thumbStepSec,
thumbSpread,
thumbSamples,
clipSeconds = 1,
variant = 'thumb', variant = 'thumb',
className, className,
showPopover = true, showPopover = true,
blur = false,
inlineVideo = false, inlineVideo = false,
}: Props) { inlineNonce = 0,
inlineControls = false,
inlineLoop = true,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
const [thumbOk, setThumbOk] = useState(true) const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true) const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false) const [metaLoaded, setMetaLoaded] = useState(false)
// ✅ nur animieren, wenn sichtbar (Viewport) // inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
// Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
// ✅ für hover-play // Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
useEffect(() => { const inlineMode: 'never' | 'always' | 'hover' =
const el = rootRef.current inlineVideo === true || inlineVideo === 'always'
if (!el) return ? 'always'
: inlineVideo === 'hover'
const obs = new IntersectionObserver( ? 'hover'
(entries) => setInView(Boolean(entries[0]?.isIntersecting)), : 'never'
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!animated) return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, inView, autoTickMs])
const previewId = useMemo(() => { const previewId = useMemo(() => {
if (!file) return '' if (!file) return ''
@ -92,25 +116,62 @@ export default function FinishedVideoPreview({
const hasDuration = const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const 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(() => { const thumbTimeSec = useMemo(() => {
if (!animated) return null if (!animated) return null
if (animatedMode !== 'frames') return null
if (!hasDuration) 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(() => { const thumbSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
if (thumbTimeSec == null) { // static thumb (oder frames: mit t=...)
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request) if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
}
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent( return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
thumbTimeSec.toFixed(2) thumbTimeSec.toFixed(2)
)}` )}&v=${encodeURIComponent(String(localTick))}`
}, [previewId, thumbTimeSec]) }, [previewId, thumbTimeSec, localTick])
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true) setMetaLoaded(true)
@ -120,24 +181,105 @@ export default function FinishedVideoPreview({
} }
if (!videoSrc) { 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(' ')} /> return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
} }
const inlineMode: 'never' | 'always' | 'hover' = // --- Inline Video sichtbar?
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
const showingInlineVideo = const showingInlineVideo =
inlineMode !== 'never' && inlineMode !== 'never' &&
inView && inView &&
videoOk && videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered)) (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 = ( const previewNode = (
<div <div
@ -147,39 +289,59 @@ export default function FinishedVideoPreview({
sizeClass, sizeClass,
className ?? '', className ?? '',
].join(' ')} ].join(' ')}
// ✅ hover only relevant for inlineMode==='hover' onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined} onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined} onFocus={wantsHover ? () => setHovered(true) : undefined}
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined} onBlur={wantsHover ? () => setHovered(false) : undefined}
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
> >
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */} {/* 1) Inline Full Video (mit Controls) */}
{showingInlineVideo ? ( {showingInlineVideo ? (
<video <video
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc} 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 muted
playsInline playsInline
preload="metadata" preload="metadata"
autoPlay 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} poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)} onError={() => setVideoOk(false)}
/> />
) : thumbSrc && thumbOk ? ( ) : thumbSrc && thumbOk ? (
/* 3) Statisches Bild / Frames */
<img <img
src={thumbSrc} src={thumbSrc}
loading="lazy" loading="lazy"
alt={file} alt={file}
className="w-full h-full object-cover" className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)} onError={() => setThumbOk(false)}
/> />
) : ( ) : (
<div className="w-full h-full bg-black" /> <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 && ( {inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video <video
src={videoSrc} src={videoSrc}
@ -193,7 +355,7 @@ export default function FinishedVideoPreview({
</div> </div>
) )
// Gallery: kein HoverPopover // Gallery: kein HoverPopover
if (!showPopover) return previewNode if (!showPopover) return previewNode
return ( return (
@ -204,7 +366,7 @@ export default function FinishedVideoPreview({
<div className="aspect-video"> <div className="aspect-video">
<video <video
src={videoSrc} src={videoSrc}
className="w-full h-full bg-black" className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
muted muted
playsInline playsInline
preload="metadata" preload="metadata"

View File

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

View File

@ -4,6 +4,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo' import LiveHlsVideo from './LiveHlsVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = { type Props = {
jobId: string jobId: string
@ -12,9 +13,11 @@ type Props = {
thumbTick?: number thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist // wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number 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 [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
@ -75,15 +78,32 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
return ( return (
<HoverPopover <HoverPopover
content={(open) => content={(open, { close }) =>
open && ( open && (
<div className="w-[420px]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="aspect-video"> <div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo <LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
src={hq}
muted={false} {/* LIVE badge */}
className="w-full h-full bg-black" <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>
</div> </div>
) )
@ -98,7 +118,7 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
src={thumb} src={thumb}
loading="lazy" loading="lazy"
alt="" alt=""
className="w-full h-full object-cover" className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setImgError(true)} onError={() => setImgError(true)}
onLoad={() => setImgError(false)} onLoad={() => setImgError(false)}
/> />

View File

@ -12,14 +12,65 @@ import {
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
ArrowsPointingInIcon, ArrowsPointingInIcon,
FireIcon, FireIcon,
HeartIcon,
HandThumbUpIcon,
TrashIcon, TrashIcon,
XMarkIcon, XMarkIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
} from '@heroicons/react/24/outline' } 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 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>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
} }
@ -59,6 +110,24 @@ export default function Player({
}: PlayerProps) { }: PlayerProps) {
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id]) 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(() => { React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
@ -91,6 +160,41 @@ export default function Player({
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null) const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
const [mounted, setMounted] = React.useState(false) 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), []) React.useEffect(() => setMounted(true), [])
// ✅ 1x initialisieren // ✅ 1x initialisieren
@ -199,18 +303,84 @@ export default function Player({
try { try {
p.pause() 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.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.() ;(p as any).load?.()
} catch {} } 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 mini = !expanded
const [miniHover, setMiniHover] = React.useState(false) const [miniHover, setMiniHover] = React.useState(false)
const [canHover, setCanHover] = React.useState(false) const [canHover, setCanHover] = React.useState(false)
const [progressActive, setProgressActive] = 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 progressTimerRef = React.useRef<number | null>(null)
const stopProgress = React.useCallback(() => { const stopProgress = React.useCallback(() => {
@ -298,29 +468,35 @@ export default function Player({
<button <button
type="button" type="button"
className={overlayBtn} className={overlayBtn}
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'} title={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'} aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onToggleFavorite?.(job) onToggleFavorite?.(job)
}} }}
disabled={!onToggleFavorite} 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>
<button <button
type="button" type="button"
className={overlayBtn} className={overlayBtn}
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'} title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'} aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onToggleLike?.(job) onToggleLike?.(job)
}} }}
disabled={!onToggleLike} 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>
<button <button
@ -342,6 +518,12 @@ export default function Player({
</div> </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( return createPortal(
<> <>
{expanded && ( {expanded && (
@ -402,15 +584,29 @@ export default function Player({
> >
<div ref={containerRef} className="absolute inset-0" /> <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="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
<div className="min-w-0"> <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"> <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">
{title} <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> </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 <button
type="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" 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'} aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'} title={expanded ? 'Minimieren' : 'Maximieren'}
> >
{expanded ? ( {expanded ? <ArrowsPointingInIcon className="h-5 w-5" /> : <ArrowsPointingOutIcon className="h-5 w-5" />}
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
</button> </button>
<button <button
@ -437,33 +629,37 @@ export default function Player({
</div> </div>
</div> </div>
{/* Bottom overlay: mini actions + status */} {/* Bottom overlay: inline-like meta */}
{!expanded && (
<>
<div <div
className={cn( className={cn(
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent', 'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
'transition-all duration-200 ease-out', 'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20' expanded ? 'h-28' : (liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20')
)} )}
style={expanded ? { bottom: expandedGradientBottom } : undefined}
/> />
<div <div
className={cn( className={cn(
'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2', 'player-ui pointer-events-none absolute inset-x-2 z-20 flex items-end justify-between gap-2',
'transition-all duration-200 ease-out', 'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7' : 'bottom-2' expanded ? '' : (liftMiniOverlay ? 'bottom-7' : 'bottom-2')
)} )}
style={expanded ? { bottom: expandedMetaBottom } : undefined}
> >
<div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur"> <div className="min-w-0">
<span className="font-semibold text-white">{job.status}</span> <div className="truncate text-sm font-semibold text-white">{model}</div>
{job.output ? <span className="ml-2 opacity-80"> {job.output}</span> : null} <div className="truncate text-[11px] text-white/80">{file || title}</div>
</div> </div>
{footerRight} <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> </div>
</div> </div>
</Card> </Card>

View File

@ -17,6 +17,7 @@ type RecorderSettings = {
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert) // ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean useChaturbateApi?: boolean
blurPreviews?: boolean
} }
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
@ -30,6 +31,7 @@ const DEFAULTS: RecorderSettings = {
autoStartAddedDownloads: true, autoStartAddedDownloads: true,
useChaturbateApi: false, useChaturbateApi: false,
blurPreviews: false,
} }
export default function RecorderSettings() { export default function RecorderSettings() {
@ -59,6 +61,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads, autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi, useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
}) })
}) })
.catch(() => { .catch(() => {
@ -117,6 +120,7 @@ export default function RecorderSettings() {
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi const useChaturbateApi = !!value.useChaturbateApi
const blurPreviews = !!value.blurPreviews
setSaving(true) setSaving(true)
try { try {
@ -131,6 +135,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads, autoStartAddedDownloads,
useChaturbateApi, useChaturbateApi,
blurPreviews,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
@ -256,6 +261,13 @@ export default function RecorderSettings() {
label="Chaturbate API" label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models." 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> </div>
</div> </div>

View File

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

View File

@ -45,6 +45,19 @@ export type SwipeCardProps = {
/** Animation timings */ /** Animation timings */
snapMs?: number snapMs?: number
commitMs?: 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 = { export type SwipeCardHandle = {
@ -82,6 +95,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, },
thresholdPx = 120, thresholdPx = 120,
thresholdRatio = 0.35, thresholdRatio = 0.35,
ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]',
snapMs = 180, snapMs = 180,
commitMs = 180, commitMs = 180,
}, },
@ -95,7 +110,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
x: number x: number
y: number y: number
dragging: boolean 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 [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null) const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
@ -190,9 +206,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (!enabled || disabled) return 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) => { onPointerMove={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) 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 ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y const ddy = e.clientY - pointer.current.y
// Erst entscheiden ob wir überhaupt "draggen" // Erst entscheiden ob wir überhaupt draggen
if (!pointer.current.dragging) { if (!pointer.current.dragging) {
// wenn Nutzer vertikal scrollt, nicht hijacken // wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
if (Math.abs(ddx) < 6) return 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.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) setAnimMs(0)
@ -214,9 +261,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const el = cardRef.current const el = cardRef.current
const w = el?.offsetWidth || 360 const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio) const threshold = Math.min(thresholdPx, w * thresholdRatio)
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null) setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) 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 threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
pointer.current.id = null 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) { if (!wasDragging) {
reset() reset()
@ -235,15 +293,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
if (dx > threshold) { if (dx > threshold) {
void commit('right', true) // keep void commit('right', true)
} else if (dx < -threshold) { } else if (dx < -threshold) {
void commit('left', true) // delete void commit('left', true)
} else { } else {
reset() reset()
} }
}} }}
onPointerCancel={() => { onPointerCancel={(e) => {
if (!enabled || disabled) return 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() reset()
}} }}
> >

View File

@ -15,58 +15,6 @@
-moz-osx-font-smoothing: grayscale; -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) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;