updated finished download

This commit is contained in:
unknown 2025-12-30 23:35:00 +01:00
parent bd6b2a50a6
commit 821fe0fef1
20 changed files with 1814 additions and 694 deletions

View File

@ -0,0 +1,219 @@
package main
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
}
func normUser(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func chaturbateUserFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
host := strings.ToLower(u.Hostname())
if !strings.Contains(host, "chaturbate.com") {
return ""
}
parts := strings.Split(u.Path, "/")
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
return normUser(p)
}
}
return ""
}
func cookieHeaderFromSettings(s RecorderSettings) string {
m, err := decryptCookieMap(s.EncryptedCookies)
if err != nil || len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
v := strings.TrimSpace(m[k])
if k == "" || v == "" {
continue
}
if i > 0 {
b.WriteString("; ")
}
b.WriteString(k)
b.WriteString("=")
b.WriteString(v)
}
return b.String()
}
func resolveChaturbateURL(m WatchedModelLite) string {
in := strings.TrimSpace(m.Input)
if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
return in
}
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
if key == "" {
return ""
}
return fmt.Sprintf("https://chaturbate.com/%s/", key)
}
// Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil {
fmt.Println("⚠️ [autostart] model store is nil")
return
}
const pollInterval = 5 * time.Second
const startGap = 5 * time.Second
const retryCooldown = 25 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastTry := map[string]time.Time{}
var lastStart time.Time
for {
s := getSettings()
// ✅ Autostart nur wenn Feature aktiviert ist
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)
if !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || !s.AutoAddToDownloadList {
queue = queue[:0]
queued = map[string]bool{}
time.Sleep(2 * time.Second)
continue
}
cookieHdr := cookieHeaderFromSettings(s)
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
if !hasChaturbateCookies(cookieHdr) {
time.Sleep(5 * time.Second)
continue
}
// online snapshot aus cache
cbMu.RLock()
rooms := append([]ChaturbateRoom(nil), cb.Rooms...)
cbMu.RUnlock()
showByUser := map[string]string{}
for _, r := range rooms {
showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
}
// running users (damit wir nicht doppelt starten)
running := map[string]bool{}
jobsMu.Lock()
for _, j := range jobs {
if j == nil || j.Status != JobRunning {
continue
}
u := chaturbateUserFromURL(j.SourceURL)
if u != "" {
running[u] = true
}
}
jobsMu.Unlock()
// watched list aus DB
watched := store.ListWatchedLite("chaturbate.com")
watchedByUser := map[string]WatchedModelLite{}
for _, m := range watched {
key := normUser(m.ModelKey)
if key != "" && m.Watching {
watchedByUser[key] = m
}
}
// queue prune
nextQueue := queue[:0]
nextQueued := map[string]bool{}
for _, it := range queue {
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
if showByUser[it.userKey] == "" {
continue
}
if running[it.userKey] {
continue
}
it.url = resolveChaturbateURL(m)
if it.url == "" {
continue
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
// enqueue new public watched
now := time.Now()
for user, m := range watchedByUser {
if showByUser[user] != "public" {
continue
}
if running[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
continue
}
u := resolveChaturbateURL(m)
if u == "" {
continue
}
queue = append(queue, autoStartItem{userKey: user, url: u})
queued[user] = true
}
// starte max. einen Job pro Loop (mit Abstand)
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
it := queue[0]
queue = queue[1:]
delete(queued, it.userKey)
lastTry[it.userKey] = time.Now()
_, err := startRecordingInternal(RecordRequest{
URL: it.url,
Cookie: cookieHdr,
})
if err != nil {
fmt.Println("❌ [autostart] start failed:", it.url, err)
} else {
fmt.Println("▶️ [autostart] started:", it.url)
lastStart = time.Now()
}
}
time.Sleep(pollInterval)
}
}

View File

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

View File

