updated
This commit is contained in:
parent
32a74e0be0
commit
abdd071bfd
@ -65,6 +65,7 @@ type RecorderSettingsState = {
|
|||||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||||
teaserAudio?: boolean
|
teaserAudio?: boolean
|
||||||
lowDiskPauseBelowGB?: number
|
lowDiskPauseBelowGB?: number
|
||||||
|
enableNotifications?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobEvent = {
|
type JobEvent = {
|
||||||
@ -124,6 +125,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
|||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
lowDiskPauseBelowGB: 5,
|
lowDiskPauseBelowGB: 5,
|
||||||
|
enableNotifications: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredModel = {
|
type StoredModel = {
|
||||||
@ -203,6 +205,27 @@ function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'aw
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||||||
|
return normalizePendingShow(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomStatusLabelDE(v: unknown): string {
|
||||||
|
switch (normalizeRoomStatusText(v)) {
|
||||||
|
case 'public':
|
||||||
|
return 'öffentlich'
|
||||||
|
case 'private':
|
||||||
|
return 'privat'
|
||||||
|
case 'hidden':
|
||||||
|
return 'hidden'
|
||||||
|
case 'away':
|
||||||
|
return 'away'
|
||||||
|
case 'offline':
|
||||||
|
return 'offline'
|
||||||
|
default:
|
||||||
|
return 'unbekannt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHttpUrl(raw: string): string | null {
|
function normalizeHttpUrl(raw: string): string | null {
|
||||||
let v = (raw ?? '').trim()
|
let v = (raw ?? '').trim()
|
||||||
if (!v) return null
|
if (!v) return null
|
||||||
@ -450,6 +473,9 @@ export default function App() {
|
|||||||
|
|
||||||
const notifyRef = useRef(notify)
|
const notifyRef = useRef(notify)
|
||||||
|
|
||||||
|
const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
|
||||||
|
const roomStatusBaselineReadyRef = useRef(false)
|
||||||
|
|
||||||
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
|
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
|
||||||
const cookieProblemLastAtRef = useRef(0)
|
const cookieProblemLastAtRef = useRef(0)
|
||||||
|
|
||||||
@ -1131,6 +1157,67 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [buildModelsByKey])
|
}, [buildModelsByKey])
|
||||||
|
|
||||||
|
const maybeNotifyWatchedModelStatusChange = useCallback((
|
||||||
|
keyLower: string,
|
||||||
|
nextStatusRaw: unknown,
|
||||||
|
patch?: {
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (!keyLower) return
|
||||||
|
if (!recSettingsRef.current.enableNotifications) return
|
||||||
|
if (!roomStatusBaselineReadyRef.current) return
|
||||||
|
|
||||||
|
const model = modelsByKeyRef.current[keyLower]
|
||||||
|
if (!model?.watching) {
|
||||||
|
lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
||||||
|
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
|
||||||
|
|
||||||
|
// keine Änderung
|
||||||
|
if (prevStatus === nextStatus) return
|
||||||
|
|
||||||
|
// keine "erste Sichtung" als Wechsel melden
|
||||||
|
const hadPreviousRealStatus =
|
||||||
|
Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) &&
|
||||||
|
prevStatus !== 'unknown'
|
||||||
|
|
||||||
|
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
|
||||||
|
|
||||||
|
if (!hadPreviousRealStatus) return
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
String(model?.modelKey ?? keyLower).trim() || keyLower
|
||||||
|
|
||||||
|
const imageUrl =
|
||||||
|
String(patch?.imageUrl ?? '').trim() ||
|
||||||
|
String(model?.imageUrl ?? '').trim() ||
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const title =
|
||||||
|
nextStatus === 'public'
|
||||||
|
? `${displayName} ist jetzt öffentlich`
|
||||||
|
: `${displayName} Status geändert`
|
||||||
|
|
||||||
|
const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}`
|
||||||
|
|
||||||
|
notifyRef.current?.info(title, message, {
|
||||||
|
imageUrl,
|
||||||
|
imageAlt: displayName,
|
||||||
|
durationMs: 5000,
|
||||||
|
onClick: () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('open-model-details', {
|
||||||
|
detail: { modelKey: keyLower },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const enqueueStart = useCallback(
|
const enqueueStart = useCallback(
|
||||||
(item: StartQueueItem) => {
|
(item: StartQueueItem) => {
|
||||||
const norm0 = normalizeHttpUrl(item.url)
|
const norm0 = normalizeHttpUrl(item.url)
|
||||||
@ -1223,6 +1310,10 @@ export default function App() {
|
|||||||
? Boolean(msg.isOnline)
|
? Boolean(msg.isOnline)
|
||||||
: roomStatus !== 'offline'
|
: roomStatus !== 'offline'
|
||||||
|
|
||||||
|
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
|
||||||
|
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
|
||||||
|
})
|
||||||
|
|
||||||
applyRoomStateToModel(modelKey, {
|
applyRoomStateToModel(modelKey, {
|
||||||
roomStatus,
|
roomStatus,
|
||||||
isOnline,
|
isOnline,
|
||||||
@ -1247,7 +1338,7 @@ export default function App() {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [applyJobDelta, applyRoomStateToModel, enqueueStart])
|
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
|
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||||
@ -1614,6 +1705,24 @@ export default function App() {
|
|||||||
modelsByKeyRef.current = modelsByKey
|
modelsByKeyRef.current = modelsByKey
|
||||||
}, [modelsByKey])
|
}, [modelsByKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const [rawKey, model] of Object.entries(modelsByKey || {})) {
|
||||||
|
const key = String(rawKey ?? '').trim().toLowerCase()
|
||||||
|
if (!key) continue
|
||||||
|
|
||||||
|
const status = normalizeRoomStatusText((model as any)?.roomStatus)
|
||||||
|
next[key] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
lastKnownRoomStatusByKeyRef.current = next
|
||||||
|
|
||||||
|
if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) {
|
||||||
|
roomStatusBaselineReadyRef.current = true
|
||||||
|
}
|
||||||
|
}, [modelsByKey])
|
||||||
|
|
||||||
const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
|
const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingAutoStartByKeyRef.current = pendingAutoStartByKey
|
pendingAutoStartByKeyRef.current = pendingAutoStartByKey
|
||||||
@ -1707,6 +1816,10 @@ export default function App() {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const snap = byKey[key] ?? { show: 'offline' as const }
|
const snap = byKey[key] ?? { show: 'offline' as const }
|
||||||
|
|
||||||
|
maybeNotifyWatchedModelStatusChange(key, snap.show, {
|
||||||
|
imageUrl: snap.imageUrl,
|
||||||
|
})
|
||||||
|
|
||||||
applyPendingRoomSnapshot(key, snap)
|
applyPendingRoomSnapshot(key, snap)
|
||||||
|
|
||||||
if (snap.show === 'public') {
|
if (snap.show === 'public') {
|
||||||
|
|||||||
@ -276,81 +276,6 @@ async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit)
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueuedMutationTask = {
|
|
||||||
id: string
|
|
||||||
run: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMutationQueue() {
|
|
||||||
const queueRef = React.useRef<QueuedMutationTask[]>([])
|
|
||||||
const runningRef = React.useRef(false)
|
|
||||||
const scheduledRef = React.useRef(false)
|
|
||||||
|
|
||||||
// verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion
|
|
||||||
const pendingIdsRef = React.useRef<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const schedulePump = React.useCallback(() => {
|
|
||||||
if (scheduledRef.current) return
|
|
||||||
scheduledRef.current = true
|
|
||||||
|
|
||||||
const kick = () => {
|
|
||||||
scheduledRef.current = false
|
|
||||||
void pump()
|
|
||||||
}
|
|
||||||
|
|
||||||
// best-effort "im Hintergrund"
|
|
||||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
|
||||||
;(window as any).requestIdleCallback(kick, { timeout: 250 })
|
|
||||||
} else {
|
|
||||||
setTimeout(kick, 0)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pump = React.useCallback(async () => {
|
|
||||||
if (runningRef.current) return
|
|
||||||
runningRef.current = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (queueRef.current.length > 0) {
|
|
||||||
const task = queueRef.current.shift()
|
|
||||||
if (!task) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
await task.run()
|
|
||||||
} finally {
|
|
||||||
pendingIdsRef.current.delete(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield zwischen Tasks -> UI bleibt responsiver
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
runningRef.current = false
|
|
||||||
|
|
||||||
// falls währenddessen neue Tasks reinkamen
|
|
||||||
if (queueRef.current.length > 0) {
|
|
||||||
schedulePump()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [schedulePump])
|
|
||||||
|
|
||||||
const enqueue = React.useCallback((id: string, run: () => Promise<void>) => {
|
|
||||||
if (!id) return false
|
|
||||||
if (pendingIdsRef.current.has(id)) return false
|
|
||||||
|
|
||||||
pendingIdsRef.current.add(id)
|
|
||||||
queueRef.current.push({ id, run })
|
|
||||||
schedulePump()
|
|
||||||
return true
|
|
||||||
}, [schedulePump])
|
|
||||||
|
|
||||||
const isQueued = React.useCallback((id: string) => {
|
|
||||||
return pendingIdsRef.current.has(id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { enqueue, isQueued }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FinishedDownloads({
|
export default function FinishedDownloads({
|
||||||
jobs,
|
jobs,
|
||||||
doneJobs,
|
doneJobs,
|
||||||
@ -385,8 +310,6 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
|
||||||
const mutationQueue = useMutationQueue()
|
|
||||||
|
|
||||||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||||||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
||||||
@ -398,6 +321,8 @@ export default function FinishedDownloads({
|
|||||||
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false)
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
|
||||||
const refillInFlightRef = React.useRef(false)
|
const refillInFlightRef = React.useRef(false)
|
||||||
@ -1126,37 +1051,54 @@ export default function FinishedDownloads({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const restoreRow = useCallback(
|
const restoreRow = useCallback(
|
||||||
(key: string) => {
|
(key: string, file?: string) => {
|
||||||
// Timer stoppen (falls die "commit delete"-Phase noch aussteht)
|
|
||||||
cancelRemoveTimer(key)
|
cancelRemoveTimer(key)
|
||||||
|
|
||||||
// wieder sichtbar machen
|
|
||||||
setDeletedKeys((prev) => {
|
setDeletedKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(key)
|
next.delete(key)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setRemovingKeys((prev) => {
|
setRemovingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(key)
|
next.delete(key)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setDeletingKeys((prev) => {
|
setDeletingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(key)
|
next.delete(key)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setKeepingKeys((prev) => {
|
setKeepingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(key)
|
next.delete(key)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
setHiddenFiles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(file)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[cancelRemoveTimer]
|
[cancelRemoveTimer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const animateRemove = useCallback(
|
const animateRemove = useCallback(
|
||||||
(key: string) => {
|
(key: string, file?: string) => {
|
||||||
|
if (file) {
|
||||||
|
setHiddenFiles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(file)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
markRemoving(key, true)
|
markRemoving(key, true)
|
||||||
cancelRemoveTimer(key)
|
cancelRemoveTimer(key)
|
||||||
|
|
||||||
@ -1301,7 +1243,7 @@ export default function FinishedDownloads({
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Optimistik zurückrollen
|
// Optimistik zurückrollen
|
||||||
if (optimisticRemove) {
|
if (optimisticRemove) {
|
||||||
restoreRow(rowKey)
|
restoreRow(rowKey, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
await onError?.(e)
|
await onError?.(e)
|
||||||
@ -1497,27 +1439,35 @@ export default function FinishedDownloads({
|
|||||||
if (!lastAction || undoing) return
|
if (!lastAction || undoing) return
|
||||||
setUndoing(true)
|
setUndoing(true)
|
||||||
|
|
||||||
const unhideByToken = (token: string) => {
|
const unhideRow = (keyOrToken: string, file?: string) => {
|
||||||
setDeletedKeys((prev) => {
|
setDeletedKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(token)
|
next.delete(keyOrToken)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setDeletingKeys((prev) => {
|
setDeletingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(token)
|
next.delete(keyOrToken)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setKeepingKeys((prev) => {
|
setKeepingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(token)
|
next.delete(keyOrToken)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setRemovingKeys((prev) => {
|
setRemovingKeys((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(token)
|
next.delete(keyOrToken)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
setHiddenFiles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(file)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1533,10 +1483,10 @@ export default function FinishedDownloads({
|
|||||||
const data = await res.json().catch(() => null) as any
|
const data = await res.json().catch(() => null) as any
|
||||||
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
|
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
|
||||||
|
|
||||||
if (lastAction.rowKey) unhideByToken(lastAction.rowKey)
|
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
|
||||||
// Fallbacks (falls alte lastAction ohne rowKey existiert)
|
// Fallbacks (falls alte lastAction ohne rowKey existiert)
|
||||||
unhideByToken(lastAction.originalFile)
|
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||||||
unhideByToken(restoredFile)
|
unhideRow(restoredFile, restoredFile)
|
||||||
|
|
||||||
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
|
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
|
||||||
emitCountHint(visibleDelta)
|
emitCountHint(visibleDelta)
|
||||||
@ -1557,10 +1507,10 @@ export default function FinishedDownloads({
|
|||||||
const data = await res.json().catch(() => null) as any
|
const data = await res.json().catch(() => null) as any
|
||||||
const restoredFile = String(data?.newFile || lastAction.originalFile)
|
const restoredFile = String(data?.newFile || lastAction.originalFile)
|
||||||
|
|
||||||
if (lastAction.rowKey) unhideByToken(lastAction.rowKey)
|
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
|
||||||
// Fallbacks (für ältere Actions / Sonderfälle)
|
// Fallbacks (für ältere Actions / Sonderfälle)
|
||||||
unhideByToken(lastAction.originalFile)
|
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||||||
unhideByToken(restoredFile)
|
unhideRow(restoredFile, restoredFile)
|
||||||
|
|
||||||
emitCountHint(+1)
|
emitCountHint(+1)
|
||||||
queueRefill()
|
queueRefill()
|
||||||
@ -1697,93 +1647,6 @@ export default function FinishedDownloads({
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const enqueueDeleteVideo = useCallback((job: RecordJob): boolean => {
|
|
||||||
const key = keyFor(job)
|
|
||||||
const file = baseName(job.output || '')
|
|
||||||
if (!key || !file) return false
|
|
||||||
|
|
||||||
// bereits aktiv? dann nicht nochmal
|
|
||||||
if (deletingKeys.has(key) || keepingKeys.has(key)) return false
|
|
||||||
|
|
||||||
// sofort visuelles Busy (leichtgewichtig)
|
|
||||||
markDeleting(key, true)
|
|
||||||
|
|
||||||
// ✅ sofort raus aus dem Stack (optimistisch)
|
|
||||||
animateRemove(key)
|
|
||||||
|
|
||||||
const qid = `delete:${key}`
|
|
||||||
const accepted = mutationQueue.enqueue(qid, async () => {
|
|
||||||
try {
|
|
||||||
await deleteVideo(job, { alreadyRemoved: true })
|
|
||||||
} finally {
|
|
||||||
// deleteVideo setzt markDeleting(false) selbst im finally,
|
|
||||||
// daher hier nichts zusätzlich nötig.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!accepted) {
|
|
||||||
restoreRow(key) // ✅ macht markDeleting false + removing/deleted rollback
|
|
||||||
}
|
|
||||||
|
|
||||||
return accepted
|
|
||||||
}, [
|
|
||||||
mutationQueue,
|
|
||||||
keyFor,
|
|
||||||
baseName,
|
|
||||||
deletingKeys,
|
|
||||||
keepingKeys,
|
|
||||||
markDeleting,
|
|
||||||
deleteVideo,
|
|
||||||
])
|
|
||||||
|
|
||||||
const enqueueKeepVideo = useCallback((job: RecordJob): boolean => {
|
|
||||||
const key = keyFor(job)
|
|
||||||
const file = baseName(job.output || '')
|
|
||||||
if (!key || !file) return false
|
|
||||||
|
|
||||||
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
|
|
||||||
|
|
||||||
markKeeping(key, true)
|
|
||||||
|
|
||||||
// ✅ sofort aus dem sichtbaren Stack raus
|
|
||||||
animateRemove(key)
|
|
||||||
|
|
||||||
const qid = `keep:${key}`
|
|
||||||
const accepted = mutationQueue.enqueue(qid, async () => {
|
|
||||||
try {
|
|
||||||
await keepVideo(job, { alreadyRemoved: true })
|
|
||||||
} finally {
|
|
||||||
// keepVideo macht markKeeping(false) im finally
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!accepted) {
|
|
||||||
restoreRow(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return accepted
|
|
||||||
}, [
|
|
||||||
mutationQueue,
|
|
||||||
keyFor,
|
|
||||||
baseName,
|
|
||||||
keepingKeys,
|
|
||||||
deletingKeys,
|
|
||||||
markKeeping,
|
|
||||||
keepVideo,
|
|
||||||
])
|
|
||||||
|
|
||||||
const enqueueToggleHotVideo = useCallback((job: RecordJob): boolean => {
|
|
||||||
const key = keyFor(job)
|
|
||||||
if (!key) return false
|
|
||||||
|
|
||||||
if (hotBusyKeys.has(key)) return false
|
|
||||||
|
|
||||||
const qid = `hot:${key}`
|
|
||||||
return mutationQueue.enqueue(qid, async () => {
|
|
||||||
await toggleHotVideo(job)
|
|
||||||
})
|
|
||||||
}, [mutationQueue, keyFor, toggleHotVideo, hotBusyKeys])
|
|
||||||
|
|
||||||
const applyRenamedOutput = useCallback(
|
const applyRenamedOutput = useCallback(
|
||||||
(job: RecordJob): RecordJob => {
|
(job: RecordJob): RecordJob => {
|
||||||
const out = norm(job.output || '')
|
const out = norm(job.output || '')
|
||||||
@ -1840,7 +1703,9 @@ export default function FinishedDownloads({
|
|||||||
const list = Array.from(map.entries())
|
const list = Array.from(map.entries())
|
||||||
.map(([k, j]) => ({ k, j }))
|
.map(([k, j]) => ({ k, j }))
|
||||||
.filter(({ k, j }) => {
|
.filter(({ k, j }) => {
|
||||||
|
const file = baseName(j.output || '')
|
||||||
if (deletedKeys.has(k)) return false
|
if (deletedKeys.has(k)) return false
|
||||||
|
if (file && hiddenFiles.has(file)) return false
|
||||||
|
|
||||||
// .trash niemals anzeigen
|
// .trash niemals anzeigen
|
||||||
if (isTrashOutput(j.output)) return false
|
if (isTrashOutput(j.output)) return false
|
||||||
@ -1871,7 +1736,7 @@ export default function FinishedDownloads({
|
|||||||
.map(({ j }) => j)
|
.map(({ j }) => j)
|
||||||
|
|
||||||
return list
|
return list
|
||||||
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput, keyFor, baseName])
|
}, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName])
|
||||||
|
|
||||||
const viewRows = rows
|
const viewRows = rows
|
||||||
|
|
||||||
@ -1927,14 +1792,14 @@ export default function FinishedDownloads({
|
|||||||
markDeleting(key, true)
|
markDeleting(key, true)
|
||||||
|
|
||||||
// ✅ Optimistik: überall gleich -> animiert raus
|
// ✅ Optimistik: überall gleich -> animiert raus
|
||||||
animateRemove(key)
|
animateRemove(key, detail.file)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail.phase === 'error') {
|
if (detail.phase === 'error') {
|
||||||
// ✅ alles zurückrollen -> wieder sichtbar
|
// ✅ alles zurückrollen -> wieder sichtbar
|
||||||
restoreRow(key)
|
restoreRow(key, detail.file)
|
||||||
|
|
||||||
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
|
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
|
||||||
swipeRefs.current.get(key)?.reset()
|
swipeRefs.current.get(key)?.reset()
|
||||||
@ -2608,9 +2473,6 @@ export default function FinishedDownloads({
|
|||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2657,9 +2519,6 @@ export default function FinishedDownloads({
|
|||||||
onAddToDownloads={onAddToDownloads}
|
onAddToDownloads={onAddToDownloads}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -2699,9 +2558,6 @@ export default function FinishedDownloads({
|
|||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
|
||||||
enqueueKeepVideo={enqueueKeepVideo}
|
|
||||||
enqueueToggleHot={enqueueToggleHotVideo}
|
|
||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -2716,14 +2572,6 @@ export default function FinishedDownloads({
|
|||||||
setTeaserKey(null)
|
setTeaserKey(null)
|
||||||
setHoverTeaserKey(null)
|
setHoverTeaserKey(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const j of pageRows) {
|
|
||||||
const f = baseName(j.output || '')
|
|
||||||
if (!f) continue
|
|
||||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } }))
|
|
||||||
window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
window.scrollTo({ top: 0, behavior: 'auto' })
|
window.scrollTo({ top: 0, behavior: 'auto' })
|
||||||
onPageChange(p)
|
onPageChange(p)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -84,10 +84,6 @@ type Props = {
|
|||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueKeepVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueToggleHot?: (job: RecordJob) => boolean
|
|
||||||
|
|
||||||
postworkByFile: Record<string, {
|
postworkByFile: Record<string, {
|
||||||
file: string
|
file: string
|
||||||
assetId?: string
|
assetId?: string
|
||||||
@ -591,10 +587,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
|
|
||||||
enqueueDeleteVideo,
|
|
||||||
enqueueKeepVideo,
|
|
||||||
enqueueToggleHot,
|
|
||||||
postworkByFile
|
postworkByFile
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
@ -722,6 +714,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
blur?: boolean
|
blur?: boolean
|
||||||
animateUnblurOnMount?: boolean
|
animateUnblurOnMount?: boolean
|
||||||
preloadTeaserWhenStill?: boolean
|
preloadTeaserWhenStill?: boolean
|
||||||
|
renderRole?: 'top' | 'back' | 'preload' | 'desktop'
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
@ -922,6 +915,14 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
|
|
||||||
|
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
|
||||||
|
const previewInstanceKey = [
|
||||||
|
k,
|
||||||
|
previewRenderRole,
|
||||||
|
inlineActive ? 'inline' : 'teaser',
|
||||||
|
allowTeaserAnimation ? 'anim' : 'still',
|
||||||
|
].join('::')
|
||||||
|
|
||||||
// ✅ Shell an Gallery angelehnt
|
// ✅ Shell an Gallery angelehnt
|
||||||
const shellCls = [
|
const shellCls = [
|
||||||
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||||
@ -1017,6 +1018,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
|
key={previewInstanceKey}
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
@ -1287,6 +1289,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
forceLoadStill: true,
|
forceLoadStill: true,
|
||||||
blur: true,
|
blur: true,
|
||||||
preloadTeaserWhenStill: true,
|
preloadTeaserWhenStill: true,
|
||||||
|
renderRole: 'back',
|
||||||
})
|
})
|
||||||
|
|
||||||
const depth = idx
|
const depth = idx
|
||||||
@ -1323,6 +1326,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
isDecorative: true,
|
isDecorative: true,
|
||||||
forceLoadStill: true,
|
forceLoadStill: true,
|
||||||
preloadTeaserWhenStill: true,
|
preloadTeaserWhenStill: true,
|
||||||
|
renderRole: 'preload',
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1350,6 +1354,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
mobileStackTopOnlyVideo: true,
|
mobileStackTopOnlyVideo: true,
|
||||||
disableScrubber: true,
|
disableScrubber: true,
|
||||||
animateUnblurOnMount: true,
|
animateUnblurOnMount: true,
|
||||||
|
renderRole: 'top',
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1371,12 +1376,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
doubleTapMaxMovePx={48}
|
doubleTapMaxMovePx={48}
|
||||||
onDoubleTap={async () => {
|
onDoubleTap={async () => {
|
||||||
if (isHot) return
|
if (isHot) return
|
||||||
|
|
||||||
if (enqueueToggleHot) {
|
|
||||||
enqueueToggleHot(j)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await onToggleHot?.(j)
|
await onToggleHot?.(j)
|
||||||
}}
|
}}
|
||||||
onTap={() => {
|
onTap={() => {
|
||||||
@ -1387,14 +1386,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSwipeLeft={() => {
|
onSwipeLeft={() => deleteVideo(j)}
|
||||||
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
|
onSwipeRight={() => keepVideo(j)}
|
||||||
return deleteVideo(j)
|
|
||||||
}}
|
|
||||||
onSwipeRight={() => {
|
|
||||||
if (enqueueKeepVideo) return enqueueKeepVideo(j)
|
|
||||||
return keepVideo(j)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cardInner}
|
{cardInner}
|
||||||
</SwipeCard>
|
</SwipeCard>
|
||||||
|
|||||||
@ -93,11 +93,6 @@ type Props = {
|
|||||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
|
||||||
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueKeepVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueToggleHot?: (job: RecordJob) => boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||||
@ -186,7 +181,6 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
deletingKeys,
|
deletingKeys,
|
||||||
keepingKeys,
|
keepingKeys,
|
||||||
removingKeys,
|
removingKeys,
|
||||||
deletedKeys,
|
|
||||||
|
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
|
|
||||||
@ -204,9 +198,6 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onSplit,
|
onSplit,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
enqueueDeleteVideo,
|
|
||||||
enqueueKeepVideo,
|
|
||||||
enqueueToggleHot,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||||
@ -331,8 +322,10 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
const resObj = resolutionObjOf(j)
|
const resObj = resolutionObjOf(j)
|
||||||
const resLabel = formatResolution(resObj)
|
const resLabel = formatResolution(resObj)
|
||||||
|
|
||||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
const isRemoving = removingKeys.has(k)
|
||||||
const deleted = deletedKeys.has(k)
|
const isDeleting = deletingKeys.has(k)
|
||||||
|
const isKeeping = keepingKeys.has(k)
|
||||||
|
const busy = isDeleting || isKeeping || isRemoving
|
||||||
|
|
||||||
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
|
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
|
||||||
const modelImageSrc = firstNonEmptyString(
|
const modelImageSrc = firstNonEmptyString(
|
||||||
@ -474,146 +467,152 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
showModelPreviewInThumb || showScrubberSpriteInThumb
|
showModelPreviewInThumb || showScrubberSpriteInThumb
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={k} className="relative">
|
||||||
key={k}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={[
|
|
||||||
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
|
||||||
'bg-white dark:bg-gray-900/40',
|
|
||||||
'transition-all duration-200',
|
|
||||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
|
||||||
busy && 'pointer-events-none opacity-70',
|
|
||||||
deletingKeys.has(k) &&
|
|
||||||
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
|
||||||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
|
||||||
deleted && 'hidden',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={() => onOpenPlayer(j)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumb */}
|
|
||||||
<div
|
<div
|
||||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
role="button"
|
||||||
ref={registerTeaserHostIfNeeded(k)}
|
tabIndex={0}
|
||||||
onMouseEnter={() => {
|
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
||||||
setHoveredThumbKey(k)
|
onClick={() => onOpenPlayer(j)}
|
||||||
onHoverPreviewKeyChange?.(k)
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
onMouseLeave={() => {
|
|
||||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
|
||||||
onHoverPreviewKeyChange?.(null)
|
|
||||||
clearScrubIndex(k)
|
|
||||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
<div
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
className={[
|
||||||
<div className="absolute inset-0">
|
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||||
<FinishedVideoPreview
|
'bg-white dark:bg-gray-900/40',
|
||||||
job={j}
|
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
'will-change-[transform,opacity]',
|
||||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||||
onDuration={handleDuration}
|
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||||
variant="fill"
|
isDeleting &&
|
||||||
showPopover={false}
|
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||||
blur={blurPreviews}
|
isKeeping &&
|
||||||
animated={shouldAnimateTeaser({
|
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||||
state: teaserState,
|
isRemoving
|
||||||
itemKey: k,
|
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
|
||||||
disabled: hideTeaserUnderOverlay,
|
: 'opacity-100 translate-y-0 scale-100',
|
||||||
})}
|
]
|
||||||
animatedMode="teaser"
|
.filter(Boolean)
|
||||||
animatedTrigger="always"
|
.join(' ')}
|
||||||
muted={previewMuted}
|
>
|
||||||
popoverMuted={previewMuted}
|
{/* Thumb */}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
<div
|
||||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
/>
|
ref={registerTeaserHostIfNeeded(k)}
|
||||||
</div>
|
onMouseEnter={() => {
|
||||||
|
setHoveredThumbKey(k)
|
||||||
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
onHoverPreviewKeyChange?.(k)
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
}}
|
||||||
<img
|
onMouseLeave={() => {
|
||||||
src={spriteUrl}
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||||
alt=""
|
onHoverPreviewKeyChange?.(null)
|
||||||
className="hidden"
|
clearScrubIndex(k)
|
||||||
loading="lazy"
|
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||||
decoding="async"
|
}}
|
||||||
aria-hidden="true"
|
>
|
||||||
/>
|
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
||||||
) : null}
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
|
<div className="absolute inset-0">
|
||||||
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
|
<FinishedVideoPreview
|
||||||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
job={j}
|
||||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
<div
|
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||||
className="h-full w-full"
|
onDuration={handleDuration}
|
||||||
style={spriteFrameStyle}
|
variant="fill"
|
||||||
|
showPopover={false}
|
||||||
|
blur={blurPreviews}
|
||||||
|
animated={shouldAnimateTeaser({
|
||||||
|
state: teaserState,
|
||||||
|
itemKey: k,
|
||||||
|
disabled: hideTeaserUnderOverlay,
|
||||||
|
})}
|
||||||
|
animatedMode="teaser"
|
||||||
|
animatedTrigger="always"
|
||||||
|
muted={previewMuted}
|
||||||
|
popoverMuted={previewMuted}
|
||||||
|
scrubProgressRatio={scrubProgressRatio}
|
||||||
|
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
|
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
||||||
{showModelPreviewInThumb && modelImageSrc ? (
|
{hasSpriteScrubber && spriteUrl ? (
|
||||||
<div className="absolute inset-0 z-[6]">
|
|
||||||
<img
|
<img
|
||||||
src={modelImageSrc}
|
src={spriteUrl}
|
||||||
alt={model ? `${model} preview` : 'Model preview'}
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
className="hidden"
|
||||||
draggable={false}
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
) : null}
|
||||||
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
|
||||||
MODEL PREVIEW
|
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
|
||||||
|
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||||||
|
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="h-full w-full"
|
||||||
|
style={spriteFrameStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
|
||||||
|
{showModelPreviewInThumb && modelImageSrc ? (
|
||||||
|
<div className="absolute inset-0 z-[6]">
|
||||||
|
<img
|
||||||
|
src={modelImageSrc}
|
||||||
|
alt={model ? `${model} preview` : 'Model preview'}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
||||||
|
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
||||||
|
MODEL PREVIEW
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
||||||
{hasScrubber && hoveredThumbKey === k ? (
|
{hasScrubber && hoveredThumbKey === k ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<PreviewScrubber
|
<PreviewScrubber
|
||||||
className="pointer-events-auto px-1"
|
className="pointer-events-auto px-1"
|
||||||
imageCount={scrubberCount}
|
imageCount={scrubberCount}
|
||||||
activeIndex={activeScrubIndex}
|
activeIndex={activeScrubIndex}
|
||||||
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
||||||
onIndexClick={(index) => {
|
onIndexClick={(index) => {
|
||||||
setScrubIndexForKey(k, index)
|
setScrubIndexForKey(k, index)
|
||||||
handleScrubberClickIndex(j, index, scrubberCount)
|
handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
}}
|
}}
|
||||||
stepSeconds={scrubberStepSeconds}
|
stepSeconds={scrubberStepSeconds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Meta-Overlay im Video: unten rechts */}
|
{/* Meta-Overlay im Video: unten rechts */}
|
||||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' • ')}
|
.join(' • ')}
|
||||||
>
|
>
|
||||||
<span>{dur}</span>
|
<span>{dur}</span>
|
||||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||||
{resLabel ? <span>{resLabel}</span> : null}
|
{resLabel ? <span>{resLabel}</span> : null}
|
||||||
<span aria-hidden="true">•</span>
|
<span aria-hidden="true">•</span>
|
||||||
<span>{size}</span>
|
<span>{size}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer / Meta */}
|
{/* Footer / Meta */}
|
||||||
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||||||
@ -635,140 +634,124 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
|
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
|
||||||
<div className="mt-1 flex items-center justify-between gap-2">
|
<div className="mt-1 flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
|
|
||||||
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (modelImageSrc) setHoveredModelPreviewKey(k)
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{model || '—'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{showPostworkBadge ? (
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
|
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
|
||||||
postworkBadge.state === 'running'
|
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
|
||||||
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
|
]
|
||||||
: postworkBadge.state === 'queued'
|
.filter(Boolean)
|
||||||
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
|
.join(' ')}
|
||||||
: postworkBadge.state === 'done'
|
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
|
||||||
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
|
onMouseEnter={(e) => {
|
||||||
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
|
e.stopPropagation()
|
||||||
].join(' ')}
|
if (modelImageSrc) setHoveredModelPreviewKey(k)
|
||||||
title={
|
}}
|
||||||
postworkBadge.label ||
|
onMouseLeave={(e) => {
|
||||||
(postworkBadge.state === 'running'
|
e.stopPropagation()
|
||||||
? 'Nacharbeiten laufen'
|
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||||
: postworkBadge.state === 'queued'
|
}}
|
||||||
? 'Warte auf Nacharbeiten'
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
: postworkBadge.state === 'done'
|
onClick={(e) => e.stopPropagation()}
|
||||||
? 'Nacharbeiten abgeschlossen'
|
|
||||||
: 'Nacharbeiten fehlgeschlagen')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
{model || '—'}
|
||||||
{postworkBadge.label ||
|
</span>
|
||||||
(postworkBadge.state === 'running'
|
|
||||||
? 'Nacharbeiten laufen…'
|
{showPostworkBadge ? (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
|
||||||
|
postworkBadge.state === 'running'
|
||||||
|
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
|
||||||
: postworkBadge.state === 'queued'
|
: postworkBadge.state === 'queued'
|
||||||
? 'Warte auf Nacharbeiten…'
|
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
|
||||||
|
: postworkBadge.state === 'done'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
|
||||||
|
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
|
||||||
|
].join(' ')}
|
||||||
|
title={
|
||||||
|
postworkBadge.label ||
|
||||||
|
(postworkBadge.state === 'running'
|
||||||
|
? 'Nacharbeiten laufen'
|
||||||
|
: postworkBadge.state === 'queued'
|
||||||
|
? 'Warte auf Nacharbeiten'
|
||||||
: postworkBadge.state === 'done'
|
: postworkBadge.state === 'done'
|
||||||
? 'Nacharbeiten abgeschlossen'
|
? 'Nacharbeiten abgeschlossen'
|
||||||
: 'Nacharbeiten fehlgeschlagen')}
|
: 'Nacharbeiten fehlgeschlagen')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{postworkBadge.label ||
|
||||||
|
(postworkBadge.state === 'running'
|
||||||
|
? 'Nacharbeiten laufen…'
|
||||||
|
: postworkBadge.state === 'queued'
|
||||||
|
? 'Warte auf Nacharbeiten…'
|
||||||
|
: postworkBadge.state === 'done'
|
||||||
|
? 'Nacharbeiten abgeschlossen'
|
||||||
|
: 'Nacharbeiten fehlgeschlagen')}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||||||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
||||||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
|
||||||
<div
|
|
||||||
className="mt-2 shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
||||||
<RecordJobActions
|
|
||||||
job={jobForDetails(j)}
|
|
||||||
variant="table"
|
|
||||||
busy={busy}
|
|
||||||
collapseToMenu
|
|
||||||
compact={true}
|
|
||||||
isHot={isHot}
|
|
||||||
isFavorite={isFav}
|
|
||||||
isLiked={isLiked}
|
|
||||||
isWatching={isWatching}
|
|
||||||
onToggleWatch={onToggleWatch}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
onToggleLike={onToggleLike}
|
|
||||||
onToggleHot={async (job) => {
|
|
||||||
if (enqueueToggleHot) {
|
|
||||||
const accepted = enqueueToggleHot(job)
|
|
||||||
if (accepted) return
|
|
||||||
}
|
|
||||||
return onToggleHot(job)
|
|
||||||
}}
|
|
||||||
onKeep={async (job) => {
|
|
||||||
if (enqueueKeepVideo) {
|
|
||||||
const accepted = enqueueKeepVideo(job)
|
|
||||||
if (accepted) return true
|
|
||||||
}
|
|
||||||
return keepVideo(job)
|
|
||||||
}}
|
|
||||||
onDelete={async (job) => {
|
|
||||||
if (enqueueDeleteVideo) {
|
|
||||||
const accepted = enqueueDeleteVideo(job)
|
|
||||||
if (accepted) return true
|
|
||||||
}
|
|
||||||
return deleteVideo(job)
|
|
||||||
}}
|
|
||||||
onSplit={onSplit}
|
|
||||||
onAddToDownloads={onAddToDownloads}
|
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
|
||||||
className="w-full gap-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Actions (wie CardView: im Footer statt im Video) */}
|
||||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
<div
|
||||||
<TagOverflowRow
|
className="mt-2 shrink-0"
|
||||||
rowKey={k}
|
onClick={(e) => e.stopPropagation()}
|
||||||
tags={tags}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
activeTagSet={activeTagSet}
|
>
|
||||||
lower={lower}
|
<div className="w-full">
|
||||||
onToggleTagFilter={onToggleTagFilter}
|
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
/>
|
<RecordJobActions
|
||||||
|
job={jobForDetails(j)}
|
||||||
|
variant="table"
|
||||||
|
busy={busy}
|
||||||
|
collapseToMenu
|
||||||
|
compact={true}
|
||||||
|
isHot={isHot}
|
||||||
|
isFavorite={isFav}
|
||||||
|
isLiked={isLiked}
|
||||||
|
isWatching={isWatching}
|
||||||
|
onToggleWatch={onToggleWatch}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleLike={onToggleLike}
|
||||||
|
onToggleHot={onToggleHot}
|
||||||
|
onKeep={keepVideo}
|
||||||
|
onDelete={deleteVideo}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
|
className="w-full gap-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<TagOverflowRow
|
||||||
|
rowKey={k}
|
||||||
|
tags={tags}
|
||||||
|
activeTagSet={activeTagSet}
|
||||||
|
lower={lower}
|
||||||
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && rows.length === 0 ? (
|
{isLoading && rows.length === 0 ? (
|
||||||
|
|||||||
@ -92,11 +92,6 @@ type Props = {
|
|||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
|
||||||
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueKeepVideo?: (job: RecordJob) => boolean
|
|
||||||
enqueueToggleHot?: (job: RecordJob) => boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsTableView({
|
export default function FinishedDownloadsTableView({
|
||||||
@ -145,9 +140,6 @@ export default function FinishedDownloadsTableView({
|
|||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
enqueueDeleteVideo,
|
|
||||||
enqueueKeepVideo,
|
|
||||||
enqueueToggleHot,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sort, setSort] = React.useState<SortState>(null)
|
const [sort, setSort] = React.useState<SortState>(null)
|
||||||
|
|
||||||
@ -502,31 +494,9 @@ export default function FinishedDownloadsTableView({
|
|||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleHot={
|
onToggleHot={onToggleHot}
|
||||||
onToggleHot
|
onKeep={keepVideo}
|
||||||
? async (job) => {
|
onDelete={deleteVideo}
|
||||||
if (enqueueToggleHot) {
|
|
||||||
const accepted = enqueueToggleHot(job)
|
|
||||||
if (accepted) return
|
|
||||||
}
|
|
||||||
return onToggleHot(job)
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onKeep={async (job) => {
|
|
||||||
if (enqueueKeepVideo) {
|
|
||||||
const accepted = enqueueKeepVideo(job)
|
|
||||||
if (accepted) return true
|
|
||||||
}
|
|
||||||
return keepVideo(job)
|
|
||||||
}}
|
|
||||||
onDelete={async (job) => {
|
|
||||||
if (enqueueDeleteVideo) {
|
|
||||||
const accepted = enqueueDeleteVideo(job)
|
|
||||||
if (accepted) return true
|
|
||||||
}
|
|
||||||
return deleteVideo(job)
|
|
||||||
}}
|
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import RecordJobActions from './RecordJobActions'
|
|||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { apiUrl, apiFetch } from '../../lib/api'
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
import LiveVideo from './LiveVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
|
import { formatResolution, formatFps } from './formatters'
|
||||||
|
|
||||||
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||||||
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||||
@ -103,17 +104,6 @@ const pickNum = (...vals: any[]): number | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFps(n?: number | null): string {
|
|
||||||
if (!n || !Number.isFinite(n)) return '—'
|
|
||||||
const digits = n >= 10 ? 0 : 2
|
|
||||||
return `${n.toFixed(digits)} fps`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatResolution(h?: number | null): string {
|
|
||||||
if (!h || !Number.isFinite(h)) return '—'
|
|
||||||
return `${Math.round(h)}p`
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDateFromOutput(output?: string): Date | null {
|
function parseDateFromOutput(output?: string): Date | null {
|
||||||
const fileRaw = baseName(output || '')
|
const fileRaw = baseName(output || '')
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
@ -432,19 +422,31 @@ export default function Player({
|
|||||||
setPreviewSrc(previewA)
|
setPreviewSrc(previewA)
|
||||||
}, [previewA])
|
}, [previewA])
|
||||||
|
|
||||||
const videoH = React.useMemo(
|
|
||||||
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
|
||||||
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
|
||||||
)
|
|
||||||
|
|
||||||
const fps = React.useMemo(
|
const fps = React.useMemo(
|
||||||
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
|
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
|
||||||
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
|
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [intrH, setIntrH] = React.useState<number | null>(null)
|
const [intrSize, setIntrSize] = React.useState<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
const resolutionLabel = React.useMemo(() => formatResolution(intrH ?? videoH), [intrH, videoH])
|
const videoW = React.useMemo(
|
||||||
|
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
|
||||||
|
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoH = React.useMemo(
|
||||||
|
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
||||||
|
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolutionLabel = React.useMemo(
|
||||||
|
() =>
|
||||||
|
formatResolution(
|
||||||
|
intrSize ??
|
||||||
|
(videoW && videoH ? { w: videoW, h: videoH } : null)
|
||||||
|
),
|
||||||
|
[intrSize, videoW, videoH]
|
||||||
|
)
|
||||||
const fpsLabel = React.useMemo(() => formatFps(fps), [fps])
|
const fpsLabel = React.useMemo(() => formatFps(fps), [fps])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -541,9 +543,15 @@ export default function Player({
|
|||||||
const updateIntrinsicDims = React.useCallback(() => {
|
const updateIntrinsicDims = React.useCallback(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
if (!p || p.isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
|
|
||||||
|
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 0
|
||||||
const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
|
const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
|
||||||
if (typeof h === 'number' && h > 0 && Number.isFinite(h)) {
|
|
||||||
setIntrH(h)
|
if (
|
||||||
|
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
|
||||||
|
typeof h === 'number' && Number.isFinite(h) && h > 0
|
||||||
|
) {
|
||||||
|
setIntrSize({ w, h })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -1199,7 +1207,7 @@ export default function Player({
|
|||||||
|
|
||||||
const h =
|
const h =
|
||||||
pickNum(
|
pickNum(
|
||||||
intrH,
|
intrSize?.h,
|
||||||
(playerRef.current as any)?.videoHeight?.(),
|
(playerRef.current as any)?.videoHeight?.(),
|
||||||
anyJob.videoHeight,
|
anyJob.videoHeight,
|
||||||
anyJob.height,
|
anyJob.height,
|
||||||
@ -1211,7 +1219,7 @@ export default function Player({
|
|||||||
// Fallback wenn nur Höhe bekannt ist
|
// Fallback wenn nur Höhe bekannt ist
|
||||||
// (lieber stabiler fallback als kaputt)
|
// (lieber stabiler fallback als kaputt)
|
||||||
return 16 / 9
|
return 16 / 9
|
||||||
}, [job, intrH])
|
}, [job, intrSize])
|
||||||
|
|
||||||
const clampMiniRect = React.useCallback(
|
const clampMiniRect = React.useCallback(
|
||||||
(r: { x: number; y: number; w: number; h: number }) => {
|
(r: { x: number; y: number; w: number; h: number }) => {
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
// frontend/src/components/ui/formatters.ts
|
// frontend/src/components/ui/formatters.ts
|
||||||
|
|
||||||
|
export function formatFps(n?: number | null): string {
|
||||||
|
if (!n || !Number.isFinite(n)) return '—'
|
||||||
|
const digits = n >= 10 ? 0 : 2
|
||||||
|
return `${n.toFixed(digits)} fps`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatResolution(r?: { w: number; h: number } | null): string {
|
export function formatResolution(r?: { w: number; h: number } | null): string {
|
||||||
if (!r) return ''
|
if (!r) return ''
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\components\ui\notify.ts
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { Toast } from './ToastProvider'
|
import type { Toast } from './ToastProvider'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user