This commit is contained in:
Linrador 2026-01-29 11:47:18 +01:00
parent 50515d44b0
commit ce68074a5a
22 changed files with 2365 additions and 648 deletions

View File

@ -32,7 +32,12 @@ func broadcastAutostartPaused(paused bool) {
} }
func isAutostartPaused() bool { func isAutostartPaused() bool {
return atomic.LoadInt32(&autostartPaused) == 1 // dein bestehender "User-Pause" Check bleibt wie er ist …
if atomic.LoadInt32(&autostartPaused) == 1 {
return true
}
// ✅ zusätzlich: Disk-Notfall pausiert Autostart
return atomic.LoadInt32(&diskEmergency) == 1
} }
func setAutostartPaused(v bool) { func setAutostartPaused(v bool) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

168
backend/generated_gc.go Normal file
View File

@ -0,0 +1,168 @@
// backend\generated_gc.go
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
var generatedGCRunning int32
// Startet den GC im Hintergrund, aber nur wenn nicht schon einer läuft.
func triggerGeneratedGarbageCollectorAsync() {
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
return
}
go func() {
defer atomic.StoreInt32(&generatedGCRunning, 0)
runGeneratedGarbageCollector() // ohne Sleep
}()
}
// Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Ordner, für die es kein Video in /done mehr gibt.
func startGeneratedGarbageCollector() {
time.Sleep(3 * time.Second)
runGeneratedGarbageCollector()
}
// Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup)
func runGeneratedGarbageCollector() {
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
fmt.Println("🧹 [gc] resolve doneDir failed:", err)
return
}
doneAbs = strings.TrimSpace(doneAbs)
if doneAbs == "" {
return
}
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren
live := make(map[string]struct{}, 4096)
_ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
name := d.Name()
if d.IsDir() {
if strings.EqualFold(name, ".trash") {
return fs.SkipDir
}
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return nil
}
info, err := d.Info()
if err != nil || info.IsDir() || info.Size() <= 0 {
return nil
}
base := strings.TrimSuffix(name, ext)
id, err := sanitizeID(stripHotPrefix(base))
if err != nil || id == "" {
return nil
}
live[id] = struct{}{}
return nil
})
// 2) /generated/meta/<id> prüfen
metaRoot, err := generatedMetaRoot()
if err == nil {
metaRoot = strings.TrimSpace(metaRoot)
}
if err != nil || metaRoot == "" {
return
}
removedMeta := 0
checkedMeta := 0
if entries, err := os.ReadDir(metaRoot); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
id := strings.TrimSpace(e.Name())
if id == "" || strings.HasPrefix(id, ".") {
continue
}
checkedMeta++
if _, ok := live[id]; ok {
continue
}
removeGeneratedForID(id)
removedMeta++
}
}
fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
// 3) Optional: legacy /generated/<id>
genRoot, err := generatedRoot()
if err == nil {
genRoot = strings.TrimSpace(genRoot)
}
if err != nil || genRoot == "" {
return
}
reserved := map[string]struct{}{
"meta": {},
"covers": {},
"cover": {},
"temp": {},
"tmp": {},
".trash": {},
}
removedLegacy := 0
checkedLegacy := 0
if entries, err := os.ReadDir(genRoot); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
name := strings.TrimSpace(e.Name())
if name == "" || strings.HasPrefix(name, ".") {
continue
}
if _, ok := reserved[strings.ToLower(name)]; ok {
continue
}
checkedLegacy++
if _, ok := live[name]; ok {
continue
}
removeGeneratedForID(name)
removedLegacy++
}
}
if checkedLegacy > 0 || removedLegacy > 0 {
fmt.Printf("🧹 [gc] generated legacy checked=%d removed_orphans=%d\n", checkedLegacy, removedLegacy)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// backend\myfreecams_autostart.go
package main package main
import ( import (
@ -17,7 +19,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
const cooldown = 2 * time.Minute const cooldown = 2 * time.Minute
// wie lange wir nach Start warten, ob eine Datei entsteht // wie lange wir nach Start warten, ob eine Datei entsteht
const outputProbeMax = 12 * time.Second const outputProbeMax = 20 * time.Second
lastAttempt := map[string]time.Time{} lastAttempt := map[string]time.Time{}

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

File diff suppressed because one or more lines are too long

View File

@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-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, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-DSZfASIn.js"></script> <script type="module" crossorigin src="/assets/index-BqjSaPox.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hlx7oHN0.css"> <link rel="stylesheet" crossorigin href="/assets/index-CRe6vAJq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1 +1 @@
DATABASE_URL="file:./prisma/models.db" DATABASE_URL="file:./prisma/models.db"

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-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, viewport-fit=cover" />
<title>App</title> <title>App</title>
</head> </head>
<body> <body>

View File

@ -188,6 +188,22 @@ export default function App() {
const notify = useNotify() const notify = useNotify()
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
const [showPerfMon, setShowPerfMon] = useState(false)
useEffect(() => {
const w = window as any
const id =
typeof w.requestIdleCallback === 'function'
? w.requestIdleCallback(() => setShowPerfMon(true), { timeout: 1500 })
: window.setTimeout(() => setShowPerfMon(true), 800)
return () => {
if (typeof w.cancelIdleCallback === 'function') w.cancelIdleCallback(id)
else window.clearTimeout(id)
}
}, [])
const DONE_PAGE_SIZE = 8 const DONE_PAGE_SIZE = 8
type DoneSortMode = type DoneSortMode =
@ -1049,31 +1065,55 @@ export default function App() {
return startUrl(sourceUrl) return startUrl(sourceUrl)
} }
const handleDeleteJob = useCallback(async (job: RecordJob) => { const handleDeleteJobWithUndo = useCallback(
const file = baseName(job.output || '') async (job: RecordJob): Promise<void | { undoToken?: string }> => {
if (!file) return const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })) window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
try { try {
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' }) // ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })) const data = await apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
window.setTimeout(() => { window.dispatchEvent(
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) )
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
window.setTimeout(() => { window.setTimeout(() => {
void refreshDoneNow() setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
}, 350) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
} catch (e: any) { setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) }, 320)
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr window.setTimeout(() => {
} void refreshDoneNow()
}, [notify]) }, 350)
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {} // ✅ kein null mehr
} catch (e: any) {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
return // ✅ void statt null
}
},
[notify, refreshDoneNow]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
const handleKeepJob = useCallback( const handleKeepJob = useCallback(
async (job: RecordJob) => { async (job: RecordJob) => {
@ -1102,30 +1142,50 @@ export default function App() {
[selectedTab, refreshDoneNow, notify] [selectedTab, refreshDoneNow, notify]
) )
const handleToggleHot = useCallback(async (job: RecordJob) => { const handleToggleHot = useCallback(
const file = baseName(job.output || '') async (job: RecordJob) => {
if (!file) return const file = baseName(job.output || '')
if (!file) return
try { try {
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, `/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' } { method: 'POST' }
) )
const newOutput = replaceBasename(job.output || '', res.newFile) // ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player)
window.dispatchEvent(
new CustomEvent('finished-downloads:rename', {
detail: { oldFile: res.oldFile, newFile: res.newFile },
})
)
setPlayerJob((prev) => (prev ? { ...prev, output: newOutput } : prev)) const apply = (out: string) => replaceBasename(out || '', res.newFile)
setDoneJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j)) // ✅ 1) Player immer updaten
) setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
setJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j)) // ✅ 2) doneJobs über ID (Fallback: basename)
) setDoneJobs((prev) =>
} catch (e: any) { prev.map((j) => {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) const match = j.id === job.id || baseName(j.output || '') === file
return return match ? { ...j, output: apply(j.output || '') } : j
} })
}, [notify]) )
// ✅ 3) jobs (/record/list) über ID (Fallback: basename)
setJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
return match ? { ...j, output: apply(j.output || '') } : j
})
)
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
}
},
[notify]
)
// --- flags patch (wie bei dir) --- // --- flags patch (wie bei dir) ---
async function patchModelFlags(patch: any): Promise<any | null> { async function patchModelFlags(patch: any): Promise<any | null> {
@ -1970,7 +2030,7 @@ export default function App() {
}, [recSettings.useChaturbateApi]) }, [recSettings.useChaturbateApi])
return ( return (
<div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> <div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden"> <div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" /> <div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" /> <div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
@ -2033,7 +2093,7 @@ export default function App() {
</div> </div>
<div className="mt-2 flex items-stretch gap-2"> <div className="mt-2 flex items-stretch gap-2">
<PerformanceMonitor mode="inline" className="flex-1" /> {showPerfMon ? <PerformanceMonitor mode="inline" className="flex-1" /> : <div className="flex-1" />}
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setCookieModalOpen(true)} onClick={() => setCookieModalOpen(true)}
@ -2047,7 +2107,7 @@ export default function App() {
</div> </div>
<div className="hidden sm:flex items-center gap-2 h-full"> <div className="hidden sm:flex items-center gap-2 h-full">
<PerformanceMonitor mode="inline" /> {showPerfMon ? <PerformanceMonitor mode="inline" /> : null}
<Button variant="secondary" onClick={() => setCookieModalOpen(true)} className="h-9 px-3"> <Button variant="secondary" onClick={() => setCookieModalOpen(true)} className="h-9 px-3">
Cookies Cookies
</Button> </Button>
@ -2136,7 +2196,7 @@ export default function App() {
pageSize={DONE_PAGE_SIZE} pageSize={DONE_PAGE_SIZE}
onPageChange={setDonePage} onPageChange={setDonePage}
onOpenPlayer={openPlayer} onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJob} onDeleteJob={handleDeleteJobWithUndo}
onToggleHot={handleToggleHot} onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}

View File

@ -53,7 +53,9 @@ type Props = {
teaserPlayback?: TeaserPlaybackMode teaserPlayback?: TeaserPlaybackMode
teaserAudio?: boolean teaserAudio?: boolean
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onDeleteJob?: (job: RecordJob) => void | Promise<void> onDeleteJob?: (
job: RecordJob
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
@ -76,6 +78,12 @@ const baseName = (p: string) => {
} }
const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id
const isTrashOutput = (output?: string) => {
const p = norm(String(output ?? ''))
// match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
return p.includes('/.trash/') || p.endsWith('/.trash')
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—' if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000) const totalSec = Math.floor(ms / 1000)
@ -226,6 +234,14 @@ export default function FinishedDownloads({
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())
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string }
| { kind: 'keep'; keptFile: string; originalFile: string }
| { kind: 'hot'; currentFile: string }
const [lastAction, setLastAction] = React.useState<UndoAction | null>(null)
const [undoing, setUndoing] = React.useState(false)
// 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({}) const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
@ -255,6 +271,107 @@ export default function FinishedDownloads({
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map()) const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
const unhide = (file: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(file)
return next
})
}
try {
if (lastAction.kind === 'delete') {
const res = await fetch(
`/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
unhide(lastAction.originalFile)
unhide(restoredFile)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } }))
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'keep') {
const res = await fetch(
`/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null) as any
const restoredFile = String(data?.newFile || lastAction.originalFile)
unhide(lastAction.originalFile)
unhide(restoredFile)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: +1 } }))
queueRefill()
setLastAction(null)
return
}
if (lastAction.kind === 'hot') {
// HOT ist reversibel über denselben Toggle-Endpunkt
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// UI-optimistik (damit es sofort stimmt)
const data = (await res.json().catch(() => null)) as any
const oldFile = String(data?.oldFile || lastAction.currentFile)
const newFile = String(data?.newFile || '')
if (newFile) {
applyRename(oldFile, newFile)
}
queueRefill()
setLastAction(null)
return
}
} catch (e: any) {
notify.error('Undo fehlgeschlagen', String(e?.message || e))
} finally {
setUndoing(false)
}
}, [lastAction, undoing, notify, queueRefill])
// 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
const [tagFilter, setTagFilter] = React.useState<string[]>([]) const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
@ -503,6 +620,32 @@ export default function FinishedDownloads({
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden) // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({}) const [durations, setDurations] = React.useState<Record<string, number>>({})
// ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount)
const durationsRef = React.useRef<Record<string, number>>({})
const durationsFlushTimerRef = React.useRef<number | null>(null)
React.useEffect(() => {
durationsRef.current = durations
}, [durations])
const flushDurationsSoon = React.useCallback(() => {
if (durationsFlushTimerRef.current != null) return
durationsFlushTimerRef.current = window.setTimeout(() => {
durationsFlushTimerRef.current = null
// neue Objekt-Referenz, damit React aktualisiert
setDurations({ ...durationsRef.current })
}, 200)
}, [])
React.useEffect(() => {
return () => {
if (durationsFlushTimerRef.current != null) {
window.clearTimeout(durationsFlushTimerRef.current)
durationsFlushTimerRef.current = null
}
}
}, [])
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null) const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
const previewMuted = !Boolean(teaserAudio) const previewMuted = !Boolean(teaserAudio)
@ -611,20 +754,37 @@ export default function FinishedDownloads({
try { try {
await releasePlayingFile(file, { close: true }) await releasePlayingFile(file, { close: true })
// ✅ Wenn App-Handler vorhanden: den benutzen (inkl. Events + State-Update) // ✅ Wenn App-Handler vorhanden: den benutzen
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
if (onDeleteJob) { if (onDeleteJob) {
await onDeleteJob(job) const r = await onDeleteJob(job)
const undoToken = (r as any)?.undoToken
if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file })
} else {
// ohne Token kein Restore möglich -> nicht so tun als gäbe es Undo
setLastAction(null)
notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
}
return true return true
} }
// Fallback (falls mal ohne App-Handler verwendet) // Fallback: Backend direkt
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' }) const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`) throw new Error(text || `HTTP ${res.status}`)
} }
// ✅ Backend liefert undoToken (Trash)
const data = (await res.json().catch(() => null)) as any
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file })
else setLastAction(null)
animateRemove(key) animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf) // ✅ Tab-Count sofort korrigieren (App hört drauf)
@ -638,7 +798,15 @@ export default function FinishedDownloads({
markDeleting(key, false) markDeleting(key, false)
} }
}, },
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, notify] [
deletingKeys,
markDeleting,
releasePlayingFile,
onDeleteJob,
animateRemove,
notify,
setLastAction,
]
) )
const keepVideo = useCallback( const keepVideo = useCallback(
@ -655,12 +823,20 @@ export default function FinishedDownloads({
markKeeping(key, true) markKeeping(key, true)
try { try {
await releasePlayingFile(file, { close: true }) await releasePlayingFile(file, { close: true })
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }) const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`) throw new Error(text || `HTTP ${res.status}`)
} }
// ✅ Backend liefert ggf. newFile (uniqueDestPath)
const data = (await res.json().catch(() => null)) as any
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
// ✅ Undo-Info merken
setLastAction({ kind: 'keep', keptFile, originalFile: file })
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished // ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key) animateRemove(key)
@ -675,9 +851,52 @@ export default function FinishedDownloads({
markKeeping(key, false) markKeeping(key, false)
} }
}, },
[keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove, notify] [
keepingKeys,
deletingKeys,
markKeeping,
releasePlayingFile,
animateRemove,
notify,
setLastAction,
]
) )
const applyRename = useCallback((oldFile: string, newFile: string) => {
if (!oldFile || !newFile || oldFile === newFile) return
// 1) renamedFiles: alte/konfliktierende Kanten entfernen, dann neue setzen
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
// entferne alles, was mit old/new kollidiert (Keys ODER Values)
for (const [k, v] of Object.entries(next)) {
if (k === oldFile || k === newFile || v === oldFile || v === newFile) {
delete next[k]
}
}
next[oldFile] = newFile
return next
})
// 2) durations-Key mitziehen + Ref/State synchron halten
const cur = durationsRef.current || {}
const v = (cur as any)[oldFile]
if (typeof v === 'number') {
const next = { ...(cur as any) }
delete next[oldFile]
next[newFile] = v
durationsRef.current = next
setDurations(next)
} else if (oldFile in cur) {
const next = { ...(cur as any) }
delete next[oldFile]
durationsRef.current = next
setDurations(next)
}
}, [])
const toggleHotVideo = useCallback( const toggleHotVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob) => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
@ -691,31 +910,14 @@ export default function FinishedDownloads({
// ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen // ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen
const applyOptimisticRename = (oldFile: string, newFile: string) => { const applyOptimisticRename = (oldFile: string, newFile: string) => {
if (!newFile || newFile === oldFile) return applyRename(oldFile, newFile)
setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
setDurations((prev) => {
const v = prev[oldFile]
if (typeof v !== 'number') return prev
const { [oldFile]: _omit, ...rest } = prev
return { ...rest, [newFile]: v }
})
} }
// ✅ falls Backend andere Namen liefert: Server-Truth nachziehen // ✅ falls Backend andere Namen liefert: Server-Truth nachziehen
const applyServerTruth = (apiOld: string, apiNew: string, optimisticNew: string) => { const applyServerTruth = (apiOld: string, apiNew: string, optimisticNew: string) => {
if (!apiNew) return if (!apiNew) return
if (apiNew === optimisticNew && apiOld === file) return if (apiNew === optimisticNew && apiOld === file) return
applyRename(apiOld, apiNew)
setRenamedFiles((prev) => ({ ...prev, [apiOld]: apiNew }))
setDurations((prev) => {
const v = prev[apiOld] ?? prev[optimisticNew]
if (typeof v !== 'number') return prev
const { [apiOld]: _omit1, [optimisticNew]: _omit2, ...rest } = prev as any
return { ...rest, [apiNew]: v }
})
} }
try { try {
@ -724,9 +926,12 @@ export default function FinishedDownloads({
const oldFile = file const oldFile = file
const optimisticNew = toggledName(oldFile) const optimisticNew = toggledName(oldFile)
// ✅ Wichtig: Optimistik IMMER anwenden auch wenn ein externer onToggleHot Handler existiert // ✅ Wichtig: Optimistik IMMER anwenden
applyOptimisticRename(oldFile, optimisticNew) applyOptimisticRename(oldFile, optimisticNew)
// ✅ Undo: HOT ist reversibel => wir merken den aktuellen Dateinamen nach der Aktion
setLastAction({ kind: 'hot', currentFile: optimisticNew })
// ✅ Externer Handler (App) danach Liste auffrischen // ✅ Externer Handler (App) danach Liste auffrischen
if (onToggleHot) { if (onToggleHot) {
await onToggleHot(job) await onToggleHot(job)
@ -747,12 +952,15 @@ export default function FinishedDownloads({
applyServerTruth(apiOld, apiNew, optimisticNew) applyServerTruth(apiOld, apiNew, optimisticNew)
// ✅ Undo-Dateiname ggf. auf Server-Truth setzen
if (apiNew) setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill() queueRefill()
} catch (e: any) { } catch (e: any) {
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e)) notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
} }
}, },
[baseName, releasePlayingFile, onToggleHot, notify, queueRefill] [ releasePlayingFile, onToggleHot, notify, queueRefill, applyRename, setLastAction ]
) )
const runtimeSecondsForSort = useCallback((job: RecordJob) => { const runtimeSecondsForSort = useCallback((job: RecordJob) => {
@ -809,9 +1017,13 @@ export default function FinishedDownloads({
} }
const list = Array.from(map.values()).filter((j) => { const list = Array.from(map.values()).filter((j) => {
if (deletedKeys.has(keyFor(j))) return false if (deletedKeys.has(keyFor(j))) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
}) // ✅ .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
})
return list return list
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput]) }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
@ -853,6 +1065,21 @@ export default function FinishedDownloads({
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view, queueRefill]) }, [animateRemove, markDeleting, markDeleted, view, queueRefill])
useEffect(() => {
const onExternalRename = (ev: Event) => {
const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
const oldFile = String(detail?.oldFile ?? '').trim()
const newFile = String(detail?.newFile ?? '').trim()
if (!oldFile || !newFile || oldFile === newFile) return
// ✅ nutzt eure bestehende Logik inkl. Aufräumen + durations-move
applyRename(oldFile, newFile)
}
window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
}, [applyRename])
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
@ -1006,14 +1233,13 @@ export default function FinishedDownloads({
const handleDuration = 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) => {
const old = prev[k] const old = durationsRef.current[k]
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) { if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return
return prev // keine unnötigen Re-Renders
} durationsRef.current = { ...durationsRef.current, [k]: seconds }
return { ...prev, [k]: seconds } flushDurationsSoon()
}) }, [flushDurationsSoon])
}, [])
const columns: Column<RecordJob>[] = [ const columns: Column<RecordJob>[] = [
{ {
@ -1403,6 +1629,21 @@ export default function FinishedDownloads({
)} )}
{/* Views */} {/* Views */}
<Button
size={isSmall ? 'sm' : 'md'}
variant="soft"
disabled={!lastAction || undoing}
onClick={undoLastAction}
title={
!lastAction
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
>
Undo
</Button>
<ButtonGroup <ButtonGroup
value={view} value={view}
onChange={(id) => setView(id as ViewMode)} onChange={(id) => setView(id as ViewMode)}

View File

@ -22,9 +22,11 @@ export default function LiveHlsVideo({
const [broken, setBroken] = useState(false) const [broken, setBroken] = useState(false)
const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null) const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null)
// ✅ Nur für "harte Reloads" (triggert Effect neu)
const [reloadKey, setReloadKey] = useState(1)
// ✅ pro Mount/Wechsel einmal eine „frische“ URL erzwingen (hilft v.a. Safari/iOS) // ✅ manifestUrl ist stabil pro reloadKey
const manifestUrl = useMemo(() => withNonce(src, Date.now()), [src]) const manifestUrl = useMemo(() => withNonce(src, reloadKey), [src, reloadKey])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -34,8 +36,7 @@ export default function LiveHlsVideo({
const videoEl = ref.current const videoEl = ref.current
if (!videoEl) return if (!videoEl) return
const video: HTMLVideoElement = videoEl
const video = videoEl // <- jetzt: HTMLVideoElement (nicht null)
setBroken(false) setBroken(false)
setBrokenReason(null) setBrokenReason(null)
@ -49,61 +50,62 @@ export default function LiveHlsVideo({
watchdogTimer = null watchdogTimer = null
} }
const hardReloadNative = () => { const hardReload = () => {
if (cancelled) return if (cancelled) return
cleanupTimers() cleanupTimers()
// ✅ Effect neu starten
// src einmal „resetten“, dann neu setzen (Safari hängt sonst manchmal) setReloadKey((x) => x + 1)
try {
video.pause()
} catch {}
video.removeAttribute('src')
video.load()
const url = withNonce(src, Date.now())
video.src = url
video.load()
video.play().catch(() => {})
} }
async function waitForManifestWithSegments(): Promise<{ ok: boolean; reason?: 'private' | 'offline' }> { async function waitForManifestWithSegments(): Promise<{ ok: boolean; reason?: 'private' | 'offline' }> {
const started = Date.now() const started = Date.now()
while (!cancelled && Date.now() - started < 20_000) { while (!cancelled && Date.now() - started < 90_000) {
try { try {
const r = await fetch(manifestUrl, { cache: 'no-store' }) // ✅ immer frisch pollen (Cache umgehen)
const url = withNonce(src, Date.now())
const r = await fetch(url, { cache: 'no-store' })
if (r.status === 403) return { ok: false, reason: 'private' } if (r.status === 403) return { ok: false, reason: 'private' }
if (r.status === 404) return { ok: false, reason: 'offline' } if (r.status === 404) return { ok: false, reason: 'offline' }
if (r.status === 204) { if (r.ok) {
// Preview wird noch erzeugt
} else if (r.ok) {
const txt = await r.text() const txt = await r.text()
if (txt.includes('#EXTINF')) return { ok: true } if (txt.includes('#EXTINF')) return { ok: true }
} }
} catch { } catch {}
// ignore, retry await new Promise((res) => setTimeout(res, 500))
}
await new Promise((res) => setTimeout(res, 400))
} }
// kein reason => "noch nicht ready"
return { ok: false } return { ok: false }
} }
async function start() { async function start() {
const res = await waitForManifestWithSegments() const res = await waitForManifestWithSegments()
if (!res.ok || cancelled) { if (cancelled) return
if (!cancelled) {
setBrokenReason(res.reason ?? null) if (!res.ok) {
// ✅ Nur echte Endzustände dauerhaft anzeigen
if (res.reason === 'private' || res.reason === 'offline') {
setBrokenReason(res.reason)
setBroken(true) setBroken(true)
return
} }
// ✅ Sonst: retry per reloadKey (ohne broken)
window.setTimeout(() => {
if (!cancelled) hardReload()
}, 800)
return return
} }
// ✅ Safari / iOS: Native HLS // ✅ Safari / iOS: Native HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) { if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.pause()
video.removeAttribute('src')
video.load()
video.src = manifestUrl video.src = manifestUrl
video.load() video.load()
video.play().catch(() => {}) video.play().catch(() => {})
@ -118,14 +120,12 @@ export default function LiveHlsVideo({
lastProgressTs = Date.now() lastProgressTs = Date.now()
} }
} }
const scheduleStallReload = () => { const scheduleStallReload = () => {
if (stallTimer) return if (stallTimer) return
stallTimer = window.setTimeout(() => { stallTimer = window.setTimeout(() => {
stallTimer = null stallTimer = null
// wenn wir seit ein paar Sekunden keinen Fortschritt hatten -> reload if (!cancelled && Date.now() - lastProgressTs > 3500) hardReload()
if (!cancelled && Date.now() - lastProgressTs > 3500) {
hardReloadNative()
}
}, 800) }, 800)
} }
@ -134,13 +134,9 @@ export default function LiveHlsVideo({
video.addEventListener('stalled', scheduleStallReload) video.addEventListener('stalled', scheduleStallReload)
video.addEventListener('error', scheduleStallReload) video.addEventListener('error', scheduleStallReload)
// zusätzlicher Watchdog
watchdogTimer = window.setInterval(() => { watchdogTimer = window.setInterval(() => {
if (cancelled) return if (cancelled) return
// nur wenn autoplay läuft (nicht wenn User bewusst pausiert) if (!video.paused && Date.now() - lastProgressTs > 6000) hardReload()
if (!video.paused && Date.now() - lastProgressTs > 6000) {
hardReloadNative()
}
}, 2000) }, 2000)
return () => { return () => {
@ -160,13 +156,12 @@ export default function LiveHlsVideo({
hls = new Hls({ hls = new Hls({
lowLatencyMode: true, lowLatencyMode: true,
liveSyncDurationCount: 2, liveSyncDurationCount: 2,
maxBufferLength: 8, // etwas entspannter maxBufferLength: 8,
}) })
hls.on(Hls.Events.ERROR, (_evt, data) => { hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!hls) return if (!hls) return
// ✅ Recovery statt direkt broken
if (data.fatal) { if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad() hls.startLoad()
@ -176,7 +171,8 @@ export default function LiveHlsVideo({
hls.recoverMediaError() hls.recoverMediaError()
return return
} }
setBroken(true) // ✅ statt "für immer kaputt": einmal hart reloaden
hardReload()
} }
}) })
@ -200,9 +196,11 @@ export default function LiveHlsVideo({
try { try {
nativeCleanup?.() nativeCleanup?.()
} catch {} } catch {}
hls?.destroy() try {
hls?.destroy()
} catch {}
} }
}, [src, manifestUrl, muted]) }, [src, muted, manifestUrl]) // <- manifestUrl ist OK, weil reloadKey NICHT im Effect gesetzt wird
if (broken) { if (broken) {
return ( return (
@ -212,7 +210,6 @@ export default function LiveHlsVideo({
) )
} }
return ( return (
<video <video
ref={ref} ref={ref}
@ -220,6 +217,8 @@ export default function LiveHlsVideo({
playsInline playsInline
autoPlay autoPlay
muted={muted} muted={muted}
preload="auto"
crossOrigin="anonymous"
onClick={() => { onClick={() => {
const v = ref.current const v = ref.current
if (v) { if (v) {

View File

@ -16,6 +16,8 @@ import {
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy' import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import Button from './Button' import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveHlsVideo from './LiveHlsVideo'
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) const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -114,6 +116,7 @@ function formatResolution(w?: number | null, h?: number | null): string {
function parseDateFromOutput(output?: string): Date | null { function parseDateFromOutput(output?: string): Date | null {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
if (!file) return null if (!file) return null
const stem = file.replace(/\.[^.]+$/, '') const stem = file.replace(/\.[^.]+$/, '')
@ -230,6 +233,30 @@ export default function Player({
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 fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const playName = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isRunning = job.status === 'running'
const [hlsReady, setHlsReady] = React.useState(false)
const isLive = isRunning && hlsReady
// ✅ Backend erwartet "id=" (nicht "name=")
// running: echte job.id (jobs-map lookup)
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
const finishedStem = React.useMemo(
() => (playName || '').replace(/\.[^.]+$/, ''),
[playName]
)
const previewId = React.useMemo(
() => (isRunning ? job.id : (finishedStem || job.id)),
[isRunning, job.id, finishedStem]
)
const isHotFile = fileRaw.startsWith('HOT ') const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => { const model = React.useMemo(() => {
const k = (modelKey || '').trim() const k = (modelKey || '').trim()
@ -248,9 +275,6 @@ export default function Player({
return '—' return '—'
}, [job]) }, [job])
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit // Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
const dateLabel = React.useMemo(() => { const dateLabel = React.useMemo(() => {
const fromName = parseDateFromOutput(job.output) const fromName = parseDateFromOutput(job.output)
@ -278,13 +302,21 @@ export default function Player({
// Vorschaubild oben // Vorschaubild oben
const previewA = React.useMemo( const previewA = React.useMemo(
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=preview.jpg`, () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
[job.id] [previewId]
) )
const previewB = React.useMemo( const previewB = React.useMemo(
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=thumbs.jpg`, () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.jpg`),
[job.id] [previewId]
) )
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
const liveHlsSrc = React.useMemo(
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&play=1&file=index_hq.m3u8`),
[previewId]
)
const [previewSrc, setPreviewSrc] = React.useState(previewA) const [previewSrc, setPreviewSrc] = React.useState(previewA)
React.useEffect(() => { React.useEffect(() => {
@ -313,69 +345,106 @@ export default function Player({
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose]) }, [onClose])
const hlsIndexUrl = React.useMemo( const hlsIndexUrl = React.useMemo(() => {
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=index_hq.m3u8`, const u = `/api/record/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1`
[job.id] return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
) }, [previewId, isRunning])
const [hlsReady, setHlsReady] = React.useState(job.status !== 'running') React.useEffect(() => {
if (!isRunning) {
React.useEffect(() => { setHlsReady(false)
if (job.status !== 'running') {
setHlsReady(true)
return return
} }
let alive = true let alive = true
const ctrl = new AbortController() const ctrl = new AbortController()
setHlsReady(false) setHlsReady(false)
const poll = async () => { const poll = async () => {
for (let i = 0; i < 100 && alive && !ctrl.signal.aborted; i++) { for (let i = 0; i < 120 && alive && !ctrl.signal.aborted; i++) {
try { try {
const res = await fetch(hlsIndexUrl, { const res = await apiFetch(hlsIndexUrl, {
method: 'HEAD', method: 'GET',
cache: 'no-store', cache: 'no-store',
signal: ctrl.signal, signal: ctrl.signal,
headers: { 'cache-control': 'no-cache' },
}) })
if (res.status === 200) {
if (alive) setHlsReady(true) if (res.ok) {
return const text = await res.text()
// ✅ muss wirklich wie eine m3u8 aussehen und mindestens 1 Segment enthalten
const hasM3u = text.includes('#EXTM3U')
const hasSegment =
/#EXTINF:/i.test(text) ||
/\.ts(\?|$)/i.test(text) ||
/\.m4s(\?|$)/i.test(text)
if (hasM3u && hasSegment) {
if (alive) setHlsReady(true)
return
}
} }
} catch {} } catch {}
await new Promise((r) => setTimeout(r, 600)) await new Promise((r) => setTimeout(r, 500))
} }
} }
poll() poll()
return () => { return () => {
alive = false alive = false
ctrl.abort() ctrl.abort()
} }
}, [job.status, hlsIndexUrl]) }, [isRunning, hlsIndexUrl])
const media = React.useMemo(() => { const media = React.useMemo(() => {
if (job.status === 'running') { // ✅ Live wird NICHT mehr über Video.js gespielt
return hlsReady if (isRunning) return { src: '', type: '' }
? { src: hlsIndexUrl, type: 'application/x-mpegURL' }
: { src: '', type: 'application/x-mpegURL' }
}
const file = baseName(job.output?.trim() || '') const file = baseName(job.output?.trim() || '')
if (file) { if (file) {
return { src: `/api/record/video?file=${encodeURIComponent(file)}`, type: 'video/mp4' } const ext = file.toLowerCase().split('.').pop()
const type =
ext === 'mp4' ? 'video/mp4' :
ext === 'ts' ? 'video/mp2t' :
'application/octet-stream'
return { src: apiUrl(`/api/record/video?file=${encodeURIComponent(file)}`), type }
} }
return { src: `/api/record/video?id=${encodeURIComponent(job.id)}`, type: 'video/mp4' } return { src: apiUrl(`/api/record/video?id=${encodeURIComponent(job.id)}`), type: 'video/mp4' }
}, [job.status, job.output, job.id, hlsReady, hlsIndexUrl]) }, [isRunning, job.output, job.id])
const containerRef = React.useRef<HTMLDivElement | null>(null) const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null) const playerRef = React.useRef<VideoJsPlayer | null>(null)
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)
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0)
React.useEffect(() => {
if (typeof window === 'undefined') return
const vv = window.visualViewport
if (!vv) return
const bump = () => setVvTick((x) => x + 1)
// bump once + listen
bump()
vv.addEventListener('resize', bump)
vv.addEventListener('scroll', bump)
window.addEventListener('resize', bump)
window.addEventListener('orientationchange', bump)
return () => {
vv.removeEventListener('resize', bump)
vv.removeEventListener('scroll', bump)
window.removeEventListener('resize', bump)
window.removeEventListener('orientationchange', bump)
}
}, [])
const [controlBarH, setControlBarH] = React.useState(56) const [controlBarH, setControlBarH] = React.useState(56)
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null) const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null)
@ -454,10 +523,108 @@ export default function Player({
setPortalTarget(el) setPortalTarget(el)
}, [isDesktop]) }, [isDesktop])
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
if (!isLive) return
if (!media.src) return
const seekToLive = () => {
try {
const lt = (p as any).liveTracker
if (lt && typeof lt.seekToLiveEdge === 'function') {
lt.seekToLiveEdge()
return
}
// Fallback: direkt auf liveCurrentTime
const liveNow = lt?.liveCurrentTime?.()
if (typeof liveNow === 'number' && Number.isFinite(liveNow) && liveNow > 0) {
// minimaler Offset, um direkt wieder zu “buffer underrun” zu vermeiden
p.currentTime(Math.max(0, liveNow - 0.2))
}
} catch {}
}
// 1) Beim Laden sofort an Live-Edge
const onMeta = () => seekToLive()
p.on('loadedmetadata', onMeta)
// 2) Auch beim Play nochmal (manche Browser sind da zickig)
const onPlay = () => seekToLive()
p.on('playing', onPlay)
// 3) “Stay live”: wenn wir zu weit hinten sind, nachziehen
const t = window.setInterval(() => {
try {
if (p.paused()) return
if ((p as any).seeking?.()) return
const lt = (p as any).liveTracker
const liveNow = lt?.liveCurrentTime?.()
if (typeof liveNow !== 'number' || !Number.isFinite(liveNow)) return
const cur = p.currentTime() || 0
const behind = liveNow - cur
// ✅ wenn > 4s hinter Live -> nachziehen
if (behind > 4) {
p.currentTime(Math.max(0, liveNow - 0.2))
}
} catch {}
}, 1500)
// einmal sofort
seekToLive()
return () => {
try { p.off('loadedmetadata', onMeta) } catch {}
try { p.off('playing', onPlay) } catch {}
window.clearInterval(t)
}
}, [isLive, media.src, media.type])
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
if (!isRunning || !hlsReady) return
let lastT = -1
let lastWall = Date.now()
const tick = window.setInterval(() => {
try {
if (p.paused()) return
const ct = p.currentTime() || 0
// wenn Zeit nicht weiterläuft
if (ct <= lastT + 0.01) {
const stuckFor = Date.now() - lastWall
if (stuckFor > 4000) {
const lt = (p as any).liveTracker
if (lt?.seekToLiveEdge) {
lt.seekToLiveEdge()
} else {
// fallback: kleiner hop nach vorne
p.currentTime(Math.max(0, ct + 0.5))
}
lastWall = Date.now()
}
} else {
lastT = ct
lastWall = Date.now()
}
} catch {}
}, 1000)
return () => window.clearInterval(tick)
}, [isRunning, hlsReady])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!mounted) return if (!mounted) return
if (!containerRef.current) return if (!containerRef.current) return
if (playerRef.current) return if (playerRef.current) return
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
const videoEl = document.createElement('video') const videoEl = document.createElement('video')
videoEl.className = 'video-js vjs-big-play-centered w-full h-full' videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
@ -476,6 +643,16 @@ export default function Player({
fluid: false, fluid: false,
fill: true, fill: true,
// ✅ Live UI (wir verstecken zwar die Seekbar, aber LiveTracker ist nützlich)
liveui: true,
// ✅ optional: VHS Low-Latency (wenn deine Video.js-Version es unterstützt)
html5: {
vhs: {
lowLatencyMode: true,
},
},
inactivityTimeout: 0, inactivityTimeout: 0,
controlBar: { controlBar: {
@ -516,7 +693,17 @@ export default function Player({
} }
} }
} }
}, [mounted, startMuted]) }, [mounted, startMuted, isRunning])
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const el = p.el() as HTMLElement | null
if (!el) return
el.classList.toggle('is-live-download', Boolean(isLive))
}, [isLive])
React.useEffect(() => { React.useEffect(() => {
if (!mounted) return if (!mounted) return
@ -527,7 +714,14 @@ export default function Player({
const t = p.currentTime() || 0 const t = p.currentTime() || 0
p.muted(startMuted) p.muted(startMuted)
if (!media.src) return if (!media.src) {
try {
p.pause()
;(p as any).reset?.()
p.error(null as any) // Video.js Error-State leeren
} catch {}
return
}
p.src({ src: media.src, type: media.type }) p.src({ src: media.src, type: media.type })
@ -541,7 +735,8 @@ export default function Player({
p.one('loadedmetadata', () => { p.one('loadedmetadata', () => {
if ((p as any).isDisposed?.()) return if ((p as any).isDisposed?.()) return
try { p.playbackRate(1) } catch {} try { p.playbackRate(1) } catch {}
if (t > 0 && media.type !== 'application/x-mpegURL') p.currentTime(t) const isHls = /mpegurl/i.test(media.type)
if (t > 0 && !isHls) p.currentTime(t)
tryPlay() tryPlay()
}) })
@ -617,17 +812,27 @@ export default function Player({
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener) return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
}, [job.output, releaseMedia, onClose]) }, [job.output, releaseMedia, onClose])
const getViewport = () => { const getViewport = () => {
if (typeof window === 'undefined') return { w: 0, h: 0 } if (typeof window === 'undefined') return { w: 0, h: 0, ox: 0, oy: 0, bottomInset: 0 }
const vv = (window as any).visualViewport as VisualViewport | undefined
const vv = window.visualViewport
if (vv && Number.isFinite(vv.width) && Number.isFinite(vv.height)) { if (vv && Number.isFinite(vv.width) && Number.isFinite(vv.height)) {
return { w: Math.floor(vv.width), h: Math.floor(vv.height) } const w = Math.floor(vv.width)
const h = Math.floor(vv.height)
const ox = Math.floor(vv.offsetLeft || 0)
const oy = Math.floor(vv.offsetTop || 0)
// Space below the visual viewport (Safari bottom bar / keyboard)
const bottomInset = Math.max(0, Math.floor(window.innerHeight - (vv.height + vv.offsetTop)))
return { w, h, ox, oy, bottomInset }
} }
const de = document.documentElement const de = document.documentElement
return { const w = de?.clientWidth || window.innerWidth
w: de?.clientWidth || window.innerWidth, const h = de?.clientHeight || window.innerHeight
h: de?.clientHeight || window.innerHeight, return { w, h, ox: 0, oy: 0, bottomInset: 0 }
}
} }
const clampRect = React.useCallback((r: WinRect, ratio?: number): WinRect => { const clampRect = React.useCallback((r: WinRect, ratio?: number): WinRect => {
@ -997,7 +1202,6 @@ export default function Player({
const phaseRaw = String((job as any).phase ?? '') const phaseRaw = String((job as any).phase ?? '')
const phase = phaseRaw.toLowerCase() const phase = phaseRaw.toLowerCase()
const isRunning = job.status === 'running'
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving' const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending
@ -1107,8 +1311,14 @@ export default function Player({
const fullSize = expanded || miniDesktop const fullSize = expanded || miniDesktop
const overlayBottom = `calc(${controlBarH}px + env(safe-area-inset-bottom))` const liveBottom = `env(safe-area-inset-bottom)`
const metaBottom = `calc(${controlBarH + 8}px + env(safe-area-inset-bottom))` const vjsBottom = `calc(${controlBarH}px + env(safe-area-inset-bottom))`
const overlayBottom = isRunning ? liveBottom : vjsBottom
const metaBottom = isRunning
? `calc(8px + env(safe-area-inset-bottom))`
: `calc(${controlBarH + 8}px + env(safe-area-inset-bottom))`
const topOverlayTop = miniDesktop ? 'top-4' : 'top-2' const topOverlayTop = miniDesktop ? 'top-4' : 'top-2'
const showSideInfo = expanded && isDesktop const showSideInfo = expanded && isDesktop
@ -1129,7 +1339,24 @@ export default function Player({
}} }}
> >
<div className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}> <div className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}>
<div ref={containerRef} className="absolute inset-0" /> {isRunning ? (
<div className="absolute inset-0 bg-black">
<LiveHlsVideo
src={liveHlsSrc}
muted={startMuted}
className="w-full h-full object-contain"
/>
{/* LIVE badge wie im ModelPreview */}
<div className="absolute right-2 bottom-2 z-[60] pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
</div>
) : (
<div ref={containerRef} className="absolute inset-0" />
)}
{/* ✅ Top overlay: Grid -> links Actions, Mitte Drag, rechts Window controls */} {/* ✅ Top overlay: Grid -> links Actions, Mitte Drag, rechts Window controls */}
<div className={cn('absolute inset-x-2 z-30', topOverlayTop)}> <div className={cn('absolute inset-x-2 z-30', topOverlayTop)}>
@ -1219,13 +1446,17 @@ export default function Player({
</div> </div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white"> <div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-semibold">{job.status}</span> {!isRunning && (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-semibold">{job.status}</span>
)}
{(isHot || isHotFile) ? ( {(isHot || isHotFile) ? (
<span className="rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-white"> <span className="rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-white">
HOT HOT
</span> </span>
) : null} ) : null}
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span> {!isRunning && (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span>
)}
{sizeLabel !== '—' ? ( {sizeLabel !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span> <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span>
) : null} ) : null}
@ -1410,11 +1641,11 @@ export default function Player({
</Card> </Card>
) )
const { w: vw, h: vh } = getViewport() const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
const expandedRect = { const expandedRect = {
left: 16, left: ox + 16,
top: 16, top: oy + 16,
width: Math.max(0, vw - 32), width: Math.max(0, vw - 32),
height: Math.max(0, vh - 32), height: Math.max(0, vh - 32),
} }
@ -1427,6 +1658,12 @@ export default function Player({
return createPortal( return createPortal(
<> <>
<style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */
.is-live-download .vjs-progress-control {
display: none !important;
}
`}</style>
{expanded || miniDesktop ? ( {expanded || miniDesktop ? (
<div <div
className={cn( className={cn(
@ -1488,7 +1725,17 @@ export default function Player({
- left-0 right-0 sorgt für volle Breite auf Mobilgeräten. - left-0 right-0 sorgt für volle Breite auf Mobilgeräten.
- md: Overrides für Desktop-Zentrierung. - md: Overrides für Desktop-Zentrierung.
*/ */
<div className="fixed z-[2147483647] w-full left-0 right-0 bottom-0 md:left-1/2 md:-translate-x-1/2 pb-[env(safe-area-inset-bottom)] shadow-2xl"> <div
className="
fixed z-[2147483647] inset-x-0 w-full
shadow-2xl
md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
"
style={{
// ✅ iOS: keep above Safari bottom bar / keyboard + keep safe-area
bottom: `calc(${bottomInset}px + env(safe-area-inset-bottom))`,
}}
>
{cardEl} {cardEl}
</div> </div>
)} )}

View File

@ -141,6 +141,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null rafRef.current = null
} }
dxRef.current = 0 dxRef.current = 0
// ✅ TouchAction zurücksetzen (falls während Drag auf none gestellt)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
setAnimMs(snapMs) setAnimMs(snapMs)
setDx(0) setDx(0)
@ -155,6 +157,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null rafRef.current = null
} }
// ✅ TouchAction zurücksetzen (Sicherheit)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
const el = cardRef.current const el = cardRef.current
const w = el?.offsetWidth || 360 const w = el?.offsetWidth || 360
@ -315,6 +320,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ jetzt erst beginnen wir zu swipen // ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true pointer.current.dragging = true
// ✅ während horizontalem Drag keine Scroll-Gesten verhandeln
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
// ✅ Anim nur 1x beim Drag-Start deaktivieren // ✅ Anim nur 1x beim Drag-Start deaktivieren
setAnimMs(0) setAnimMs(0)
@ -364,6 +372,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {} } catch {}
} }
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) { if (!wasDragging) {
// ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten // ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten
@ -412,6 +422,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
dxRef.current = 0 dxRef.current = 0
try { (e.currentTarget as HTMLElement).style.touchAction = 'pan-y' } catch {}
reset() reset()
}} }}
> >

20
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,20 @@
// frontend/src/lib/api.ts
// ✅ DEV: immer relativ, damit Vite-Proxy (/api -> :9999) greift → kein CORS
// ✅ PROD: optional per VITE_API_BASE überschreiben
const API_BASE =
import.meta.env.VITE_API_BASE ??
(import.meta.env.DEV ? '' : '')
export const apiUrl = (path: string) => {
const p = path.startsWith('/') ? path : `/${path}`
return `${API_BASE}${p}`
}
export async function apiFetch(path: string, init?: RequestInit) {
return fetch(apiUrl(path), {
...init,
// falls du Cookies/Sessions brauchst:
credentials: 'include',
})
}

View File

@ -1,3 +1,5 @@
// frontend\vite.config.ts
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
@ -12,14 +14,11 @@ export default defineConfig({
server: { server: {
host: true, // oder '0.0.0.0' host: true, // oder '0.0.0.0'
proxy: { proxy: {
'/api': { "/api": {
target: 'http://10.0.1.25:9999', target: "http://localhost:9999",
changeOrigin: true,
},
'/generated': {
target: 'http://localhost:9999',
changeOrigin: true, changeOrigin: true,
secure: false,
}, },
}, },
}, }
}) })