updated
This commit is contained in:
parent
7d7387d8bb
commit
d909d951a3
@ -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)
|
||||
|
||||
@ -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.
1391
backend/main.go
1391
backend/main.go
File diff suppressed because it is too large
Load Diff
Binary file not shown.
1
backend/web/dist/assets/index-ByYRHYVi.css
vendored
Normal file
1
backend/web/dist/assets/index-ByYRHYVi.css
vendored
Normal file
File diff suppressed because one or more lines are too long
333
backend/web/dist/assets/index-IS5yelG1.js
vendored
Normal file
333
backend/web/dist/assets/index-IS5yelG1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-ie8TR6qH.css
vendored
1
backend/web/dist/assets/index-ie8TR6qH.css
vendored
File diff suppressed because one or more lines are too long
332
backend/web/dist/assets/index-jMGU1_s9.js
vendored
332
backend/web/dist/assets/index-jMGU1_s9.js
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
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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={[
|
||||
|
||||
@ -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 ')
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
8
frontend/src/components/ui/hotName.ts
Normal file
8
frontend/src/components/ui/hotName.ts
Normal 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, '')
|
||||
@ -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', '')
|
||||
}
|
||||
|
||||
@ -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: 200–300 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
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user