updated ui
This commit is contained in:
parent
821fe0fef1
commit
c751430af5
259
backend/main.go
259
backend/main.go
@ -86,6 +86,10 @@ var durCache = struct {
|
|||||||
m map[string]durEntry
|
m map[string]durEntry
|
||||||
}{m: map[string]durEntry{}}
|
}{m: map[string]durEntry{}}
|
||||||
|
|
||||||
|
var startedAtFromFilenameRe = regexp.MustCompile(
|
||||||
|
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
|
||||||
|
)
|
||||||
|
|
||||||
func durationSecondsCached(ctx context.Context, 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 {
|
||||||
@ -595,7 +599,8 @@ func extractLastFrameJPEG(path string) ([]byte, error) {
|
|||||||
"-sseof", "-0.1",
|
"-sseof", "-0.1",
|
||||||
"-i", path,
|
"-i", path,
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-q:v", "4",
|
"-vf", "scale=320:-2",
|
||||||
|
"-q:v", "7",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -625,7 +630,8 @@ func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) {
|
|||||||
"-ss", seek,
|
"-ss", seek,
|
||||||
"-i", path,
|
"-i", path,
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-q:v", "4",
|
"-vf", "scale=320:-2",
|
||||||
|
"-q:v", "7",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -781,7 +787,6 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
|||||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ContainsAny(id, `/\`) {
|
if strings.ContainsAny(id, `/\`) {
|
||||||
http.Error(w, "ungültige id", http.StatusBadRequest)
|
http.Error(w, "ungültige id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@ -806,32 +811,55 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if outPath == "" {
|
if outPath == "" {
|
||||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 NEU: dynamischer Frame an Zeitposition t (z.B. für animierte Thumbnails)
|
|
||||||
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
|
|
||||||
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
|
||||||
if img, err := extractFrameAtTimeJPEG(outPath, sec); err == nil {
|
|
||||||
servePreviewJPEGBytes(w, img)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// wenn ffmpeg hier scheitert, geht's unten mit statischem Preview weiter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔸 ALT: einmaliges Preview cachen (preview.jpg) – Fallback
|
|
||||||
previewDir := filepath.Join(os.TempDir(), "rec_preview", id)
|
previewDir := filepath.Join(os.TempDir(), "rec_preview", id)
|
||||||
if err := os.MkdirAll(previewDir, 0o755); err != nil {
|
if err := os.MkdirAll(previewDir, 0o755); err != nil {
|
||||||
http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError)
|
http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jpegPath := filepath.Join(previewDir, "preview.jpg")
|
// ✅ Cleanup: hält Cache klein + entfernt .part
|
||||||
|
// Empfehlung: 250 Frames pro Video, max 14 Tage behalten
|
||||||
|
const maxFrames = 250
|
||||||
|
const maxAge = 14 * 24 * time.Hour
|
||||||
|
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
|
||||||
|
|
||||||
|
// ✅ Frame bei Zeitposition t + Disk-Cache
|
||||||
|
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
|
||||||
|
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
||||||
|
key := int(sec*10 + 0.5) // 0.1s Raster, gerundet
|
||||||
|
if key < 0 {
|
||||||
|
key = 0
|
||||||
|
}
|
||||||
|
cachedFramePath := filepath.Join(previewDir, fmt.Sprintf("t_%09d.jpg", key))
|
||||||
|
|
||||||
|
if fi, err := os.Stat(cachedFramePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
servePreviewJPEGFile(w, r, cachedFramePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actualSec := float64(key) / 10.0
|
||||||
|
if img, err := extractFrameAtTimeJPEG(outPath, actualSec); err == nil && len(img) > 0 {
|
||||||
|
tmp := cachedFramePath + ".part"
|
||||||
|
_ = os.WriteFile(tmp, img, 0o644)
|
||||||
|
_ = os.Rename(tmp, cachedFramePath)
|
||||||
|
|
||||||
|
// nach neuem Write einmal kurz pruning (optional, aber hält hartes Limit)
|
||||||
|
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
|
||||||
|
|
||||||
|
servePreviewJPEGBytes(w, img)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// wenn ffmpeg scheitert -> unten statisches preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statisches preview.jpg (Fallback, gecached)
|
||||||
|
jpegPath := filepath.Join(previewDir, "preview.jpg")
|
||||||
if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
servePreviewJPEGFile(w, r, jpegPath)
|
servePreviewJPEGFile(w, r, jpegPath)
|
||||||
return
|
return
|
||||||
@ -847,10 +875,75 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
|||||||
img = img2
|
img = img2
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = os.WriteFile(jpegPath, img, 0o644)
|
tmp := jpegPath + ".part"
|
||||||
|
_ = os.WriteFile(tmp, img, 0o644)
|
||||||
|
_ = os.Rename(tmp, jpegPath)
|
||||||
|
|
||||||
servePreviewJPEGBytes(w, img)
|
servePreviewJPEGBytes(w, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) {
|
||||||
|
entries, err := os.ReadDir(previewDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type frame struct {
|
||||||
|
path string
|
||||||
|
mt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var frames []frame
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
path := filepath.Join(previewDir, name)
|
||||||
|
|
||||||
|
// .part Dateien immer weg
|
||||||
|
if strings.HasSuffix(name, ".part") {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional: preview.jpg neu erzeugen lassen, wenn uralt
|
||||||
|
if name == "preview.jpg" {
|
||||||
|
if info, err := e.Info(); err == nil {
|
||||||
|
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur t_*.jpg verwalten
|
||||||
|
if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// alte Frames löschen
|
||||||
|
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
frames = append(frames, frame{path: path, mt: info.ModTime()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anzahl begrenzen: älteste zuerst löschen
|
||||||
|
if maxFrames > 0 && len(frames) > maxFrames {
|
||||||
|
sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) })
|
||||||
|
toDelete := len(frames) - maxFrames
|
||||||
|
for i := 0; i < toDelete; i++ {
|
||||||
|
_ = os.Remove(frames[i].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
|
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||||
@ -1015,7 +1108,8 @@ func extractFirstFrameJPEG(path string) ([]byte, error) {
|
|||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-i", path,
|
"-i", path,
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-q:v", "4",
|
"-vf", "scale=320:-2",
|
||||||
|
"-q:v", "7",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1160,7 +1254,6 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
|
|||||||
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)
|
||||||
|
|
||||||
@ -1289,7 +1382,18 @@ func hasChaturbateCookies(cookieStr string) bool {
|
|||||||
func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||||
defer func() {
|
defer func() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
jobsMu.Lock()
|
||||||
|
defer jobsMu.Unlock()
|
||||||
|
|
||||||
job.EndedAt = &now
|
job.EndedAt = &now
|
||||||
|
|
||||||
|
// ✅ "Dauer" = Laufzeit (Recording Runtime), nicht ffprobe
|
||||||
|
if job.StartedAt.After(time.Time{}) {
|
||||||
|
sec := now.Sub(job.StartedAt).Seconds()
|
||||||
|
if sec > 0 {
|
||||||
|
job.DurationSeconds = sec
|
||||||
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
hc := NewHTTPClient(req.UserAgent)
|
hc := NewHTTPClient(req.UserAgent)
|
||||||
@ -1589,15 +1693,33 @@ 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 := durationSecondsCacheOnly(full, fi)
|
start := t
|
||||||
|
stem := base
|
||||||
|
if strings.HasPrefix(stem, "HOT ") {
|
||||||
|
stem = strings.TrimPrefix(stem, "HOT ")
|
||||||
|
}
|
||||||
|
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
|
||||||
|
mm, _ := strconv.Atoi(m[2])
|
||||||
|
dd, _ := strconv.Atoi(m[3])
|
||||||
|
yy, _ := strconv.Atoi(m[4])
|
||||||
|
hh, _ := strconv.Atoi(m[5])
|
||||||
|
mi, _ := strconv.Atoi(m[6])
|
||||||
|
ss, _ := strconv.Atoi(m[7])
|
||||||
|
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
dur := 0.0
|
||||||
|
if t.After(start) {
|
||||||
|
dur = t.Sub(start).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
list = append(list, &RecordJob{
|
list = append(list, &RecordJob{
|
||||||
ID: base,
|
ID: base,
|
||||||
Output: full,
|
Output: full,
|
||||||
Status: JobFinished,
|
Status: JobFinished,
|
||||||
StartedAt: t,
|
StartedAt: start,
|
||||||
EndedAt: &t,
|
EndedAt: &t,
|
||||||
DurationSeconds: dur,
|
DurationSeconds: dur, // ✅ Runtime
|
||||||
SizeBytes: fi.Size(),
|
SizeBytes: fi.Size(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1670,97 +1792,6 @@ type durationItem struct {
|
|||||||
Error string `json:"error,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 {
|
||||||
|
|||||||
Binary file not shown.
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-ZZZa38Qs.css
vendored
Normal file
1
backend/web/dist/assets/index-ZZZa38Qs.css
vendored
Normal file
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-wVqrTYvi.js"></script>
|
<script type="module" crossorigin src="/assets/index-zKk-xTZ_.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CIN0UidG.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-ZZZa38Qs.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useEffect, useCallback } from 'react'
|
||||||
import Table, { type Column, type SortState } from './Table'
|
import Table, { type Column, type SortState } from './Table'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
@ -77,19 +77,10 @@ function formatBytes(bytes?: number | null): string {
|
|||||||
return `${v.toFixed(digits)} ${units[i]}`
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Fallback: reine Aufnahmezeit aus startedAt/endedAt
|
|
||||||
function runtimeFromTimestamps(job: RecordJob): string {
|
|
||||||
const start = Date.parse(String(job.startedAt || ''))
|
|
||||||
const end = Date.parse(String(job.endedAt || ''))
|
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return '—'
|
|
||||||
return formatDuration(end - start)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMediaQuery(query: string) {
|
function useMediaQuery(query: string) {
|
||||||
const [matches, setMatches] = React.useState(false)
|
const [matches, setMatches] = React.useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia(query)
|
const mql = window.matchMedia(query)
|
||||||
const onChange = () => setMatches(mql.matches)
|
const onChange = () => setMatches(mql.matches)
|
||||||
onChange()
|
onChange()
|
||||||
@ -162,6 +153,9 @@ export default function FinishedDownloads({
|
|||||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||||||
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
||||||
|
|
||||||
|
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||||
|
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||||||
|
|
||||||
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
|
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
|
||||||
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
@ -186,14 +180,14 @@ export default function FinishedDownloads({
|
|||||||
const SORT_KEY = 'finishedDownloads_sort'
|
const SORT_KEY = 'finishedDownloads_sort'
|
||||||
const [sortMode, setSortMode] = React.useState<SortMode>('completed_desc')
|
const [sortMode, setSortMode] = React.useState<SortMode>('completed_desc')
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const v = window.localStorage.getItem(SORT_KEY) as SortMode | null
|
const v = window.localStorage.getItem(SORT_KEY) as SortMode | null
|
||||||
if (v) setSortMode(v)
|
if (v) setSortMode(v)
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(SORT_KEY, sortMode)
|
window.localStorage.setItem(SORT_KEY, sortMode)
|
||||||
} catch {}
|
} catch {}
|
||||||
@ -206,7 +200,7 @@ export default function FinishedDownloads({
|
|||||||
// ⭐ Models-Flags (Fav/Like) aus Backend-Store
|
// ⭐ Models-Flags (Fav/Like) aus Backend-Store
|
||||||
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({})
|
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({})
|
||||||
|
|
||||||
const refreshModelsByKey = React.useCallback(async () => {
|
const refreshModelsByKey = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models/list', { cache: 'no-store' as any })
|
const res = await fetch('/api/models/list', { cache: 'no-store' as any })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
@ -233,17 +227,17 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshModelsByKey()
|
void refreshModelsByKey()
|
||||||
}, [refreshModelsByKey])
|
}, [refreshModelsByKey])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = () => void refreshModelsByKey()
|
const onChanged = () => void refreshModelsByKey()
|
||||||
window.addEventListener('models-changed', onChanged as any)
|
window.addEventListener('models-changed', onChanged as any)
|
||||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||||
}, [refreshModelsByKey])
|
}, [refreshModelsByKey])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||||
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
||||||
@ -257,7 +251,7 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(VIEW_KEY, view)
|
localStorage.setItem(VIEW_KEY, view)
|
||||||
} catch {}
|
} catch {}
|
||||||
@ -268,7 +262,7 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
|
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
|
||||||
|
|
||||||
const tryAutoplayInline = React.useCallback((domId: string) => {
|
const tryAutoplayInline = useCallback((domId: string) => {
|
||||||
const host = document.getElementById(domId)
|
const host = document.getElementById(domId)
|
||||||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||||||
if (!v) return false
|
if (!v) return false
|
||||||
@ -282,11 +276,11 @@ export default function FinishedDownloads({
|
|||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startInline = React.useCallback((key: string) => {
|
const startInline = useCallback((key: string) => {
|
||||||
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openPlayer = React.useCallback((job: RecordJob) => {
|
const openPlayer = useCallback((job: RecordJob) => {
|
||||||
setInlinePlay(null)
|
setInlinePlay(null)
|
||||||
onOpenPlayer(job)
|
onOpenPlayer(job)
|
||||||
}, [onOpenPlayer])
|
}, [onOpenPlayer])
|
||||||
@ -301,7 +295,7 @@ export default function FinishedDownloads({
|
|||||||
setCtx({ x, y, job })
|
setCtx({ x, y, job })
|
||||||
}
|
}
|
||||||
|
|
||||||
const markDeleting = React.useCallback((key: string, value: boolean) => {
|
const markDeleting = useCallback((key: string, value: boolean) => {
|
||||||
setDeletingKeys((prev) => {
|
setDeletingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (value) next.add(key)
|
if (value) next.add(key)
|
||||||
@ -310,7 +304,7 @@ export default function FinishedDownloads({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const markDeleted = React.useCallback((key: string) => {
|
const markDeleted = useCallback((key: string) => {
|
||||||
setDeletedKeys((prev) => {
|
setDeletedKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.add(key)
|
next.add(key)
|
||||||
@ -320,7 +314,7 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
|
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const markKeeping = React.useCallback((key: string, value: boolean) => {
|
const markKeeping = useCallback((key: string, value: boolean) => {
|
||||||
setKeepingKeys((prev) => {
|
setKeepingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (value) next.add(key)
|
if (value) next.add(key)
|
||||||
@ -332,7 +326,7 @@ export default function FinishedDownloads({
|
|||||||
// neben deletedKeys / deletingKeys
|
// neben deletedKeys / deletingKeys
|
||||||
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
|
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const markRemoving = React.useCallback((key: string, value: boolean) => {
|
const markRemoving = useCallback((key: string, value: boolean) => {
|
||||||
setRemovingKeys((prev) => {
|
setRemovingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (value) next.add(key)
|
if (value) next.add(key)
|
||||||
@ -341,7 +335,7 @@ export default function FinishedDownloads({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const animateRemove = React.useCallback((key: string) => {
|
const animateRemove = useCallback((key: string) => {
|
||||||
// 1) rot + fade-out starten
|
// 1) rot + fade-out starten
|
||||||
markRemoving(key, true)
|
markRemoving(key, true)
|
||||||
|
|
||||||
@ -352,7 +346,7 @@ export default function FinishedDownloads({
|
|||||||
}, 320)
|
}, 320)
|
||||||
}, [markDeleted, markRemoving])
|
}, [markDeleted, markRemoving])
|
||||||
|
|
||||||
const releasePlayingFile = React.useCallback(
|
const releasePlayingFile = useCallback(
|
||||||
async (file: string, opts?: { close?: boolean }) => {
|
async (file: string, opts?: { close?: boolean }) => {
|
||||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||||
if (opts?.close) {
|
if (opts?.close) {
|
||||||
@ -363,7 +357,7 @@ export default function FinishedDownloads({
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteVideo = React.useCallback(
|
const deleteVideo = useCallback(
|
||||||
async (job: RecordJob): Promise<boolean> => {
|
async (job: RecordJob): Promise<boolean> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const key = keyFor(job)
|
const key = keyFor(job)
|
||||||
@ -407,7 +401,7 @@ export default function FinishedDownloads({
|
|||||||
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove]
|
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove]
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepVideo = React.useCallback(
|
const keepVideo = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob) => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const key = keyFor(job)
|
const key = keyFor(job)
|
||||||
@ -476,20 +470,13 @@ export default function FinishedDownloads({
|
|||||||
})
|
})
|
||||||
}, [ctx, deleteVideo, onOpenPlayer])
|
}, [ctx, deleteVideo, onOpenPlayer])
|
||||||
|
|
||||||
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
|
||||||
const k = keyFor(job)
|
|
||||||
const sec =
|
|
||||||
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
|
|
||||||
? (job as any).durationSeconds
|
|
||||||
: durations[k]
|
|
||||||
|
|
||||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
|
||||||
|
|
||||||
const start = Date.parse(String(job.startedAt || ''))
|
const start = Date.parse(String(job.startedAt || ''))
|
||||||
const end = Date.parse(String(job.endedAt || ''))
|
const end = Date.parse(String(job.endedAt || ''))
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return Number.POSITIVE_INFINITY
|
if (Number.isFinite(start) && Number.isFinite(end) && end > start) return (end - start) / 1000
|
||||||
return (end - start) / 1000
|
const sec = (job as any)?.durationSeconds
|
||||||
}, [durations])
|
return (typeof sec === 'number' && sec > 0) ? sec : Number.POSITIVE_INFINITY
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
@ -585,11 +572,11 @@ export default function FinishedDownloads({
|
|||||||
return arr
|
return arr
|
||||||
}, [rows, sortMode, durations])
|
}, [rows, sortMode, durations])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleCount(PAGE_SIZE)
|
setVisibleCount(PAGE_SIZE)
|
||||||
}, [rows.length])
|
}, [rows.length])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onExternalDelete = (ev: Event) => {
|
const onExternalDelete = (ev: Event) => {
|
||||||
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
||||||
if (!detail?.file) return
|
if (!detail?.file) return
|
||||||
@ -633,83 +620,74 @@ export default function FinishedDownloads({
|
|||||||
.filter((j) => !deletedKeys.has(keyFor(j)))
|
.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||||
.slice(0, visibleCount)
|
.slice(0, visibleCount)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = view === 'cards' || view === 'gallery'
|
||||||
|
if (!active) { setTeaserKey(null); return }
|
||||||
|
|
||||||
const requestedDurationsRef = React.useRef<Set<string>>(new Set())
|
// in Cards: wenn Inline-Player aktiv ist, diesen festhalten
|
||||||
|
if (view === 'cards' && inlinePlay?.key) { setTeaserKey(inlinePlay.key); return }
|
||||||
|
|
||||||
React.useEffect(() => {
|
let raf = 0
|
||||||
const wantsRuntimeSort = view === 'table' && sort?.key === 'runtime'
|
const schedule = () => {
|
||||||
if (!wantsRuntimeSort) return
|
if (raf) return
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
raf = 0
|
||||||
|
const cx = window.innerWidth / 2
|
||||||
|
const cy = window.innerHeight / 2
|
||||||
|
|
||||||
const missing: string[] = []
|
let bestKey: string | null = null
|
||||||
for (const j of rows) {
|
let best = Number.POSITIVE_INFINITY
|
||||||
const file = baseName(j.output || '')
|
|
||||||
if (!file) continue
|
|
||||||
|
|
||||||
// bereits bekannt?
|
for (const [key, el] of teaserHostsRef.current) {
|
||||||
const k = keyFor(j)
|
const r = el.getBoundingClientRect()
|
||||||
const sec =
|
if (r.bottom <= 0 || r.top >= window.innerHeight) continue
|
||||||
(typeof (j as any).durationSeconds === 'number' && (j as any).durationSeconds > 0)
|
const ex = r.left + r.width / 2
|
||||||
? (j as any).durationSeconds
|
const ey = r.top + r.height / 2
|
||||||
: durations[k]
|
const d = Math.hypot(ex - cx, ey - cy)
|
||||||
|
if (d < best) { best = d; bestKey = key }
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof sec === 'number' && sec > 0) continue
|
setTeaserKey(prev => (prev === bestKey ? prev : bestKey))
|
||||||
if (requestedDurationsRef.current.has(file)) continue
|
|
||||||
|
|
||||||
requestedDurationsRef.current.add(file)
|
})
|
||||||
missing.push(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length === 0) return
|
schedule()
|
||||||
|
window.addEventListener('scroll', schedule, { passive: true })
|
||||||
const ctrl = new AbortController()
|
window.addEventListener('resize', schedule)
|
||||||
|
return () => {
|
||||||
;(async () => {
|
if (raf) cancelAnimationFrame(raf)
|
||||||
const BATCH = 25
|
window.removeEventListener('scroll', schedule)
|
||||||
for (let i = 0; i < missing.length; i += BATCH) {
|
window.removeEventListener('resize', schedule)
|
||||||
const batch = missing.slice(i, i + BATCH)
|
}
|
||||||
const res = await fetch('/api/record/duration', {
|
}, [view, visibleRows.length, inlinePlay?.key])
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ files: batch }),
|
|
||||||
signal: ctrl.signal,
|
|
||||||
})
|
|
||||||
if (!res.ok) break
|
|
||||||
|
|
||||||
const data: Array<{ file: string; durationSeconds?: number }> = await res.json()
|
|
||||||
|
|
||||||
setDurations((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const it of data) {
|
|
||||||
if (it?.file && typeof it.durationSeconds === 'number' && it.durationSeconds > 0) {
|
|
||||||
next[it.file] = it.durationSeconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})().catch(() => {})
|
|
||||||
|
|
||||||
return () => ctrl.abort()
|
|
||||||
}, [view, sort?.key, rows]) // absichtlich NICHT durations als dep
|
|
||||||
|
|
||||||
|
|
||||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||||
const runtimeOf = (job: RecordJob): string => {
|
const runtimeOf = (job: RecordJob): string => {
|
||||||
const k = keyFor(job)
|
const start = Date.parse(String(job.startedAt || ''))
|
||||||
const sec =
|
const end = Date.parse(String(job.endedAt || ''))
|
||||||
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
|
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||||
? (job as any).durationSeconds
|
return formatDuration(end - start)
|
||||||
: durations[k]
|
|
||||||
|
|
||||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
|
||||||
return formatDuration(sec * 1000)
|
|
||||||
}
|
}
|
||||||
return runtimeFromTimestamps(job)
|
|
||||||
|
// Fallback (falls mal nur durationSeconds kommt)
|
||||||
|
const sec = (job as any)?.durationSeconds
|
||||||
|
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return formatDuration(sec * 1000)
|
||||||
|
|
||||||
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registerTeaserHost = useCallback(
|
||||||
|
(key: string) => (el: HTMLDivElement | null) => {
|
||||||
|
if (el) teaserHostsRef.current.set(key, el)
|
||||||
|
else teaserHostsRef.current.delete(key)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||||||
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
const handleDuration = useCallback((job: RecordJob, seconds: number) => {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||||||
const k = keyFor(job)
|
const k = keyFor(job)
|
||||||
setDurations((prev) => {
|
setDurations((prev) => {
|
||||||
@ -930,7 +908,7 @@ export default function FinishedDownloads({
|
|||||||
// ✅ Hooks immer zuerst – unabhängig von rows
|
// ✅ Hooks immer zuerst – unabhängig von rows
|
||||||
const isSmall = useMediaQuery('(max-width: 639px)')
|
const isSmall = useMediaQuery('(max-width: 639px)')
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall) {
|
if (!isSmall) {
|
||||||
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
|
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
|
||||||
swipeRefs.current = new Map()
|
swipeRefs.current = new Map()
|
||||||
@ -1066,6 +1044,7 @@ export default function FinishedDownloads({
|
|||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
<div
|
<div
|
||||||
id={inlineDomId}
|
id={inlineDomId}
|
||||||
|
ref={registerTeaserHost(k)} // <- NEU
|
||||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -1082,7 +1061,7 @@ export default function FinishedDownloads({
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
animated
|
animated={teaserKey === k && !inlineActive}
|
||||||
animatedMode="clips"
|
animatedMode="clips"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
clipSeconds={1}
|
clipSeconds={1}
|
||||||
@ -1403,6 +1382,7 @@ export default function FinishedDownloads({
|
|||||||
{/* Thumb */}
|
{/* Thumb */}
|
||||||
<div
|
<div
|
||||||
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
||||||
|
ref={registerTeaserHost(k)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -1417,9 +1397,9 @@ export default function FinishedDownloads({
|
|||||||
variant="fill"
|
variant="fill"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
animated
|
animated={teaserKey === k}
|
||||||
animatedMode="clips"
|
animatedMode="clips"
|
||||||
animatedTrigger="hover"
|
animatedTrigger="always"
|
||||||
clipSeconds={1}
|
clipSeconds={1}
|
||||||
thumbSamples={18}
|
thumbSamples={18}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,14 +12,65 @@ import {
|
|||||||
ArrowsPointingOutIcon,
|
ArrowsPointingOutIcon,
|
||||||
ArrowsPointingInIcon,
|
ArrowsPointingInIcon,
|
||||||
FireIcon,
|
FireIcon,
|
||||||
HeartIcon,
|
|
||||||
HandThumbUpIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
|
StarIcon as StarOutlineIcon,
|
||||||
|
HeartIcon as HeartOutlineIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
import {
|
||||||
|
StarIcon as StarSolidIcon,
|
||||||
|
HeartIcon as HeartSolidIcon,
|
||||||
|
} from '@heroicons/react/24/solid'
|
||||||
|
|
||||||
|
|
||||||
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||||||
|
|
||||||
|
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const h = Math.floor(totalSec / 3600)
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const s = totalSec % 60
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
if (m > 0) return `${m}m ${s}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number | null): string {
|
||||||
|
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = bytes
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
|
||||||
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelNameFromOutput = (output?: string) => {
|
||||||
|
const fileRaw = baseName(output || '')
|
||||||
|
const file = stripHotPrefix(fileRaw)
|
||||||
|
if (!file) return '—'
|
||||||
|
|
||||||
|
const stem = file.replace(/\.[^.]+$/, '')
|
||||||
|
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||||||
|
if (m?.[1]) return m[1]
|
||||||
|
|
||||||
|
const i = stem.lastIndexOf('_')
|
||||||
|
return i > 0 ? stem.slice(0, i) : stem
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
@ -59,6 +110,24 @@ export default function Player({
|
|||||||
}: PlayerProps) {
|
}: PlayerProps) {
|
||||||
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id])
|
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id])
|
||||||
|
|
||||||
|
const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
|
||||||
|
const isHotFile = fileRaw.startsWith('HOT ')
|
||||||
|
const model = React.useMemo(() => modelNameFromOutput(job.output), [job.output])
|
||||||
|
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
|
||||||
|
|
||||||
|
const runtimeLabel = React.useMemo(() => {
|
||||||
|
const start = Date.parse(String((job as any).startedAt || ''))
|
||||||
|
const end = Date.parse(String((job as any).endedAt || ''))
|
||||||
|
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||||
|
return formatDuration(end - start)
|
||||||
|
}
|
||||||
|
const sec = (job as any).durationSeconds
|
||||||
|
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return formatDuration(sec * 1000)
|
||||||
|
return '—'
|
||||||
|
}, [job])
|
||||||
|
|
||||||
|
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
@ -91,6 +160,41 @@ export default function Player({
|
|||||||
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
|
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
const [controlBarH, setControlBarH] = React.useState(56)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mounted) return
|
||||||
|
const p = playerRef.current
|
||||||
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
|
const root = p.el() as HTMLElement | null
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const bar = root.querySelector('.vjs-control-bar') as HTMLElement | null
|
||||||
|
if (!bar) return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const h = Math.round(bar.getBoundingClientRect().height || 0)
|
||||||
|
if (h > 0) setControlBarH(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
|
||||||
|
// live nachziehen, falls Video.js/Fullscreen/Responsive die Höhe ändert
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
ro = new ResizeObserver(update)
|
||||||
|
ro.observe(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
ro?.disconnect()
|
||||||
|
}
|
||||||
|
}, [mounted, expanded])
|
||||||
|
|
||||||
React.useEffect(() => setMounted(true), [])
|
React.useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
// ✅ 1x initialisieren
|
// ✅ 1x initialisieren
|
||||||
@ -249,6 +353,34 @@ export default function Player({
|
|||||||
const [canHover, setCanHover] = React.useState(false)
|
const [canHover, setCanHover] = React.useState(false)
|
||||||
|
|
||||||
const [progressActive, setProgressActive] = React.useState(false)
|
const [progressActive, setProgressActive] = React.useState(false)
|
||||||
|
|
||||||
|
const [isPaused, setIsPaused] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mounted) return
|
||||||
|
const p = playerRef.current
|
||||||
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
try {
|
||||||
|
setIsPaused(Boolean((p as any).paused?.()))
|
||||||
|
} catch {
|
||||||
|
setIsPaused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
p.on('play', update)
|
||||||
|
p.on('pause', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
p.off('play', update)
|
||||||
|
p.off('pause', update)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [mounted])
|
||||||
|
|
||||||
const progressTimerRef = React.useRef<number | null>(null)
|
const progressTimerRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
const stopProgress = React.useCallback(() => {
|
const stopProgress = React.useCallback(() => {
|
||||||
@ -336,29 +468,35 @@ export default function Player({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={overlayBtn}
|
className={overlayBtn}
|
||||||
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
|
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
|
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleFavorite?.(job)
|
onToggleFavorite?.(job)
|
||||||
}}
|
}}
|
||||||
disabled={!onToggleFavorite}
|
disabled={!onToggleFavorite}
|
||||||
>
|
>
|
||||||
<HeartIcon className={cn('h-5 w-5', isFavorite ? 'text-pink-300' : 'text-white')} />
|
{(() => {
|
||||||
|
const Icon = isFavorite ? StarSolidIcon : StarOutlineIcon
|
||||||
|
return <Icon className={cn('h-5 w-5', isFavorite ? 'text-amber-300' : 'text-white/90')} />
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={overlayBtn}
|
className={overlayBtn}
|
||||||
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
|
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||||
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
|
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleLike?.(job)
|
onToggleLike?.(job)
|
||||||
}}
|
}}
|
||||||
disabled={!onToggleLike}
|
disabled={!onToggleLike}
|
||||||
>
|
>
|
||||||
<HandThumbUpIcon className={cn('h-5 w-5', isLiked ? 'text-indigo-200' : 'text-white')} />
|
{(() => {
|
||||||
|
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
|
||||||
|
return <Icon className={cn('h-5 w-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -380,6 +518,12 @@ export default function Player({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const controlsVisible = !canHover || progressActive || isPaused
|
||||||
|
const controlsInsetPx = expanded ? (controlsVisible ? controlBarH : 0) : 0
|
||||||
|
|
||||||
|
const expandedGradientBottom = `calc(${controlsInsetPx}px + env(safe-area-inset-bottom))`
|
||||||
|
const expandedMetaBottom = `calc(${controlsInsetPx + 8}px + env(safe-area-inset-bottom))`
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@ -440,15 +584,29 @@ export default function Player({
|
|||||||
>
|
>
|
||||||
<div ref={containerRef} className="absolute inset-0" />
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
|
|
||||||
{/* Top overlay: title + window controls */}
|
{/* Top overlay: inline-like header + actions + window controls */}
|
||||||
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
|
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="player-ui max-w-[70vw] sm:max-w-[320px] truncate rounded-md bg-black/45 px-2.5 py-1.5 text-xs font-semibold text-white backdrop-blur">
|
<div className="player-ui max-w-[70vw] sm:max-w-[360px] rounded-md bg-black/45 px-2.5 py-1.5 text-white backdrop-blur">
|
||||||
{title}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">{model}</div>
|
||||||
|
{isHotFile ? (
|
||||||
|
<span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
|
||||||
|
HOT
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-white/80">
|
||||||
|
{file || title}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 pointer-events-auto">
|
||||||
|
{/* Inline-like actions (Hot/Fav/Like/Delete) */}
|
||||||
|
{footerRight}
|
||||||
|
|
||||||
|
{/* Window controls */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
|
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
|
||||||
@ -456,11 +614,7 @@ export default function Player({
|
|||||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||||
title={expanded ? 'Minimieren' : 'Maximieren'}
|
title={expanded ? 'Minimieren' : 'Maximieren'}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? <ArrowsPointingInIcon className="h-5 w-5" /> : <ArrowsPointingOutIcon className="h-5 w-5" />}
|
||||||
<ArrowsPointingInIcon className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -475,33 +629,37 @@ export default function Player({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom overlay: mini actions + status */}
|
{/* Bottom overlay: inline-like meta */}
|
||||||
{!expanded && (
|
<div
|
||||||
<>
|
className={cn(
|
||||||
<div
|
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
|
||||||
className={cn(
|
'transition-all duration-200 ease-out',
|
||||||
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
|
expanded ? 'h-28' : (liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20')
|
||||||
'transition-all duration-200 ease-out',
|
)}
|
||||||
liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20'
|
style={expanded ? { bottom: expandedGradientBottom } : undefined}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2',
|
'player-ui pointer-events-none absolute inset-x-2 z-20 flex items-end justify-between gap-2',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
liftMiniOverlay ? 'bottom-7' : 'bottom-2'
|
expanded ? '' : (liftMiniOverlay ? 'bottom-7' : 'bottom-2')
|
||||||
)}
|
)}
|
||||||
>
|
style={expanded ? { bottom: expandedMetaBottom } : undefined}
|
||||||
<div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur">
|
>
|
||||||
<span className="font-semibold text-white">{job.status}</span>
|
<div className="min-w-0">
|
||||||
{job.output ? <span className="ml-2 opacity-80">• {job.output}</span> : null}
|
<div className="truncate text-sm font-semibold text-white">{model}</div>
|
||||||
</div>
|
<div className="truncate text-[11px] text-white/80">{file || title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{footerRight}
|
<div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white">
|
||||||
</div>
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-semibold">{job.status}</span>
|
||||||
</>
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span>
|
||||||
)}
|
{sizeLabel !== '—' ? (
|
||||||
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user