updated ui

This commit is contained in:
Linrador 2025-12-31 18:27:30 +01:00
parent 821fe0fef1
commit c751430af5
8 changed files with 501 additions and 332 deletions

View File

@ -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.

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" /> <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>

View File

@ -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}
/> />

View File

@ -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>