@ -50,6 +50,8 @@ type RecordJob struct {
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
@ -84,7 +86,7 @@ var durCache = struct {
m map[string]durEntry
}{m: map[string]durEntry{}}
func durationSecondsCached(path string) (float64, error) {
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
@ -98,7 +100,7 @@ func durationSecondsCached(path string) (float64, error) {
durCache.mu.Unlock()
// ffprobe (oder notfalls ffmpeg -i parsen)
cmd := exec.Command("ffprobe",
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
@ -133,6 +135,7 @@ type RecorderSettings struct {
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
BlurPreviews bool `json:"blurPreviews,omitempty"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies,omitempty"`
@ -149,6 +152,7 @@ var (
AutoStartAddedDownloads: false,
UseChaturbateAPI: false,
BlurPreviews: false,
EncryptedCookies: "",
}
settingsFile = "recorder_settings.json"
@ -1139,7 +1143,9 @@ func registerFrontend(mux *http.ServeMux) {
}
// routes.go (package main)
func registerRoutes(mux *http.ServeMux) {
func registerRoutes(mux *http.ServeMux) *ModelStore {
mux.HandleFunc("/api/cookies", cookiesHandler)
mux.HandleFunc("/api/settings", recordSettingsHandler)
mux.HandleFunc("/api/settings/browse", settingsBrowse)
@ -1150,9 +1156,11 @@ func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/record/list", recordList)
mux.HandleFunc("/api/record/video", recordVideo)
mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/record/keep", recordKeepVideo)
mux.HandleFunc("/api/record/duration", recordDuration)
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
@ -1169,6 +1177,8 @@ func registerRoutes(mux *http.ServeMux) {
// ✅ Frontend (SPA) ausliefern
registerFrontend(mux)
return store
}
// --- main ---
@ -1176,7 +1186,10 @@ func main() {
loadSettings()
mux := http.NewServeMux()
registerRoutes(mux)
store := registerRoutes(mux)
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil {
@ -1191,6 +1204,40 @@ type RecordRequest struct {
UserAgent string `json:"userAgent,omitempty"`
}
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL)
if url == "" {
return nil, errors.New("url fehlt")
}
// Duplicate-running guard (identische URL)
jobsMu.Lock()
for _, j := range jobs {
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
jobsMu.Unlock()
return j, nil
}
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: url,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req)
return job, nil
}
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
@ -1203,30 +1250,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
return
}
if req.URL == "" {
http.Error(w, "url fehlt", http.StatusBadRequest)
job, err := startRecordingInternal(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: req.URL,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobsMu.Lock()
jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
_ = json.NewEncoder(w).Encode(job)
}
func parseCookieString(cookieStr string) map[string]string {
@ -1429,8 +1460,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
serveVideoFile(w, r, outPath)
return
}
@ -1490,9 +1520,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
serveVideoFile(w, r, outPath)
}
func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
durCache.mu.Lock()
e, ok := durCache.m[path]
durCache.mu.Unlock()
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
return e.sec
}
return 0
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
@ -1550,7 +1589,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime()
dur, _ := durationSecondsCached(full)
dur := durationSecondsCacheOnly(full, fi)
list = append(list, &RecordJob{
ID: base,
@ -1559,6 +1598,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
StartedAt: t,
EndedAt: &t,
DurationSeconds: dur,
SizeBytes: fi.Size(),
})
}
@ -1572,6 +1612,155 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(list)
}
type doneMetaResp struct {
Count int `json:"count"`
}
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
entries, err := os.ReadDir(doneAbs)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
cnt := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
if ext != ".mp4" && ext != ".ts" {
continue
}
cnt++
}
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
}
type durationReq struct {
Files []string `json:"files"`
}
type durationItem struct {
File string `json:"file"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Error string `json:"error,omitempty"`
}
func recordDuration(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req durationReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Hard limit, damit niemand dir 5000 files schickt
if len(req.Files) > 200 {
http.Error(w, "too many files", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "failed to resolve done dir", http.StatusInternalServerError)
return
}
// De-dupe
seen := make(map[string]struct{}, len(req.Files))
files := make([]string, 0, len(req.Files))
for _, f := range req.Files {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
files = append(files, f)
}
// Server-side Concurrency Limit (z.B. 2-4)
sem := make(chan struct{}, 3)
out := make([]durationItem, len(files))
var wg sync.WaitGroup
for i, file := range files {
wg.Add(1)
go func(i int, file string) {
defer wg.Done()
// ✅ sanitize: nur basename erlauben
if filepath.Base(file) != file || strings.Contains(file, "/") || strings.Contains(file, "\\") {
out[i] = durationItem{File: file, Error: "invalid file"}
return
}
full := filepath.Join(doneAbs, file)
// Existiert?
fi, err := os.Stat(full)
if err != nil || fi.IsDir() {
out[i] = durationItem{File: file, Error: "not found"}
return
}
// Cache-hit? (spart ffprobe)
if sec := durationSecondsCacheOnly(full, fi); sec > 0 {
out[i] = durationItem{File: file, DurationSeconds: sec}
return
}
sem <- struct{}{}
defer func() { <-sem }()
sec, err := durationSecondsCached(r.Context(), full) // ctx-fähig, siehe unten
if err != nil || sec <= 0 {
out[i] = durationItem{File: file, Error: "ffprobe failed"}
return
}
out[i] = durationItem{File: file, DurationSeconds: sec}
}(i, file)
}
wg.Wait()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
@ -1636,8 +1825,8 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
}
if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
if isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
@ -1652,6 +1841,27 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
})
}
func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
// ServeContent unterstützt Range Requests (wichtig für Video)
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
@ -1890,33 +2100,31 @@ func moveFile(src, dst string) error {
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
func isSharingViolation(err error) bool {
if runtime.GOOS != "windows" {
return false
}
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
var pe *os.PathError
if errors.As(err, &pe) {
if errno, ok := pe.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
}
return errors.Is(pe.Err, windowsSharingViolation)
}
// Fallback über Text
s := strings.ToLower(err.Error())
return strings.Contains(s, "sharing violation") ||
strings.Contains(s, "used by another process") ||
strings.Contains(s, "wird von einem anderen prozess verwendet")
}
var le *os.LinkError
if errors.As(err, &le) {
if errno, ok := le.Err.(syscall.Errno); ok {
return errno == windowsSharingViolation
}
return errors.Is(le.Err, windowsSharingViolation)
}
return errors.Is(err, windowsSharingViolation)
}
func renameWithRetry(src, dst string) error {
func removeWithRetry(path string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Rename(src, dst)
for i := 0; i < 40; i++ { // ~4s bei 100ms
err = os.Remove(path)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
@ -1925,14 +2133,14 @@ func renameWithRetry(src, dst string) error {
return err
}
func removeWithRetry(path string) error {
func renameWithRetry(oldPath, newPath string) error {
var err error
for i := 0; i < 15; i++ { // ~1.5s
err = os.Remove(path)
for i := 0; i < 40; i++ {
err = os.Rename(oldPath, newPath)
if err == nil {
return nil
}
if runtime.GOOS == "windows" && isSharingViolation(err) {
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
@ -2029,8 +2237,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
}
}
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
w.Write([]byte(`{"ok":"stopped"}`))
}
@ -2076,8 +2282,6 @@ func RecordStream(
}
}
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
@ -2087,8 +2291,6 @@ func RecordStream(
_ = file.Close()
}()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben

Binary file not shown.

View File

@ -0,0 +1,9 @@
//go:build !windows
package main
import "os"
func openForReadShareDelete(path string) (*os.File, error) {
return os.Open(path)
}

View File

@ -0,0 +1,31 @@
//go:build windows
package main
import (
"os"
"syscall"
)
func openForReadShareDelete(path string) (*os.File, error) {
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
// Wichtig: FILE_SHARE_DELETE erlaubt Rename/Move/Delete während Lesen/Streaming
h, err := syscall.CreateFile(
p,
syscall.GENERIC_READ,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(h), path), nil
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
<script type="module" crossorigin src="/assets/index-wVqrTYvi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CIN0UidG.css">
</head>
<body>
<div id="root"></div>

View File

@ -38,6 +38,7 @@ type RecorderSettings = {
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
blurPreviews?: boolean
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -47,6 +48,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
useChaturbateApi: false,
blurPreviews: false,
}
type StoredModel = {
@ -59,29 +61,6 @@ type StoredModel = {
liked?: boolean | null
}
type ChaturbateRoom = {
username: string
current_show?: 'public' | 'private' | 'hidden' | 'away' | string
}
type ChaturbateOnlineResponse = {
enabled: boolean
fetchedAt?: string
lastError?: string
count?: number
rooms: ChaturbateRoom[]
}
type PendingWatchedRoom = {
id: string
modelKey: string
url: string
currentShow: string
}
const sleep = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms))
function extractFirstHttpUrl(text: string): string | null {
const t = (text ?? '').trim()
if (!t) return null
@ -132,11 +111,11 @@ export default function App() {
const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const watchedModelsRef = useRef<StoredModel[]>([])
const [, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false)
@ -148,12 +127,6 @@ export default function App() {
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
// ✅ Watched+Online (wartend) + Autostart-Queue
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
const autoStartQueueRef = useRef<Array<{ userKey: string; url: string }>>([])
const autoStartQueuedUsersRef = useRef<Set<string>>(new Set())
const autoStartWorkerRef = useRef(false)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
@ -223,39 +196,6 @@ export default function App() {
}
}, [])
// ✅ 2) Watched-Chaturbate-Models (kleine Payload) nur für den Online-Abgleich/Autostart
useEffect(() => {
if (!recSettings.useChaturbateApi) {
watchedModelsRef.current = []
return
}
let cancelled = false
let inFlight = false
const load = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<StoredModel[]>('/api/models/watched?host=chaturbate.com', { cache: 'no-store' })
if (cancelled) return
watchedModelsRef.current = Array.isArray(list) ? list : []
} catch {
if (!cancelled) watchedModelsRef.current = []
} finally {
inFlight = false
}
}
load()
const t = window.setInterval(load, document.hidden ? 30000 : 10000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
const initialCookies = useMemo(
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
[cookies]
@ -269,8 +209,8 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running')
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length },
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount },
{ id: 'settings', label: 'Einstellungen' },
]
@ -332,6 +272,41 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded])
useEffect(() => {
let cancelled = false
let t: number | undefined
const loadDoneMeta = async () => {
try {
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
if (!res.ok) return
const meta = (await res.json()) as { count?: number }
if (!cancelled) setDoneCount(meta.count ?? 0)
} catch {
// ignore
} finally {
if (!cancelled) {
// wenn Tab nicht aktiv/Seite im Hintergrund: weniger oft
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneMeta, ms)
}
}
}
const onVis = () => {
if (!document.hidden) void loadDoneMeta()
}
document.addEventListener('visibilitychange', onVis)
void loadDoneMeta()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [])
useEffect(() => {
if (sourceUrl.trim() === '') {
setParsed(null)
@ -386,19 +361,47 @@ export default function App() {
}, [])
useEffect(() => {
// ✅ nur pollen, wenn Finished-Tab aktiv ist
if (selectedTab !== 'finished') return
let cancelled = false
let inFlight = false
const loadDone = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/done')
setDoneJobs(Array.isArray(list) ? list : [])
const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
} catch {
setDoneJobs([])
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
if (!cancelled) setDoneJobs([])
} finally {
inFlight = false
}
}
// beim Betreten des Tabs einmal sofort laden
loadDone()
const t = setInterval(loadDone, 5000)
return () => clearInterval(t)
}, [])
// ✅ weniger aggressiv pollen
const baseMs = 20000 // 20s
const tickMs = document.hidden ? 60000 : baseMs
const t = window.setInterval(loadDone, tickMs)
// ✅ wenn Tab wieder sichtbar wird: direkt refresh
const onVis = () => {
if (!document.hidden) void loadDone()
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab])
function isChaturbate(url: string): boolean {
try {
@ -602,210 +605,49 @@ export default function App() {
})
}
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
const handleToggleFavorite = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return
const next = !Boolean(m.favorite)
const updated = await patchModelFlags({ id: m.id, favorite: next })
setPlayerModel(updated)
const updated = await patchModelFlags({
id: m.id,
favorite: next,
...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
})
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
},
[playerJob, playerModel]
)
const handleToggleLike = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
const handleToggleLike = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) return
const next = !(m.liked === true)
const updated = await patchModelFlags({ id: m.id, liked: next })
const curLiked = m.liked === true
const updated = curLiked
? await patchModelFlags({ id: m.id, clearLiked: true }) // ✅ aus
: await patchModelFlags({ id: m.id, liked: true, favorite: false }) // ✅ an + fav aus
setPlayerModel(updated)
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const normUser = (s: string) => (s || '').trim().toLowerCase()
const chaturbateUserFromUrl = (u: string): string | null => {
try {
const url = new URL(u)
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null
const parts = url.pathname.split('/').filter(Boolean)
return parts[0] ? normUser(parts[0]) : null
} catch {
return null
}
}
// ✅ 1) Poll: alle watched+online Models als "wartend" anzeigen (public/private/hidden/away)
// und public-Models in eine Start-Queue legen
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setPendingWatchedRooms([])
autoStartQueueRef.current = []
autoStartQueuedUsersRef.current = new Set()
return
}
let cancelled = false
let inFlight = false
const poll = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const canAutoStart = hasRequiredChaturbateCookies(cookiesRef.current)
const modelsList = watchedModelsRef.current
const online = await apiJSON<ChaturbateOnlineResponse>('/api/chaturbate/online', { cache: 'no-store' })
if (!online?.enabled) return
// online username -> show
const showByUser = new Map<string, string>()
for (const r of online.rooms ?? []) {
showByUser.set(normUser(r.username), String(r.current_show || 'unknown').toLowerCase())
}
// running username set (damit wir nichts doppelt starten/anzeigen)
const runningUsers = new Set(
jobsRef.current
.filter((j) => j.status === 'running')
.map((j) => chaturbateUserFromUrl(String(j.sourceUrl || '')))
.filter(Boolean) as string[]
},
[playerJob, playerModel]
)
// watched username set
const watchedModels = (modelsList ?? []).filter(
(m) => Boolean(m?.watching) && (
String(m?.host || '').toLowerCase().includes('chaturbate.com') || isChaturbate(String(m?.input || ''))
)
)
const watchedUsers = new Set(watchedModels.map((m) => normUser(m.modelKey)).filter(Boolean))
// ✅ Queue aufräumen: raus, wenn nicht mehr watched, offline oder schon running
{
const nextQueue: Array<{ userKey: string; url: string }> = []
for (const q of autoStartQueueRef.current) {
if (!watchedUsers.has(q.userKey)) continue
if (!showByUser.has(q.userKey)) continue
if (runningUsers.has(q.userKey)) continue
nextQueue.push(q)
}
autoStartQueueRef.current = nextQueue
autoStartQueuedUsersRef.current = new Set(nextQueue.map((q) => q.userKey))
}
// ✅ Pending Map: alle watched+online, die NICHT running sind
const pendingMap = new Map<string, PendingWatchedRoom>()
for (const m of watchedModels) {
const key = normUser(m.modelKey)
if (!key) continue
const currentShow = showByUser.get(key)
if (!currentShow) continue // offline -> nicht pending
// running -> nicht pending (steht ja in Jobs)
if (runningUsers.has(key)) continue
const url = /^https?:\/\//i.test(m.input || '')
? String(m.input).trim()
: `https://chaturbate.com/${m.modelKey}/`
// ✅ erst mal ALLE watched+online als wartend anzeigen (auch public)
if (!pendingMap.has(key)) {
pendingMap.set(key, { id: m.id, modelKey: m.modelKey, url, currentShow })
}
// ✅ public in Queue (wenn Cookies da), aber ohne Duplikate
if (currentShow === 'public' && canAutoStart && !autoStartQueuedUsersRef.current.has(key)) {
autoStartQueueRef.current.push({ userKey: key, url })
autoStartQueuedUsersRef.current.add(key)
}
}
if (!cancelled) setPendingWatchedRooms([...pendingMap.values()])
} catch {
// silent
} finally {
inFlight = false
}
}
poll()
const t = window.setInterval(poll, document.hidden ? 15000 : 5000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
// ✅ 2) Worker: startet Queue nacheinander (5s Pause nach jedem Start)
useEffect(() => {
if (!recSettings.useChaturbateApi) return
let cancelled = false
const loop = async () => {
if (autoStartWorkerRef.current) return
autoStartWorkerRef.current = true
try {
while (!cancelled) {
// wenn UI gerade manuell startet -> warten
if (busyRef.current) {
await sleep(500)
continue
}
const next = autoStartQueueRef.current.shift()
if (!next) {
await sleep(1000)
continue
}
// aus queued-set entfernen (damit Poll ggf. neu einreihen kann, falls Start nicht klappt)
autoStartQueuedUsersRef.current.delete(next.userKey)
// start attempt (silent)
const ok = await startUrl(next.url, { silent: true })
if (ok) {
// pending sofort rausnehmen, damit UI direkt "running" zeigt
setPendingWatchedRooms((prev) => prev.filter((p) => normUser(p.modelKey) !== next.userKey))
}
// ✅ 5s Abstand nach (erfolgreichem) Starten ich warte auch bei failure,
// damit wir nicht in eine schnelle Retry-Schleife laufen.
if (ok) {
await sleep(5000)
} else {
await sleep(5000)
}
}
} finally {
autoStartWorkerRef.current = false
}
}
void loop()
return () => {
cancelled = true
}
}, [recSettings.useChaturbateApi, startUrl])
useEffect(() => {
if (!autoAddEnabled && !autoStartEnabled) return
if (!navigator.clipboard?.readText) return
@ -933,9 +775,9 @@ export default function App() {
{selectedTab === 'running' && (
<RunningDownloads
jobs={runningJobs}
pending={pendingWatchedRooms}
onOpenPlayer={openPlayer}
onStopJob={stopJob}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
)}
@ -944,6 +786,11 @@ export default function App() {
jobs={jobs}
doneJobs={doneJobs}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
)}
@ -977,13 +824,11 @@ export default function App() {
<Player
job={playerJob}
expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((v) => !v)}
onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => setPlayerJob(null)}
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true}
onDelete={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}

View File

@ -16,16 +16,29 @@ import {
RectangleStackIcon,
Squares2X2Icon,
TrashIcon,
FireIcon,
EllipsisVerticalIcon,
BookmarkSquareIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
} from '@heroicons/react/24/outline'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
} from '@heroicons/react/24/solid'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
blurPreviews?: boolean
onOpenPlayer: (job: RecordJob) => void
onDeleteJob?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
}
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
@ -36,6 +49,10 @@ const baseName = (p: string) => {
}
const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
@ -47,6 +64,20 @@ function formatDuration(ms: number): string {
return `${s}s`
}
function formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
// Fallback: reine Aufnahmezeit aus startedAt/endedAt
function runtimeFromTimestamps(job: RecordJob): string {
const start = Date.parse(String(job.startedAt || ''))
@ -55,6 +86,26 @@ function runtimeFromTimestamps(job: RecordJob): string {
return formatDuration(end - start)
}
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches)
onChange()
if (mql.addEventListener) mql.addEventListener('change', onChange)
else mql.addListener(onChange)
return () => {
if (mql.removeEventListener) mql.removeEventListener('change', onChange)
else mql.removeListener(onChange)
}
}, [query])
return matches
}
const httpCodeFromError = (err?: string) => {
const m = (err ?? '').match(/\bHTTP\s+(\d{3})\b/i)
return m ? `HTTP ${m[1]}` : null
@ -75,8 +126,38 @@ const modelNameFromOutput = (output?: string) => {
return i > 0 ? stem.slice(0, i) : stem
}
type StoredModelFlags = {
id: string
modelKey: string
favorite?: boolean
liked?: boolean | null
}
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
const lower = (s: string) => (s || '').trim().toLowerCase()
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v =
anyJob.sizeBytes ??
anyJob.fileSizeBytes ??
anyJob.bytes ??
anyJob.size ??
null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
export default function FinishedDownloads({
jobs,
doneJobs,
blurPreviews,
onOpenPlayer,
onDeleteJob,
onToggleHot,
onToggleFavorite,
onToggleLike,
}: Props) {
const PAGE_SIZE = 50
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
@ -90,10 +171,78 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
type ViewMode = 'table' | 'cards' | 'gallery'
const VIEW_KEY = 'finishedDownloads_view'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'model_asc'
| 'model_desc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
const SORT_KEY = 'finishedDownloads_sort'
const [sortMode, setSortMode] = React.useState<SortMode>('completed_desc')
React.useEffect(() => {
try {
const v = window.localStorage.getItem(SORT_KEY) as SortMode | null
if (v) setSortMode(v)
} catch {}
}, [])
React.useEffect(() => {
try {
window.localStorage.setItem(SORT_KEY, sortMode)
} catch {}
}, [sortMode])
const [view, setView] = React.useState<ViewMode>('table')
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
// ⭐ Models-Flags (Fav/Like) aus Backend-Store
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({})
const refreshModelsByKey = React.useCallback(async () => {
try {
const res = await fetch('/api/models/list', { cache: 'no-store' as any })
if (!res.ok) return
const list = (await res.json()) as StoredModelFlags[]
const map: Record<string, StoredModelFlags> = {}
for (const m of Array.isArray(list) ? list : []) {
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
// wenn mehrere Hosts etc.: bevorzuge Eintrag mit “mehr Signal”
const cur = map[k]
if (!cur) {
map[k] = m
continue
}
const score = (x: StoredModelFlags) => (x.favorite ? 2 : 0) + (x.liked === true ? 1 : 0)
if (score(m) > score(cur)) map[k] = m
}
setModelsByKey(map)
} catch {
// optional: console.debug(...)
}
}, [])
React.useEffect(() => {
void refreshModelsByKey()
}, [refreshModelsByKey])
React.useEffect(() => {
const onChanged = () => void refreshModelsByKey()
window.addEventListener('models-changed', onChanged as any)
return () => window.removeEventListener('models-changed', onChanged as any)
}, [refreshModelsByKey])
React.useEffect(() => {
try {
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
@ -117,6 +266,31 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({})
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
const tryAutoplayInline = React.useCallback((domId: string) => {
const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false
v.muted = true
v.playsInline = true
v.setAttribute('playsinline', 'true')
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
return true
}, [])
const startInline = React.useCallback((key: string) => {
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
}, [])
const openPlayer = React.useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
const openCtx = (job: RecordJob, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
@ -178,6 +352,17 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}, 320)
}, [markDeleted, markRemoving])
const releasePlayingFile = React.useCallback(
async (file: string, opts?: { close?: boolean }) => {
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
}
await new Promise((r) => window.setTimeout(r, 250))
},
[]
)
const deleteVideo = React.useCallback(
async (job: RecordJob): Promise<boolean> => {
const file = baseName(job.output || '')
@ -191,11 +376,25 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
markDeleting(key, true)
try {
await releasePlayingFile(file, { close: true })
// ✅ Wenn App-Handler vorhanden: den benutzen (inkl. Events + State-Update)
if (onDeleteJob) {
await onDeleteJob(job)
// ✅ optional: sofort aus der Liste animieren (fühlt sich besser an)
animateRemove(key)
return true
}
// Fallback (falls mal ohne App-Handler verwendet)
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
animateRemove(key)
return true
} catch (e: any) {
@ -205,7 +404,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
markDeleting(key, false)
}
},
[deletingKeys, markDeleting, animateRemove]
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove]
)
const keepVideo = React.useCallback(
@ -221,6 +420,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
markKeeping(key, true)
try {
await releasePlayingFile(file, { close: true })
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
@ -237,7 +437,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
markKeeping(key, false)
}
},
[keepingKeys, deletingKeys, markKeeping, animateRemove]
[keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove]
)
const items = React.useMemo<ContextMenuItem[]>(() => {
@ -313,6 +513,78 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return list
}, [jobs, doneJobs, deletedKeys])
const endedAtMs = (j: RecordJob) => (j.endedAt ? new Date(j.endedAt).getTime() : 0)
const modelForSort = (j: RecordJob) => modelNameFromOutput(j.output || '').toLowerCase()
const fileForSort = (j: RecordJob) => {
const raw = baseName(j.output || '').toLowerCase()
return stripHotPrefix(raw)
}
const durationSecondsForSort = (j: RecordJob) => {
const k = keyFor(j)
const s =
(typeof (j as any).durationSeconds === 'number' && (j as any).durationSeconds > 0)
? (j as any).durationSeconds
: durations[k]
return typeof s === 'number' && Number.isFinite(s) && s > 0 ? s : NaN
}
const sizeBytesForSort = (j: RecordJob) => {
const s = sizeBytesOf(j)
return typeof s === 'number' ? s : NaN
}
const cmpStr = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
const cmpNum = (a: number, b: number) => a - b
const cmpMaybeNum = (a: number, b: number, dir: 1 | -1) => {
const aOk = Number.isFinite(a)
const bOk = Number.isFinite(b)
if (!aOk && !bOk) return 0
if (!aOk) return 1
if (!bOk) return -1
return dir * cmpNum(a, b)
}
const sortedNonTableRows = React.useMemo(() => {
const arr = [...rows]
arr.sort((a, b) => {
switch (sortMode) {
case 'completed_asc':
return cmpNum(endedAtMs(a), endedAtMs(b)) || cmpStr(keyFor(a), keyFor(b))
case 'completed_desc':
return cmpNum(endedAtMs(b), endedAtMs(a)) || cmpStr(keyFor(a), keyFor(b))
case 'model_asc':
return cmpStr(modelForSort(a), modelForSort(b)) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'model_desc':
return cmpStr(modelForSort(b), modelForSort(a)) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'file_asc':
return cmpStr(fileForSort(a), fileForSort(b)) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'file_desc':
return cmpStr(fileForSort(b), fileForSort(a)) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'duration_asc':
return cmpMaybeNum(durationSecondsForSort(a), durationSecondsForSort(b), 1) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'duration_desc':
return cmpMaybeNum(durationSecondsForSort(a), durationSecondsForSort(b), -1) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'size_asc':
return cmpMaybeNum(sizeBytesForSort(a), sizeBytesForSort(b), 1) || cmpNum(endedAtMs(b), endedAtMs(a))
case 'size_desc':
return cmpMaybeNum(sizeBytesForSort(a), sizeBytesForSort(b), -1) || cmpNum(endedAtMs(b), endedAtMs(a))
default:
return cmpNum(endedAtMs(b), endedAtMs(a))
}
})
return arr
}, [rows, sortMode, durations])
React.useEffect(() => {
setVisibleCount(PAGE_SIZE)
}, [rows.length])
@ -355,7 +627,71 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view])
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
const viewRows = view === 'table' ? rows : sortedNonTableRows
const visibleRows = viewRows
.filter((j) => !deletedKeys.has(keyFor(j)))
.slice(0, visibleCount)
const requestedDurationsRef = React.useRef<Set<string>>(new Set())
React.useEffect(() => {
const wantsRuntimeSort = view === 'table' && sort?.key === 'runtime'
if (!wantsRuntimeSort) return
const missing: string[] = []
for (const j of rows) {
const file = baseName(j.output || '')
if (!file) continue
// bereits bekannt?
const k = keyFor(j)
const sec =
(typeof (j as any).durationSeconds === 'number' && (j as any).durationSeconds > 0)
? (j as any).durationSeconds
: durations[k]
if (typeof sec === 'number' && sec > 0) continue
if (requestedDurationsRef.current.has(file)) continue
requestedDurationsRef.current.add(file)
missing.push(file)
}
if (missing.length === 0) return
const ctrl = new AbortController()
;(async () => {
const BATCH = 25
for (let i = 0; i < missing.length; i += BATCH) {
const batch = missing.slice(i, i + BATCH)
const res = await fetch('/api/record/duration', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ files: batch }),
signal: ctrl.signal,
})
if (!res.ok) break
const data: Array<{ file: string; durationSeconds?: number }> = await res.json()
setDurations((prev) => {
const next = { ...prev }
for (const it of data) {
if (it?.file && typeof it.durationSeconds === 'number' && it.durationSeconds > 0) {
next[it.file] = it.durationSeconds
}
}
return next
})
}
})().catch(() => {})
return () => ctrl.abort()
}, [view, sort?.key, rows]) // absichtlich NICHT durations als dep
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => {
@ -411,6 +747,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
/>
</div>
)
@ -506,6 +843,21 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
key: 'size',
header: 'Größe',
align: 'right',
sortable: true,
sortValue: (j) => {
const s = sizeBytesOf(j)
return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY
},
cell: (j) => (
<span className="font-medium text-gray-900 dark:text-white">
{formatBytes(sizeBytesOf(j))}
</span>
),
},
{
key: 'actions',
header: 'Aktionen',
@ -572,8 +924,18 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
</div>
)
},
},
]
}]
// ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)')
React.useEffect(() => {
if (!isSmall) {
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
swipeRefs.current = new Map()
}
}, [isSmall])
if (rows.length === 0) {
return (
@ -627,16 +989,46 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
</div>
</div>
{view !== 'table' && (
<div className="ml-2">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
<select
id="finished-sort"
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-white/5 dark:text-white"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="model_asc">Modelname AZ</option>
<option value="model_desc">Modelname ZA</option>
<option value="file_asc">Dateiname AZ</option>
<option value="file_desc">Dateiname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
)}
{/* ✅ Cards */}
{view === 'cards' && (
<div className="space-y-3">
{visibleRows.map((j) => {
const k = keyFor(j)
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const statusNode =
j.status === 'failed' ? (
@ -647,19 +1039,9 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
<span className="font-medium">{j.status}</span>
)
return (
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
onTap={() => onOpenPlayer(j)}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const cardInner = (
<div
role="button"
tabIndex={0}
@ -674,6 +1056,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
]
.filter(Boolean)
.join(' ')}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
@ -681,7 +1064,16 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
>
<Card noBodyPadding className="overflow-hidden">
{/* Preview */}
<div className="relative aspect-video bg-black/5 dark:bg-white/5">
<div
id={inlineDomId}
className="relative aspect-video bg-black/5 dark:bg-white/5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall) return // ✅ Mobile: SwipeCard-onTap macht das
startInline(k) // ✅ Desktop: Click startet inline
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
@ -689,13 +1081,35 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
onDuration={handleDuration}
className="w-full h-full"
showPopover={false}
blur={blurPreviews}
animated
animatedMode="clips"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
/>
{/* dunkler Verlauf unten für Text */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent" />
{/* Gradient overlay bottom */}
<div
className={[
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
/>
{/* Overlay bottom */}
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
<div
className={[
'pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{model}</div>
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(file) || '—'}</div>
@ -707,12 +1121,25 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
HOT
</span>
) : null}
<span className="rounded-md bg-black/40 px-2 py-1 text-[11px] font-semibold text-white">
{dur}
</span>
</div>
</div>
{!isSmall && inlinePlay?.key === k && (
<button
type="button"
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setInlinePlay((prev) => ({ key: k, nonce: (prev?.key === k ? prev.nonce + 1 : 1) }))
}}
title="Von vorne starten"
aria-label="Von vorne starten"
>
</button>
)}
{/* Actions top-right */}
<div className="absolute right-2 top-2 flex items-center gap-2">
{(() => {
@ -720,26 +1147,36 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const modelKey = modelNameFromOutput(j.output)
const flags = modelsByKey[lower(modelKey)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
return (
<>
{!isSmall && (
<>
{/* Keep */}
<button
type="button"
className={iconBtn}
title="Behalten"
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) void h.swipeRight()
else void keepVideo(j)
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
{/* Delete */}
<button
type="button"
className={iconBtn}
@ -750,14 +1187,75 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) void h.swipeLeft()
else void deleteVideo(j)
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)}
{/* HOT */}
<button
type="button"
className={iconBtn}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy || !onToggleHot}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
// wichtig gegen File-Lock beim Rename:
releasePlayingFile(fileRaw, { close: true })
await new Promise((r) => setTimeout(r, 150))
await onToggleHot?.(j)
}}
>
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
</button>
{/* Favorite */}
<button
type="button"
className={iconBtn}
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
disabled={busy || !onToggleFavorite}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await onToggleFavorite?.(j)
}}
>
{(() => {
const Icon = isFav ? StarSolidIcon : StarOutlineIcon
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} />
})()}
</button>
{/* Like */}
<button
type="button"
className={iconBtn}
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
disabled={busy || !onToggleLike}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await onToggleLike?.(j)
}}
>
{(() => {
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
})()}
</button>
{/* Menu */}
<button
type="button"
className={iconBtn}
@ -786,6 +1284,8 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
Status: {statusNode}
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
<span className="mx-2 opacity-60"></span>
Größe: <span className="font-medium">{size}</span>
</div>
</div>
@ -797,8 +1297,40 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
</div>
</Card>
</div>
</SwipeCard>
)
// ✅ Mobile: SwipeCard, Desktop: normale Card
return isSmall ? (
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
ignoreFromBottomPx={110}
onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}`
// ✅ State sofort committen (damit Video direkt im DOM ist)
flushSync(() => startInline(k))
// ✅ direkt versuchen (innerhalb des Tap-Tasks)
if (!tryAutoplayInline(domId)) {
// Fallback: nächster Frame (falls Video erst im Commit auftaucht)
requestAnimationFrame(() => tryAutoplayInline(domId))
}
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
) : (
<React.Fragment key={k}>{cardInner}</React.Fragment>
)
})}
</div>
)}
@ -839,6 +1371,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
@ -869,7 +1402,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
>
{/* Thumb */}
<div
className="relative aspect-video bg-black/5 dark:bg-white/5"
className="group relative aspect-video bg-black/5 dark:bg-white/5"
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
@ -883,18 +1416,40 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
onDuration={handleDuration}
variant="fill"
showPopover={false}
inlineVideo="hover"
blur={blurPreviews}
animated
animatedMode="clips"
animatedTrigger="hover"
clipSeconds={1}
thumbSamples={18}
/>
{/* Gradient overlay bottom */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/65 to-transparent" />
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 h-16
bg-gradient-to-t from-black/65 to-transparent
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
/>
{/* Bottom text */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
>
<div className="truncate text-xs font-semibold">{model}</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className="truncate">{file || '—'}</span>
<span className="shrink-0 rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
<div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
</div>
</div>
</div>

View File

@ -6,30 +6,49 @@ import HoverPopover from './HoverPopover'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips'
type AnimatedTrigger = 'always' | 'hover'
type Props = {
export type FinishedVideoPreviewProps = {
job: RecordJob
getFileName: (path: string) => string
durationSeconds?: number
onDuration?: (job: RecordJob, seconds: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
animated?: boolean
animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger
/** nur für frames */
autoTickMs?: number
thumbStepSec?: number
thumbSpread?: boolean
thumbSamples?: number
/** nur für clips */
clipSeconds?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant
/** optionales Zusatz-Styling */
className?: string
showPopover?: boolean
blur?: boolean
/**
* inline video:
* - false: nur Bild
* - false: nur Bild/Teaser
* - true/'always': immer inline abspielen (wenn inView)
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
* - 'hover': nur bei Hover/Focus abspielen, sonst Bild
*/
inlineVideo?: InlineVideoMode
/** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */
inlineNonce?: number
/** Inline-Playback: Controls anzeigen? */
inlineControls?: boolean
/** Inline-Playback: loopen? */
inlineLoop?: boolean
}
export default function FinishedVideoPreview({
@ -37,46 +56,51 @@ export default function FinishedVideoPreview({
getFileName,
durationSeconds,
onDuration,
animated = false,
animatedMode = 'frames',
animatedTrigger = 'always',
autoTickMs = 15000,
thumbStepSec,
thumbSpread,
thumbSamples,
clipSeconds = 1,
variant = 'thumb',
className,
showPopover = true,
blur = false,
inlineVideo = false,
}: Props) {
inlineNonce = 0,
inlineControls = false,
inlineLoop = true,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
// ✅ nur animieren, wenn sichtbar (Viewport)
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
// Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0)
// ✅ für hover-play
// Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!animated) return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, inView, autoTickMs])
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
const previewId = useMemo(() => {
if (!file) return ''
@ -92,25 +116,62 @@ export default function FinishedVideoPreview({
const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// --- Tick für "frames"
useEffect(() => {
if (!animated) return
if (animatedMode !== 'frames') return
if (!inView || document.hidden) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs])
// --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => {
if (!animated) return null
if (animatedMode !== 'frames') return null
if (!hasDuration) return null
const step = 3
const total = Math.max(durationSeconds! - 0.1, step)
return (localTick * step) % total
}, [animated, hasDuration, durationSeconds, localTick])
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
const dur = durationSeconds!
const step = Math.max(0.25, thumbStepSec ?? 3)
if (thumbSpread) {
const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur)))
const idx = localTick % count
const span = Math.max(0.1, dur - step)
const base = Math.min(0.25, span * 0.02)
const t = (idx / count) * span + base
return Math.min(dur - 0.05, Math.max(0.05, t))
}
const total = Math.max(dur - 0.1, step)
const t = (localTick * step) % total
return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
const thumbSrc = useMemo(() => {
if (!previewId) return ''
if (thumbTimeSec == null) {
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
}
// static thumb (oder frames: mit t=...)
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
thumbTimeSec.toFixed(2)
)}`
}, [previewId, thumbTimeSec])
)}&v=${encodeURIComponent(String(localTick))}`
}, [previewId, thumbTimeSec, localTick])
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
@ -120,24 +181,105 @@ export default function FinishedVideoPreview({
}
if (!videoSrc) {
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' &&
inView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
// --- Teaser Clip Zeiten (nur clips)
const clipTimes = useMemo(() => {
if (!animated) return []
if (animatedMode !== 'clips') return []
if (!hasDuration) return []
const dur = durationSeconds!
const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
const span = Math.max(0.1, dur - clipLen)
const base = Math.min(0.25, span * 0.02)
const times: number[] = []
for (let i = 0; i < count; i++) {
const t = (i / count) * span + base
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
}
return times
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
// --- Teaser aktiv? (nur inView, nicht inline, optional nur hover)
const teaserActive =
animated &&
animatedMode === 'clips' &&
inView &&
!document.hidden &&
videoOk &&
clipTimes.length > 0 &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered)
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover')
// --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop)
const teaserRef = useRef<HTMLVideoElement | null>(null)
const clipIdxRef = useRef(0)
const clipStartRef = useRef(0)
useEffect(() => {
const v = teaserRef.current
if (!v) return
if (!teaserActive) {
v.pause()
return
}
if (!clipTimes.length) return
clipIdxRef.current = clipIdxRef.current % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
const start = () => {
try {
v.currentTime = clipStartRef.current
} catch {}
const p = v.play()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}
const onLoaded = () => start()
const onTimeUpdate = () => {
if (!clipTimes.length) return
if (v.currentTime - clipStartRef.current >= clipSeconds) {
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
try {
v.currentTime = clipStartRef.current + 0.01
} catch {}
}
}
v.addEventListener('loadedmetadata', onLoaded)
v.addEventListener('timeupdate', onTimeUpdate)
// Wenn metadata schon da ist:
if (v.readyState >= 1) start()
return () => {
v.removeEventListener('loadedmetadata', onLoaded)
v.removeEventListener('timeupdate', onTimeUpdate)
v.pause()
}
}, [teaserActive, clipTimesKey, clipSeconds])
const previewNode = (
<div
@ -147,39 +289,59 @@ export default function FinishedVideoPreview({
sizeClass,
className ?? '',
].join(' ')}
// ✅ hover only relevant for inlineMode==='hover'
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined}
>
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
{/* 1) Inline Full Video (mit Controls) */}
{showingInlineVideo ? (
<video
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc}
className="w-full h-full object-cover bg-black pointer-events-none"
className={[
'w-full h-full object-cover bg-black',
blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"
autoPlay
loop
controls={inlineControls}
loop={inlineLoop}
poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : teaserActive ? (
/* 2) Teaser Clips (1s Segmente) */
<video
ref={teaserRef}
key={`teaser-${previewId}-${clipTimesKey}`}
src={videoSrc}
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"
poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : thumbSrc && thumbOk ? (
/* 3) Statisches Bild / Frames */
<img
src={thumbSrc}
loading="lazy"
alt={file}
className="w-full h-full object-cover"
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video
src={videoSrc}
@ -193,7 +355,7 @@ export default function FinishedVideoPreview({
</div>
)
// Gallery: kein HoverPopover
// Gallery: kein HoverPopover
if (!showPopover) return previewNode
return (
@ -204,7 +366,7 @@ export default function FinishedVideoPreview({
<div className="aspect-video">
<video
src={videoSrc}
className="w-full h-full bg-black"
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"

View File

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

View File

@ -4,6 +4,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
jobId: string
@ -12,9 +13,11 @@ type Props = {
thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number
blur?: boolean
}
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: Props) {
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blur = false }: Props) {
const blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
@ -75,15 +78,32 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
return (
<HoverPopover
content={(open) =>
content={(open, { close }) =>
open && (
<div className="w-[420px]">
<div className="aspect-video">
<LiveHlsVideo
src={hq}
muted={false}
className="w-full h-full bg-black"
/>
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
{/* LIVE badge */}
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
{/* Close */}
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
aria-label="Live-Vorschau schließen"
title="Vorschau schließen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
close()
}}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
)
@ -98,7 +118,7 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
src={thumb}
loading="lazy"
alt=""
className="w-full h-full object-cover"
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setImgError(true)}
onLoad={() => setImgError(false)}
/>

View File

@ -199,12 +199,50 @@ export default function Player({
try {
p.pause()
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
// video.js hat reset() -> stoppt Tech + Requests oft zuverlässiger
;(p as any).reset?.()
} catch {}
try {
// Source leeren, damit Browser/Tech die HTTP-Verbindung abbricht
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => {
const onRelease = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim()
if (!file) return
const current = baseName(job.output?.trim() || '')
if (current && current === file) {
releaseMedia()
}
}
window.addEventListener('player:release', onRelease as EventListener)
return () => window.removeEventListener('player:release', onRelease as EventListener)
}, [job.output, releaseMedia])
React.useEffect(() => {
const onCloseIfFile = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim()
if (!file) return
const current = baseName(job.output?.trim() || '')
if (current && current === file) {
releaseMedia()
onClose()
}
}
window.addEventListener('player:close', onCloseIfFile as EventListener)
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
}, [job.output, releaseMedia, onClose])
const mini = !expanded
const [miniHover, setMiniHover] = React.useState(false)

View File

@ -17,6 +17,7 @@ type RecorderSettings = {
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean
blurPreviews?: boolean
}
const DEFAULTS: RecorderSettings = {
@ -30,6 +31,7 @@ const DEFAULTS: RecorderSettings = {
autoStartAddedDownloads: true,
useChaturbateApi: false,
blurPreviews: false,
}
export default function RecorderSettings() {
@ -59,6 +61,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
})
})
.catch(() => {
@ -117,6 +120,7 @@ export default function RecorderSettings() {
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi
const blurPreviews = !!value.blurPreviews
setSaving(true)
try {
@ -131,6 +135,7 @@ export default function RecorderSettings() {
autoStartAddedDownloads,
useChaturbateApi,
blurPreviews,
}),
})
if (!res.ok) {
@ -256,6 +261,13 @@ export default function RecorderSettings() {
label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/>
<LabeledSwitch
checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
/>
</div>
</div>
</div>

View File

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

View File

@ -45,6 +45,19 @@ export type SwipeCardProps = {
/** Animation timings */
snapMs?: number
commitMs?: number
/**
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
*/
ignoreFromBottomPx?: number
/**
* Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird.
* (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben)
*/
ignoreSelector?: string
}
export type SwipeCardHandle = {
@ -82,6 +95,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
},
thresholdPx = 120,
thresholdRatio = 0.35,
ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]',
snapMs = 180,
commitMs = 180,
},
@ -95,7 +110,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
x: number
y: number
dragging: boolean
}>({ id: null, x: 0, y: 0, dragging: false })
captured: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false })
const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
@ -190,9 +206,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
const target = e.target as HTMLElement | null
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// ✅ 2) Ignoriere Start im unteren Bereich (z.B. Video-Controls/Progressbar)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
pointer.current = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
dragging: false,
captured: false,
}
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
@ -200,12 +232,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y
// Erst entscheiden ob wir überhaupt "draggen"
// Erst entscheiden ob wir überhaupt draggen
if (!pointer.current.dragging) {
// wenn Nutzer vertikal scrollt, nicht hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
if (Math.abs(ddx) < 6) return
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
pointer.current.id = null
return
}
// "Dead zone" bis wirklich horizontal gedrückt wird
if (Math.abs(ddx) < 12) return
// ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
try {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pointer.current.captured = true
} catch {
pointer.current.captured = false
}
}
setAnimMs(0)
@ -214,9 +261,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
@ -226,7 +273,18 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
pointer.current.id = null
pointer.current.dragging = false
pointer.current.captured = false
// Capture sauber lösen (falls gesetzt)
if (wasCaptured) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {}
}
if (!wasDragging) {
reset()
@ -235,15 +293,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
if (dx > threshold) {
void commit('right', true) // keep
void commit('right', true)
} else if (dx < -threshold) {
void commit('left', true) // delete
void commit('left', true)
} else {
reset()
}
}}
onPointerCancel={() => {
onPointerCancel={(e) => {
if (!enabled || disabled) return
if (pointer.current.captured && pointer.current.id != null) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {}
}
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false }
reset()
}}
>

View File

@ -15,58 +15,6 @@
-moz-osx-font-smoothing: grayscale;
}
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
.vjs-mini .video-js .vjs-control-bar{
z-index: 40; /* über Overlays */
background: rgba(0,0,0,.65);
backdrop-filter: blur(8px);
}
/* Progressbar deutlich höher */
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
height: 10px;
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
.vjs-mini .video-js .vjs-play-progress{
border-radius: 9999px;
background: rgba(99,102,241,.95);
}
.vjs-mini .video-js .vjs-load-progress{
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
.vjs-controls-on-activity .video-js .vjs-control-bar{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
.vjs-controls-on-activity .player-ui{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .player-ui,
.vjs-controls-on-activity:focus-within .player-ui{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;