updated
This commit is contained in:
parent
50515d44b0
commit
ce68074a5a
@ -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.
BIN
backend/ffmpeg
BIN
backend/ffmpeg
Binary file not shown.
168
backend/generated_gc.go
Normal file
168
backend/generated_gc.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1179
backend/main.go
1179
backend/main.go
File diff suppressed because it is too large
Load Diff
@ -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.
345
backend/web/dist/assets/index-BqjSaPox.js
vendored
Normal file
345
backend/web/dist/assets/index-BqjSaPox.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CRe6vAJq.css
vendored
Normal file
1
backend/web/dist/assets/index-CRe6vAJq.css
vendored
Normal file
File diff suppressed because one or more lines are too long
336
backend/web/dist/assets/index-DSZfASIn.js
vendored
336
backend/web/dist/assets/index-DSZfASIn.js
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-hlx7oHN0.css
vendored
1
backend/web/dist/assets/index-hlx7oHN0.css
vendored
File diff suppressed because one or more lines are too long
6
backend/web/dist/index.html
vendored
6
backend/web/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -1 +1 @@
|
||||
DATABASE_URL="file:./prisma/models.db"
|
||||
DATABASE_URL="file:./prisma/models.db"
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
20
frontend/src/lib/api.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user