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")
|
||||||
|
|||||||
320
backend/main.go
320
backend/main.go
@ -50,7 +50,9 @@ type RecordJob struct {
|
|||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
|
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
PreviewDir string `json:"-"`
|
PreviewDir string `json:"-"`
|
||||||
PreviewImage string `json:"-"`
|
PreviewImage string `json:"-"`
|
||||||
@ -84,7 +86,7 @@ var durCache = struct {
|
|||||||
m map[string]durEntry
|
m map[string]durEntry
|
||||||
}{m: map[string]durEntry{}}
|
}{m: map[string]durEntry{}}
|
||||||
|
|
||||||
func durationSecondsCached(path string) (float64, error) {
|
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@ -98,7 +100,7 @@ func durationSecondsCached(path string) (float64, error) {
|
|||||||
durCache.mu.Unlock()
|
durCache.mu.Unlock()
|
||||||
|
|
||||||
// ffprobe (oder notfalls ffmpeg -i parsen)
|
// ffprobe (oder notfalls ffmpeg -i parsen)
|
||||||
cmd := exec.Command("ffprobe",
|
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||||
"-v", "error",
|
"-v", "error",
|
||||||
"-show_entries", "format=duration",
|
"-show_entries", "format=duration",
|
||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
@ -133,6 +135,7 @@ type RecorderSettings struct {
|
|||||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
|
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
|
||||||
|
|
||||||
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
|
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
|
||||||
|
BlurPreviews bool `json:"blurPreviews,omitempty"`
|
||||||
|
|
||||||
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
||||||
EncryptedCookies string `json:"encryptedCookies,omitempty"`
|
EncryptedCookies string `json:"encryptedCookies,omitempty"`
|
||||||
@ -149,6 +152,7 @@ var (
|
|||||||
AutoStartAddedDownloads: false,
|
AutoStartAddedDownloads: false,
|
||||||
|
|
||||||
UseChaturbateAPI: false,
|
UseChaturbateAPI: false,
|
||||||
|
BlurPreviews: false,
|
||||||
EncryptedCookies: "",
|
EncryptedCookies: "",
|
||||||
}
|
}
|
||||||
settingsFile = "recorder_settings.json"
|
settingsFile = "recorder_settings.json"
|
||||||
@ -1139,7 +1143,9 @@ func registerFrontend(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// routes.go (package main)
|
// routes.go (package main)
|
||||||
func registerRoutes(mux *http.ServeMux) {
|
func registerRoutes(mux *http.ServeMux) *ModelStore {
|
||||||
|
mux.HandleFunc("/api/cookies", cookiesHandler)
|
||||||
|
|
||||||
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
||||||
mux.HandleFunc("/api/settings/browse", settingsBrowse)
|
mux.HandleFunc("/api/settings/browse", settingsBrowse)
|
||||||
|
|
||||||
@ -1150,9 +1156,11 @@ func registerRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/record/list", recordList)
|
mux.HandleFunc("/api/record/list", recordList)
|
||||||
mux.HandleFunc("/api/record/video", recordVideo)
|
mux.HandleFunc("/api/record/video", recordVideo)
|
||||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
mux.HandleFunc("/api/record/done", recordDoneList)
|
||||||
|
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||||
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||||
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||||
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||||
|
mux.HandleFunc("/api/record/duration", recordDuration)
|
||||||
|
|
||||||
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
|
|
||||||
@ -1169,6 +1177,8 @@ func registerRoutes(mux *http.ServeMux) {
|
|||||||
|
|
||||||
// ✅ Frontend (SPA) ausliefern
|
// ✅ Frontend (SPA) ausliefern
|
||||||
registerFrontend(mux)
|
registerFrontend(mux)
|
||||||
|
|
||||||
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- main ---
|
// --- main ---
|
||||||
@ -1176,7 +1186,10 @@ func main() {
|
|||||||
loadSettings()
|
loadSettings()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
registerRoutes(mux)
|
store := registerRoutes(mux)
|
||||||
|
|
||||||
|
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
|
||||||
|
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
|
||||||
|
|
||||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||||
if err := http.ListenAndServe(":9999", mux); err != nil {
|
if err := http.ListenAndServe(":9999", mux); err != nil {
|
||||||
@ -1191,6 +1204,40 @@ type RecordRequest struct {
|
|||||||
UserAgent string `json:"userAgent,omitempty"`
|
UserAgent string `json:"userAgent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
||||||
|
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||||
|
url := strings.TrimSpace(req.URL)
|
||||||
|
if url == "" {
|
||||||
|
return nil, errors.New("url fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate-running guard (identische URL)
|
||||||
|
jobsMu.Lock()
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
|
||||||
|
jobsMu.Unlock()
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID := uuid.NewString()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
job := &RecordJob{
|
||||||
|
ID: jobID,
|
||||||
|
SourceURL: url,
|
||||||
|
Status: JobRunning,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs[jobID] = job
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
go runJob(ctx, job, req)
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
@ -1203,30 +1250,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.URL == "" {
|
job, err := startRecordingInternal(req)
|
||||||
http.Error(w, "url fehlt", http.StatusBadRequest)
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobID := uuid.NewString()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
job := &RecordJob{
|
|
||||||
ID: jobID,
|
|
||||||
SourceURL: req.URL,
|
|
||||||
Status: JobRunning,
|
|
||||||
StartedAt: time.Now(),
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
|
||||||
jobs[jobID] = job
|
|
||||||
jobsMu.Unlock()
|
|
||||||
|
|
||||||
go runJob(ctx, job, req)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(job)
|
_ = json.NewEncoder(w).Encode(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCookieString(cookieStr string) map[string]string {
|
func parseCookieString(cookieStr string) map[string]string {
|
||||||
@ -1429,8 +1460,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "video/mp4")
|
serveVideoFile(w, r, outPath)
|
||||||
http.ServeFile(w, r, outPath)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1490,9 +1520,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
serveVideoFile(w, r, outPath)
|
||||||
w.Header().Set("Content-Type", "video/mp4")
|
}
|
||||||
http.ServeFile(w, r, outPath)
|
|
||||||
|
func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
|
||||||
|
durCache.mu.Lock()
|
||||||
|
e, ok := durCache.m[path]
|
||||||
|
durCache.mu.Unlock()
|
||||||
|
|
||||||
|
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
|
||||||
|
return e.sec
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -1550,7 +1589,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
t := fi.ModTime()
|
t := fi.ModTime()
|
||||||
|
|
||||||
dur, _ := durationSecondsCached(full)
|
dur := durationSecondsCacheOnly(full, fi)
|
||||||
|
|
||||||
list = append(list, &RecordJob{
|
list = append(list, &RecordJob{
|
||||||
ID: base,
|
ID: base,
|
||||||
@ -1559,6 +1598,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
StartedAt: t,
|
StartedAt: t,
|
||||||
EndedAt: &t,
|
EndedAt: &t,
|
||||||
DurationSeconds: dur,
|
DurationSeconds: dur,
|
||||||
|
SizeBytes: fi.Size(),
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1572,6 +1612,155 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(list)
|
_ = json.NewEncoder(w).Encode(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type doneMetaResp struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", "GET")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getSettings()
|
||||||
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(doneAbs) == "" {
|
||||||
|
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(doneAbs)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cnt := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
|
||||||
|
if ext != ".mp4" && ext != ".ts" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cnt++
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
|
||||||
|
}
|
||||||
|
|
||||||
|
type durationReq struct {
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type durationItem struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDuration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req durationReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard limit, damit niemand dir 5000 files schickt
|
||||||
|
if len(req.Files) > 200 {
|
||||||
|
http.Error(w, "too many files", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getSettings()
|
||||||
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to resolve done dir", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-dupe
|
||||||
|
seen := make(map[string]struct{}, len(req.Files))
|
||||||
|
files := make([]string, 0, len(req.Files))
|
||||||
|
for _, f := range req.Files {
|
||||||
|
f = strings.TrimSpace(f)
|
||||||
|
if f == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[f]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[f] = struct{}{}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side Concurrency Limit (z.B. 2-4)
|
||||||
|
sem := make(chan struct{}, 3)
|
||||||
|
|
||||||
|
out := make([]durationItem, len(files))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, file := range files {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, file string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// ✅ sanitize: nur basename erlauben
|
||||||
|
if filepath.Base(file) != file || strings.Contains(file, "/") || strings.Contains(file, "\\") {
|
||||||
|
out[i] = durationItem{File: file, Error: "invalid file"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
full := filepath.Join(doneAbs, file)
|
||||||
|
|
||||||
|
// Existiert?
|
||||||
|
fi, err := os.Stat(full)
|
||||||
|
if err != nil || fi.IsDir() {
|
||||||
|
out[i] = durationItem{File: file, Error: "not found"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-hit? (spart ffprobe)
|
||||||
|
if sec := durationSecondsCacheOnly(full, fi); sec > 0 {
|
||||||
|
out[i] = durationItem{File: file, DurationSeconds: sec}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
sec, err := durationSecondsCached(r.Context(), full) // ctx-fähig, siehe unten
|
||||||
|
if err != nil || sec <= 0 {
|
||||||
|
out[i] = durationItem{File: file, Error: "ffprobe failed"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out[i] = durationItem{File: file, DurationSeconds: sec}
|
||||||
|
}(i, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
||||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||||
@ -1636,8 +1825,8 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := removeWithRetry(target); err != nil {
|
if err := removeWithRetry(target); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if isSharingViolation(err) {
|
||||||
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
@ -1652,6 +1841,27 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
f, err := openForReadShareDelete(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil || fi.IsDir() || fi.Size() == 0 {
|
||||||
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
|
||||||
|
// ServeContent unterstützt Range Requests (wichtig für Video)
|
||||||
|
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
@ -1890,33 +2100,31 @@ func moveFile(src, dst string) error {
|
|||||||
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
|
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
|
||||||
|
|
||||||
func isSharingViolation(err error) bool {
|
func isSharingViolation(err error) bool {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
|
||||||
var pe *os.PathError
|
var pe *os.PathError
|
||||||
if errors.As(err, &pe) {
|
if errors.As(err, &pe) {
|
||||||
if errno, ok := pe.Err.(syscall.Errno); ok {
|
if errno, ok := pe.Err.(syscall.Errno); ok {
|
||||||
return errno == windowsSharingViolation
|
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
|
||||||
}
|
}
|
||||||
return errors.Is(pe.Err, windowsSharingViolation)
|
|
||||||
}
|
}
|
||||||
|
// Fallback über Text
|
||||||
var le *os.LinkError
|
s := strings.ToLower(err.Error())
|
||||||
if errors.As(err, &le) {
|
return strings.Contains(s, "sharing violation") ||
|
||||||
if errno, ok := le.Err.(syscall.Errno); ok {
|
strings.Contains(s, "used by another process") ||
|
||||||
return errno == windowsSharingViolation
|
strings.Contains(s, "wird von einem anderen prozess verwendet")
|
||||||
}
|
|
||||||
return errors.Is(le.Err, windowsSharingViolation)
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Is(err, windowsSharingViolation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameWithRetry(src, dst string) error {
|
func removeWithRetry(path string) error {
|
||||||
var err error
|
var err error
|
||||||
for i := 0; i < 15; i++ { // ~1.5s
|
for i := 0; i < 40; i++ { // ~4s bei 100ms
|
||||||
err = os.Rename(src, dst)
|
err = os.Remove(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if isSharingViolation(err) {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1925,14 +2133,14 @@ func renameWithRetry(src, dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeWithRetry(path string) error {
|
func renameWithRetry(oldPath, newPath string) error {
|
||||||
var err error
|
var err error
|
||||||
for i := 0; i < 15; i++ { // ~1.5s
|
for i := 0; i < 40; i++ {
|
||||||
err = os.Remove(path)
|
err = os.Rename(oldPath, newPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if isSharingViolation(err) {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -2029,8 +2237,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
|
|
||||||
|
|
||||||
w.Write([]byte(`{"ok":"stopped"}`))
|
w.Write([]byte(`{"ok":"stopped"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2076,8 +2282,6 @@ func RecordStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
|
|
||||||
|
|
||||||
// 4) Datei öffnen
|
// 4) Datei öffnen
|
||||||
file, err := os.Create(outputPath)
|
file, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -2087,8 +2291,6 @@ func RecordStream(
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
fmt.Println("📡 Aufnahme gestartet:", outputPath)
|
|
||||||
|
|
||||||
// 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR
|
// 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR
|
||||||
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
|
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
|
||||||
// Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben
|
// Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben
|
||||||
|
|||||||
Binary file not shown.
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,209 +605,48 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
|
const handleToggleFavorite = useCallback(
|
||||||
let m = playerModel
|
async (job: RecordJob) => {
|
||||||
if (!m) {
|
const file = baseName(job.output || '')
|
||||||
m = await resolveModelForJob(job)
|
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||||
setPlayerModel(m)
|
|
||||||
}
|
|
||||||
if (!m) return
|
|
||||||
|
|
||||||
const next = !Boolean(m.favorite)
|
let m = sameAsPlayer ? playerModel : null
|
||||||
const updated = await patchModelFlags({ id: m.id, favorite: next })
|
if (!m) m = await resolveModelForJob(job)
|
||||||
|
if (!m) return
|
||||||
|
|
||||||
setPlayerModel(updated)
|
const next = !Boolean(m.favorite)
|
||||||
window.dispatchEvent(new Event('models-changed'))
|
|
||||||
}, [playerModel])
|
|
||||||
|
|
||||||
const handleToggleLike = useCallback(async (job: RecordJob) => {
|
const updated = await patchModelFlags({
|
||||||
let m = playerModel
|
id: m.id,
|
||||||
if (!m) {
|
favorite: next,
|
||||||
m = await resolveModelForJob(job)
|
...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
|
||||||
setPlayerModel(m)
|
})
|
||||||
}
|
|
||||||
if (!m) return
|
|
||||||
|
|
||||||
const next = !(m.liked === true)
|
if (sameAsPlayer) setPlayerModel(updated)
|
||||||
const updated = await patchModelFlags({ id: m.id, liked: next })
|
window.dispatchEvent(new Event('models-changed'))
|
||||||
|
},
|
||||||
|
[playerJob, playerModel]
|
||||||
|
)
|
||||||
|
|
||||||
setPlayerModel(updated)
|
const handleToggleLike = useCallback(
|
||||||
window.dispatchEvent(new Event('models-changed'))
|
async (job: RecordJob) => {
|
||||||
}, [playerModel])
|
const file = baseName(job.output || '')
|
||||||
|
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||||
|
|
||||||
|
let m = sameAsPlayer ? playerModel : null
|
||||||
|
if (!m) m = await resolveModelForJob(job)
|
||||||
|
if (!m) return
|
||||||
|
|
||||||
const normUser = (s: string) => (s || '').trim().toLowerCase()
|
const curLiked = m.liked === true
|
||||||
|
const updated = curLiked
|
||||||
|
? await patchModelFlags({ id: m.id, clearLiked: true }) // ✅ aus
|
||||||
|
: await patchModelFlags({ id: m.id, liked: true, favorite: false }) // ✅ an + fav aus
|
||||||
|
|
||||||
const chaturbateUserFromUrl = (u: string): string | null => {
|
if (sameAsPlayer) setPlayerModel(updated)
|
||||||
try {
|
window.dispatchEvent(new Event('models-changed'))
|
||||||
const url = new URL(u)
|
},
|
||||||
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null
|
[playerJob, playerModel]
|
||||||
const parts = url.pathname.split('/').filter(Boolean)
|
)
|
||||||
return parts[0] ? normUser(parts[0]) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 1) Poll: alle watched+online Models als "wartend" anzeigen (public/private/hidden/away)
|
|
||||||
// und public-Models in eine Start-Queue legen
|
|
||||||
useEffect(() => {
|
|
||||||
if (!recSettings.useChaturbateApi) {
|
|
||||||
setPendingWatchedRooms([])
|
|
||||||
autoStartQueueRef.current = []
|
|
||||||
autoStartQueuedUsersRef.current = new Set()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
let inFlight = false
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
if (cancelled || inFlight) return
|
|
||||||
inFlight = true
|
|
||||||
try {
|
|
||||||
const canAutoStart = hasRequiredChaturbateCookies(cookiesRef.current)
|
|
||||||
|
|
||||||
const modelsList = watchedModelsRef.current
|
|
||||||
|
|
||||||
const online = await apiJSON<ChaturbateOnlineResponse>('/api/chaturbate/online', { cache: 'no-store' })
|
|
||||||
|
|
||||||
if (!online?.enabled) return
|
|
||||||
|
|
||||||
// online username -> show
|
|
||||||
const showByUser = new Map<string, string>()
|
|
||||||
for (const r of online.rooms ?? []) {
|
|
||||||
showByUser.set(normUser(r.username), String(r.current_show || 'unknown').toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
// running username set (damit wir nichts doppelt starten/anzeigen)
|
|
||||||
const runningUsers = new Set(
|
|
||||||
jobsRef.current
|
|
||||||
.filter((j) => j.status === 'running')
|
|
||||||
.map((j) => chaturbateUserFromUrl(String(j.sourceUrl || '')))
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
)
|
|
||||||
|
|
||||||
// watched username set
|
|
||||||
const watchedModels = (modelsList ?? []).filter(
|
|
||||||
(m) => Boolean(m?.watching) && (
|
|
||||||
String(m?.host || '').toLowerCase().includes('chaturbate.com') || isChaturbate(String(m?.input || ''))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const watchedUsers = new Set(watchedModels.map((m) => normUser(m.modelKey)).filter(Boolean))
|
|
||||||
|
|
||||||
// ✅ Queue aufräumen: raus, wenn nicht mehr watched, offline oder schon running
|
|
||||||
{
|
|
||||||
const nextQueue: Array<{ userKey: string; url: string }> = []
|
|
||||||
for (const q of autoStartQueueRef.current) {
|
|
||||||
if (!watchedUsers.has(q.userKey)) continue
|
|
||||||
if (!showByUser.has(q.userKey)) continue
|
|
||||||
if (runningUsers.has(q.userKey)) continue
|
|
||||||
nextQueue.push(q)
|
|
||||||
}
|
|
||||||
autoStartQueueRef.current = nextQueue
|
|
||||||
autoStartQueuedUsersRef.current = new Set(nextQueue.map((q) => q.userKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Pending Map: alle watched+online, die NICHT running sind
|
|
||||||
const pendingMap = new Map<string, PendingWatchedRoom>()
|
|
||||||
|
|
||||||
for (const m of watchedModels) {
|
|
||||||
const key = normUser(m.modelKey)
|
|
||||||
if (!key) continue
|
|
||||||
|
|
||||||
const currentShow = showByUser.get(key)
|
|
||||||
if (!currentShow) continue // offline -> nicht pending
|
|
||||||
|
|
||||||
// running -> nicht pending (steht ja in Jobs)
|
|
||||||
if (runningUsers.has(key)) continue
|
|
||||||
|
|
||||||
const url = /^https?:\/\//i.test(m.input || '')
|
|
||||||
? String(m.input).trim()
|
|
||||||
: `https://chaturbate.com/${m.modelKey}/`
|
|
||||||
|
|
||||||
// ✅ erst mal ALLE watched+online als wartend anzeigen (auch public)
|
|
||||||
if (!pendingMap.has(key)) {
|
|
||||||
pendingMap.set(key, { id: m.id, modelKey: m.modelKey, url, currentShow })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ public in Queue (wenn Cookies da), aber ohne Duplikate
|
|
||||||
if (currentShow === 'public' && canAutoStart && !autoStartQueuedUsersRef.current.has(key)) {
|
|
||||||
autoStartQueueRef.current.push({ userKey: key, url })
|
|
||||||
autoStartQueuedUsersRef.current.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) setPendingWatchedRooms([...pendingMap.values()])
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
inFlight = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
poll()
|
|
||||||
const t = window.setInterval(poll, document.hidden ? 15000 : 5000)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
window.clearInterval(t)
|
|
||||||
}
|
|
||||||
}, [recSettings.useChaturbateApi])
|
|
||||||
|
|
||||||
// ✅ 2) Worker: startet Queue nacheinander (5s Pause nach jedem Start)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!recSettings.useChaturbateApi) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const loop = async () => {
|
|
||||||
if (autoStartWorkerRef.current) return
|
|
||||||
autoStartWorkerRef.current = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!cancelled) {
|
|
||||||
// wenn UI gerade manuell startet -> warten
|
|
||||||
if (busyRef.current) {
|
|
||||||
await sleep(500)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = autoStartQueueRef.current.shift()
|
|
||||||
if (!next) {
|
|
||||||
await sleep(1000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// aus queued-set entfernen (damit Poll ggf. neu einreihen kann, falls Start nicht klappt)
|
|
||||||
autoStartQueuedUsersRef.current.delete(next.userKey)
|
|
||||||
|
|
||||||
// start attempt (silent)
|
|
||||||
const ok = await startUrl(next.url, { silent: true })
|
|
||||||
if (ok) {
|
|
||||||
// pending sofort rausnehmen, damit UI direkt "running" zeigt
|
|
||||||
setPendingWatchedRooms((prev) => prev.filter((p) => normUser(p.modelKey) !== next.userKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 5s Abstand nach (erfolgreichem) Starten – ich warte auch bei failure,
|
|
||||||
// damit wir nicht in eine schnelle Retry-Schleife laufen.
|
|
||||||
if (ok) {
|
|
||||||
await sleep(5000)
|
|
||||||
} else {
|
|
||||||
await sleep(5000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
autoStartWorkerRef.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [recSettings.useChaturbateApi, startUrl])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoAddEnabled && !autoStartEnabled) return
|
if (!autoAddEnabled && !autoStartEnabled) return
|
||||||
@ -933,9 +775,9 @@ export default function App() {
|
|||||||
{selectedTab === 'running' && (
|
{selectedTab === 'running' && (
|
||||||
<RunningDownloads
|
<RunningDownloads
|
||||||
jobs={runningJobs}
|
jobs={runningJobs}
|
||||||
pending={pendingWatchedRooms}
|
|
||||||
onOpenPlayer={openPlayer}
|
onOpenPlayer={openPlayer}
|
||||||
onStopJob={stopJob}
|
onStopJob={stopJob}
|
||||||
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -944,6 +786,11 @@ export default function App() {
|
|||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
doneJobs={doneJobs}
|
doneJobs={doneJobs}
|
||||||
onOpenPlayer={openPlayer}
|
onOpenPlayer={openPlayer}
|
||||||
|
onDeleteJob={handleDeleteJob}
|
||||||
|
onToggleHot={handleToggleHot}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
onToggleLike={handleToggleLike}
|
||||||
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -977,13 +824,11 @@ export default function App() {
|
|||||||
<Player
|
<Player
|
||||||
job={playerJob}
|
job={playerJob}
|
||||||
expanded={playerExpanded}
|
expanded={playerExpanded}
|
||||||
onToggleExpand={() => setPlayerExpanded((v) => !v)}
|
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||||||
onClose={() => setPlayerJob(null)}
|
onClose={() => setPlayerJob(null)}
|
||||||
|
|
||||||
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
||||||
isFavorite={Boolean(playerModel?.favorite)}
|
isFavorite={Boolean(playerModel?.favorite)}
|
||||||
isLiked={playerModel?.liked === true}
|
isLiked={playerModel?.liked === true}
|
||||||
|
|
||||||
onDelete={handleDeleteJob}
|
onDelete={handleDeleteJob}
|
||||||
onToggleHot={handleToggleHot}
|
onToggleHot={handleToggleHot}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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