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 {
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) {

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
import (
@ -17,7 +19,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
const cooldown = 2 * time.Minute
// 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{}

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>
<meta charset="UTF-8" />
<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>
<script type="module" crossorigin src="/assets/index-DSZfASIn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hlx7oHN0.css">
<script type="module" crossorigin src="/assets/index-BqjSaPox.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CRe6vAJq.css">
</head>
<body>
<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>
<meta charset="UTF-8" />
<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>
</head>
<body>

View File

@ -188,6 +188,22 @@ export default function App() {
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
type DoneSortMode =
@ -1049,31 +1065,55 @@ export default function App() {
return startUrl(sourceUrl)
}
const handleDeleteJob = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
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 {
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))
try {
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
const data = await apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
window.setTimeout(() => {
void refreshDoneNow()
}, 350)
} 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 // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
}
}, [notify])
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
window.setTimeout(() => {
void refreshDoneNow()
}, 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(
async (job: RecordJob) => {
@ -1102,30 +1142,50 @@ export default function App() {
[selectedTab, refreshDoneNow, notify]
)
const handleToggleHot = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
const handleToggleHot = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
try {
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
try {
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ 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))
setDoneJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
)
setJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
)
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
return
}
}, [notify])
const apply = (out: string) => replaceBasename(out || '', res.newFile)
// ✅ 1) Player immer updaten
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
// ✅ 2) doneJobs über ID (Fallback: basename)
setDoneJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
return match ? { ...j, output: apply(j.output || '') } : j
})
)
// ✅ 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) ---
async function patchModelFlags(patch: any): Promise<any | null> {
@ -1970,7 +2030,7 @@ export default function App() {
}, [recSettings.useChaturbateApi])
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 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" />
@ -2033,7 +2093,7 @@ export default function App() {
</div>
<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
variant="secondary"
onClick={() => setCookieModalOpen(true)}
@ -2047,7 +2107,7 @@ export default function App() {
</div>
<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">
Cookies
</Button>
@ -2136,7 +2196,7 @@ export default function App() {
pageSize={DONE_PAGE_SIZE}
onPageChange={setDonePage}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJob}
onDeleteJob={handleDeleteJobWithUndo}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}

View File

@ -53,7 +53,9 @@ type Props = {
teaserPlayback?: TeaserPlaybackMode
teaserAudio?: boolean
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>
onToggleFavorite?: (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 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 {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
@ -226,6 +234,14 @@ export default function FinishedDownloads({
const [deletedKeys, setDeletedKeys] = 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
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 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)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
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)
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 previewMuted = !Boolean(teaserAudio)
@ -611,20 +754,37 @@ export default function FinishedDownloads({
try {
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) {
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
}
// Fallback (falls mal ohne App-Handler verwendet)
// Fallback: Backend direkt
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ 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)
// ✅ Tab-Count sofort korrigieren (App hört drauf)
@ -638,7 +798,15 @@ export default function FinishedDownloads({
markDeleting(key, false)
}
},
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, notify]
[
deletingKeys,
markDeleting,
releasePlayingFile,
onDeleteJob,
animateRemove,
notify,
setLastAction,
]
)
const keepVideo = useCallback(
@ -655,12 +823,20 @@ export default function FinishedDownloads({
markKeeping(key, true)
try {
await releasePlayingFile(file, { close: true })
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
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
animateRemove(key)
@ -675,9 +851,52 @@ export default function FinishedDownloads({
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(
async (job: RecordJob) => {
const file = baseName(job.output || '')
@ -691,31 +910,14 @@ export default function FinishedDownloads({
// ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen
const applyOptimisticRename = (oldFile: string, newFile: string) => {
if (!newFile || newFile === oldFile) return
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 }
})
applyRename(oldFile, newFile)
}
// ✅ falls Backend andere Namen liefert: Server-Truth nachziehen
const applyServerTruth = (apiOld: string, apiNew: string, optimisticNew: string) => {
if (!apiNew) return
if (apiNew === optimisticNew && apiOld === file) return
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 }
})
applyRename(apiOld, apiNew)
}
try {
@ -724,9 +926,12 @@ export default function FinishedDownloads({
const oldFile = file
const optimisticNew = toggledName(oldFile)
// ✅ Wichtig: Optimistik IMMER anwenden auch wenn ein externer onToggleHot Handler existiert
// ✅ Wichtig: Optimistik IMMER anwenden
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
if (onToggleHot) {
await onToggleHot(job)
@ -747,12 +952,15 @@ export default function FinishedDownloads({
applyServerTruth(apiOld, apiNew, optimisticNew)
// ✅ Undo-Dateiname ggf. auf Server-Truth setzen
if (apiNew) setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill()
} catch (e: any) {
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) => {
@ -809,9 +1017,13 @@ export default function FinishedDownloads({
}
const list = Array.from(map.values()).filter((j) => {
if (deletedKeys.has(keyFor(j))) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
})
if (deletedKeys.has(keyFor(j))) return false
// ✅ .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
})
return list
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
@ -853,6 +1065,21 @@ export default function FinishedDownloads({
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [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 base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
@ -1006,14 +1233,13 @@ export default function FinishedDownloads({
const handleDuration = useCallback((job: RecordJob, seconds: number) => {
if (!Number.isFinite(seconds) || seconds <= 0) return
const k = keyFor(job)
setDurations((prev) => {
const old = prev[k]
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) {
return prev // keine unnötigen Re-Renders
}
return { ...prev, [k]: seconds }
})
}, [])
const old = durationsRef.current[k]
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return
durationsRef.current = { ...durationsRef.current, [k]: seconds }
flushDurationsSoon()
}, [flushDurationsSoon])
const columns: Column<RecordJob>[] = [
{
@ -1403,6 +1629,21 @@ export default function FinishedDownloads({
)}
{/* 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
value={view}
onChange={(id) => setView(id as ViewMode)}

View File

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

View File

@ -16,6 +16,8 @@ import {
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveHlsVideo from './LiveHlsVideo'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
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 {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return null
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 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 model = React.useMemo(() => {
const k = (modelKey || '').trim()
@ -248,9 +275,6 @@ export default function Player({
return '—'
}, [job])
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
const dateLabel = React.useMemo(() => {
const fromName = parseDateFromOutput(job.output)
@ -278,13 +302,21 @@ export default function Player({
// Vorschaubild oben
const previewA = React.useMemo(
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=preview.jpg`,
[job.id]
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
[previewId]
)
const previewB = React.useMemo(
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=thumbs.jpg`,
[job.id]
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.jpg`),
[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)
React.useEffect(() => {
@ -313,69 +345,106 @@ export default function Player({
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const hlsIndexUrl = React.useMemo(
() => `/api/record/preview?id=${encodeURIComponent(job.id)}&file=index_hq.m3u8`,
[job.id]
)
const hlsIndexUrl = React.useMemo(() => {
const u = `/api/record/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1`
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
}, [previewId, isRunning])
const [hlsReady, setHlsReady] = React.useState(job.status !== 'running')
React.useEffect(() => {
if (job.status !== 'running') {
setHlsReady(true)
React.useEffect(() => {
if (!isRunning) {
setHlsReady(false)
return
}
let alive = true
const ctrl = new AbortController()
setHlsReady(false)
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 {
const res = await fetch(hlsIndexUrl, {
method: 'HEAD',
const res = await apiFetch(hlsIndexUrl, {
method: 'GET',
cache: 'no-store',
signal: ctrl.signal,
headers: { 'cache-control': 'no-cache' },
})
if (res.status === 200) {
if (alive) setHlsReady(true)
return
if (res.ok) {
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 {}
await new Promise((r) => setTimeout(r, 600))
await new Promise((r) => setTimeout(r, 500))
}
}
poll()
return () => {
alive = false
ctrl.abort()
}
}, [job.status, hlsIndexUrl])
}, [isRunning, hlsIndexUrl])
const media = React.useMemo(() => {
if (job.status === 'running') {
return hlsReady
? { src: hlsIndexUrl, type: 'application/x-mpegURL' }
: { src: '', type: 'application/x-mpegURL' }
}
// ✅ Live wird NICHT mehr über Video.js gespielt
if (isRunning) return { src: '', type: '' }
const file = baseName(job.output?.trim() || '')
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' }
}, [job.status, job.output, job.id, hlsReady, hlsIndexUrl])
return { src: apiUrl(`/api/record/video?id=${encodeURIComponent(job.id)}`), type: 'video/mp4' }
}, [isRunning, job.output, job.id])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
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 [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null)
@ -454,10 +523,108 @@ export default function Player({
setPortalTarget(el)
}, [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(() => {
if (!mounted) return
if (!containerRef.current) return
if (playerRef.current) return
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
const videoEl = document.createElement('video')
videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
@ -476,6 +643,16 @@ export default function Player({
fluid: false,
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,
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(() => {
if (!mounted) return
@ -527,7 +714,14 @@ export default function Player({
const t = p.currentTime() || 0
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 })
@ -541,7 +735,8 @@ export default function Player({
p.one('loadedmetadata', () => {
if ((p as any).isDisposed?.()) return
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()
})
@ -617,17 +812,27 @@ export default function Player({
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
}, [job.output, releaseMedia, onClose])
const getViewport = () => {
if (typeof window === 'undefined') return { w: 0, h: 0 }
const vv = (window as any).visualViewport as VisualViewport | undefined
const getViewport = () => {
if (typeof window === 'undefined') return { w: 0, h: 0, ox: 0, oy: 0, bottomInset: 0 }
const vv = window.visualViewport
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
return {
w: de?.clientWidth || window.innerWidth,
h: de?.clientHeight || window.innerHeight,
}
const w = de?.clientWidth || window.innerWidth
const h = de?.clientHeight || window.innerHeight
return { w, h, ox: 0, oy: 0, bottomInset: 0 }
}
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 phase = phaseRaw.toLowerCase()
const isRunning = job.status === 'running'
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending
@ -1107,8 +1311,14 @@ export default function Player({
const fullSize = expanded || miniDesktop
const overlayBottom = `calc(${controlBarH}px + env(safe-area-inset-bottom))`
const metaBottom = `calc(${controlBarH + 8}px + env(safe-area-inset-bottom))`
const liveBottom = `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 showSideInfo = expanded && isDesktop
@ -1129,7 +1339,24 @@ export default function Player({
}}
>
<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 */}
<div className={cn('absolute inset-x-2 z-30', topOverlayTop)}>
@ -1219,13 +1446,17 @@ export default function Player({
</div>
<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) ? (
<span className="rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-white">
HOT
</span>
) : 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 !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span>
) : null}
@ -1410,11 +1641,11 @@ export default function Player({
</Card>
)
const { w: vw, h: vh } = getViewport()
const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
const expandedRect = {
left: 16,
top: 16,
left: ox + 16,
top: oy + 16,
width: Math.max(0, vw - 32),
height: Math.max(0, vh - 32),
}
@ -1427,6 +1658,12 @@ export default function Player({
return createPortal(
<>
<style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */
.is-live-download .vjs-progress-control {
display: none !important;
}
`}</style>
{expanded || miniDesktop ? (
<div
className={cn(
@ -1488,7 +1725,17 @@ export default function Player({
- left-0 right-0 sorgt für volle Breite auf Mobilgeräten.
- 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}
</div>
)}

View File

@ -141,6 +141,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null
}
dxRef.current = 0
// ✅ TouchAction zurücksetzen (falls während Drag auf none gestellt)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
setAnimMs(snapMs)
setDx(0)
@ -155,6 +157,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null
}
// ✅ TouchAction zurücksetzen (Sicherheit)
if (cardRef.current) cardRef.current.style.touchAction = 'pan-y'
const el = cardRef.current
const w = el?.offsetWidth || 360
@ -315,6 +320,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ jetzt erst beginnen wir zu swipen
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
setAnimMs(0)
@ -364,6 +372,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {}
}
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) {
// ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten
@ -412,6 +422,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
dxRef.current = 0
try { (e.currentTarget as HTMLElement).style.touchAction = 'pan-y' } catch {}
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 react from '@vitejs/plugin-react'
import path from 'path'
@ -12,14 +14,11 @@ export default defineConfig({
server: {
host: true, // oder '0.0.0.0'
proxy: {
'/api': {
target: 'http://10.0.1.25:9999',
changeOrigin: true,
},
'/generated': {
target: 'http://localhost:9999',
"/api": {
target: "http://localhost:9999",
changeOrigin: true,
secure: false,
},
},
},
}
})