updated finished download
This commit is contained in:
parent
bd6b2a50a6
commit
821fe0fef1
219
backend/chaturbate_autostart.go
Normal file
219
backend/chaturbate_autostart.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -93,7 +93,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
|
||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||
func startChaturbateOnlinePoller() {
|
||||
const interval = 5 * time.Second
|
||||
const interval = 10 * time.Second
|
||||
|
||||
// nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s)
|
||||
lastLoggedCount := -1
|
||||
@ -143,10 +143,6 @@ func startChaturbateOnlinePoller() {
|
||||
cb.FetchedAt = time.Now()
|
||||
cbMu.Unlock()
|
||||
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cbMu.Unlock()
|
||||
|
||||
// success logging only on changes
|
||||
if lastLoggedErr != "" {
|
||||
fmt.Println("✅ [chaturbate] online rooms fetch recovered")
|
||||
|
||||
320
backend/main.go
320
backend/main.go
@ -50,6 +50,8 @@ type RecordJob struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
PreviewDir string `json:"-"`
|
||||
@ -84,7 +86,7 @@ var durCache = struct {
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@ -98,7 +100,7 @@ func durationSecondsCached(path string) (float64, error) {
|
||||
durCache.mu.Unlock()
|
||||
|
||||
// ffprobe (oder notfalls ffmpeg -i parsen)
|
||||
cmd := exec.Command("ffprobe",
|
||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
@ -133,6 +135,7 @@ type RecorderSettings struct {
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
|
||||
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
|
||||
BlurPreviews bool `json:"blurPreviews,omitempty"`
|
||||
|
||||
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
||||
EncryptedCookies string `json:"encryptedCookies,omitempty"`
|
||||
@ -149,6 +152,7 @@ var (
|
||||
AutoStartAddedDownloads: false,
|
||||
|
||||
UseChaturbateAPI: false,
|
||||
BlurPreviews: false,
|
||||
EncryptedCookies: "",
|
||||
}
|
||||
settingsFile = "recorder_settings.json"
|
||||
@ -1139,7 +1143,9 @@ func registerFrontend(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
// routes.go (package main)
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
func registerRoutes(mux *http.ServeMux) *ModelStore {
|
||||
mux.HandleFunc("/api/cookies", cookiesHandler)
|
||||
|
||||
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
||||
mux.HandleFunc("/api/settings/browse", settingsBrowse)
|
||||
|
||||
@ -1150,9 +1156,11 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/record/list", recordList)
|
||||
mux.HandleFunc("/api/record/video", recordVideo)
|
||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
||||
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||
mux.HandleFunc("/api/record/duration", recordDuration)
|
||||
|
||||
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
|
||||
@ -1169,6 +1177,8 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ✅ Frontend (SPA) ausliefern
|
||||
registerFrontend(mux)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// --- main ---
|
||||
@ -1176,7 +1186,10 @@ func main() {
|
||||
loadSettings()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(mux)
|
||||
store := registerRoutes(mux)
|
||||
|
||||
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
|
||||
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
|
||||
|
||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||
if err := http.ListenAndServe(":9999", mux); err != nil {
|
||||
@ -1191,6 +1204,40 @@ type RecordRequest struct {
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
||||
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
url := strings.TrimSpace(req.URL)
|
||||
if url == "" {
|
||||
return nil, errors.New("url fehlt")
|
||||
}
|
||||
|
||||
// Duplicate-running guard (identische URL)
|
||||
jobsMu.Lock()
|
||||
for _, j := range jobs {
|
||||
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
|
||||
jobsMu.Unlock()
|
||||
return j, nil
|
||||
}
|
||||
}
|
||||
|
||||
jobID := uuid.NewString()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
job := &RecordJob{
|
||||
ID: jobID,
|
||||
SourceURL: url,
|
||||
Status: JobRunning,
|
||||
StartedAt: time.Now(),
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
jobs[jobID] = job
|
||||
jobsMu.Unlock()
|
||||
|
||||
go runJob(ctx, job, req)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -1203,30 +1250,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
http.Error(w, "url fehlt", http.StatusBadRequest)
|
||||
job, err := startRecordingInternal(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jobID := uuid.NewString()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
job := &RecordJob{
|
||||
ID: jobID,
|
||||
SourceURL: req.URL,
|
||||
Status: JobRunning,
|
||||
StartedAt: time.Now(),
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
jobs[jobID] = job
|
||||
jobsMu.Unlock()
|
||||
|
||||
go runJob(ctx, job, req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(job)
|
||||
_ = json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
func parseCookieString(cookieStr string) map[string]string {
|
||||
@ -1429,8 +1460,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeFile(w, r, outPath)
|
||||
serveVideoFile(w, r, outPath)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1490,9 +1520,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeFile(w, r, outPath)
|
||||
serveVideoFile(w, r, outPath)
|
||||
}
|
||||
|
||||
func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
|
||||
durCache.mu.Lock()
|
||||
e, ok := durCache.m[path]
|
||||
durCache.mu.Unlock()
|
||||
|
||||
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
|
||||
return e.sec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1550,7 +1589,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
t := fi.ModTime()
|
||||
|
||||
dur, _ := durationSecondsCached(full)
|
||||
dur := durationSecondsCacheOnly(full, fi)
|
||||
|
||||
list = append(list, &RecordJob{
|
||||
ID: base,
|
||||
@ -1559,6 +1598,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
StartedAt: t,
|
||||
EndedAt: &t,
|
||||
DurationSeconds: dur,
|
||||
SizeBytes: fi.Size(),
|
||||
})
|
||||
|
||||
}
|
||||
@ -1572,6 +1612,155 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
type doneMetaResp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(doneAbs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||
return
|
||||
}
|
||||
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cnt := 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
|
||||
if ext != ".mp4" && ext != ".ts" {
|
||||
continue
|
||||
}
|
||||
cnt++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
|
||||
}
|
||||
|
||||
type durationReq struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type durationItem struct {
|
||||
File string `json:"file"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func 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) {
|
||||
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
||||
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 runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||
if isSharingViolation(err) {
|
||||
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
@ -1652,6 +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) {
|
||||
if r.Method != http.MethodPost {
|
||||
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
|
||||
|
||||
func isSharingViolation(err error) bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
if errno, ok := pe.Err.(syscall.Errno); ok {
|
||||
return errno == windowsSharingViolation
|
||||
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
|
||||
}
|
||||
return errors.Is(pe.Err, windowsSharingViolation)
|
||||
}
|
||||
// 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
|
||||
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 {
|
||||
func removeWithRetry(path string) error {
|
||||
var err error
|
||||
for i := 0; i < 15; i++ { // ~1.5s
|
||||
err = os.Rename(src, dst)
|
||||
for i := 0; i < 40; i++ { // ~4s bei 100ms
|
||||
err = os.Remove(path)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
if isSharingViolation(err) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@ -1925,14 +2133,14 @@ func renameWithRetry(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func removeWithRetry(path string) error {
|
||||
func renameWithRetry(oldPath, newPath string) error {
|
||||
var err error
|
||||
for i := 0; i < 15; i++ { // ~1.5s
|
||||
err = os.Remove(path)
|
||||
for i := 0; i < 40; i++ {
|
||||
err = os.Rename(oldPath, newPath)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
if isSharingViolation(err) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@ -2029,8 +2237,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
|
||||
|
||||
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
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
@ -2087,8 +2291,6 @@ func RecordStream(
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
fmt.Println("📡 Aufnahme gestartet:", outputPath)
|
||||
|
||||
// 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR
|
||||
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
|
||||
// Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben
|
||||
|
||||
Binary file not shown.
9
backend/sharedelete_other.go
Normal file
9
backend/sharedelete_other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func openForReadShareDelete(path string) (*os.File, error) {
|
||||
return os.Open(path)
|
||||
}
|
||||
31
backend/sharedelete_windows.go
Normal file
31
backend/sharedelete_windows.go
Normal 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
|
||||
}
|
||||
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
Normal file
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
|
||||
<script type="module" crossorigin src="/assets/index-wVqrTYvi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CIN0UidG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -38,6 +38,7 @@ type RecorderSettings = {
|
||||
autoAddToDownloadList?: boolean
|
||||
autoStartAddedDownloads?: boolean
|
||||
useChaturbateApi?: boolean
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
@ -47,6 +48,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
autoAddToDownloadList: false,
|
||||
autoStartAddedDownloads: false,
|
||||
useChaturbateApi: false,
|
||||
blurPreviews: false,
|
||||
}
|
||||
|
||||
type StoredModel = {
|
||||
@ -59,29 +61,6 @@ type StoredModel = {
|
||||
liked?: boolean | null
|
||||
}
|
||||
|
||||
|
||||
type ChaturbateRoom = {
|
||||
username: string
|
||||
current_show?: 'public' | 'private' | 'hidden' | 'away' | string
|
||||
}
|
||||
|
||||
type ChaturbateOnlineResponse = {
|
||||
enabled: boolean
|
||||
fetchedAt?: string
|
||||
lastError?: string
|
||||
count?: number
|
||||
rooms: ChaturbateRoom[]
|
||||
}
|
||||
|
||||
type PendingWatchedRoom = {
|
||||
id: string
|
||||
modelKey: string
|
||||
url: string
|
||||
currentShow: string
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms))
|
||||
|
||||
function extractFirstHttpUrl(text: string): string | null {
|
||||
const t = (text ?? '').trim()
|
||||
if (!t) return null
|
||||
@ -132,11 +111,11 @@ export default function App() {
|
||||
const [, setParseError] = useState<string | null>(null)
|
||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||||
const [doneCount, setDoneCount] = useState<number>(0)
|
||||
const [modelsCount, setModelsCount] = useState(0)
|
||||
|
||||
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
||||
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||||
const watchedModelsRef = useRef<StoredModel[]>([])
|
||||
const [, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [cookieModalOpen, setCookieModalOpen] = useState(false)
|
||||
@ -148,12 +127,6 @@ export default function App() {
|
||||
|
||||
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
|
||||
|
||||
// ✅ Watched+Online (wartend) + Autostart-Queue
|
||||
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
|
||||
const autoStartQueueRef = useRef<Array<{ userKey: string; url: string }>>([])
|
||||
const autoStartQueuedUsersRef = useRef<Set<string>>(new Set())
|
||||
const autoStartWorkerRef = useRef(false)
|
||||
|
||||
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
|
||||
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
|
||||
|
||||
@ -223,39 +196,6 @@ export default function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ✅ 2) Watched-Chaturbate-Models (kleine Payload) – nur für den Online-Abgleich/Autostart
|
||||
useEffect(() => {
|
||||
if (!recSettings.useChaturbateApi) {
|
||||
watchedModelsRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const load = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/watched?host=chaturbate.com', { cache: 'no-store' })
|
||||
if (cancelled) return
|
||||
watchedModelsRef.current = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
if (!cancelled) watchedModelsRef.current = []
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const t = window.setInterval(load, document.hidden ? 30000 : 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
}
|
||||
}, [recSettings.useChaturbateApi])
|
||||
|
||||
|
||||
const initialCookies = useMemo(
|
||||
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
|
||||
[cookies]
|
||||
@ -269,8 +209,8 @@ export default function App() {
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running')
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length },
|
||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length },
|
||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
||||
{ id: 'models', label: 'Models', count: modelsCount },
|
||||
{ id: 'settings', label: 'Einstellungen' },
|
||||
]
|
||||
@ -332,6 +272,41 @@ export default function App() {
|
||||
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
||||
}, [cookies, cookiesLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let t: number | undefined
|
||||
|
||||
const loadDoneMeta = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const meta = (await res.json()) as { count?: number }
|
||||
if (!cancelled) setDoneCount(meta.count ?? 0)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
// wenn Tab nicht aktiv/Seite im Hintergrund: weniger oft
|
||||
const ms = document.hidden ? 60_000 : 30_000
|
||||
t = window.setTimeout(loadDoneMeta, ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onVis = () => {
|
||||
if (!document.hidden) void loadDoneMeta()
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
void loadDoneMeta()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (t) window.clearTimeout(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceUrl.trim() === '') {
|
||||
setParsed(null)
|
||||
@ -386,19 +361,47 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ nur pollen, wenn Finished-Tab aktiv ist
|
||||
if (selectedTab !== 'finished') return
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const loadDone = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const list = await apiJSON<RecordJob[]>('/api/record/done')
|
||||
setDoneJobs(Array.isArray(list) ? list : [])
|
||||
const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
|
||||
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
setDoneJobs([])
|
||||
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
|
||||
if (!cancelled) setDoneJobs([])
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
// beim Betreten des Tabs einmal sofort laden
|
||||
loadDone()
|
||||
const t = setInterval(loadDone, 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// ✅ weniger aggressiv pollen
|
||||
const baseMs = 20000 // 20s
|
||||
const tickMs = document.hidden ? 60000 : baseMs
|
||||
const t = window.setInterval(loadDone, tickMs)
|
||||
|
||||
// ✅ wenn Tab wieder sichtbar wird: direkt refresh
|
||||
const onVis = () => {
|
||||
if (!document.hidden) void loadDone()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [selectedTab])
|
||||
|
||||
|
||||
function isChaturbate(url: string): boolean {
|
||||
try {
|
||||
@ -602,210 +605,49 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
|
||||
let m = playerModel
|
||||
if (!m) {
|
||||
m = await resolveModelForJob(job)
|
||||
setPlayerModel(m)
|
||||
}
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (job: RecordJob) => {
|
||||
const file = baseName(job.output || '')
|
||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||
|
||||
let m = sameAsPlayer ? playerModel : null
|
||||
if (!m) m = await resolveModelForJob(job)
|
||||
if (!m) return
|
||||
|
||||
const 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'))
|
||||
}, [playerModel])
|
||||
},
|
||||
[playerJob, playerModel]
|
||||
)
|
||||
|
||||
const handleToggleLike = useCallback(async (job: RecordJob) => {
|
||||
let m = playerModel
|
||||
if (!m) {
|
||||
m = await resolveModelForJob(job)
|
||||
setPlayerModel(m)
|
||||
}
|
||||
const handleToggleLike = useCallback(
|
||||
async (job: RecordJob) => {
|
||||
const file = baseName(job.output || '')
|
||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||
|
||||
let m = sameAsPlayer ? playerModel : null
|
||||
if (!m) m = await resolveModelForJob(job)
|
||||
if (!m) return
|
||||
|
||||
const next = !(m.liked === true)
|
||||
const updated = await patchModelFlags({ id: m.id, liked: next })
|
||||
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
|
||||
|
||||
setPlayerModel(updated)
|
||||
if (sameAsPlayer) setPlayerModel(updated)
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
}, [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[]
|
||||
},
|
||||
[playerJob, playerModel]
|
||||
)
|
||||
|
||||
// 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(() => {
|
||||
if (!autoAddEnabled && !autoStartEnabled) return
|
||||
if (!navigator.clipboard?.readText) return
|
||||
@ -933,9 +775,9 @@ export default function App() {
|
||||
{selectedTab === 'running' && (
|
||||
<RunningDownloads
|
||||
jobs={runningJobs}
|
||||
pending={pendingWatchedRooms}
|
||||
onOpenPlayer={openPlayer}
|
||||
onStopJob={stopJob}
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -944,6 +786,11 @@ export default function App() {
|
||||
jobs={jobs}
|
||||
doneJobs={doneJobs}
|
||||
onOpenPlayer={openPlayer}
|
||||
onDeleteJob={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleLike={handleToggleLike}
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -977,13 +824,11 @@ export default function App() {
|
||||
<Player
|
||||
job={playerJob}
|
||||
expanded={playerExpanded}
|
||||
onToggleExpand={() => setPlayerExpanded((v) => !v)}
|
||||
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||||
onClose={() => setPlayerJob(null)}
|
||||
|
||||
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
||||
isFavorite={Boolean(playerModel?.favorite)}
|
||||
isLiked={playerModel?.liked === true}
|
||||
|
||||
onDelete={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
|
||||
@ -16,16 +16,29 @@ import {
|
||||
RectangleStackIcon,
|
||||
Squares2X2Icon,
|
||||
TrashIcon,
|
||||
FireIcon,
|
||||
EllipsisVerticalIcon,
|
||||
BookmarkSquareIcon,
|
||||
StarIcon as StarOutlineIcon,
|
||||
HeartIcon as HeartOutlineIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import {
|
||||
StarIcon as StarSolidIcon,
|
||||
HeartIcon as HeartSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
doneJobs: RecordJob[]
|
||||
blurPreviews?: boolean
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
onDeleteJob?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
}
|
||||
|
||||
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
|
||||
@ -36,6 +49,10 @@ const baseName = (p: string) => {
|
||||
}
|
||||
const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
@ -47,6 +64,20 @@ function formatDuration(ms: number): string {
|
||||
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]}`
|
||||
}
|
||||
|
||||
|
||||
// Fallback: reine Aufnahmezeit aus startedAt/endedAt
|
||||
function runtimeFromTimestamps(job: RecordJob): string {
|
||||
const start = Date.parse(String(job.startedAt || ''))
|
||||
@ -55,6 +86,26 @@ function runtimeFromTimestamps(job: RecordJob): string {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(query)
|
||||
const onChange = () => setMatches(mql.matches)
|
||||
onChange()
|
||||
|
||||
if (mql.addEventListener) mql.addEventListener('change', onChange)
|
||||
else mql.addListener(onChange)
|
||||
|
||||
return () => {
|
||||
if (mql.removeEventListener) mql.removeEventListener('change', onChange)
|
||||
else mql.removeListener(onChange)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
const httpCodeFromError = (err?: string) => {
|
||||
const m = (err ?? '').match(/\bHTTP\s+(\d{3})\b/i)
|
||||
return m ? `HTTP ${m[1]}` : null
|
||||
@ -75,8 +126,38 @@ const modelNameFromOutput = (output?: string) => {
|
||||
return i > 0 ? stem.slice(0, i) : stem
|
||||
}
|
||||
|
||||
type StoredModelFlags = {
|
||||
id: string
|
||||
modelKey: string
|
||||
favorite?: boolean
|
||||
liked?: boolean | null
|
||||
}
|
||||
|
||||
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
||||
const lower = (s: string) => (s || '').trim().toLowerCase()
|
||||
|
||||
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
export default function FinishedDownloads({
|
||||
jobs,
|
||||
doneJobs,
|
||||
blurPreviews,
|
||||
onOpenPlayer,
|
||||
onDeleteJob,
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
}: Props) {
|
||||
const PAGE_SIZE = 50
|
||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||||
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
||||
@ -90,10 +171,78 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
type ViewMode = 'table' | 'cards' | 'gallery'
|
||||
const VIEW_KEY = 'finishedDownloads_view'
|
||||
|
||||
type SortMode =
|
||||
| 'completed_desc'
|
||||
| 'completed_asc'
|
||||
| 'model_asc'
|
||||
| 'model_desc'
|
||||
| 'file_asc'
|
||||
| 'file_desc'
|
||||
| 'duration_desc'
|
||||
| 'duration_asc'
|
||||
| 'size_desc'
|
||||
| 'size_asc'
|
||||
|
||||
const SORT_KEY = 'finishedDownloads_sort'
|
||||
const [sortMode, setSortMode] = React.useState<SortMode>('completed_desc')
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const v = window.localStorage.getItem(SORT_KEY) as SortMode | null
|
||||
if (v) setSortMode(v)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SORT_KEY, sortMode)
|
||||
} catch {}
|
||||
}, [sortMode])
|
||||
|
||||
const [view, setView] = React.useState<ViewMode>('table')
|
||||
|
||||
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||||
|
||||
// ⭐ Models-Flags (Fav/Like) aus Backend-Store
|
||||
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({})
|
||||
|
||||
const refreshModelsByKey = React.useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/models/list', { cache: 'no-store' as any })
|
||||
if (!res.ok) return
|
||||
const list = (await res.json()) as StoredModelFlags[]
|
||||
|
||||
const map: Record<string, StoredModelFlags> = {}
|
||||
for (const m of Array.isArray(list) ? list : []) {
|
||||
const k = lower(String(m?.modelKey ?? ''))
|
||||
if (!k) continue
|
||||
|
||||
// wenn mehrere Hosts etc.: bevorzuge Eintrag mit “mehr Signal”
|
||||
const cur = map[k]
|
||||
if (!cur) {
|
||||
map[k] = m
|
||||
continue
|
||||
}
|
||||
const score = (x: StoredModelFlags) => (x.favorite ? 2 : 0) + (x.liked === true ? 1 : 0)
|
||||
if (score(m) > score(cur)) map[k] = m
|
||||
}
|
||||
|
||||
setModelsByKey(map)
|
||||
} catch {
|
||||
// optional: console.debug(...)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshModelsByKey()
|
||||
}, [refreshModelsByKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onChanged = () => void refreshModelsByKey()
|
||||
window.addEventListener('models-changed', onChanged as any)
|
||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||
}, [refreshModelsByKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||
@ -117,6 +266,31 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||
|
||||
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
|
||||
|
||||
const tryAutoplayInline = React.useCallback((domId: string) => {
|
||||
const host = document.getElementById(domId)
|
||||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||||
if (!v) return false
|
||||
|
||||
v.muted = true
|
||||
v.playsInline = true
|
||||
v.setAttribute('playsinline', 'true')
|
||||
|
||||
const p = v.play?.()
|
||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const startInline = React.useCallback((key: string) => {
|
||||
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
||||
}, [])
|
||||
|
||||
const openPlayer = React.useCallback((job: RecordJob) => {
|
||||
setInlinePlay(null)
|
||||
onOpenPlayer(job)
|
||||
}, [onOpenPlayer])
|
||||
|
||||
const openCtx = (job: RecordJob, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -178,6 +352,17 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
}, 320)
|
||||
}, [markDeleted, markRemoving])
|
||||
|
||||
const releasePlayingFile = React.useCallback(
|
||||
async (file: string, opts?: { close?: boolean }) => {
|
||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||
if (opts?.close) {
|
||||
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
|
||||
}
|
||||
await new Promise((r) => window.setTimeout(r, 250))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const deleteVideo = React.useCallback(
|
||||
async (job: RecordJob): Promise<boolean> => {
|
||||
const file = baseName(job.output || '')
|
||||
@ -191,11 +376,25 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
|
||||
markDeleting(key, true)
|
||||
try {
|
||||
await releasePlayingFile(file, { close: true })
|
||||
|
||||
// ✅ Wenn App-Handler vorhanden: den benutzen (inkl. Events + State-Update)
|
||||
if (onDeleteJob) {
|
||||
await onDeleteJob(job)
|
||||
|
||||
// ✅ optional: sofort aus der Liste animieren (fühlt sich besser an)
|
||||
animateRemove(key)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback (falls mal ohne App-Handler verwendet)
|
||||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
animateRemove(key)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
@ -205,7 +404,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
markDeleting(key, false)
|
||||
}
|
||||
},
|
||||
[deletingKeys, markDeleting, animateRemove]
|
||||
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove]
|
||||
)
|
||||
|
||||
const keepVideo = React.useCallback(
|
||||
@ -221,6 +420,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
|
||||
markKeeping(key, true)
|
||||
try {
|
||||
await releasePlayingFile(file, { close: true })
|
||||
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
@ -237,7 +437,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
markKeeping(key, false)
|
||||
}
|
||||
},
|
||||
[keepingKeys, deletingKeys, markKeeping, animateRemove]
|
||||
[keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove]
|
||||
)
|
||||
|
||||
const items = React.useMemo<ContextMenuItem[]>(() => {
|
||||
@ -313,6 +513,78 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
return list
|
||||
}, [jobs, doneJobs, deletedKeys])
|
||||
|
||||
const endedAtMs = (j: RecordJob) => (j.endedAt ? new Date(j.endedAt).getTime() : 0)
|
||||
|
||||
const modelForSort = (j: RecordJob) => modelNameFromOutput(j.output || '').toLowerCase()
|
||||
|
||||
const fileForSort = (j: RecordJob) => {
|
||||
const raw = baseName(j.output || '').toLowerCase()
|
||||
return stripHotPrefix(raw)
|
||||
}
|
||||
|
||||
const durationSecondsForSort = (j: RecordJob) => {
|
||||
const k = keyFor(j)
|
||||
const s =
|
||||
(typeof (j as any).durationSeconds === 'number' && (j as any).durationSeconds > 0)
|
||||
? (j as any).durationSeconds
|
||||
: durations[k]
|
||||
return typeof s === 'number' && Number.isFinite(s) && s > 0 ? s : NaN
|
||||
}
|
||||
|
||||
const sizeBytesForSort = (j: RecordJob) => {
|
||||
const s = sizeBytesOf(j)
|
||||
return typeof s === 'number' ? s : NaN
|
||||
}
|
||||
|
||||
const cmpStr = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
const cmpNum = (a: number, b: number) => a - b
|
||||
const cmpMaybeNum = (a: number, b: number, dir: 1 | -1) => {
|
||||
const aOk = Number.isFinite(a)
|
||||
const bOk = Number.isFinite(b)
|
||||
if (!aOk && !bOk) return 0
|
||||
if (!aOk) return 1
|
||||
if (!bOk) return -1
|
||||
return dir * cmpNum(a, b)
|
||||
}
|
||||
|
||||
const sortedNonTableRows = React.useMemo(() => {
|
||||
const arr = [...rows]
|
||||
|
||||
arr.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'completed_asc':
|
||||
return cmpNum(endedAtMs(a), endedAtMs(b)) || cmpStr(keyFor(a), keyFor(b))
|
||||
case 'completed_desc':
|
||||
return cmpNum(endedAtMs(b), endedAtMs(a)) || cmpStr(keyFor(a), keyFor(b))
|
||||
|
||||
case 'model_asc':
|
||||
return cmpStr(modelForSort(a), modelForSort(b)) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
case 'model_desc':
|
||||
return cmpStr(modelForSort(b), modelForSort(a)) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
|
||||
case 'file_asc':
|
||||
return cmpStr(fileForSort(a), fileForSort(b)) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
case 'file_desc':
|
||||
return cmpStr(fileForSort(b), fileForSort(a)) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
|
||||
case 'duration_asc':
|
||||
return cmpMaybeNum(durationSecondsForSort(a), durationSecondsForSort(b), 1) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
case 'duration_desc':
|
||||
return cmpMaybeNum(durationSecondsForSort(a), durationSecondsForSort(b), -1) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
|
||||
case 'size_asc':
|
||||
return cmpMaybeNum(sizeBytesForSort(a), sizeBytesForSort(b), 1) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
case 'size_desc':
|
||||
return cmpMaybeNum(sizeBytesForSort(a), sizeBytesForSort(b), -1) || cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
|
||||
default:
|
||||
return cmpNum(endedAtMs(b), endedAtMs(a))
|
||||
}
|
||||
})
|
||||
|
||||
return arr
|
||||
}, [rows, sortMode, durations])
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE)
|
||||
}, [rows.length])
|
||||
@ -355,7 +627,71 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||||
}, [animateRemove, markDeleting, markDeleted, view])
|
||||
|
||||
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
|
||||
const viewRows = view === 'table' ? rows : sortedNonTableRows
|
||||
|
||||
const visibleRows = viewRows
|
||||
.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||
.slice(0, visibleCount)
|
||||
|
||||
|
||||
const requestedDurationsRef = React.useRef<Set<string>>(new Set())
|
||||
|
||||
React.useEffect(() => {
|
||||
const wantsRuntimeSort = view === 'table' && sort?.key === 'runtime'
|
||||
if (!wantsRuntimeSort) return
|
||||
|
||||
const missing: string[] = []
|
||||
for (const j of rows) {
|
||||
const file = baseName(j.output || '')
|
||||
if (!file) continue
|
||||
|
||||
// bereits bekannt?
|
||||
const k = keyFor(j)
|
||||
const sec =
|
||||
(typeof (j as any).durationSeconds === 'number' && (j as any).durationSeconds > 0)
|
||||
? (j as any).durationSeconds
|
||||
: durations[k]
|
||||
|
||||
if (typeof sec === 'number' && sec > 0) continue
|
||||
if (requestedDurationsRef.current.has(file)) continue
|
||||
|
||||
requestedDurationsRef.current.add(file)
|
||||
missing.push(file)
|
||||
}
|
||||
|
||||
if (missing.length === 0) return
|
||||
|
||||
const ctrl = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
const BATCH = 25
|
||||
for (let i = 0; i < missing.length; i += BATCH) {
|
||||
const batch = missing.slice(i, i + BATCH)
|
||||
const res = await fetch('/api/record/duration', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ files: batch }),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
if (!res.ok) break
|
||||
|
||||
const data: Array<{ file: string; durationSeconds?: number }> = await res.json()
|
||||
|
||||
setDurations((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const it of data) {
|
||||
if (it?.file && typeof it.durationSeconds === 'number' && it.durationSeconds > 0) {
|
||||
next[it.file] = it.durationSeconds
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
})().catch(() => {})
|
||||
|
||||
return () => ctrl.abort()
|
||||
}, [view, sort?.key, rows]) // absichtlich NICHT durations als dep
|
||||
|
||||
|
||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||
const runtimeOf = (job: RecordJob): string => {
|
||||
@ -411,6 +747,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
onDuration={handleDuration}
|
||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -506,6 +843,21 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
sortValue: (j) => runtimeSecondsForSort(j),
|
||||
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
header: 'Größe',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
sortValue: (j) => {
|
||||
const s = sizeBytesOf(j)
|
||||
return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY
|
||||
},
|
||||
cell: (j) => (
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatBytes(sizeBytesOf(j))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktionen',
|
||||
@ -572,8 +924,18 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}]
|
||||
|
||||
|
||||
// ✅ Hooks immer zuerst – unabhängig von rows
|
||||
const isSmall = useMediaQuery('(max-width: 639px)')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSmall) {
|
||||
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
|
||||
swipeRefs.current = new Map()
|
||||
}
|
||||
}, [isSmall])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@ -627,16 +989,46 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view !== 'table' && (
|
||||
<div className="ml-2">
|
||||
<label className="sr-only" htmlFor="finished-sort">
|
||||
Sortierung
|
||||
</label>
|
||||
<select
|
||||
id="finished-sort"
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
|
||||
dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
>
|
||||
<option value="completed_desc">Fertiggestellt am ↓</option>
|
||||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||||
<option value="model_asc">Modelname A→Z</option>
|
||||
<option value="model_desc">Modelname Z→A</option>
|
||||
<option value="file_asc">Dateiname A→Z</option>
|
||||
<option value="file_desc">Dateiname Z→A</option>
|
||||
<option value="duration_desc">Dauer ↓</option>
|
||||
<option value="duration_asc">Dauer ↑</option>
|
||||
<option value="size_desc">Größe ↓</option>
|
||||
<option value="size_asc">Größe ↑</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ Cards */}
|
||||
{view === 'cards' && (
|
||||
<div className="space-y-3">
|
||||
{visibleRows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const statusNode =
|
||||
j.status === 'failed' ? (
|
||||
@ -647,19 +1039,9 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
<span className="font-medium">{j.status}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SwipeCard
|
||||
ref={(h) => {
|
||||
if (h) swipeRefs.current.set(k, h)
|
||||
else swipeRefs.current.delete(k)
|
||||
}}
|
||||
key={k}
|
||||
enabled
|
||||
disabled={busy}
|
||||
onTap={() => onOpenPlayer(j)}
|
||||
onSwipeLeft={() => deleteVideo(j)}
|
||||
onSwipeRight={() => keepVideo(j)}
|
||||
>
|
||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
const cardInner = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@ -674,6 +1056,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||
}}
|
||||
@ -681,7 +1064,16 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
>
|
||||
<Card noBodyPadding className="overflow-hidden">
|
||||
{/* Preview */}
|
||||
<div className="relative aspect-video bg-black/5 dark:bg-white/5">
|
||||
<div
|
||||
id={inlineDomId}
|
||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isSmall) return // ✅ Mobile: SwipeCard-onTap macht das
|
||||
startInline(k) // ✅ Desktop: Click startet inline
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
@ -689,13 +1081,35 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
onDuration={handleDuration}
|
||||
className="w-full h-full"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated
|
||||
animatedMode="clips"
|
||||
animatedTrigger="always"
|
||||
clipSeconds={1}
|
||||
thumbSamples={18}
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
/>
|
||||
|
||||
{/* dunkler Verlauf unten für Text */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent" />
|
||||
{/* Gradient overlay bottom */}
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
|
||||
'transition-opacity duration-150',
|
||||
inlineActive ? 'opacity-0' : 'opacity-100',
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
{/* Overlay bottom */}
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3',
|
||||
'transition-opacity duration-150',
|
||||
inlineActive ? 'opacity-0' : 'opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{model}</div>
|
||||
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(file) || '—'}</div>
|
||||
@ -707,12 +1121,25 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md bg-black/40 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{dur}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSmall && inlinePlay?.key === k && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setInlinePlay((prev) => ({ key: k, nonce: (prev?.key === k ? prev.nonce + 1 : 1) }))
|
||||
}}
|
||||
title="Von vorne starten"
|
||||
aria-label="Von vorne starten"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Actions top-right */}
|
||||
<div className="absolute right-2 top-2 flex items-center gap-2">
|
||||
{(() => {
|
||||
@ -720,26 +1147,36 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = fileRaw.startsWith('HOT ')
|
||||
const modelKey = modelNameFromOutput(j.output)
|
||||
const flags = modelsByKey[lower(modelKey)]
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isSmall && (
|
||||
<>
|
||||
{/* Keep */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Behalten"
|
||||
title="Behalten (nach keep verschieben)"
|
||||
aria-label="Behalten"
|
||||
disabled={busy}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const h = swipeRefs.current.get(k)
|
||||
if (h) void h.swipeRight()
|
||||
else void keepVideo(j)
|
||||
void keepVideo(j)
|
||||
}}
|
||||
>
|
||||
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
||||
<BookmarkSquareIcon className="size-5 text-emerald-300" />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
@ -750,14 +1187,75 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const h = swipeRefs.current.get(k)
|
||||
if (h) void h.swipeLeft()
|
||||
else void deleteVideo(j)
|
||||
void deleteVideo(j)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
||||
<TrashIcon className="size-5 text-red-300" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HOT */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||
disabled={busy || !onToggleHot}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// wichtig gegen File-Lock beim Rename:
|
||||
releasePlayingFile(fileRaw, { close: true })
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
await onToggleHot?.(j)
|
||||
}}
|
||||
>
|
||||
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
|
||||
</button>
|
||||
|
||||
{/* Favorite */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
disabled={busy || !onToggleFavorite}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await onToggleFavorite?.(j)
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = isFav ? StarSolidIcon : StarOutlineIcon
|
||||
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} />
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||
disabled={busy || !onToggleLike}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await onToggleLike?.(j)
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
|
||||
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{/* Menu */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
@ -786,6 +1284,8 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
Status: {statusNode}
|
||||
<span className="mx-2 opacity-60">•</span>
|
||||
Dauer: <span className="font-medium">{dur}</span>
|
||||
<span className="mx-2 opacity-60">•</span>
|
||||
Größe: <span className="font-medium">{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -797,8 +1297,40 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</SwipeCard>
|
||||
)
|
||||
|
||||
// ✅ Mobile: SwipeCard, Desktop: normale Card
|
||||
return isSmall ? (
|
||||
<SwipeCard
|
||||
ref={(h) => {
|
||||
if (h) swipeRefs.current.set(k, h)
|
||||
else swipeRefs.current.delete(k)
|
||||
}}
|
||||
key={k}
|
||||
enabled
|
||||
disabled={busy}
|
||||
ignoreFromBottomPx={110}
|
||||
onTap={() => {
|
||||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
// ✅ State sofort committen (damit Video direkt im DOM ist)
|
||||
flushSync(() => startInline(k))
|
||||
|
||||
// ✅ direkt versuchen (innerhalb des Tap-Tasks)
|
||||
if (!tryAutoplayInline(domId)) {
|
||||
// Fallback: nächster Frame (falls Video erst im Commit auftaucht)
|
||||
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||
}
|
||||
}}
|
||||
onSwipeLeft={() => deleteVideo(j)}
|
||||
onSwipeRight={() => keepVideo(j)}
|
||||
>
|
||||
{cardInner}
|
||||
</SwipeCard>
|
||||
) : (
|
||||
<React.Fragment key={k}>{cardInner}</React.Fragment>
|
||||
)
|
||||
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@ -839,6 +1371,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const deleted = deletedKeys.has(k)
|
||||
@ -869,7 +1402,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
>
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -883,18 +1416,40 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
onDuration={handleDuration}
|
||||
variant="fill"
|
||||
showPopover={false}
|
||||
inlineVideo="hover"
|
||||
blur={blurPreviews}
|
||||
animated
|
||||
animatedMode="clips"
|
||||
animatedTrigger="hover"
|
||||
clipSeconds={1}
|
||||
thumbSamples={18}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay bottom */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/65 to-transparent" />
|
||||
<div
|
||||
className="
|
||||
pointer-events-none absolute inset-x-0 bottom-0 h-16
|
||||
bg-gradient-to-t from-black/65 to-transparent
|
||||
transition-opacity duration-150
|
||||
group-hover:opacity-0 group-focus-within:opacity-0
|
||||
"
|
||||
/>
|
||||
|
||||
{/* Bottom text */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
|
||||
<div
|
||||
className="
|
||||
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
|
||||
transition-opacity duration-150
|
||||
group-hover:opacity-0 group-focus-within:opacity-0
|
||||
"
|
||||
>
|
||||
<div className="truncate text-xs font-semibold">{model}</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
|
||||
<span className="truncate">{file || '—'}</span>
|
||||
<span className="shrink-0 rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
||||
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -6,30 +6,49 @@ import HoverPopover from './HoverPopover'
|
||||
|
||||
type Variant = 'thumb' | 'fill'
|
||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||
type AnimatedMode = 'frames' | 'clips'
|
||||
type AnimatedTrigger = 'always' | 'hover'
|
||||
|
||||
type Props = {
|
||||
export type FinishedVideoPreviewProps = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
durationSeconds?: number
|
||||
onDuration?: (job: RecordJob, seconds: number) => void
|
||||
|
||||
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
|
||||
animated?: boolean
|
||||
animatedMode?: AnimatedMode
|
||||
animatedTrigger?: AnimatedTrigger
|
||||
|
||||
/** nur für frames */
|
||||
autoTickMs?: number
|
||||
thumbStepSec?: number
|
||||
thumbSpread?: boolean
|
||||
thumbSamples?: number
|
||||
|
||||
/** nur für clips */
|
||||
clipSeconds?: number
|
||||
|
||||
/** neu: thumb = w-20 h-16, fill = w-full h-full */
|
||||
variant?: Variant
|
||||
|
||||
/** optionales Zusatz-Styling */
|
||||
className?: string
|
||||
|
||||
showPopover?: boolean
|
||||
|
||||
blur?: boolean
|
||||
|
||||
/**
|
||||
* inline video:
|
||||
* - false: nur Bild
|
||||
* - false: nur Bild/Teaser
|
||||
* - true/'always': immer inline abspielen (wenn inView)
|
||||
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
|
||||
* - 'hover': nur bei Hover/Focus abspielen, sonst Bild
|
||||
*/
|
||||
inlineVideo?: InlineVideoMode
|
||||
/** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */
|
||||
inlineNonce?: number
|
||||
/** Inline-Playback: Controls anzeigen? */
|
||||
inlineControls?: boolean
|
||||
/** Inline-Playback: loopen? */
|
||||
inlineLoop?: boolean
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -37,46 +56,51 @@ export default function FinishedVideoPreview({
|
||||
getFileName,
|
||||
durationSeconds,
|
||||
onDuration,
|
||||
|
||||
animated = false,
|
||||
animatedMode = 'frames',
|
||||
animatedTrigger = 'always',
|
||||
|
||||
autoTickMs = 15000,
|
||||
thumbStepSec,
|
||||
thumbSpread,
|
||||
thumbSamples,
|
||||
|
||||
clipSeconds = 1,
|
||||
|
||||
variant = 'thumb',
|
||||
className,
|
||||
showPopover = true,
|
||||
blur = false,
|
||||
|
||||
inlineVideo = false,
|
||||
}: Props) {
|
||||
inlineNonce = 0,
|
||||
inlineControls = false,
|
||||
inlineLoop = true,
|
||||
}: FinishedVideoPreviewProps) {
|
||||
const file = getFileName(job.output || '')
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
|
||||
const [thumbOk, setThumbOk] = useState(true)
|
||||
const [videoOk, setVideoOk] = useState(true)
|
||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||
|
||||
// ✅ nur animieren, wenn sichtbar (Viewport)
|
||||
// inView (Viewport)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
// Tick nur für frames-Mode
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
|
||||
// ✅ für hover-play
|
||||
// Hover-State (für inline hover ODER teaser hover)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) return
|
||||
if (!inView || document.hidden) return
|
||||
|
||||
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||
return () => window.clearInterval(id)
|
||||
}, [animated, inView, autoTickMs])
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
const previewId = useMemo(() => {
|
||||
if (!file) return ''
|
||||
@ -92,25 +116,62 @@ export default function FinishedVideoPreview({
|
||||
const hasDuration =
|
||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
|
||||
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
// --- Tick für "frames"
|
||||
useEffect(() => {
|
||||
if (!animated) return
|
||||
if (animatedMode !== 'frames') return
|
||||
if (!inView || document.hidden) return
|
||||
|
||||
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||
return () => window.clearInterval(id)
|
||||
}, [animated, animatedMode, inView, autoTickMs])
|
||||
|
||||
// --- Thumbnail time (nur frames!)
|
||||
const thumbTimeSec = useMemo(() => {
|
||||
if (!animated) return null
|
||||
if (animatedMode !== 'frames') return null
|
||||
if (!hasDuration) return null
|
||||
const step = 3
|
||||
const total = Math.max(durationSeconds! - 0.1, step)
|
||||
return (localTick * step) % total
|
||||
}, [animated, hasDuration, durationSeconds, localTick])
|
||||
|
||||
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
|
||||
const dur = durationSeconds!
|
||||
const step = Math.max(0.25, thumbStepSec ?? 3)
|
||||
|
||||
if (thumbSpread) {
|
||||
const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur)))
|
||||
const idx = localTick % count
|
||||
const span = Math.max(0.1, dur - step)
|
||||
const base = Math.min(0.25, span * 0.02)
|
||||
const t = (idx / count) * span + base
|
||||
return Math.min(dur - 0.05, Math.max(0.05, t))
|
||||
}
|
||||
|
||||
const total = Math.max(dur - 0.1, step)
|
||||
const t = (localTick * step) % total
|
||||
return Math.min(dur - 0.05, Math.max(0.05, t))
|
||||
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
|
||||
|
||||
const thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
if (thumbTimeSec == null) {
|
||||
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||
}
|
||||
// static thumb (oder frames: mit t=...)
|
||||
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
|
||||
thumbTimeSec.toFixed(2)
|
||||
)}`
|
||||
}, [previewId, thumbTimeSec])
|
||||
)}&v=${encodeURIComponent(String(localTick))}`
|
||||
}, [previewId, thumbTimeSec, localTick])
|
||||
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
setMetaLoaded(true)
|
||||
@ -120,24 +181,105 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
if (!videoSrc) {
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
}
|
||||
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
// --- Inline Video sichtbar?
|
||||
const showingInlineVideo =
|
||||
inlineMode !== 'never' &&
|
||||
inView &&
|
||||
videoOk &&
|
||||
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
// --- Teaser Clip Zeiten (nur clips)
|
||||
const clipTimes = useMemo(() => {
|
||||
if (!animated) return []
|
||||
if (animatedMode !== 'clips') return []
|
||||
if (!hasDuration) return []
|
||||
|
||||
const dur = durationSeconds!
|
||||
const clipLen = Math.max(0.25, clipSeconds)
|
||||
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
|
||||
|
||||
const span = Math.max(0.1, dur - clipLen)
|
||||
const base = Math.min(0.25, span * 0.02)
|
||||
|
||||
const times: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = (i / count) * span + base
|
||||
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
|
||||
}
|
||||
return times
|
||||
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds])
|
||||
|
||||
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
||||
|
||||
// --- Teaser aktiv? (nur inView, nicht inline, optional nur hover)
|
||||
const teaserActive =
|
||||
animated &&
|
||||
animatedMode === 'clips' &&
|
||||
inView &&
|
||||
!document.hidden &&
|
||||
videoOk &&
|
||||
clipTimes.length > 0 &&
|
||||
!showingInlineVideo &&
|
||||
(animatedTrigger === 'always' || hovered)
|
||||
|
||||
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
|
||||
const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover')
|
||||
|
||||
// --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop)
|
||||
const teaserRef = useRef<HTMLVideoElement | null>(null)
|
||||
const clipIdxRef = useRef(0)
|
||||
const clipStartRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const v = teaserRef.current
|
||||
if (!v) return
|
||||
|
||||
if (!teaserActive) {
|
||||
v.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (!clipTimes.length) return
|
||||
|
||||
clipIdxRef.current = clipIdxRef.current % clipTimes.length
|
||||
clipStartRef.current = clipTimes[clipIdxRef.current]
|
||||
|
||||
const start = () => {
|
||||
try {
|
||||
v.currentTime = clipStartRef.current
|
||||
} catch {}
|
||||
const p = v.play()
|
||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
||||
}
|
||||
|
||||
const onLoaded = () => start()
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!clipTimes.length) return
|
||||
if (v.currentTime - clipStartRef.current >= clipSeconds) {
|
||||
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
|
||||
clipStartRef.current = clipTimes[clipIdxRef.current]
|
||||
try {
|
||||
v.currentTime = clipStartRef.current + 0.01
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
v.addEventListener('loadedmetadata', onLoaded)
|
||||
v.addEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
// Wenn metadata schon da ist:
|
||||
if (v.readyState >= 1) start()
|
||||
|
||||
return () => {
|
||||
v.removeEventListener('loadedmetadata', onLoaded)
|
||||
v.removeEventListener('timeupdate', onTimeUpdate)
|
||||
v.pause()
|
||||
}
|
||||
}, [teaserActive, clipTimesKey, clipSeconds])
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
@ -147,39 +289,59 @@ export default function FinishedVideoPreview({
|
||||
sizeClass,
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
// ✅ hover only relevant for inlineMode==='hover'
|
||||
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
|
||||
onFocus={wantsHover ? () => setHovered(true) : undefined}
|
||||
onBlur={wantsHover ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
|
||||
{/* 1) Inline Full Video (mit Controls) */}
|
||||
{showingInlineVideo ? (
|
||||
<video
|
||||
key={`inline-${previewId}-${inlineNonce}`}
|
||||
src={videoSrc}
|
||||
className="w-full h-full object-cover bg-black pointer-events-none"
|
||||
className={[
|
||||
'w-full h-full object-cover bg-black',
|
||||
blurCls,
|
||||
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
autoPlay
|
||||
loop
|
||||
controls={inlineControls}
|
||||
loop={inlineLoop}
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : teaserActive ? (
|
||||
/* 2) Teaser Clips (1s Segmente) */
|
||||
<video
|
||||
ref={teaserRef}
|
||||
key={`teaser-${previewId}-${clipTimesKey}`}
|
||||
src={videoSrc}
|
||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : thumbSrc && thumbOk ? (
|
||||
/* 3) Statisches Bild / Frames */
|
||||
<img
|
||||
src={thumbSrc}
|
||||
loading="lazy"
|
||||
alt={file}
|
||||
className="w-full h-full object-cover"
|
||||
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
|
||||
onError={() => setThumbOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
|
||||
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
@ -193,7 +355,7 @@ export default function FinishedVideoPreview({
|
||||
</div>
|
||||
)
|
||||
|
||||
// ✅ Gallery: kein HoverPopover
|
||||
// Gallery: kein HoverPopover
|
||||
if (!showPopover) return previewNode
|
||||
|
||||
return (
|
||||
@ -204,7 +366,7 @@ export default function FinishedVideoPreview({
|
||||
<div className="aspect-video">
|
||||
<video
|
||||
src={videoSrc}
|
||||
className="w-full h-full bg-black"
|
||||
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
|
||||
@ -13,10 +13,13 @@ import Card from './Card'
|
||||
|
||||
type Pos = { left: number; top: number }
|
||||
|
||||
type HoverPopoverAPI = { close: () => void }
|
||||
|
||||
type HoverPopoverProps = PropsWithChildren<{
|
||||
// Entweder direkt ein ReactNode
|
||||
// oder eine Renderfunktion, die den Open-Status bekommt
|
||||
content: ReactNode | ((open: boolean) => ReactNode)
|
||||
// (2. Param erlaubt z.B. Close-Button im Popover)
|
||||
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
|
||||
}>
|
||||
|
||||
export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
@ -53,6 +56,11 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
clearCloseTimeout()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const computePos = () => {
|
||||
const trigger = triggerRef.current
|
||||
const pop = popoverRef.current
|
||||
@ -116,7 +124,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
// Hilfsfunktion: content normalisieren
|
||||
const renderContent = () =>
|
||||
typeof content === 'function'
|
||||
? (content as (open: boolean) => ReactNode)(open)
|
||||
? (content as any)(open, { close })
|
||||
: content
|
||||
|
||||
return (
|
||||
@ -144,7 +152,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
<Card
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]"
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 max-w-[calc(100vw-16px)]"
|
||||
noBodyPadding
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import HoverPopover from './HoverPopover'
|
||||
import LiveHlsVideo from './LiveHlsVideo'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type Props = {
|
||||
jobId: string
|
||||
@ -12,9 +13,11 @@ type Props = {
|
||||
thumbTick?: number
|
||||
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
|
||||
autoTickMs?: number
|
||||
blur?: boolean
|
||||
}
|
||||
|
||||
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: Props) {
|
||||
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blur = false }: Props) {
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
@ -75,15 +78,32 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={(open) =>
|
||||
content={(open, { close }) =>
|
||||
open && (
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<LiveHlsVideo
|
||||
src={hq}
|
||||
muted={false}
|
||||
className="w-full h-full bg-black"
|
||||
/>
|
||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
|
||||
|
||||
{/* LIVE badge */}
|
||||
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||
Live
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
|
||||
aria-label="Live-Vorschau schließen"
|
||||
title="Vorschau schließen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -98,7 +118,7 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
|
||||
src={thumb}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
|
||||
onError={() => setImgError(true)}
|
||||
onLoad={() => setImgError(false)}
|
||||
/>
|
||||
|
||||
@ -199,12 +199,50 @@ export default function Player({
|
||||
|
||||
try {
|
||||
p.pause()
|
||||
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
|
||||
// video.js hat reset() -> stoppt Tech + Requests oft zuverlässiger
|
||||
;(p as any).reset?.()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// Source leeren, damit Browser/Tech die HTTP-Verbindung abbricht
|
||||
p.src({ src: '', type: 'video/mp4' } as any)
|
||||
;(p as any).load?.()
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onRelease = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||
const file = (detail?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
const current = baseName(job.output?.trim() || '')
|
||||
if (current && current === file) {
|
||||
releaseMedia()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('player:release', onRelease as EventListener)
|
||||
return () => window.removeEventListener('player:release', onRelease as EventListener)
|
||||
}, [job.output, releaseMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onCloseIfFile = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||
const file = (detail?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
const current = baseName(job.output?.trim() || '')
|
||||
if (current && current === file) {
|
||||
releaseMedia()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('player:close', onCloseIfFile as EventListener)
|
||||
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
|
||||
}, [job.output, releaseMedia, onClose])
|
||||
|
||||
const mini = !expanded
|
||||
|
||||
const [miniHover, setMiniHover] = React.useState(false)
|
||||
|
||||
@ -17,6 +17,7 @@ type RecorderSettings = {
|
||||
|
||||
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
|
||||
useChaturbateApi?: boolean
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const DEFAULTS: RecorderSettings = {
|
||||
@ -30,6 +31,7 @@ const DEFAULTS: RecorderSettings = {
|
||||
autoStartAddedDownloads: true,
|
||||
|
||||
useChaturbateApi: false,
|
||||
blurPreviews: false,
|
||||
}
|
||||
|
||||
export default function RecorderSettings() {
|
||||
@ -59,6 +61,7 @@ export default function RecorderSettings() {
|
||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||||
|
||||
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
||||
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@ -117,6 +120,7 @@ export default function RecorderSettings() {
|
||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
||||
|
||||
const useChaturbateApi = !!value.useChaturbateApi
|
||||
const blurPreviews = !!value.blurPreviews
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
@ -131,6 +135,7 @@ export default function RecorderSettings() {
|
||||
autoStartAddedDownloads,
|
||||
|
||||
useChaturbateApi,
|
||||
blurPreviews,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
@ -256,6 +261,13 @@ export default function RecorderSettings() {
|
||||
label="Chaturbate API"
|
||||
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.blurPreviews}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
|
||||
label="Vorschaubilder blurren"
|
||||
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ type Props = {
|
||||
pending?: PendingWatchedRoom[]
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
onStopJob: (id: string) => void
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const baseName = (p: string) =>
|
||||
@ -55,13 +56,13 @@ const runtimeOf = (j: RecordJob) => {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob }: Props) {
|
||||
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
|
||||
const columns = useMemo<Column<RecordJob>[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <ModelPreview jobId={j.id} />,
|
||||
cell: (j) => <ModelPreview jobId={j.id} blur={blurPreviews} />,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
@ -199,7 +200,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<ModelPreview jobId={j.id} />
|
||||
<ModelPreview jobId={j.id} blur={blurPreviews} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@ -45,6 +45,19 @@ export type SwipeCardProps = {
|
||||
/** Animation timings */
|
||||
snapMs?: number
|
||||
commitMs?: number
|
||||
/**
|
||||
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
|
||||
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
|
||||
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
|
||||
*/
|
||||
ignoreFromBottomPx?: number
|
||||
|
||||
/**
|
||||
* Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird.
|
||||
* (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben)
|
||||
*/
|
||||
ignoreSelector?: string
|
||||
|
||||
}
|
||||
|
||||
export type SwipeCardHandle = {
|
||||
@ -82,6 +95,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
},
|
||||
thresholdPx = 120,
|
||||
thresholdRatio = 0.35,
|
||||
ignoreFromBottomPx = 72,
|
||||
ignoreSelector = '[data-swipe-ignore]',
|
||||
snapMs = 180,
|
||||
commitMs = 180,
|
||||
},
|
||||
@ -95,7 +110,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
x: number
|
||||
y: number
|
||||
dragging: boolean
|
||||
}>({ id: null, x: 0, y: 0, dragging: false })
|
||||
captured: boolean
|
||||
}>({ id: null, x: 0, y: 0, dragging: false, captured: false })
|
||||
|
||||
const [dx, setDx] = React.useState(0)
|
||||
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
||||
@ -190,9 +206,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
|
||||
const target = e.target as HTMLElement | null
|
||||
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
||||
|
||||
// ✅ 2) Ignoriere Start im unteren Bereich (z.B. Video-Controls/Progressbar)
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const fromBottom = rect.bottom - e.clientY
|
||||
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
|
||||
|
||||
pointer.current = {
|
||||
id: e.pointerId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
dragging: false,
|
||||
captured: false,
|
||||
}
|
||||
}}
|
||||
|
||||
onPointerMove={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
@ -200,12 +232,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const ddx = e.clientX - pointer.current.x
|
||||
const ddy = e.clientY - pointer.current.y
|
||||
|
||||
// Erst entscheiden ob wir überhaupt "draggen"
|
||||
// Erst entscheiden ob wir überhaupt draggen
|
||||
if (!pointer.current.dragging) {
|
||||
// wenn Nutzer vertikal scrollt, nicht hijacken
|
||||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
|
||||
if (Math.abs(ddx) < 6) return
|
||||
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
|
||||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
|
||||
pointer.current.id = null
|
||||
return
|
||||
}
|
||||
|
||||
// "Dead zone" bis wirklich horizontal gedrückt wird
|
||||
if (Math.abs(ddx) < 12) return
|
||||
|
||||
// ✅ jetzt erst beginnen wir zu swipen
|
||||
pointer.current.dragging = true
|
||||
|
||||
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
pointer.current.captured = true
|
||||
} catch {
|
||||
pointer.current.captured = false
|
||||
}
|
||||
}
|
||||
|
||||
setAnimMs(0)
|
||||
@ -214,9 +261,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
|
||||
}}
|
||||
|
||||
onPointerUp={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
@ -226,7 +273,18 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
const wasDragging = pointer.current.dragging
|
||||
const wasCaptured = pointer.current.captured
|
||||
|
||||
pointer.current.id = null
|
||||
pointer.current.dragging = false
|
||||
pointer.current.captured = false
|
||||
|
||||
// Capture sauber lösen (falls gesetzt)
|
||||
if (wasCaptured) {
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!wasDragging) {
|
||||
reset()
|
||||
@ -235,15 +293,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}
|
||||
|
||||
if (dx > threshold) {
|
||||
void commit('right', true) // keep
|
||||
void commit('right', true)
|
||||
} else if (dx < -threshold) {
|
||||
void commit('left', true) // delete
|
||||
void commit('left', true)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
onPointerCancel={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.captured && pointer.current.id != null) {
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
||||
} catch {}
|
||||
}
|
||||
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false }
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
|
||||
@ -15,58 +15,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
|
||||
.vjs-mini .video-js .vjs-control-bar{
|
||||
z-index: 40; /* über Overlays */
|
||||
background: rgba(0,0,0,.65);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Progressbar deutlich höher */
|
||||
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-play-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(99,102,241,.95);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-load-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
|
||||
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
|
||||
.vjs-controls-on-activity .video-js .vjs-control-bar{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
|
||||
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
|
||||
.vjs-controls-on-activity .player-ui{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .player-ui,
|
||||
.vjs-controls-on-activity:focus-within .player-ui{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user