This commit is contained in:
Linrador 2026-01-19 12:56:16 +01:00
parent 7d7387d8bb
commit d909d951a3
29 changed files with 2870 additions and 1452 deletions

View File

@ -23,26 +23,6 @@ type BioContextResp struct {
Bio any `json:"bio,omitempty"`
}
func sanitizeModelKey(s string) string {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
// erlaubte chars: a-z A-Z 0-9 _ - .
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '_' || r == '-' || r == '.':
return r
default:
return -1
}
}, s)
return strings.TrimSpace(s)
}
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)

View File

@ -443,6 +443,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
"enabled": false,
"fetchedAt": time.Time{},
"count": 0,
"total": 0,
"lastError": "",
"rooms": []any{},
}
@ -462,6 +463,21 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock()
// ✅ total = Anzahl online rooms (ggf. show-gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
if len(allowedShow) == 0 {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if allowedShow[s] {
total++
}
}
}
}
// ---------------------------
// Refresh/Bootstrap-Strategie:
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
@ -570,6 +586,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
"enabled": true,
"fetchedAt": fetchedAt,
"count": len(outRooms),
"total": total,
"lastError": lastErr,
"rooms": outRooms, // ✅ klein & schnell
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

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

@ -4,9 +4,9 @@
<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" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-jMGU1_s9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ie8TR6qH.css">
<title>App</title>
<script type="module" crossorigin src="/assets/index-IS5yelG1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ByYRHYVi.css">
</head>
<body>
<div id="root"></div>

View File

@ -4,7 +4,7 @@
<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" />
<title>frontend</title>
<title>App</title>
</head>
<body>
<div id="root"></div>

View File

