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