updated
This commit is contained in:
parent
7d7387d8bb
commit
d909d951a3
@ -23,26 +23,6 @@ type BioContextResp struct {
|
|||||||
Bio any `json:"bio,omitempty"`
|
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) {
|
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@ -443,6 +443,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"fetchedAt": time.Time{},
|
"fetchedAt": time.Time{},
|
||||||
"count": 0,
|
"count": 0,
|
||||||
|
"total": 0,
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"rooms": []any{},
|
"rooms": []any{},
|
||||||
}
|
}
|
||||||
@ -462,6 +463,21 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
|
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
|
||||||
cbMu.RUnlock()
|
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:
|
// Refresh/Bootstrap-Strategie:
|
||||||
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
|
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
|
||||||
@ -570,6 +586,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fetchedAt": fetchedAt,
|
"fetchedAt": fetchedAt,
|
||||||
"count": len(outRooms),
|
"count": len(outRooms),
|
||||||
|
"total": total,
|
||||||
"lastError": lastErr,
|
"lastError": lastErr,
|
||||||
"rooms": outRooms, // ✅ klein & schnell
|
"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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-jMGU1_s9.js"></script>
|
<script type="module" crossorigin src="/assets/index-IS5yelG1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-ie8TR6qH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-ByYRHYVi.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import Downloads from './components/ui/Downloads'
|
|||||||
import ModelsTab from './components/ui/ModelsTab'
|
import ModelsTab from './components/ui/ModelsTab'
|
||||||
import ProgressBar from './components/ui/ProgressBar'
|
import ProgressBar from './components/ui/ProgressBar'
|
||||||
import ModelDetails from './components/ui/ModelDetails'
|
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 PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
||||||
@ -51,6 +51,7 @@ type RecorderSettings = {
|
|||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||||
teaserAudio?: boolean
|
teaserAudio?: boolean
|
||||||
|
lowDiskPauseBelowGB?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||||
@ -62,10 +63,11 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
|||||||
useChaturbateApi: false,
|
useChaturbateApi: false,
|
||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsWatcher: false,
|
||||||
autoDeleteSmallDownloads: false,
|
autoDeleteSmallDownloads: false,
|
||||||
autoDeleteSmallDownloadsBelowMB: 50,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
blurPreviews: false,
|
blurPreviews: false,
|
||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
|
lowDiskPauseBelowGB: 3000,
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredModel = {
|
type StoredModel = {
|
||||||
@ -106,6 +108,7 @@ type ChaturbateOnlineRoom = {
|
|||||||
type ChaturbateOnlineResponse = {
|
type ChaturbateOnlineResponse = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
rooms: ChaturbateOnlineRoom[]
|
rooms: ChaturbateOnlineRoom[]
|
||||||
|
total?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHttpUrl(raw: string): string | null {
|
function normalizeHttpUrl(raw: string): string | null {
|
||||||
@ -222,6 +225,8 @@ export default function App() {
|
|||||||
const [doneCount, setDoneCount] = useState<number>(0)
|
const [doneCount, setDoneCount] = useState<number>(0)
|
||||||
const [modelsCount, setModelsCount] = useState(0)
|
const [modelsCount, setModelsCount] = useState(0)
|
||||||
|
|
||||||
|
const [onlineModelsCount, setOnlineModelsCount] = useState(0)
|
||||||
|
|
||||||
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
|
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
|
||||||
const [nowMs, setNowMs] = useState<number>(() => Date.now())
|
const [nowMs, setNowMs] = useState<number>(() => Date.now())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -413,9 +418,6 @@ export default function App() {
|
|||||||
const pendingStartUrlRef = useRef<string | null>(null)
|
const pendingStartUrlRef = useRef<string | null>(null)
|
||||||
const lastClipboardUrlRef = useRef<string>('')
|
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
|
// ✅ Zentraler Snapshot: username(lower) -> room
|
||||||
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
|
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
|
||||||
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
|
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
|
||||||
@ -569,7 +571,7 @@ export default function App() {
|
|||||||
const onFocus = () => void load()
|
const onFocus = () => void load()
|
||||||
|
|
||||||
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
|
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
|
||||||
window.addEventListener('hover', onFocus)
|
window.addEventListener('focus', onFocus)
|
||||||
document.addEventListener('visibilitychange', onFocus)
|
document.addEventListener('visibilitychange', onFocus)
|
||||||
|
|
||||||
load()
|
load()
|
||||||
@ -577,7 +579,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
|
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
|
||||||
window.removeEventListener('hover', onFocus)
|
window.removeEventListener('focus', onFocus)
|
||||||
document.removeEventListener('visibilitychange', onFocus)
|
document.removeEventListener('visibilitychange', onFocus)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@ -618,15 +620,20 @@ export default function App() {
|
|||||||
|
|
||||||
const runningJobs = jobs.filter((j) => j.status === 'running')
|
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
|
let c = 0
|
||||||
for (const m of Object.values(modelsByKey)) {
|
for (const m of Object.values(modelsByKey)) {
|
||||||
|
if (!m?.watching) continue
|
||||||
|
if (!isChaturbateStoreModel(m)) continue
|
||||||
|
|
||||||
const k = lower(String(m?.modelKey ?? ''))
|
const k = lower(String(m?.modelKey ?? ''))
|
||||||
if (!k) continue
|
if (!k) continue
|
||||||
if (onlineStoreKeysLower[k]) c++
|
|
||||||
|
if (cbOnlineByKeyLower[k]) c++
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}, [modelsByKey, onlineStoreKeysLower])
|
}, [modelsByKey, cbOnlineByKeyLower, isChaturbateStoreModel])
|
||||||
|
|
||||||
const { onlineFavCount, onlineLikedCount } = useMemo(() => {
|
const { onlineFavCount, onlineLikedCount } = useMemo(() => {
|
||||||
let fav = 0
|
let fav = 0
|
||||||
@ -635,14 +642,14 @@ export default function App() {
|
|||||||
for (const m of Object.values(modelsByKey)) {
|
for (const m of Object.values(modelsByKey)) {
|
||||||
const k = lower(String(m?.modelKey ?? ''))
|
const k = lower(String(m?.modelKey ?? ''))
|
||||||
if (!k) continue
|
if (!k) continue
|
||||||
if (!onlineStoreKeysLower[k]) continue
|
if (!cbOnlineByKeyLower[k]) continue
|
||||||
|
|
||||||
if (m?.favorite) fav++
|
if (m?.favorite) fav++
|
||||||
if (m?.liked === true) liked++
|
if (m?.liked === true) liked++
|
||||||
}
|
}
|
||||||
|
|
||||||
return { onlineFavCount: fav, onlineLikedCount: liked }
|
return { onlineFavCount: fav, onlineLikedCount: liked }
|
||||||
}, [modelsByKey, onlineStoreKeysLower])
|
}, [modelsByKey, cbOnlineByKeyLower])
|
||||||
|
|
||||||
const tabs: TabItem[] = [
|
const tabs: TabItem[] = [
|
||||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
||||||
@ -706,18 +713,28 @@ export default function App() {
|
|||||||
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
||||||
}, [cookies, cookiesLoaded])
|
}, [cookies, cookiesLoaded])
|
||||||
|
|
||||||
// done meta polling (unverändert)
|
// ✅ done count polling über /api/record/done (kein /done/meta mehr)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let t: number | undefined
|
let t: number | undefined
|
||||||
|
|
||||||
const loadDoneMeta = async () => {
|
const loadDoneCount = async () => {
|
||||||
try {
|
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
|
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) {
|
if (!cancelled) {
|
||||||
setDoneCount(meta.count ?? 0)
|
setDoneCount(count)
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -725,24 +742,24 @@ export default function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const ms = document.hidden ? 60_000 : 30_000
|
const ms = document.hidden ? 60_000 : 30_000
|
||||||
t = window.setTimeout(loadDoneMeta, ms)
|
t = window.setTimeout(loadDoneCount, ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (!document.hidden) void loadDoneMeta()
|
if (!document.hidden) void loadDoneCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
void loadDoneMeta()
|
void loadDoneCount()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
if (t) window.clearTimeout(t)
|
if (t) window.clearTimeout(t)
|
||||||
document.removeEventListener('visibilitychange', onVis)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [doneSort])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
|
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
|
||||||
@ -834,29 +851,62 @@ export default function App() {
|
|||||||
if (selectedTab !== 'finished') return
|
if (selectedTab !== 'finished') return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let inFlight = false
|
const inFlightRef = { current: false }
|
||||||
|
const ac = new AbortController()
|
||||||
|
|
||||||
const loadDone = async () => {
|
const loadDone = async () => {
|
||||||
if (cancelled || inFlight) return
|
if (cancelled || inFlightRef.current) return
|
||||||
inFlight = true
|
inFlightRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const list = await apiJSON<RecordJob[]>(
|
const res = await fetch(
|
||||||
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
|
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
|
||||||
{ cache: 'no-store' as any }
|
`&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 {
|
} catch {
|
||||||
if (!cancelled) setDoneJobs([])
|
if (!cancelled) {
|
||||||
|
setDoneJobs([])
|
||||||
|
setDoneCount(0)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false
|
inFlightRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDone()
|
void loadDone()
|
||||||
|
|
||||||
const baseMs = 20000
|
const baseMs = 20000
|
||||||
const tickMs = document.hidden ? 60000 : baseMs
|
const t = window.setInterval(() => {
|
||||||
const t = window.setInterval(loadDone, tickMs)
|
if (!document.hidden) void loadDone()
|
||||||
|
}, baseMs)
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (!document.hidden) void loadDone()
|
if (!document.hidden) void loadDone()
|
||||||
@ -865,6 +915,7 @@ export default function App() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
ac.abort()
|
||||||
window.clearInterval(t)
|
window.clearInterval(t)
|
||||||
document.removeEventListener('visibilitychange', onVis)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
}
|
}
|
||||||
@ -873,21 +924,36 @@ export default function App() {
|
|||||||
const refreshDoneNow = useCallback(
|
const refreshDoneNow = useCallback(
|
||||||
async (preferPage?: number) => {
|
async (preferPage?: number) => {
|
||||||
try {
|
try {
|
||||||
const meta = await apiJSON<{ count?: number }>('/api/record/done/meta', { cache: 'no-store' as any })
|
const wanted = typeof preferPage === 'number' ? preferPage : donePage
|
||||||
const countRaw = typeof meta?.count === 'number' ? meta.count : 0
|
|
||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
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)
|
setDoneCount(count)
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
|
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)
|
const target = Math.min(Math.max(1, wanted), maxPage)
|
||||||
if (target !== donePage) setDonePage(target)
|
if (target !== donePage) setDonePage(target)
|
||||||
|
|
||||||
const list = await apiJSON<RecordJob[]>(
|
// wenn target anders ist, optional nochmal mit target laden:
|
||||||
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
|
if (target === wanted) {
|
||||||
{ cache: 'no-store' as any }
|
setDoneJobs(items)
|
||||||
)
|
} else {
|
||||||
setDoneJobs(Array.isArray(list) ? list : [])
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -967,7 +1033,7 @@ export default function App() {
|
|||||||
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
|
||||||
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
|
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
|
||||||
}
|
}
|
||||||
}, [])
|
}, [notify])
|
||||||
|
|
||||||
const handleKeepJob = useCallback(
|
const handleKeepJob = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob) => {
|
||||||
@ -993,7 +1059,7 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedTab, refreshDoneNow]
|
[selectedTab, refreshDoneNow, notify]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleHot = useCallback(async (job: RecordJob) => {
|
const handleToggleHot = useCallback(async (job: RecordJob) => {
|
||||||
@ -1682,7 +1748,7 @@ export default function App() {
|
|||||||
void startUrl(pending)
|
void startUrl(pending)
|
||||||
}, [busy, autoStartEnabled, startUrl])
|
}, [busy, autoStartEnabled, startUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stop = startChaturbateOnlinePolling({
|
const stop = startChaturbateOnlinePolling({
|
||||||
getModels: () => {
|
getModels: () => {
|
||||||
if (!recSettingsRef.current.useChaturbateApi) return []
|
if (!recSettingsRef.current.useChaturbateApi) return []
|
||||||
@ -1713,7 +1779,6 @@ export default function App() {
|
|||||||
if (!data?.enabled) {
|
if (!data?.enabled) {
|
||||||
setCbOnlineByKeyLower({})
|
setCbOnlineByKeyLower({})
|
||||||
cbOnlineByKeyLowerRef.current = {}
|
cbOnlineByKeyLowerRef.current = {}
|
||||||
setOnlineStoreKeysLower({})
|
|
||||||
setPendingWatchedRooms([])
|
setPendingWatchedRooms([])
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
return
|
return
|
||||||
@ -1735,7 +1800,6 @@ export default function App() {
|
|||||||
const kl = String(k || '').trim().toLowerCase()
|
const kl = String(k || '').trim().toLowerCase()
|
||||||
if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
|
if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
|
||||||
}
|
}
|
||||||
setOnlineStoreKeysLower(nextOnlineStore)
|
|
||||||
|
|
||||||
// Pending Watched Rooms (nur im running Tab)
|
// Pending Watched Rooms (nur im running Tab)
|
||||||
if (!recSettingsRef.current.useChaturbateApi) {
|
if (!recSettingsRef.current.useChaturbateApi) {
|
||||||
@ -1829,6 +1893,42 @@ export default function App() {
|
|||||||
return () => stop()
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
<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">
|
<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 className="tabular-nums">{onlineModelsCount}</span>
|
||||||
</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
|
<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"
|
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"
|
title="Fav online"
|
||||||
@ -2002,7 +2110,6 @@ export default function App() {
|
|||||||
setDoneSort(m)
|
setDoneSort(m)
|
||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
}}
|
}}
|
||||||
onRefreshDone={refreshDoneNow}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{playerJob ? (
|
||||||
<Player
|
<Player
|
||||||
job={playerJob}
|
job={playerJob}
|
||||||
modelKey={playerModelKey ?? undefined}
|
modelKey={playerModelKey ?? undefined}
|
||||||
|
modelsByKey={modelsByKey}
|
||||||
expanded={playerExpanded}
|
expanded={playerExpanded}
|
||||||
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||||||
onClose={() => setPlayerJob(null)}
|
onClose={() => setPlayerJob(null)}
|
||||||
@ -2046,16 +2164,6 @@ export default function App() {
|
|||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ModelDetails
|
|
||||||
open={Boolean(detailsModelKey)}
|
|
||||||
modelKey={detailsModelKey}
|
|
||||||
onClose={() => setDetailsModelKey(null)}
|
|
||||||
onOpenPlayer={openPlayer}
|
|
||||||
runningJobs={runningJobs}
|
|
||||||
cookies={cookies}
|
|
||||||
blurPreviews={recSettings.blurPreviews}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\components\ui\Downloads.tsx
|
// frontend\src\components\ui\Downloads.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect } from 'react'
|
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import Table, { type Column } from './Table'
|
import Table, { type Column } from './Table'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -547,12 +547,15 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||||
|
|
||||||
const [watchedPaused, setWatchedPaused] = useState(false)
|
const [watchedPaused, setWatchedPaused] = useState(false)
|
||||||
|
const watchedPausedRef = useRef<boolean | null>(null)
|
||||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||||
|
|
||||||
const refreshWatchedState = useCallback(async () => {
|
const refreshWatchedState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
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 {
|
} catch {
|
||||||
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
|
// 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>(
|
const unsub = subscribeSSE<AutostartState>(
|
||||||
'/api/autostart/state/stream',
|
'/api/autostart/state/stream',
|
||||||
'autostart',
|
'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 () => {
|
return () => {
|
||||||
@ -656,7 +664,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasActive) return
|
if (!hasActive) return
|
||||||
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
|
const t = window.setInterval(() => setNowMs(Date.now()), 15000)
|
||||||
return () => window.clearInterval(t)
|
return () => window.clearInterval(t)
|
||||||
}, [hasActive])
|
}, [hasActive])
|
||||||
|
|
||||||
@ -738,15 +746,22 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
const j = r.job
|
const j = r.job
|
||||||
const f = baseName(j.output || '')
|
const f = baseName(j.output || '')
|
||||||
const name = modelNameFromOutput(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 isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const stopInitiated = Boolean(stopInitiatedIds[j.id])
|
const stopInitiated = Boolean(stopInitiatedIds[j.id])
|
||||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
|
||||||
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
|
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
|
||||||
|
|
||||||
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
// ✅ Status-Text neben dem Modelname: NUR Job-Status
|
||||||
const statusText = rawStatus || 'unknown'
|
// (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -757,20 +772,21 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
||||||
// Status-Farben
|
rawStatus === 'running'
|
||||||
isStopping
|
? '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'
|
||||||
? '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'
|
: isStoppedFinal
|
||||||
: j.status === 'finished'
|
? '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'
|
||||||
? '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'
|
: rawStatus === 'failed'
|
||||||
: j.status === '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'
|
? '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
|
: rawStatus === 'finished'
|
||||||
? '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-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-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',
|
: 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(' ')}
|
].join(' ')}
|
||||||
title={statusText}
|
title={badgeText}
|
||||||
>
|
>
|
||||||
{statusText}
|
{badgeText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="block max-w-[220px] truncate" title={j.output}>
|
<span className="block max-w-[220px] truncate" title={j.output}>
|
||||||
@ -984,70 +1000,70 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{(hasAnyPending || hasJobs) ? (
|
{/* ✅ Toolbar immer sichtbar (auch wenn keine Jobs/Pending existieren) */}
|
||||||
<>
|
<div className="sticky top-[56px] z-20">
|
||||||
{/* Toolbar (sticky) wie FinishedDownloads */}
|
<div
|
||||||
<div className="sticky top-[56px] z-20">
|
className="
|
||||||
<div
|
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
|
||||||
className="
|
backdrop-blur supports-[backdrop-filter]:bg-white/60
|
||||||
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
|
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
|
||||||
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="flex items-center justify-between gap-2 p-3">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
{/* Title + Count */}
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
Downloads
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* ✅ Content abhängig von Jobs/Pending */}
|
||||||
|
{(hasAnyPending || hasJobs) ? (
|
||||||
|
<>
|
||||||
{/* Mobile: Cards */}
|
{/* Mobile: Cards */}
|
||||||
<div className="mt-3 grid gap-4 sm:hidden">
|
<div className="mt-3 grid gap-4 sm:hidden">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
@ -1085,9 +1101,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
|
||||||
{!hasAnyPending && !hasJobs ? (
|
|
||||||
<Card grayBody>
|
<Card grayBody>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,14 @@ import Card from './Card'
|
|||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
import ButtonGroup from './ButtonGroup'
|
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 { type SwipeCardHandle } from './SwipeCard'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
|
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
|
||||||
@ -22,7 +29,8 @@ import RecordJobActions from './RecordJobActions'
|
|||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { useNotify } from './notify'
|
import { useNotify } from './notify'
|
||||||
import LazyMount from './LazyMount'
|
import LazyMount from './LazyMount'
|
||||||
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
import Switch from './Switch'
|
||||||
|
|
||||||
type SortMode =
|
type SortMode =
|
||||||
| 'completed_desc'
|
| 'completed_desc'
|
||||||
@ -55,13 +63,13 @@ type Props = {
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
onPageChange: (page: number) => void
|
onPageChange: (page: number) => void
|
||||||
onRefreshDone?: (preferPage?: number) => void | Promise<void>
|
|
||||||
assetNonce?: number
|
assetNonce?: number
|
||||||
sortMode: SortMode
|
sortMode: SortMode
|
||||||
onSortModeChange: (m: SortMode) => void
|
onSortModeChange: (m: SortMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
|
const norm = (p: string) => (p || '').replaceAll('\\', '/')
|
||||||
|
|
||||||
const baseName = (p: string) => {
|
const baseName = (p: string) => {
|
||||||
const n = norm(p)
|
const n = norm(p)
|
||||||
const parts = n.split('/')
|
const parts = n.split('/')
|
||||||
@ -118,8 +126,6 @@ const httpCodeFromError = (err?: string) => {
|
|||||||
return m ? `HTTP ${m[1]}` : null
|
return m ? `HTTP ${m[1]}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
|
||||||
|
|
||||||
const modelNameFromOutput = (output?: string) => {
|
const modelNameFromOutput = (output?: string) => {
|
||||||
const fileRaw = baseName(output || '')
|
const fileRaw = baseName(output || '')
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
@ -197,7 +203,6 @@ export default function FinishedDownloads({
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onRefreshDone,
|
|
||||||
assetNonce,
|
assetNonce,
|
||||||
sortMode,
|
sortMode,
|
||||||
onSortModeChange,
|
onSortModeChange,
|
||||||
@ -241,8 +246,13 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
type ViewMode = 'table' | 'cards' | 'gallery'
|
type ViewMode = 'table' | 'cards' | 'gallery'
|
||||||
const VIEW_KEY = 'finishedDownloads_view'
|
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 [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())
|
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||||||
|
|
||||||
@ -287,18 +297,23 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const fetchAllDoneJobs = useCallback(
|
const fetchAllDoneJobs = useCallback(
|
||||||
async (signal?: AbortSignal) => {
|
async (signal?: AbortSignal) => {
|
||||||
const res = await fetch(`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}`, {
|
const res = await fetch(
|
||||||
cache: 'no-store' as any,
|
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||||
signal,
|
{
|
||||||
})
|
cache: 'no-store' as any,
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const list = await res.json().catch(() => [])
|
const data = await res.json().catch(() => null)
|
||||||
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
|
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||||||
setOverrideDoneJobs(arr)
|
const count = Number(data?.count ?? data?.totalCount ?? items.length)
|
||||||
setOverrideDoneTotal(arr.length)
|
|
||||||
|
setOverrideDoneJobs(items)
|
||||||
|
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
||||||
},
|
},
|
||||||
[sortMode]
|
[sortMode, includeKeep]
|
||||||
)
|
)
|
||||||
|
|
||||||
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
||||||
@ -338,16 +353,18 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
// 1) META holen (count), damit Pagination stimmt
|
// 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
|
||||||
const metaRes = await fetch('/api/record/done/meta', {
|
const listRes = await fetch(
|
||||||
cache: 'no-store' as any,
|
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||||
signal: ac.signal,
|
{ cache: 'no-store' as any, signal: ac.signal }
|
||||||
})
|
)
|
||||||
|
|
||||||
if (metaRes.ok) {
|
if (listRes.ok) {
|
||||||
const meta = await metaRes.json().catch(() => null)
|
const data = await listRes.json().catch(() => null)
|
||||||
const count = Number(meta?.count ?? 0)
|
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) {
|
if (Number.isFinite(count) && count >= 0) {
|
||||||
setOverrideDoneTotal(count)
|
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 {
|
} catch {
|
||||||
// Abort / Fehler ignorieren
|
// Abort / Fehler ignorieren
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => ac.abort()
|
return () => ac.abort()
|
||||||
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs])
|
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs, includeKeep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
|
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
|
||||||
@ -386,6 +392,42 @@ export default function FinishedDownloads({
|
|||||||
setOverrideDoneTotal(null)
|
setOverrideDoneTotal(null)
|
||||||
}, [doneJobs, doneTotal, globalFilterActive])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||||
@ -406,6 +448,36 @@ export default function FinishedDownloads({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [view])
|
}, [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)
|
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||||
|
|
||||||
@ -486,12 +558,9 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
// ✅ wichtig: Seite sofort neu laden -> Item rückt nach
|
// ✅ wichtig: Seite sofort neu laden -> Item rückt nach
|
||||||
queueRefill()
|
queueRefill()
|
||||||
|
|
||||||
// optional: Parent sync (kann bleiben, muss aber nicht)
|
|
||||||
void onRefreshDone?.(page)
|
|
||||||
}, 320)
|
}, 320)
|
||||||
},
|
},
|
||||||
[markDeleted, markRemoving, queueRefill, onRefreshDone, page]
|
[markDeleted, markRemoving, queueRefill]
|
||||||
)
|
)
|
||||||
|
|
||||||
const releasePlayingFile = useCallback(
|
const releasePlayingFile = useCallback(
|
||||||
@ -524,9 +593,6 @@ export default function FinishedDownloads({
|
|||||||
if (onDeleteJob) {
|
if (onDeleteJob) {
|
||||||
await onDeleteJob(job)
|
await onDeleteJob(job)
|
||||||
|
|
||||||
// ✅ nach erfolgreichem Delete die Page nachziehen
|
|
||||||
queueRefill()
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -546,7 +612,7 @@ export default function FinishedDownloads({
|
|||||||
markDeleting(key, false)
|
markDeleting(key, false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill, notify]
|
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, notify]
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepVideo = useCallback(
|
const keepVideo = useCallback(
|
||||||
@ -707,7 +773,6 @@ export default function FinishedDownloads({
|
|||||||
if (view === 'cards') {
|
if (view === 'cards') {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
markDeleted(key)
|
markDeleted(key)
|
||||||
void onRefreshDone?.(page) // ✅ HIER dazu
|
|
||||||
}, 320)
|
}, 320)
|
||||||
} else {
|
} else {
|
||||||
animateRemove(key)
|
animateRemove(key)
|
||||||
@ -722,13 +787,12 @@ export default function FinishedDownloads({
|
|||||||
} else if (detail.phase === 'success') {
|
} else if (detail.phase === 'success') {
|
||||||
markDeleting(key, false)
|
markDeleting(key, false)
|
||||||
queueRefill()
|
queueRefill()
|
||||||
void onRefreshDone?.(page)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||||||
return () => window.removeEventListener('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 visibleRows = useMemo(() => {
|
||||||
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||||
@ -949,16 +1013,16 @@ export default function FinishedDownloads({
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (j) => {
|
sortValue: (j) => {
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const isHot = fileRaw.startsWith('HOT ')
|
const isHot = isHotName(fileRaw)
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
|
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
|
||||||
},
|
},
|
||||||
cell: (j) => {
|
cell: (j) => {
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const isHot = fileRaw.startsWith('HOT ')
|
const isHot = isHotName(fileRaw)
|
||||||
const model = modelNameFromOutput(j.output)
|
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
|
const model = modelNameFromOutput(j.output)
|
||||||
const modelKey = lower(modelNameFromOutput(j.output))
|
const modelKey = lower(modelNameFromOutput(j.output))
|
||||||
const tags = parseTags(modelsByKey[modelKey]?.tags)
|
const tags = parseTags(modelsByKey[modelKey]?.tags)
|
||||||
const show = tags.slice(0, 6)
|
const show = tags.slice(0, 6)
|
||||||
@ -1113,7 +1177,7 @@ export default function FinishedDownloads({
|
|||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const isHot = fileRaw.startsWith('HOT ')
|
const isHot = isHotName(fileRaw)
|
||||||
const modelKey = lower(modelNameFromOutput(j.output))
|
const modelKey = lower(modelNameFromOutput(j.output))
|
||||||
const flags = modelsByKey[modelKey]
|
const flags = modelsByKey[modelKey]
|
||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
@ -1246,6 +1310,22 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</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 */}
|
{/* Desktop: Suche neben Sort */}
|
||||||
<div className="hidden sm:flex items-center gap-2 min-w-0">
|
<div className="hidden sm:flex items-center gap-2 min-w-0">
|
||||||
<input
|
<input
|
||||||
@ -1265,6 +1345,24 @@ export default function FinishedDownloads({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 */}
|
{/* Views */}
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
value={view}
|
value={view}
|
||||||
@ -1295,24 +1393,54 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Suche */}
|
{/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
|
||||||
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
id="finished-mobile-options"
|
||||||
<input
|
className={[
|
||||||
value={searchQuery}
|
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
mobileOptionsOpen ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0',
|
||||||
placeholder="Suchen…"
|
].join(' ')}
|
||||||
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
|
{/* Mobile Suche */}
|
||||||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
|
<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
|
||||||
{(searchQuery || '').trim() !== '' ? (
|
value={searchQuery}
|
||||||
<Button size="sm" variant="soft" onClick={clearSearch}>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
Clear
|
placeholder="Suchen…"
|
||||||
</Button>
|
className="
|
||||||
) : null}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1444,7 +1572,6 @@ export default function FinishedDownloads({
|
|||||||
swipeRefs={swipeRefs}
|
swipeRefs={swipeRefs}
|
||||||
keyFor={keyFor}
|
keyFor={keyFor}
|
||||||
baseName={baseName}
|
baseName={baseName}
|
||||||
stripHotPrefix={stripHotPrefix}
|
|
||||||
modelNameFromOutput={modelNameFromOutput}
|
modelNameFromOutput={modelNameFromOutput}
|
||||||
runtimeOf={runtimeOf}
|
runtimeOf={runtimeOf}
|
||||||
sizeBytesOf={sizeBytesOf}
|
sizeBytesOf={sizeBytesOf}
|
||||||
@ -1467,6 +1594,7 @@ export default function FinishedDownloads({
|
|||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
|
assetNonce={assetNonce ?? 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1507,7 +1635,6 @@ export default function FinishedDownloads({
|
|||||||
hoverTeaserKey={hoverTeaserKey}
|
hoverTeaserKey={hoverTeaserKey}
|
||||||
keyFor={keyFor}
|
keyFor={keyFor}
|
||||||
baseName={baseName}
|
baseName={baseName}
|
||||||
stripHotPrefix={stripHotPrefix}
|
|
||||||
modelNameFromOutput={modelNameFromOutput}
|
modelNameFromOutput={modelNameFromOutput}
|
||||||
runtimeOf={runtimeOf}
|
runtimeOf={runtimeOf}
|
||||||
sizeBytesOf={sizeBytesOf}
|
sizeBytesOf={sizeBytesOf}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import LazyMount from './LazyMount'
|
import LazyMount from './LazyMount'
|
||||||
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
@ -36,16 +36,18 @@ type Props = {
|
|||||||
inlinePlay: InlinePlayState
|
inlinePlay: InlinePlayState
|
||||||
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
|
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
|
||||||
|
|
||||||
|
|
||||||
deletingKeys: Set<string>
|
deletingKeys: Set<string>
|
||||||
keepingKeys: Set<string>
|
keepingKeys: Set<string>
|
||||||
removingKeys: Set<string>
|
removingKeys: Set<string>
|
||||||
|
|
||||||
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
|
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
|
||||||
|
|
||||||
|
assetNonce?: number
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
keyFor: (j: RecordJob) => string
|
keyFor: (j: RecordJob) => string
|
||||||
baseName: (p: string) => string
|
baseName: (p: string) => string
|
||||||
stripHotPrefix: (s: string) => string
|
|
||||||
modelNameFromOutput: (output?: string) => string
|
modelNameFromOutput: (output?: string) => string
|
||||||
runtimeOf: (job: RecordJob) => string
|
runtimeOf: (job: RecordJob) => string
|
||||||
sizeBytesOf: (job: RecordJob) => number | null
|
sizeBytesOf: (job: RecordJob) => number | null
|
||||||
@ -104,7 +106,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
teaserAudio,
|
teaserAudio,
|
||||||
hoverTeaserKey,
|
hoverTeaserKey,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
durations,
|
|
||||||
teaserKey,
|
teaserKey,
|
||||||
inlinePlay,
|
inlinePlay,
|
||||||
setInlinePlay,
|
setInlinePlay,
|
||||||
@ -114,10 +115,10 @@ export default function FinishedDownloadsCardsView({
|
|||||||
removingKeys,
|
removingKeys,
|
||||||
|
|
||||||
swipeRefs,
|
swipeRefs,
|
||||||
|
assetNonce,
|
||||||
|
|
||||||
keyFor,
|
keyFor,
|
||||||
baseName,
|
baseName,
|
||||||
stripHotPrefix,
|
|
||||||
modelNameFromOutput,
|
modelNameFromOutput,
|
||||||
runtimeOf,
|
runtimeOf,
|
||||||
sizeBytesOf,
|
sizeBytesOf,
|
||||||
@ -131,8 +132,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
tryAutoplayInline,
|
tryAutoplayInline,
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
|
|
||||||
handleDuration,
|
|
||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
|
|
||||||
@ -196,7 +195,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const isHot = fileRaw.startsWith('HOT ')
|
const isHot = isHotName(fileRaw)
|
||||||
const flags = modelsByKey[lower(model)]
|
const flags = modelsByKey[lower(model)]
|
||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
@ -280,6 +279,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
inlineLoop={false}
|
inlineLoop={false}
|
||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
|
assetNonce={assetNonce ?? 0}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import LazyMount from './LazyMount'
|
import LazyMount from './LazyMount'
|
||||||
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
@ -26,7 +28,6 @@ type Props = {
|
|||||||
|
|
||||||
keyFor: (j: RecordJob) => string
|
keyFor: (j: RecordJob) => string
|
||||||
baseName: (p: string) => string
|
baseName: (p: string) => string
|
||||||
stripHotPrefix: (s: string) => string
|
|
||||||
modelNameFromOutput: (output?: string) => string
|
modelNameFromOutput: (output?: string) => string
|
||||||
runtimeOf: (job: RecordJob) => string
|
runtimeOf: (job: RecordJob) => string
|
||||||
sizeBytesOf: (job: RecordJob) => number | null
|
sizeBytesOf: (job: RecordJob) => number | null
|
||||||
@ -67,7 +68,6 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
|
|
||||||
keyFor,
|
keyFor,
|
||||||
baseName,
|
baseName,
|
||||||
stripHotPrefix,
|
|
||||||
modelNameFromOutput,
|
modelNameFromOutput,
|
||||||
runtimeOf,
|
runtimeOf,
|
||||||
sizeBytesOf,
|
sizeBytesOf,
|
||||||
@ -97,6 +97,23 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
|
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
|
||||||
const tagsPopoverRef = React.useRef<HTMLDivElement | 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(() => {
|
React.useEffect(() => {
|
||||||
if (!openTagsKey) return
|
if (!openTagsKey) return
|
||||||
|
|
||||||
@ -167,8 +184,9 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
const restTags = tags.length - showTags.length
|
const restTags = tags.length - showTags.length
|
||||||
const fullTags = tags.join(', ')
|
const fullTags = tags.join(', ')
|
||||||
|
|
||||||
const file = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const isHot = file.startsWith('HOT ')
|
const isHot = isHotName(fileRaw)
|
||||||
|
const file = stripHotPrefix(fileRaw)
|
||||||
const dur = runtimeOf(j)
|
const dur = runtimeOf(j)
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
@ -201,7 +219,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
{/* Thumb */}
|
{/* Thumb */}
|
||||||
<div
|
<div
|
||||||
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
ref={registerTeaserHost(k)}
|
ref={registerTeaserHostIfNeeded(k)}
|
||||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import HoverPopover from './HoverPopover'
|
import HoverPopover from './HoverPopover'
|
||||||
import { DEFAULT_INLINE_MUTED } from './videoPolicy'
|
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
|
||||||
|
|
||||||
type Variant = 'thumb' | 'fill'
|
type Variant = 'thumb' | 'fill'
|
||||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||||
@ -35,6 +35,7 @@ export type FinishedVideoPreviewProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
showPopover?: boolean
|
showPopover?: boolean
|
||||||
|
|
||||||
|
|
||||||
blur?: boolean
|
blur?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,6 +59,8 @@ export type FinishedVideoPreviewProps = {
|
|||||||
/** Popover-Video muted? (Default: true) */
|
/** Popover-Video muted? (Default: true) */
|
||||||
popoverMuted?: boolean
|
popoverMuted?: boolean
|
||||||
|
|
||||||
|
noGenerateTeaser?: boolean
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedVideoPreview({
|
export default function FinishedVideoPreview({
|
||||||
@ -90,6 +93,7 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
muted = DEFAULT_INLINE_MUTED,
|
muted = DEFAULT_INLINE_MUTED,
|
||||||
popoverMuted = DEFAULT_INLINE_MUTED,
|
popoverMuted = DEFAULT_INLINE_MUTED,
|
||||||
|
noGenerateTeaser,
|
||||||
}: FinishedVideoPreviewProps) {
|
}: FinishedVideoPreviewProps) {
|
||||||
const file = getFileName(job.output || '')
|
const file = getFileName(job.output || '')
|
||||||
const blurCls = blur ? 'blur-md' : ''
|
const blurCls = blur ? 'blur-md' : ''
|
||||||
@ -105,6 +109,7 @@ export default function FinishedVideoPreview({
|
|||||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||||
|
|
||||||
const [teaserReady, setTeaserReady] = useState(false)
|
const [teaserReady, setTeaserReady] = useState(false)
|
||||||
|
const [teaserOk, setTeaserOk] = useState(true)
|
||||||
|
|
||||||
// inView (Viewport)
|
// inView (Viewport)
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
@ -163,7 +168,8 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
}, [previewId, assetNonce])
|
setTeaserOk(true)
|
||||||
|
}, [previewId, assetNonce, noGenerateTeaser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRelease = (ev: any) => {
|
const onRelease = (ev: any) => {
|
||||||
@ -245,8 +251,9 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const teaserSrc = useMemo(() => {
|
const teaserSrc = useMemo(() => {
|
||||||
if (!previewId) return ''
|
if (!previewId) return ''
|
||||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&v=${v}`
|
const noGen = noGenerateTeaser ? '&noGenerate=1' : ''
|
||||||
}, [previewId, v])
|
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
|
||||||
|
}, [previewId, v, noGenerateTeaser])
|
||||||
|
|
||||||
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
|
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
|
||||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||||
@ -256,15 +263,15 @@ export default function FinishedVideoPreview({
|
|||||||
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
|
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(() => {
|
useEffect(() => {
|
||||||
setThumbOk(true)
|
setThumbOk(true)
|
||||||
setVideoOk(true)
|
setVideoOk(true)
|
||||||
}, [previewId, assetNonce])
|
}, [previewId, assetNonce])
|
||||||
|
|
||||||
|
if (!videoSrc) {
|
||||||
|
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inline Video sichtbar?
|
// --- Inline Video sichtbar?
|
||||||
const showingInlineVideo =
|
const showingInlineVideo =
|
||||||
inlineMode !== 'never' &&
|
inlineMode !== 'never' &&
|
||||||
@ -281,9 +288,7 @@ export default function FinishedVideoPreview({
|
|||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
(animatedTrigger === 'always' || hovered) &&
|
(animatedTrigger === 'always' || hovered) &&
|
||||||
(
|
(
|
||||||
// ✅ neuer schneller Modus
|
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
|
||||||
(animatedMode === 'teaser' && Boolean(teaserSrc)) ||
|
|
||||||
// Legacy: clips nur wenn Duration bekannt
|
|
||||||
(animatedMode === 'clips' && hasDuration)
|
(animatedMode === 'clips' && hasDuration)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -318,7 +323,6 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
||||||
|
|
||||||
const teaserRef = useRef<HTMLVideoElement | null>(null)
|
|
||||||
const clipIdxRef = useRef(0)
|
const clipIdxRef = useRef(0)
|
||||||
const clipStartRef = useRef(0)
|
const clipStartRef = useRef(0)
|
||||||
|
|
||||||
@ -332,21 +336,20 @@ export default function FinishedVideoPreview({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS/Safari: Eigenschaften wirklich als Properties setzen
|
applyInlineVideoPolicy(v, { muted })
|
||||||
v.muted = Boolean(muted)
|
|
||||||
// @ts-ignore
|
|
||||||
v.defaultMuted = Boolean(muted)
|
|
||||||
v.playsInline = true
|
|
||||||
v.setAttribute('playsinline', '')
|
|
||||||
v.setAttribute('webkit-playsinline', '')
|
|
||||||
|
|
||||||
const p = v.play?.()
|
const p = v.play?.()
|
||||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
||||||
}, [teaserActive, animatedMode, teaserSrc, muted])
|
}, [teaserActive, animatedMode, teaserSrc, muted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showingInlineVideo) return
|
||||||
|
applyInlineVideoPolicy(inlineRef.current, { muted })
|
||||||
|
}, [showingInlineVideo, muted])
|
||||||
|
|
||||||
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = teaserRef.current
|
const v = clipsRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
|
|
||||||
if (!(teaserActive && animatedMode === 'clips')) {
|
if (!(teaserActive && animatedMode === 'clips')) {
|
||||||
@ -463,14 +466,17 @@ export default function FinishedVideoPreview({
|
|||||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||||
onLoadedData={() => setTeaserReady(true)}
|
onLoadedData={() => setTeaserReady(true)}
|
||||||
onPlaying={() => setTeaserReady(true)}
|
onPlaying={() => setTeaserReady(true)}
|
||||||
onError={() => setVideoOk(false)}
|
onError={() => {
|
||||||
|
setTeaserOk(false) // ✅ nur teaser abschalten
|
||||||
|
setTeaserReady(false) // ✅ overlay wieder weg
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ✅ Legacy clips (falls noch genutzt) */}
|
{/* ✅ Legacy clips (falls noch genutzt) */}
|
||||||
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
|
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
|
||||||
<video
|
<video
|
||||||
ref={teaserRef}
|
ref={clipsRef}
|
||||||
key={`clips-${previewId}-${clipTimesKey}`}
|
key={`clips-${previewId}-${clipTimesKey}`}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
className={[
|
className={[
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\components\ui\ModelDetails.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@ -93,25 +95,35 @@ function baseName(path: string) {
|
|||||||
return (path || '').split(/[\\/]/).pop() || ''
|
return (path || '').split(/[\\/]/).pop() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelKeyFromFilename(fileName: string) {
|
function normPath(p: string) {
|
||||||
const stem = fileName.replace(/\.[^.]+$/, '')
|
return String(p || '').replaceAll('\\', '/')
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 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) {
|
function stripHtmlToText(input?: string | null) {
|
||||||
if (!input) return ''
|
if (!input) return ''
|
||||||
@ -465,17 +477,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
}
|
}
|
||||||
}, [open, key, bioRefreshSeq, cookies])
|
}, [open, key, bioRefreshSeq, cookies])
|
||||||
|
|
||||||
// Done downloads (inkl. done/keep)
|
// Done downloads (inkl. done + keep/<model>/) -> ALLES laden, Pagination client-side
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let alive = true
|
let alive = true
|
||||||
|
|
||||||
setDoneLoading(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' })
|
fetch(url, { cache: 'no-store' })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: RecordJob[]) => {
|
.then((data: any) => {
|
||||||
if (!alive) return
|
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(() => {
|
.catch(() => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
@ -485,10 +508,11 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
if (!alive) return
|
if (!alive) return
|
||||||
setDoneLoading(false)
|
setDoneLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
}
|
}
|
||||||
}, [open, donePage])
|
}, [open])
|
||||||
|
|
||||||
// Running jobs
|
// Running jobs
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -525,12 +549,35 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
|
|
||||||
const doneMatches = React.useMemo(() => {
|
const doneMatches = React.useMemo(() => {
|
||||||
if (!key) return []
|
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])
|
}, [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(() => {
|
const runningMatches = React.useMemo(() => {
|
||||||
if (!key) return []
|
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])
|
}, [runningList, key])
|
||||||
|
|
||||||
const allTags = React.useMemo(() => {
|
const allTags = React.useMemo(() => {
|
||||||
@ -1116,15 +1163,24 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={doneLoading || doneMatches.length < DONE_PAGE_SIZE}
|
disabled={doneLoading || donePage <= 1}
|
||||||
onClick={() => setDonePage((p) => p + 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
|
Weiter
|
||||||
</Button>
|
</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="text-sm text-gray-600 dark:text-gray-300">Keine abgeschlossenen Downloads für dieses Model gefunden.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{doneMatches.map((j) => {
|
{doneMatchesPage.map((j) => {
|
||||||
const file = baseName(j.output || '')
|
const file = baseName(j.output || '')
|
||||||
const kept = isKeptOutputPath(j.output || '')
|
const kept = isKeptOutputPath(j.output || '')
|
||||||
const hot = file.startsWith('HOT ')
|
const hot = file.startsWith('HOT ')
|
||||||
|
|||||||
@ -178,7 +178,7 @@ export default function ModelPreview({
|
|||||||
// HLS nur für große Vorschau im Popover
|
// HLS nur für große Vorschau im Popover
|
||||||
const hq = useMemo(
|
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]
|
[jobId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -56,6 +56,7 @@ type Props = {
|
|||||||
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [cleaning, setCleaning] = useState(false)
|
||||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = 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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
header={
|
header={
|
||||||
@ -229,7 +274,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
|
<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">
|
<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>
|
||||||
</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
|
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"
|
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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,8 +37,8 @@ export type SwipeCardProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
/** Action-Bereiche */
|
/** Action-Bereiche */
|
||||||
leftAction?: SwipeAction // standard: Behalten
|
leftAction?: SwipeAction // standard: Behalten
|
||||||
rightAction?: SwipeAction // standard: Löschen
|
rightAction?: SwipeAction // standard: Löschen
|
||||||
|
|
||||||
/** Ab welcher Strecke wird ausgelöst? */
|
/** Ab welcher Strecke wird ausgelöst? */
|
||||||
thresholdPx?: number
|
thresholdPx?: number
|
||||||
@ -47,7 +47,7 @@ export type SwipeCardProps = {
|
|||||||
/** Animation timings */
|
/** Animation timings */
|
||||||
snapMs?: number
|
snapMs?: number
|
||||||
commitMs?: number
|
commitMs?: number
|
||||||
/**
|
/**
|
||||||
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
|
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
|
||||||
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
|
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
|
||||||
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
|
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
|
||||||
@ -60,13 +60,11 @@ export type SwipeCardProps = {
|
|||||||
*/
|
*/
|
||||||
ignoreSelector?: string
|
ignoreSelector?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
|
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
|
||||||
* (z.B. Buttons/Inputs innerhalb der Karte)
|
* (z.B. Buttons/Inputs innerhalb der Karte)
|
||||||
*/
|
*/
|
||||||
tapIgnoreSelector?: string
|
tapIgnoreSelector?: string
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwipeCardHandle = {
|
export type SwipeCardHandle = {
|
||||||
@ -114,10 +112,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
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 dxRef = React.useRef(0)
|
||||||
const rafRef = React.useRef<number | null>(null)
|
const rafRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
@ -153,41 +150,40 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
const commit = React.useCallback(
|
const commit = React.useCallback(
|
||||||
async (dir: 'left' | 'right', runAction: boolean) => {
|
async (dir: 'left' | 'right', runAction: boolean) => {
|
||||||
|
if (rafRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
if (rafRef.current != null) {
|
const el = cardRef.current
|
||||||
cancelAnimationFrame(rafRef.current)
|
const w = el?.offsetWidth || 360
|
||||||
rafRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = cardRef.current
|
// rausfliegen lassen
|
||||||
const w = el?.offsetWidth || 360
|
setAnimMs(commitMs)
|
||||||
|
setArmedDir(dir === 'right' ? 'right' : 'left')
|
||||||
|
const outDx = dir === 'right' ? w + 40 : -(w + 40)
|
||||||
|
dxRef.current = outDx
|
||||||
|
setDx(outDx)
|
||||||
|
|
||||||
// rausfliegen lassen
|
let ok: boolean | void = true
|
||||||
setAnimMs(commitMs)
|
if (runAction) {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
||||||
} catch {
|
} catch {
|
||||||
ok = false
|
ok = false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// wenn Aktion fehlschlägt => zurücksnappen
|
// wenn Aktion fehlschlägt => zurücksnappen
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
||||||
)
|
)
|
||||||
@ -195,9 +191,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
||||||
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
||||||
reset: () => reset(),
|
reset: () => reset(),
|
||||||
}),
|
}),
|
||||||
[commit, reset]
|
[commit, reset]
|
||||||
)
|
)
|
||||||
@ -214,10 +210,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div className={cn('absolute inset-0 flex items-center transition-all duration-200 ease-out')}
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
|
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
|
||||||
opacity: dx === 0 ? 0 : 1,
|
opacity: dx === 0 ? 0 : 1,
|
||||||
@ -300,9 +293,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
// ✅ dxRef reset (neue Gesture)
|
// ✅ dxRef reset (neue Gesture)
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
if (pointer.current.id !== e.pointerId) 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
|
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
|
||||||
setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
|
setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
|
||||||
}}
|
}}
|
||||||
|
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
if (pointer.current.id !== e.pointerId) 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 wasDragging = pointer.current.dragging
|
||||||
const wasCaptured = pointer.current.captured
|
const wasCaptured = pointer.current.captured
|
||||||
@ -405,7 +397,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
|
||||||
}}
|
}}
|
||||||
onPointerCancel={(e) => {
|
onPointerCancel={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
|
|||||||
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
|
// components/ui/videoPolicy.ts
|
||||||
export const DEFAULT_INLINE_MUTED = false
|
export const DEFAULT_INLINE_MUTED = true
|
||||||
export const DEFAULT_PLAYER_START_MUTED = false
|
export const DEFAULT_PLAYER_START_MUTED = false
|
||||||
|
|
||||||
export function applyInlineVideoPolicy(
|
export function applyInlineVideoPolicy(
|
||||||
@ -11,7 +11,11 @@ export function applyInlineVideoPolicy(
|
|||||||
|
|
||||||
// Autoplay klappt am zuverlässigsten mit muted + playsInline
|
// Autoplay klappt am zuverlässigsten mit muted + playsInline
|
||||||
el.muted = muted
|
el.muted = muted
|
||||||
|
// @ts-ignore (Safari)
|
||||||
el.defaultMuted = muted
|
el.defaultMuted = muted
|
||||||
el.playsInline = true
|
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 = {
|
export type ChaturbateOnlineResponse = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
rooms: ChaturbateOnlineRoom[]
|
rooms: ChaturbateOnlineRoom[]
|
||||||
|
total?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnlineState = ChaturbateOnlineResponse
|
type OnlineState = ChaturbateOnlineResponse
|
||||||
@ -37,9 +38,14 @@ export function startChaturbateOnlinePolling(opts: {
|
|||||||
getShow: () => string[]
|
getShow: () => string[]
|
||||||
onData: (data: OnlineState) => void
|
onData: (data: OnlineState) => void
|
||||||
intervalMs?: number
|
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) */
|
/** Optional: wird bei Fehlern aufgerufen (für Debug) */
|
||||||
onError?: (err: unknown) => void
|
onError?: (err: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const baseIntervalMs = opts.intervalMs ?? 5000
|
const baseIntervalMs = opts.intervalMs ?? 5000
|
||||||
|
|
||||||
let timer: number | null = null
|
let timer: number | null = null
|
||||||
@ -86,21 +92,26 @@ export function startChaturbateOnlinePolling(opts: {
|
|||||||
const show = showRaw.slice().sort()
|
const show = showRaw.slice().sort()
|
||||||
const modelsSorted = models.slice().sort()
|
const modelsSorted = models.slice().sort()
|
||||||
|
|
||||||
// keine Models -> rooms leeren (enabled nicht neu erfinden)
|
// ✅ ALL-mode, wenn keine Models und Option aktiv
|
||||||
if (modelsSorted.length === 0) {
|
const isAllMode = modelsSorted.length === 0 && Boolean(opts.fetchAllWhenNoModels)
|
||||||
|
|
||||||
|
// keine Models -> normalerweise rooms leeren (enabled nicht neu erfinden)
|
||||||
|
if (modelsSorted.length === 0 && !isAllMode) {
|
||||||
closeInFlight()
|
closeInFlight()
|
||||||
|
|
||||||
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
|
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
|
||||||
lastResult = empty
|
lastResult = empty
|
||||||
opts.onData(empty)
|
opts.onData(empty)
|
||||||
|
|
||||||
// hidden tab -> seltener pollen
|
|
||||||
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
|
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
|
||||||
schedule(nextMs)
|
schedule(nextMs)
|
||||||
return
|
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
|
const requestKey = key
|
||||||
lastKey = key
|
lastKey = key
|
||||||
|
|
||||||
@ -109,12 +120,14 @@ export function startChaturbateOnlinePolling(opts: {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
inFlight = controller
|
inFlight = controller
|
||||||
|
|
||||||
// ✅ Wichtig: "keepalive" NICHT setzen (kann Ressourcen kosten)
|
|
||||||
const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200–300 nehmen
|
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 mergedRooms: ChaturbateOnlineRoom[] = []
|
||||||
let mergedEnabled = false
|
let mergedEnabled = false
|
||||||
|
let mergedTotal = 0
|
||||||
let hadAnyOk = false
|
let hadAnyOk = false
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@ -136,6 +149,10 @@ export function startChaturbateOnlinePolling(opts: {
|
|||||||
const data = (await res.json()) as OnlineState
|
const data = (await res.json()) as OnlineState
|
||||||
mergedEnabled = mergedEnabled || Boolean(data?.enabled)
|
mergedEnabled = mergedEnabled || Boolean(data?.enabled)
|
||||||
mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : []))
|
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) {
|
if (!hadAnyOk) {
|
||||||
@ -144,7 +161,7 @@ export function startChaturbateOnlinePolling(opts: {
|
|||||||
return
|
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 (controller.signal.aborted) return
|
||||||
if (requestKey !== lastKey) return
|
if (requestKey !== lastKey) return
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { ToastProvider } from './components/ui/ToastProvider.tsx'
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ToastProvider position="bottom-right" maxToasts={3} defaultDurationMs={3500}>
|
<ToastProvider position="top-right" maxToasts={3} defaultDurationMs={3500}>
|
||||||
<App />
|
<App />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@ -11,6 +11,9 @@ export type RecordJob = {
|
|||||||
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
|
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
sizeBytes?: number
|
sizeBytes?: number
|
||||||
|
videoWidth?: number
|
||||||
|
videoHeight?: number
|
||||||
|
fps?: number
|
||||||
|
|
||||||
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
|
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
|
||||||
phase?: string
|
phase?: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user