@ -13,7 +13,7 @@ import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails'
import { SignalIcon, HeartIcon, HandThumbUpIcon } from '@heroicons/react/24/solid'
import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
@ -51,6 +51,7 @@ type RecorderSettings = {
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -62,10 +63,11 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 50,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 3000,
}
type StoredModel = {
@ -106,6 +108,7 @@ type ChaturbateOnlineRoom = {
type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
total?: number
}
function normalizeHttpUrl(raw: string): string | null {
@ -222,6 +225,8 @@ export default function App() {
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [onlineModelsCount, setOnlineModelsCount] = useState(0)
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
useEffect(() => {
@ -413,9 +418,6 @@ export default function App() {
const pendingStartUrlRef = useRef<string | null>(null)
const lastClipboardUrlRef = useRef<string>('')
// ✅ Online-Status für Models aus dem Model-Store
const [onlineStoreKeysLower, setOnlineStoreKeysLower] = useState<Record<string, true>>({})
// ✅ Zentraler Snapshot: username(lower) -> room
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
@ -569,7 +571,7 @@ export default function App() {
const onFocus = () => void load()
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
window.addEventListener('hover', onFocus)
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
load()
@ -577,7 +579,7 @@ export default function App() {
return () => {
cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('hover', onFocus)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [])
@ -618,15 +620,20 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running')
const onlineModelsCount = useMemo(() => {
// ✅ Anzahl Watched Models (aus Store), die online sind
const onlineWatchedModelsCount = useMemo(() => {
let c = 0
for (const m of Object.values(modelsByKey)) {
if (!m?.watching) continue
if (!isChaturbateStoreModel(m)) continue
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
if (onlineStoreKeysLower[k]) c++
if (cbOnlineByKeyLower[k]) c++
}
return c
}, [modelsByKey, onlineStoreKeysLower])
}, [modelsByKey, cbOnlineByKeyLower, isChaturbateStoreModel])
const { onlineFavCount, onlineLikedCount } = useMemo(() => {
let fav = 0
@ -635,14 +642,14 @@ export default function App() {
for (const m of Object.values(modelsByKey)) {
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
if (!onlineStoreKeysLower[k]) continue
if (!cbOnlineByKeyLower[k]) continue
if (m?.favorite) fav++
if (m?.liked === true) liked++
}
return { onlineFavCount: fav, onlineLikedCount: liked }
}, [modelsByKey, onlineStoreKeysLower])
}, [modelsByKey, cbOnlineByKeyLower])
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
@ -706,18 +713,28 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded])
// done meta polling (unverändert)
// ✅ done count polling über /api/record/done (kein /done/meta mehr)
useEffect(() => {
let cancelled = false
let t: number | undefined
const loadDoneMeta = async () => {
const loadDoneCount = async () => {
try {
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
// pageSize=1 => minimaler Payload, zählt trotzdem korrekt
const res = await fetch(
`/api/record/done?page=1&pageSize=1&withCount=1&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const meta = (await res.json()) as { count?: number }
const data = await res.json().catch(() => null)
const countRaw =
Number(data?.count ?? data?.totalCount ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (!cancelled) {
setDoneCount(meta.count ?? 0)
setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
@ -725,24 +742,24 @@ export default function App() {
} finally {
if (!cancelled) {
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneMeta, ms)
t = window.setTimeout(loadDoneCount, ms)
}
}
}
const onVis = () => {
if (!document.hidden) void loadDoneMeta()
if (!document.hidden) void loadDoneCount()
}
document.addEventListener('visibilitychange', onVis)
void loadDoneMeta()
void loadDoneCount()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [])
}, [doneSort])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
@ -834,29 +851,62 @@ export default function App() {
if (selectedTab !== 'finished') return
let cancelled = false
let inFlight = false
const inFlightRef = { current: false }
const ac = new AbortController()
const loadDone = async () => {
if (cancelled || inFlight) return
inFlight = true
if (cancelled || inFlightRef.current) return
inFlightRef.current = true
try {
const list = await apiJSON<RecordJob[]>(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
const res = await fetch(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}` +
`&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json().catch(() => null)
// akzeptiere beide Formen:
// A) { count, items }
// B) doneListResponse { totalCount, items }
// C) Legacy array
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw =
typeof data?.count === 'number'
? data.count
: typeof data?.totalCount === 'number'
? data.totalCount
: items.length
if (!cancelled) {
setDoneJobs(items)
setDoneCount(Number.isFinite(countRaw) ? countRaw : items.length)
}
} catch {
if (!cancelled) setDoneJobs([])
if (!cancelled) {
setDoneJobs([])
setDoneCount(0)
}
} finally {
inFlight = false
inFlightRef.current = false
}
}
loadDone()
void loadDone()
const baseMs = 20000
const tickMs = document.hidden ? 60000 : baseMs
const t = window.setInterval(loadDone, tickMs)
const t = window.setInterval(() => {
if (!document.hidden) void loadDone()
}, baseMs)
const onVis = () => {
if (!document.hidden) void loadDone()
@ -865,6 +915,7 @@ export default function App() {
return () => {
cancelled = true
ac.abort()
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
@ -873,21 +924,36 @@ export default function App() {
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
try {
const meta = await apiJSON<{ count?: number }>('/api/record/done/meta', { cache: 'no-store' as any })
const countRaw = typeof meta?.count === 'number' ? meta.count : 0
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const data = await apiJSON<any>(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
const list = await apiJSON<RecordJob[]>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
setDoneJobs(Array.isArray(list) ? list : [])
// wenn target anders ist, optional nochmal mit target laden:
if (target === wanted) {
setDoneJobs(items)
} else {
const data2 = await apiJSON<any>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
}
} catch {
// ignore
}
@ -967,7 +1033,7 @@ export default function App() {
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
}
}, [])
}, [notify])
const handleKeepJob = useCallback(
async (job: RecordJob) => {
@ -993,7 +1059,7 @@ export default function App() {
return
}
},
[selectedTab, refreshDoneNow]
[selectedTab, refreshDoneNow, notify]
)
const handleToggleHot = useCallback(async (job: RecordJob) => {
@ -1682,7 +1748,7 @@ export default function App() {
void startUrl(pending)
}, [busy, autoStartEnabled, startUrl])
useEffect(() => {
useEffect(() => {
const stop = startChaturbateOnlinePolling({
getModels: () => {
if (!recSettingsRef.current.useChaturbateApi) return []
@ -1713,7 +1779,6 @@ export default function App() {
if (!data?.enabled) {
setCbOnlineByKeyLower({})
cbOnlineByKeyLowerRef.current = {}
setOnlineStoreKeysLower({})
setPendingWatchedRooms([])
setLastHeaderUpdateAtMs(Date.now())
return
@ -1735,7 +1800,6 @@ export default function App() {
const kl = String(k || '').trim().toLowerCase()
if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
}
setOnlineStoreKeysLower(nextOnlineStore)
// Pending Watched Rooms (nur im running Tab)
if (!recSettingsRef.current.useChaturbateApi) {
@ -1829,6 +1893,42 @@ export default function App() {
return () => stop()
}, [])
useEffect(() => {
// ✅ nur sinnvoll, wenn Chaturbate API aktiv ist
if (!recSettings.useChaturbateApi) {
setOnlineModelsCount(0)
return
}
const stop = startChaturbateOnlinePolling({
// ✅ leer => ALL-mode (durch fetchAllWhenNoModels)
getModels: () => [],
getShow: () => ['public', 'private', 'hidden', 'away'],
// deutlich seltener, weil potentiell groß
intervalMs: 30000,
fetchAllWhenNoModels: true,
onData: (data) => {
if (!data?.enabled) {
setOnlineModelsCount(0)
return
}
const total = Number((data as any)?.total ?? 0)
setOnlineModelsCount(Number.isFinite(total) ? total : 0)
setLastHeaderUpdateAtMs(Date.now())
},
onError: (e) => {
console.error('[ALL-online poller] error', e)
},
})
return () => stop()
}, [recSettings.useChaturbateApi])
return (
<div className="min-h-screen 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">
@ -1857,6 +1957,14 @@ export default function App() {
<span className="tabular-nums">{onlineModelsCount}</span>
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="Watched online"
>
<EyeIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineWatchedModelsCount}</span>
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="Fav online"
@ -2002,7 +2110,6 @@ export default function App() {
setDoneSort(m)
setDonePage(1)
}}
onRefreshDone={refreshDoneNow}
/>
) : null}
@ -2026,10 +2133,21 @@ export default function App() {
}}
/>
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
/>
{playerJob ? (
<Player
job={playerJob}
modelKey={playerModelKey ?? undefined}
modelsByKey={modelsByKey}
expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => setPlayerJob(null)}
@ -2046,16 +2164,6 @@ export default function App() {
onToggleWatch={handleToggleWatch}
/>
) : null}
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
/>
</div>
</div>
)

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\Downloads.tsx
'use client'
import { useMemo, useState, useCallback, useEffect } from 'react'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
@ -547,12 +547,15 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false)
const watchedPausedRef = useRef<boolean | null>(null)
const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => {
try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
setWatchedPaused(Boolean(s?.paused))
const next = Boolean(s?.paused)
watchedPausedRef.current = next
setWatchedPaused(next)
} catch {
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
}
@ -566,7 +569,12 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const unsub = subscribeSSE<AutostartState>(
'/api/autostart/state/stream',
'autostart',
(data) => setWatchedPaused(Boolean((data as any)?.paused))
(data) => {
const next = Boolean((data as any)?.paused)
if (watchedPausedRef.current === next) return
watchedPausedRef.current = next
setWatchedPaused(next)
}
)
return () => {
@ -656,7 +664,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
const t = window.setInterval(() => setNowMs(Date.now()), 15000)
return () => window.clearInterval(t)
}, [hasActive])
@ -738,15 +746,22 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const j = r.job
const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output)
const phase = String((j as any).phase ?? '').trim()
const rawStatus = String(j.status ?? '').toLowerCase()
// Final "stopped" sauber erkennen (inkl. UI-Stop)
const isStopRequested = Boolean(stopRequestedIds[j.id])
const stopInitiated = Boolean(stopInitiatedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
const statusText = rawStatus || 'unknown'
// ✅ Status-Text neben dem Modelname: NUR Job-Status
// (keine phase wie assets/moving/remuxing)
const statusText = isStoppedFinal ? 'stopped' : (rawStatus || 'unknown')
// Optional: "Stoppe…" rein UI-seitig anzeigen, aber ohne phase
const showStoppingUI = !isStoppedFinal && isStopRequested
const badgeText = showStoppingUI ? 'stopping' : statusText
return (
<>
@ -757,20 +772,21 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
// Status-Farben
isStopping
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: j.status === 'finished'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: j.status === 'failed'
rawStatus === 'running'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: isStoppedFinal
? 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
: rawStatus === 'failed'
? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25'
: isStoppedFinal
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
: rawStatus === 'finished'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: showStoppingUI
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')}
title={statusText}
title={badgeText}
>
{statusText}
{badgeText}
</span>
</div>
<span className="block max-w-[220px] truncate" title={j.output}>
@ -984,70 +1000,70 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
return (
<div className="grid gap-3">
{(hasAnyPending || hasJobs) ? (
<>
{/* Toolbar (sticky) wie FinishedDownloads */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="flex items-center justify-between gap-2 p-3">
{/* Title + Count */}
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
</span>
</div>
{/* Actions + View */}
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
{/* ✅ Toolbar immer sichtbar (auch wenn keine Jobs/Pending existieren) */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="flex items-center justify-between gap-2 p-3">
{/* Title + Count */}
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
</span>
</div>
{/* Actions */}
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
</div>
</div>
{/* Content */}
{/* ✅ Content abhängig von Jobs/Pending */}
{(hasAnyPending || hasJobs) ? (
<>
{/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden">
{rows.map((r) => (
@ -1085,9 +1101,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
/>
</div>
</>
) : null}
{!hasAnyPending && !hasJobs ? (
) : (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
@ -1103,7 +1117,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
</div>
</div>
</Card>
) : null}
)}
</div>
)
}

View File

@ -9,7 +9,14 @@ import Card from './Card'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import ButtonGroup from './ButtonGroup'
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline'
import {
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
@ -22,7 +29,8 @@ import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { useNotify } from './notify'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
import Switch from './Switch'
type SortMode =
| 'completed_desc'
@ -55,13 +63,13 @@ type Props = {
page: number
pageSize: number
onPageChange: (page: number) => void
onRefreshDone?: (preferPage?: number) => void | Promise<void>
assetNonce?: number
sortMode: SortMode
onSortModeChange: (m: SortMode) => void
}
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
const norm = (p: string) => (p || '').replaceAll('\\', '/')
const baseName = (p: string) => {
const n = norm(p)
const parts = n.split('/')
@ -118,8 +126,6 @@ const httpCodeFromError = (err?: string) => {
return m ? `HTTP ${m[1]}` : null
}
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
@ -197,7 +203,6 @@ export default function FinishedDownloads({
page,
pageSize,
onPageChange,
onRefreshDone,
assetNonce,
sortMode,
onSortModeChange,
@ -241,8 +246,13 @@ export default function FinishedDownloads({
type ViewMode = 'table' | 'cards' | 'gallery'
const VIEW_KEY = 'finishedDownloads_view'
const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
const [view, setView] = React.useState<ViewMode>('table')
const [includeKeep, setIncludeKeep] = React.useState(false)
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
@ -287,18 +297,23 @@ export default function FinishedDownloads({
const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => {
const res = await fetch(`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}`, {
cache: 'no-store' as any,
signal,
})
const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
{
cache: 'no-store' as any,
signal,
}
)
if (!res.ok) return
const list = await res.json().catch(() => [])
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
setOverrideDoneJobs(arr)
setOverrideDoneTotal(arr.length)
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
},
[sortMode]
[sortMode, includeKeep]
)
const clearTagFilter = useCallback(() => setTagFilter([]), [])
@ -338,16 +353,18 @@ export default function FinishedDownloads({
;(async () => {
try {
// 1) META holen (count), damit Pagination stimmt
const metaRes = await fetch('/api/record/done/meta', {
cache: 'no-store' as any,
signal: ac.signal,
})
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null)
const count = Number(meta?.count ?? 0)
if (listRes.ok) {
const data = await listRes.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
if (Number.isFinite(count) && count >= 0) {
setOverrideDoneTotal(count)
@ -359,24 +376,13 @@ export default function FinishedDownloads({
}
}
}
// 2) LISTE für aktuelle Seite holen
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (listRes.ok) {
const list = await listRes.json().catch(() => [])
setOverrideDoneJobs(Array.isArray(list) ? list : [])
}
} catch {
// Abort / Fehler ignorieren
}
})()
return () => ac.abort()
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs])
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs, includeKeep])
useEffect(() => {
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
@ -386,6 +392,42 @@ export default function FinishedDownloads({
setOverrideDoneTotal(null)
}, [doneJobs, doneTotal, globalFilterActive])
useEffect(() => {
if (!includeKeep) {
// zurück auf "nur /done/" (Props)
if (!globalFilterActive) {
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}
return
}
// includeKeep = true:
// - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
if (globalFilterActive) return
const ac = new AbortController()
;(async () => {
try {
const res = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} catch {}
})()
return () => ac.abort()
}, [includeKeep, globalFilterActive, page, pageSize, sortMode])
useEffect(() => {
try {
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
@ -406,6 +448,36 @@ export default function FinishedDownloads({
} catch {}
}, [view])
useEffect(() => {
try {
const raw = localStorage.getItem(KEEP_KEY)
setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setIncludeKeep(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0')
} catch {}
}, [includeKeep])
useEffect(() => {
try {
const raw = localStorage.getItem(MOBILE_OPTS_KEY)
setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setMobileOptionsOpen(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0')
} catch {}
}, [mobileOptionsOpen])
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({})
@ -486,12 +558,9 @@ export default function FinishedDownloads({
// ✅ wichtig: Seite sofort neu laden -> Item rückt nach
queueRefill()
// optional: Parent sync (kann bleiben, muss aber nicht)
void onRefreshDone?.(page)
}, 320)
},
[markDeleted, markRemoving, queueRefill, onRefreshDone, page]
[markDeleted, markRemoving, queueRefill]
)
const releasePlayingFile = useCallback(
@ -524,9 +593,6 @@ export default function FinishedDownloads({
if (onDeleteJob) {
await onDeleteJob(job)
// ✅ nach erfolgreichem Delete die Page nachziehen
queueRefill()
return true
}
@ -546,7 +612,7 @@ export default function FinishedDownloads({
markDeleting(key, false)
}
},
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill, notify]
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, notify]
)
const keepVideo = useCallback(
@ -707,7 +773,6 @@ export default function FinishedDownloads({
if (view === 'cards') {
window.setTimeout(() => {
markDeleted(key)
void onRefreshDone?.(page) // ✅ HIER dazu
}, 320)
} else {
animateRemove(key)
@ -722,13 +787,12 @@ export default function FinishedDownloads({
} else if (detail.phase === 'success') {
markDeleting(key, false)
queueRefill()
void onRefreshDone?.(page)
}
}
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view, onRefreshDone, page, queueRefill])
}, [animateRemove, markDeleting, markDeleted, view, queueRefill])
const visibleRows = useMemo(() => {
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
@ -949,16 +1013,16 @@ export default function FinishedDownloads({
sortable: true,
sortValue: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
},
cell: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const model = modelNameFromOutput(j.output)
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
const show = tags.slice(0, 6)
@ -1113,7 +1177,7 @@ export default function FinishedDownloads({
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
@ -1246,6 +1310,22 @@ export default function FinishedDownloads({
</div>
)}
{/* Keep toggle (Desktop) */}
<div className="hidden sm:flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-200">Behaltene Downloads anzeigen</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="short"
className=""
/>
</div>
{/* Desktop: Suche neben Sort */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
<input
@ -1265,6 +1345,24 @@ export default function FinishedDownloads({
) : null}
</div>
{/* Mobile: Optionen ein/ausklappen */}
<button
type="button"
className="sm:hidden inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-2.5 text-xs font-semibold text-gray-900 shadow-sm
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
onClick={() => setMobileOptionsOpen((v) => !v)}
aria-expanded={mobileOptionsOpen}
aria-controls="finished-mobile-options"
>
<AdjustmentsHorizontalIcon className="size-4" />
Optionen
{mobileOptionsOpen ? (
<ChevronUpIcon className="size-4 opacity-80" />
) : (
<ChevronDownIcon className="size-4 opacity-80" />
)}
</button>
{/* Views */}
<ButtonGroup
value={view}
@ -1295,24 +1393,54 @@ export default function FinishedDownloads({
</div>
</div>
{/* Mobile Suche */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
{/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
<div
id="finished-mobile-options"
className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
>
{/* Mobile Suche */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
</div>
</div>
{/* Mobile: Keep Toggle */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Behaltene Downloads anzeigen
</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
</div>
@ -1444,7 +1572,6 @@ export default function FinishedDownloads({
swipeRefs={swipeRefs}
keyFor={keyFor}
baseName={baseName}
stripHotPrefix={stripHotPrefix}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
@ -1467,6 +1594,7 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
/>
)}
@ -1507,7 +1635,6 @@ export default function FinishedDownloads({
hoverTeaserKey={hoverTeaserKey}
keyFor={keyFor}
baseName={baseName}
stripHotPrefix={stripHotPrefix}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}

View File

@ -15,7 +15,7 @@ import {
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
@ -35,6 +35,7 @@ type Props = {
teaserKey: string | null
inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string>
keepingKeys: Set<string>
@ -42,10 +43,11 @@ type Props = {
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
assetNonce?: number
// helpers
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
stripHotPrefix: (s: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
@ -104,7 +106,6 @@ export default function FinishedDownloadsCardsView({
teaserAudio,
hoverTeaserKey,
blurPreviews,
durations,
teaserKey,
inlinePlay,
setInlinePlay,
@ -114,10 +115,10 @@ export default function FinishedDownloadsCardsView({
removingKeys,
swipeRefs,
assetNonce,
keyFor,
baseName,
stripHotPrefix,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
@ -131,8 +132,6 @@ export default function FinishedDownloadsCardsView({
tryAutoplayInline,
registerTeaserHost,
handleDuration,
deleteVideo,
keepVideo,
@ -196,7 +195,7 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
@ -280,6 +279,7 @@ export default function FinishedDownloadsCardsView({
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</LazyMount>

View File

@ -11,6 +11,8 @@ import {
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
type Props = {
rows: RecordJob[]
@ -26,7 +28,6 @@ type Props = {
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
stripHotPrefix: (s: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
@ -67,7 +68,6 @@ export default function FinishedDownloadsGalleryView({
keyFor,
baseName,
stripHotPrefix,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
@ -97,6 +97,23 @@ export default function FinishedDownloadsGalleryView({
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) {
// wichtig: sauber unhooken, falls vorher beobachtet wurde
registerTeaserHost(key)(null)
return
}
registerTeaserHost(key)(el)
},
[registerTeaserHost, shouldObserveTeasers]
)
React.useEffect(() => {
if (!openTagsKey) return
@ -167,8 +184,9 @@ export default function FinishedDownloadsGalleryView({
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
const file = baseName(j.output || '')
const isHot = file.startsWith('HOT ')
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
@ -201,7 +219,7 @@ export default function FinishedDownloadsGalleryView({
{/* Thumb */}
<div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHost(k)}
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
>

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED } from './videoPolicy'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
@ -34,6 +34,7 @@ export type FinishedVideoPreviewProps = {
variant?: Variant
className?: string
showPopover?: boolean
blur?: boolean
@ -58,6 +59,8 @@ export type FinishedVideoPreviewProps = {
/** Popover-Video muted? (Default: true) */
popoverMuted?: boolean
noGenerateTeaser?: boolean
}
export default function FinishedVideoPreview({
@ -90,6 +93,7 @@ export default function FinishedVideoPreview({
muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
@ -105,6 +109,7 @@ export default function FinishedVideoPreview({
const [metaLoaded, setMetaLoaded] = useState(false)
const [teaserReady, setTeaserReady] = useState(false)
const [teaserOk, setTeaserOk] = useState(true)
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
@ -163,7 +168,8 @@ export default function FinishedVideoPreview({
useEffect(() => {
setTeaserReady(false)
}, [previewId, assetNonce])
setTeaserOk(true)
}, [previewId, assetNonce, noGenerateTeaser])
useEffect(() => {
const onRelease = (ev: any) => {
@ -245,8 +251,9 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => {
if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&v=${v}`
}, [previewId, v])
const noGen = noGenerateTeaser ? '&noGenerate=1' : ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
@ -256,15 +263,15 @@ export default function FinishedVideoPreview({
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
}
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
useEffect(() => {
setThumbOk(true)
setVideoOk(true)
}, [previewId, assetNonce])
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' &&
@ -281,9 +288,7 @@ export default function FinishedVideoPreview({
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
(
// ✅ neuer schneller Modus
(animatedMode === 'teaser' && Boolean(teaserSrc)) ||
// Legacy: clips nur wenn Duration bekannt
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(animatedMode === 'clips' && hasDuration)
)
@ -318,7 +323,6 @@ export default function FinishedVideoPreview({
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
const teaserRef = useRef<HTMLVideoElement | null>(null)
const clipIdxRef = useRef(0)
const clipStartRef = useRef(0)
@ -332,21 +336,20 @@ export default function FinishedVideoPreview({
return
}
// iOS/Safari: Eigenschaften wirklich als Properties setzen
v.muted = Boolean(muted)
// @ts-ignore
v.defaultMuted = Boolean(muted)
v.playsInline = true
v.setAttribute('playsinline', '')
v.setAttribute('webkit-playsinline', '')
applyInlineVideoPolicy(v, { muted })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
useEffect(() => {
if (!showingInlineVideo) return
applyInlineVideoPolicy(inlineRef.current, { muted })
}, [showingInlineVideo, muted])
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => {
const v = teaserRef.current
const v = clipsRef.current
if (!v) return
if (!(teaserActive && animatedMode === 'clips')) {
@ -463,14 +466,17 @@ export default function FinishedVideoPreview({
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => setVideoOk(false)}
onError={() => {
setTeaserOk(false) // ✅ nur teaser abschalten
setTeaserReady(false) // ✅ overlay wieder weg
}}
/>
) : null}
{/* ✅ Legacy clips (falls noch genutzt) */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video
ref={teaserRef}
ref={clipsRef}
key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc}
className={[

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\ModelDetails.tsx
'use client'
import * as React from 'react'
@ -93,25 +95,35 @@ function baseName(path: string) {
return (path || '').split(/[\\/]/).pop() || ''
}
function modelKeyFromFilename(fileName: string) {
const stem = fileName.replace(/\.[^.]+$/, '')
const normalized = stem.startsWith('HOT ') ? stem.slice(4) : stem
// ✅ match: <model>_DD_MM_YYYY__HH-MM-SS oder <model>_DD_MM_YYYY_HH_MM_SS
const m1 = normalized.match(/^(.+?)_\d{2}_\d{2}_\d{4}(?:__|_)\d{2}[-_]\d{2}[-_]\d{2}/)
if (m1?.[1]) return m1[1].trim().toLowerCase()
// ✅ fallback: <model>_DD_MM_YYYY (ohne Uhrzeit)
const m2 = normalized.match(/^(.+?)_\d{2}_\d{2}_\d{4}/)
if (m2?.[1]) return m2[1].trim().toLowerCase()
// ✅ last fallback: vor dem ersten "_"
const i = normalized.indexOf('_')
if (i > 0) return normalized.slice(0, i).trim().toLowerCase()
return normalized.trim().toLowerCase()
function normPath(p: string) {
return String(p || '').replaceAll('\\', '/')
}
// ✅ liefert modelKey aus /keep/<modelname>/...
function modelKeyFromKeepPath(output?: string | null) {
const p = normPath(output || '').toLowerCase()
const m = p.match(/\/keep\/([^/]+)\//)
return (m?.[1] || '').trim().toLowerCase()
}
function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name
}
function modelNameFromOutput(output?: string) {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
// match: <model>_DD_MM_YYYY__HH-MM-SS
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
function stripHtmlToText(input?: string | null) {
if (!input) return ''
@ -465,17 +477,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
}
}, [open, key, bioRefreshSeq, cookies])
// Done downloads (inkl. done/keep)
// Done downloads (inkl. done + keep/<model>/) -> ALLES laden, Pagination client-side
React.useEffect(() => {
if (!open) return
let alive = true
setDoneLoading(true)
const url = `/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=completed_desc&includeKeep=1`
const url = `/api/record/done?all=1&sort=completed_desc&includeKeep=1`
fetch(url, { cache: 'no-store' })
.then((r) => r.json())
.then((data: RecordJob[]) => {
.then((data: any) => {
if (!alive) return
setDone(Array.isArray(data) ? data : [])
// ✅ robust: Backend kann Array ODER {items:[...]} liefern
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setDone(items)
})
.catch(() => {
if (!alive) return
@ -485,10 +508,11 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
if (!alive) return
setDoneLoading(false)
})
return () => {
alive = false
}
}, [open, donePage])
}, [open])
// Running jobs
React.useEffect(() => {
@ -525,12 +549,35 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
const doneMatches = React.useMemo(() => {
if (!key) return []
return done.filter((j) => modelKeyFromFilename(baseName(j.output || '')) === key)
return done.filter((j) => {
const out = j.output || ''
// ✅ 1) Wenn es aus /done/keep/<model>/ kommt: Ordnername ist Wahrheit
const keepKey = modelKeyFromKeepPath(out)
if (keepKey) return keepKey === key
// ✅ 2) Sonst: /done/ -> Modellname aus Dateiname
const m = modelNameFromOutput(out)
return m !== '—' && m.trim().toLowerCase() === key
})
}, [done, key])
const doneTotalPages = React.useMemo(() => {
return Math.max(1, Math.ceil(doneMatches.length / DONE_PAGE_SIZE))
}, [doneMatches.length])
const doneMatchesPage = React.useMemo(() => {
const start = (donePage - 1) * DONE_PAGE_SIZE
return doneMatches.slice(start, start + DONE_PAGE_SIZE)
}, [doneMatches, donePage])
const runningMatches = React.useMemo(() => {
if (!key) return []
return runningList.filter((j) => modelKeyFromFilename(baseName(j.output || '')) === key)
return runningList.filter((j) => {
const m = modelNameFromOutput(j.output)
return m !== '—' && m.trim().toLowerCase() === key
})
}, [runningList, key])
const allTags = React.useMemo(() => {
@ -1116,15 +1163,24 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" disabled={donePage <= 1} onClick={() => setDonePage((p) => Math.max(1, p - 1))}>
Zurück
</Button>
<span className="text-xs text-gray-600 dark:text-gray-300">Seite {donePage}</span>
<Button
size="sm"
variant="secondary"
disabled={doneLoading || doneMatches.length < DONE_PAGE_SIZE}
onClick={() => setDonePage((p) => p + 1)}
disabled={doneLoading || donePage <= 1}
onClick={() => setDonePage((p) => Math.max(1, p - 1))}
>
Zurück
</Button>
<span className="text-xs text-gray-600 dark:text-gray-300">
Seite {donePage} / {doneTotalPages}
</span>
<Button
size="sm"
variant="secondary"
disabled={doneLoading || donePage >= doneTotalPages}
onClick={() => setDonePage((p) => Math.min(doneTotalPages, p + 1))}
>
Weiter
</Button>
@ -1138,7 +1194,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
<div className="text-sm text-gray-600 dark:text-gray-300">Keine abgeschlossenen Downloads für dieses Model gefunden.</div>
) : (
<div className="grid gap-2">
{doneMatches.map((j) => {
{doneMatchesPage.map((j) => {
const file = baseName(j.output || '')
const kept = isKeptOutputPath(j.output || '')
const hot = file.startsWith('HOT ')

View File

@ -178,7 +178,7 @@ export default function ModelPreview({
// HLS nur für große Vorschau im Popover
const hq = useMemo(
() =>
`/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
`/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
[jobId]
)

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,7 @@ type Props = {
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
@ -193,6 +194,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
if (!doneDir) {
setErr('doneDir ist leer.')
return
}
if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return
}
const ok = window.confirm(`Aufräumen: Alle Dateien in "${doneDir}" löschen, die kleiner als ${mb} MB sind? (Ordner "keep" wird übersprungen)`)
if (!ok) return
setCleaning(true)
try {
const res = await fetch('/api/settings/cleanup-small-downloads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = await res.json()
setMsg(
`🧹 Aufräumen fertig: ${data.deletedFiles} Datei(en) gelöscht (${data.deletedBytesHuman}). ` +
`Geprüft: ${data.scannedFiles}. Übersprungen: ${data.skippedFiles}.`
)
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setCleaning(false)
}
}
return (
<Card
header={
@ -229,7 +274,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Generiere fehlende Vorschauen/Metadaten (z.B. Duration via meta.json) für schnelle Listenansichten.
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
</div>
</div>
@ -405,7 +450,18 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 shrink-0 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div>
</div>
</div>

View File

@ -37,8 +37,8 @@ export type SwipeCardProps = {
className?: string
/** Action-Bereiche */
leftAction?: SwipeAction // standard: Behalten
rightAction?: SwipeAction // standard: Löschen
leftAction?: SwipeAction // standard: Behalten
rightAction?: SwipeAction // standard: Löschen
/** Ab welcher Strecke wird ausgelöst? */
thresholdPx?: number
@ -47,7 +47,7 @@ export type SwipeCardProps = {
/** Animation timings */
snapMs?: number
commitMs?: number
/**
/**
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
@ -60,13 +60,11 @@ export type SwipeCardProps = {
*/
ignoreSelector?: string
/**
/**
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
* (z.B. Buttons/Inputs innerhalb der Karte)
*/
tapIgnoreSelector?: string
}
export type SwipeCardHandle = {
@ -114,10 +112,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
},
ref
) {
const cardRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
const dxRef = React.useRef(0)
const rafRef = React.useRef<number | null>(null)
@ -153,41 +150,40 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
const el = cardRef.current
const w = el?.offsetWidth || 360
const el = cardRef.current
const w = el?.offsetWidth || 360
// rausfliegen lassen
setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
// rausfliegen lassen
setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
let ok: boolean | void = true
if (runAction) {
let ok: boolean | void = true
if (runAction) {
try {
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
} catch {
ok = false
}
ok = false
}
}
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
setAnimMs(snapMs)
setArmedDir(null)
setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs)
return false
}
}
return true
return true
},
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
)
@ -195,9 +191,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
React.useImperativeHandle(
ref,
() => ({
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
reset: () => reset(),
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
reset: () => reset(),
}),
[commit, reset]
)
@ -214,10 +210,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)}
/>
<div
className={cn(
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
)}
<div className={cn('absolute inset-0 flex items-center transition-all duration-200 ease-out')}
style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1,
@ -300,9 +293,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ dxRef reset (neue Gesture)
dxRef.current = 0
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
@ -351,12 +342,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
const threshold = thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const threshold =
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
@ -405,7 +397,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
dxRef.current = 0
}}
onPointerCancel={(e) => {
if (!enabled || disabled) return
@ -442,4 +433,4 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)
})
export default SwipeCard
export default SwipeCard

View File

@ -0,0 +1,8 @@
// HOT nur, wenn es wirklich am String-Anfang steht (keine trimStart-Magie)
const HOT_RE = /^HOT[ \u00A0]+/i
const normSpaces = (s: string) => String(s || '').replaceAll('\u00A0', ' ')
export const isHotName = (s: string) => HOT_RE.test(normSpaces(s))
export const stripHotPrefix = (s: string) => normSpaces(s).replace(HOT_RE, '')

View File

@ -1,5 +1,5 @@
// components/ui/videoPolicy.ts
export const DEFAULT_INLINE_MUTED = false
export const DEFAULT_INLINE_MUTED = true
export const DEFAULT_PLAYER_START_MUTED = false
export function applyInlineVideoPolicy(
@ -11,7 +11,11 @@ export function applyInlineVideoPolicy(
// Autoplay klappt am zuverlässigsten mit muted + playsInline
el.muted = muted
// @ts-ignore (Safari)
el.defaultMuted = muted
el.playsInline = true
el.setAttribute('playsinline', 'true')
// iOS/Safari: Attribute "present" ist entscheidend (nicht "true"/"false")
el.setAttribute('playsinline', '')
el.setAttribute('webkit-playsinline', '')
}

View File

@ -10,6 +10,7 @@ export type ChaturbateOnlineRoom = {
export type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
total?: number
}
type OnlineState = ChaturbateOnlineResponse
@ -37,9 +38,14 @@ export function startChaturbateOnlinePolling(opts: {
getShow: () => string[]
onData: (data: OnlineState) => void
intervalMs?: number
// ✅ NEU: wenn getModels() leer ist, trotzdem einmal call machen (für "ALL online")
fetchAllWhenNoModels?: boolean
/** Optional: wird bei Fehlern aufgerufen (für Debug) */
onError?: (err: unknown) => void
}) {
const baseIntervalMs = opts.intervalMs ?? 5000
let timer: number | null = null
@ -86,21 +92,26 @@ export function startChaturbateOnlinePolling(opts: {
const show = showRaw.slice().sort()
const modelsSorted = models.slice().sort()
// keine Models -> rooms leeren (enabled nicht neu erfinden)
if (modelsSorted.length === 0) {
// ✅ ALL-mode, wenn keine Models und Option aktiv
const isAllMode = modelsSorted.length === 0 && Boolean(opts.fetchAllWhenNoModels)
// keine Models -> normalerweise rooms leeren (enabled nicht neu erfinden)
if (modelsSorted.length === 0 && !isAllMode) {
closeInFlight()
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
lastResult = empty
opts.onData(empty)
// hidden tab -> seltener pollen
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
return
}
const key = `${show.join(',')}|${modelsSorted.join(',')}`
// ✅ In ALL-mode senden wir q:[] (1 Request). Sonst normale Liste.
const modelsForRequest = isAllMode ? [] : modelsSorted
const key = `${show.join(',')}|${isAllMode ? '__ALL__' : modelsForRequest.join(',')}`
const requestKey = key
lastKey = key
@ -109,12 +120,14 @@ export function startChaturbateOnlinePolling(opts: {
const controller = new AbortController()
inFlight = controller
// ✅ Wichtig: "keepalive" NICHT setzen (kann Ressourcen kosten)
const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200300 nehmen
const parts = chunk(modelsSorted, CHUNK_SIZE)
// ✅ ALL-mode: genau ein Part mit [] schicken
const parts = isAllMode ? [[]] : chunk(modelsForRequest, CHUNK_SIZE)
let mergedRooms: ChaturbateOnlineRoom[] = []
let mergedEnabled = false
let mergedTotal = 0
let hadAnyOk = false
for (const part of parts) {
@ -136,6 +149,10 @@ export function startChaturbateOnlinePolling(opts: {
const data = (await res.json()) as OnlineState
mergedEnabled = mergedEnabled || Boolean(data?.enabled)
mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : []))
// ✅ NEU: total mergen (Backend liefert Gesamtzahl)
const t = Number((data as any)?.total ?? 0)
if (Number.isFinite(t) && t > mergedTotal) mergedTotal = t
}
if (!hadAnyOk) {
@ -144,7 +161,7 @@ export function startChaturbateOnlinePolling(opts: {
return
}
const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms) }
const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms), total: mergedTotal }
if (controller.signal.aborted) return
if (requestKey !== lastKey) return

View File

@ -6,7 +6,7 @@ import { ToastProvider } from './components/ui/ToastProvider.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ToastProvider position="bottom-right" maxToasts={3} defaultDurationMs={3500}>
<ToastProvider position="top-right" maxToasts={3} defaultDurationMs={3500}>
<App />
</ToastProvider>
</StrictMode>,

View File

@ -11,6 +11,9 @@ export type RecordJob = {
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
durationSeconds?: number
sizeBytes?: number
videoWidth?: number
videoHeight?: number
fps?: number
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
phase?: string