updated finished download

This commit is contained in:
unknown 2025-12-30 23:35:00 +01:00
parent bd6b2a50a6
commit 821fe0fef1
20 changed files with 1814 additions and 694 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,7 +50,9 @@ 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"`
Error string `json:"error,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"` PreviewDir string `json:"-"`
PreviewImage string `json:"-"` PreviewImage string `json:"-"`
@ -84,7 +86,7 @@ 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) { 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 +100,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 +135,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 +152,7 @@ var (
AutoStartAddedDownloads: false, AutoStartAddedDownloads: false,
UseChaturbateAPI: false, UseChaturbateAPI: false,
BlurPreviews: false,
EncryptedCookies: "", EncryptedCookies: "",
} }
settingsFile = "recorder_settings.json" settingsFile = "recorder_settings.json"
@ -1139,7 +1143,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,9 +1156,11 @@ 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)
mux.HandleFunc("/api/record/duration", recordDuration)
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
@ -1169,6 +1177,8 @@ func registerRoutes(mux *http.ServeMux) {
// ✅ Frontend (SPA) ausliefern // ✅ Frontend (SPA) ausliefern
registerFrontend(mux) registerFrontend(mux)
return store
} }
// --- main --- // --- main ---
@ -1176,7 +1186,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 +1204,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 +1250,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 {
@ -1429,8 +1460,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 +1520,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,7 +1589,7 @@ 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) dur := durationSecondsCacheOnly(full, fi)
list = append(list, &RecordJob{ list = append(list, &RecordJob{
ID: base, ID: base,
@ -1559,6 +1598,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
StartedAt: t, StartedAt: t,
EndedAt: &t, EndedAt: &t,
DurationSeconds: dur, DurationSeconds: dur,
SizeBytes: fi.Size(),
}) })
} }
@ -1572,6 +1612,155 @@ 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 recordDuration(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req durationReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Hard limit, damit niemand dir 5000 files schickt
if len(req.Files) > 200 {
http.Error(w, "too many files", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "failed to resolve done dir", http.StatusInternalServerError)
return
}
// De-dupe
seen := make(map[string]struct{}, len(req.Files))
files := make([]string, 0, len(req.Files))
for _, f := range req.Files {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
files = append(files, f)
}
// Server-side Concurrency Limit (z.B. 2-4)
sem := make(chan struct{}, 3)
out := make([]durationItem, len(files))
var wg sync.WaitGroup
for i, file := range files {
wg.Add(1)
go func(i int, file string) {
defer wg.Done()
// ✅ sanitize: nur basename erlauben
if filepath.Base(file) != file || strings.Contains(file, "/") || strings.Contains(file, "\\") {
out[i] = durationItem{File: file, Error: "invalid file"}
return
}
full := filepath.Join(doneAbs, file)
// Existiert?
fi, err := os.Stat(full)
if err != nil || fi.IsDir() {
out[i] = durationItem{File: file, Error: "not found"}
return
}
// Cache-hit? (spart ffprobe)
if sec := durationSecondsCacheOnly(full, fi); sec > 0 {
out[i] = durationItem{File: file, DurationSeconds: sec}
return
}
sem <- struct{}{}
defer func() { <-sem }()
sec, err := durationSecondsCached(r.Context(), full) // ctx-fähig, siehe unten
if err != nil || sec <= 0 {
out[i] = durationItem{File: file, Error: "ffprobe failed"}
return
}
out[i] = durationItem{File: file, DurationSeconds: sec}
}(i, file)
}
wg.Wait()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
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 +1825,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 +1841,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 +2100,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
var le *os.LinkError s := strings.ToLower(err.Error())
if errors.As(err, &le) { return strings.Contains(s, "sharing violation") ||
if errno, ok := le.Err.(syscall.Errno); ok { strings.Contains(s, "used by another process") ||
return errno == windowsSharingViolation strings.Contains(s, "wird von einem anderen prozess verwendet")
}
return errors.Is(le.Err, windowsSharingViolation)
}
return errors.Is(err, windowsSharingViolation)
} }
func renameWithRetry(src, dst string) error { func removeWithRetry(path 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 +2133,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 +2237,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 +2282,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 +2291,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

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-wVqrTYvi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css"> <link rel="stylesheet" crossorigin href="/assets/index-CIN0UidG.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,209 +605,48 @@ 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)
}
if (!m) return
const next = !Boolean(m.favorite) let m = sameAsPlayer ? playerModel : null
const updated = await patchModelFlags({ id: m.id, favorite: next }) if (!m) m = await resolveModelForJob(job)
if (!m) return
setPlayerModel(updated) const next = !Boolean(m.favorite)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const handleToggleLike = useCallback(async (job: RecordJob) => { const updated = await patchModelFlags({
let m = playerModel id: m.id,
if (!m) { favorite: next,
m = await resolveModelForJob(job) ...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
setPlayerModel(m) })
}
if (!m) return
const next = !(m.liked === true) if (sameAsPlayer) setPlayerModel(updated)
const updated = await patchModelFlags({ id: m.id, liked: next }) window.dispatchEvent(new Event('models-changed'))
},
[playerJob, playerModel]
)
setPlayerModel(updated) const handleToggleLike = useCallback(
window.dispatchEvent(new Event('models-changed')) async (job: RecordJob) => {
}, [playerModel]) 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 => { if (sameAsPlayer) setPlayerModel(updated)
try { window.dispatchEvent(new Event('models-changed'))
const url = new URL(u) },
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null [playerJob, playerModel]
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
@ -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

@ -199,12 +199,50 @@ 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)

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;