updated
This commit is contained in:
parent
c751430af5
commit
ca237ef2da
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
backend/recorder_settings.json
|
backend/recorder_settings.json
|
||||||
records
|
records
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/generated
|
||||||
|
|||||||
1160
backend/main.go
1160
backend/main.go
File diff suppressed because it is too large
Load Diff
@ -106,11 +106,15 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
|
|||||||
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
||||||
|
const DONE_PAGE_SIZE = 8
|
||||||
|
|
||||||
const [sourceUrl, setSourceUrl] = useState('')
|
const [sourceUrl, setSourceUrl] = useState('')
|
||||||
const [, setParsed] = useState<ParsedModel | null>(null)
|
const [, setParsed] = useState<ParsedModel | null>(null)
|
||||||
const [, setParseError] = useState<string | null>(null)
|
const [, setParseError] = useState<string | null>(null)
|
||||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||||||
|
const [donePage, setDonePage] = useState(1)
|
||||||
const [doneCount, setDoneCount] = useState<number>(0)
|
const [doneCount, setDoneCount] = useState<number>(0)
|
||||||
const [modelsCount, setModelsCount] = useState(0)
|
const [modelsCount, setModelsCount] = useState(0)
|
||||||
|
|
||||||
@ -307,6 +311,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
|
||||||
|
if (donePage > maxPage) setDonePage(maxPage)
|
||||||
|
}, [doneCount, donePage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sourceUrl.trim() === '') {
|
if (sourceUrl.trim() === '') {
|
||||||
setParsed(null)
|
setParsed(null)
|
||||||
@ -371,7 +380,10 @@ export default function App() {
|
|||||||
if (cancelled || inFlight) return
|
if (cancelled || inFlight) return
|
||||||
inFlight = true
|
inFlight = true
|
||||||
try {
|
try {
|
||||||
const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
|
const list = await apiJSON<RecordJob[]>(
|
||||||
|
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}`,
|
||||||
|
{ cache: 'no-store' as any }
|
||||||
|
)
|
||||||
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
|
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
|
||||||
} catch {
|
} catch {
|
||||||
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
|
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
|
||||||
@ -400,7 +412,7 @@ export default function App() {
|
|||||||
window.clearInterval(t)
|
window.clearInterval(t)
|
||||||
document.removeEventListener('visibilitychange', onVis)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
}
|
}
|
||||||
}, [selectedTab])
|
}, [selectedTab, donePage])
|
||||||
|
|
||||||
|
|
||||||
function isChaturbate(url: string): boolean {
|
function isChaturbate(url: string): boolean {
|
||||||
@ -785,6 +797,10 @@ export default function App() {
|
|||||||
<FinishedDownloads
|
<FinishedDownloads
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
doneJobs={doneJobs}
|
doneJobs={doneJobs}
|
||||||
|
doneTotal={doneCount}
|
||||||
|
page={donePage}
|
||||||
|
pageSize={DONE_PAGE_SIZE}
|
||||||
|
onPageChange={setDonePage}
|
||||||
onOpenPlayer={openPlayer}
|
onOpenPlayer={openPlayer}
|
||||||
onDeleteJob={handleDeleteJob}
|
onDeleteJob={handleDeleteJob}
|
||||||
onToggleHot={handleToggleHot}
|
onToggleHot={handleToggleHot}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// frontend/src/components/ui/FinishedDownloads.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@ -28,7 +27,10 @@ import {
|
|||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
|
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
|
||||||
|
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
|
||||||
|
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
|
||||||
|
import Pagination from './Pagination'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
@ -39,6 +41,10 @@ type Props = {
|
|||||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
|
doneTotal: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
|
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
|
||||||
@ -148,9 +154,11 @@ export default function FinishedDownloads({
|
|||||||
onToggleHot,
|
onToggleHot,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
|
doneTotal,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const PAGE_SIZE = 50
|
|
||||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
|
||||||
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
||||||
|
|
||||||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||||
@ -572,10 +580,6 @@ export default function FinishedDownloads({
|
|||||||
return arr
|
return arr
|
||||||
}, [rows, sortMode, durations])
|
}, [rows, sortMode, durations])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVisibleCount(PAGE_SIZE)
|
|
||||||
}, [rows.length])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onExternalDelete = (ev: Event) => {
|
const onExternalDelete = (ev: Event) => {
|
||||||
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
||||||
@ -616,12 +620,10 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const viewRows = view === 'table' ? rows : sortedNonTableRows
|
const viewRows = view === 'table' ? rows : sortedNonTableRows
|
||||||
|
|
||||||
const visibleRows = viewRows
|
const visibleRows = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||||
.filter((j) => !deletedKeys.has(keyFor(j)))
|
|
||||||
.slice(0, visibleCount)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active = view === 'cards' || view === 'gallery'
|
const active = view === 'cards'
|
||||||
if (!active) { setTeaserKey(null); return }
|
if (!active) { setTeaserKey(null); return }
|
||||||
|
|
||||||
// in Cards: wenn Inline-Player aktiv ist, diesen festhalten
|
// in Cards: wenn Inline-Player aktiv ist, diesen festhalten
|
||||||
@ -726,6 +728,9 @@ export default function FinishedDownloads({
|
|||||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
|
animated={true}
|
||||||
|
animatedMode="teaser"
|
||||||
|
animatedTrigger="always"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -993,337 +998,50 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ✅ Cards */}
|
|
||||||
{view === 'cards' && (
|
{view === 'cards' && (
|
||||||
<div className="space-y-3">
|
<FinishedDownloadsCardsView
|
||||||
{visibleRows.map((j) => {
|
rows={visibleRows}
|
||||||
const k = keyFor(j)
|
isSmall={isSmall}
|
||||||
const inlineActive = inlinePlay?.key === k
|
blurPreviews={blurPreviews}
|
||||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
durations={durations}
|
||||||
|
teaserKey={teaserKey}
|
||||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
inlinePlay={inlinePlay}
|
||||||
|
setInlinePlay={setInlinePlay}
|
||||||
const model = modelNameFromOutput(j.output)
|
deletingKeys={deletingKeys}
|
||||||
const file = baseName(j.output || '')
|
keepingKeys={keepingKeys}
|
||||||
const dur = runtimeOf(j)
|
removingKeys={removingKeys}
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
swipeRefs={swipeRefs}
|
||||||
|
keyFor={keyFor}
|
||||||
const statusNode =
|
baseName={baseName}
|
||||||
j.status === 'failed' ? (
|
stripHotPrefix={stripHotPrefix}
|
||||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
modelNameFromOutput={modelNameFromOutput}
|
||||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
runtimeOf={runtimeOf}
|
||||||
</span>
|
sizeBytesOf={sizeBytesOf}
|
||||||
) : (
|
formatBytes={formatBytes}
|
||||||
<span className="font-medium">{j.status}</span>
|
lower={lower}
|
||||||
)
|
onOpenPlayer={onOpenPlayer}
|
||||||
|
openPlayer={openPlayer}
|
||||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
startInline={startInline}
|
||||||
|
tryAutoplayInline={tryAutoplayInline}
|
||||||
const cardInner = (
|
registerTeaserHost={registerTeaserHost}
|
||||||
<div
|
handleDuration={handleDuration}
|
||||||
role="button"
|
deleteVideo={deleteVideo}
|
||||||
tabIndex={0}
|
keepVideo={keepVideo}
|
||||||
className={[
|
openCtx={openCtx}
|
||||||
'transition-all duration-300 ease-in-out',
|
openCtxAt={openCtxAt}
|
||||||
busy && 'pointer-events-none',
|
releasePlayingFile={releasePlayingFile}
|
||||||
deletingKeys.has(k) &&
|
modelsByKey={modelsByKey}
|
||||||
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
|
onToggleHot={onToggleHot}
|
||||||
keepingKeys.has(k) &&
|
onToggleFavorite={onToggleFavorite}
|
||||||
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
|
onToggleLike={onToggleLike}
|
||||||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
/>
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => openCtx(j, e)}
|
|
||||||
>
|
|
||||||
<Card noBodyPadding className="overflow-hidden">
|
|
||||||
{/* Preview */}
|
|
||||||
<div
|
|
||||||
id={inlineDomId}
|
|
||||||
ref={registerTeaserHost(k)} // <- NEU
|
|
||||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (isSmall) return // ✅ Mobile: SwipeCard-onTap macht das
|
|
||||||
startInline(k) // ✅ Desktop: Click startet inline
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FinishedVideoPreview
|
|
||||||
job={j}
|
|
||||||
getFileName={baseName}
|
|
||||||
durationSeconds={durations[k]}
|
|
||||||
onDuration={handleDuration}
|
|
||||||
className="w-full h-full"
|
|
||||||
showPopover={false}
|
|
||||||
blur={blurPreviews}
|
|
||||||
animated={teaserKey === k && !inlineActive}
|
|
||||||
animatedMode="clips"
|
|
||||||
animatedTrigger="always"
|
|
||||||
clipSeconds={1}
|
|
||||||
thumbSamples={18}
|
|
||||||
inlineVideo={inlineActive ? 'always' : false}
|
|
||||||
inlineNonce={inlineNonce}
|
|
||||||
inlineControls={inlineActive}
|
|
||||||
inlineLoop={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient overlay bottom */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
|
|
||||||
'transition-opacity duration-150',
|
|
||||||
inlineActive ? 'opacity-0' : 'opacity-100',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Overlay bottom */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3',
|
|
||||||
'transition-opacity duration-150',
|
|
||||||
inlineActive ? 'opacity-0' : 'opacity-100',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold text-white">{model}</div>
|
|
||||||
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(file) || '—'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-2">
|
|
||||||
{file.startsWith('HOT ') ? (
|
|
||||||
<span className="rounded-md bg-amber-500/25 px-2 py-1 text-[11px] font-semibold text-white">
|
|
||||||
HOT
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isSmall && inlinePlay?.key === k && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setInlinePlay((prev) => ({ key: k, nonce: (prev?.key === k ? prev.nonce + 1 : 1) }))
|
|
||||||
}}
|
|
||||||
title="Von vorne starten"
|
|
||||||
aria-label="Von vorne starten"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions top-right */}
|
|
||||||
<div className="absolute right-2 top-2 flex items-center gap-2">
|
|
||||||
{(() => {
|
|
||||||
const iconBtn =
|
|
||||||
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
|
||||||
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
|
||||||
|
|
||||||
const fileRaw = baseName(j.output || '')
|
|
||||||
const isHot = fileRaw.startsWith('HOT ')
|
|
||||||
const modelKey = modelNameFromOutput(j.output)
|
|
||||||
const flags = modelsByKey[lower(modelKey)]
|
|
||||||
const isFav = Boolean(flags?.favorite)
|
|
||||||
const isLiked = flags?.liked === true
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isSmall && (
|
|
||||||
<>
|
|
||||||
{/* Keep */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title="Behalten (nach keep verschieben)"
|
|
||||||
aria-label="Behalten"
|
|
||||||
disabled={busy}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
void keepVideo(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BookmarkSquareIcon className="size-5 text-emerald-300" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title="Löschen"
|
|
||||||
aria-label="Löschen"
|
|
||||||
disabled={busy}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
void deleteVideo(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-5 text-red-300" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HOT */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
|
||||||
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
|
||||||
disabled={busy || !onToggleHot}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
// wichtig gegen File-Lock beim Rename:
|
|
||||||
releasePlayingFile(fileRaw, { close: true })
|
|
||||||
await new Promise((r) => setTimeout(r, 150))
|
|
||||||
await onToggleHot?.(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Favorite */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
|
||||||
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
|
||||||
disabled={busy || !onToggleFavorite}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
await onToggleFavorite?.(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const Icon = isFav ? StarSolidIcon : StarOutlineIcon
|
|
||||||
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} />
|
|
||||||
})()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Like */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
|
||||||
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
|
||||||
disabled={busy || !onToggleLike}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
await onToggleLike?.(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
|
|
||||||
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
|
|
||||||
})()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Menu */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtn}
|
|
||||||
title="Aktionen"
|
|
||||||
aria-label="Aktionen"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
openCtxAt(j, r.left, r.bottom + 6)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EllipsisVerticalIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
<div className="min-w-0 truncate">
|
|
||||||
Status: {statusNode}
|
|
||||||
<span className="mx-2 opacity-60">•</span>
|
|
||||||
Dauer: <span className="font-medium">{dur}</span>
|
|
||||||
<span className="mx-2 opacity-60">•</span>
|
|
||||||
Größe: <span className="font-medium">{size}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{j.output ? (
|
|
||||||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400" title={j.output}>
|
|
||||||
{j.output}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ✅ Mobile: SwipeCard, Desktop: normale Card
|
|
||||||
return isSmall ? (
|
|
||||||
<SwipeCard
|
|
||||||
ref={(h) => {
|
|
||||||
if (h) swipeRefs.current.set(k, h)
|
|
||||||
else swipeRefs.current.delete(k)
|
|
||||||
}}
|
|
||||||
key={k}
|
|
||||||
enabled
|
|
||||||
disabled={busy}
|
|
||||||
ignoreFromBottomPx={110}
|
|
||||||
onTap={() => {
|
|
||||||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
|
||||||
|
|
||||||
// ✅ State sofort committen (damit Video direkt im DOM ist)
|
|
||||||
flushSync(() => startInline(k))
|
|
||||||
|
|
||||||
// ✅ direkt versuchen (innerhalb des Tap-Tasks)
|
|
||||||
if (!tryAutoplayInline(domId)) {
|
|
||||||
// Fallback: nächster Frame (falls Video erst im Commit auftaucht)
|
|
||||||
requestAnimationFrame(() => tryAutoplayInline(domId))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSwipeLeft={() => deleteVideo(j)}
|
|
||||||
onSwipeRight={() => keepVideo(j)}
|
|
||||||
>
|
|
||||||
{cardInner}
|
|
||||||
</SwipeCard>
|
|
||||||
) : (
|
|
||||||
<React.Fragment key={k}>{cardInner}</React.Fragment>
|
|
||||||
)
|
|
||||||
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ✅ Tabelle */}
|
|
||||||
{view === 'table' && (
|
{view === 'table' && (
|
||||||
<Table
|
<FinishedDownloadsTableView
|
||||||
rows={visibleRows}
|
rows={visibleRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(j) => keyFor(j)}
|
getRowKey={(j) => keyFor(j)}
|
||||||
striped
|
|
||||||
fullWidth
|
|
||||||
stickyHeader
|
|
||||||
compact
|
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onRowClick={onOpenPlayer}
|
onRowClick={onOpenPlayer}
|
||||||
@ -1342,182 +1060,30 @@ export default function FinishedDownloads({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ✅ Galerie */}
|
|
||||||
{view === 'gallery' && (
|
{view === 'gallery' && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<FinishedDownloadsGalleryView
|
||||||
{visibleRows.map((j) => {
|
rows={visibleRows}
|
||||||
const k = keyFor(j)
|
blurPreviews={blurPreviews}
|
||||||
const model = modelNameFromOutput(j.output)
|
durations={durations}
|
||||||
const file = baseName(j.output || '')
|
handleDuration={handleDuration}
|
||||||
const dur = runtimeOf(j)
|
keyFor={keyFor}
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
baseName={baseName}
|
||||||
|
stripHotPrefix={stripHotPrefix}
|
||||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
modelNameFromOutput={modelNameFromOutput}
|
||||||
const deleted = deletedKeys.has(k)
|
runtimeOf={runtimeOf}
|
||||||
|
sizeBytesOf={sizeBytesOf}
|
||||||
return (
|
formatBytes={formatBytes}
|
||||||
<div
|
deletingKeys={deletingKeys}
|
||||||
key={k}
|
keepingKeys={keepingKeys}
|
||||||
role="button"
|
removingKeys={removingKeys}
|
||||||
tabIndex={0}
|
deletedKeys={deletedKeys}
|
||||||
className={[
|
registerTeaserHost={registerTeaserHost}
|
||||||
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
onOpenPlayer={onOpenPlayer}
|
||||||
'bg-white dark:bg-gray-900/40',
|
openCtx={openCtx}
|
||||||
'transition-all duration-200',
|
openCtxAt={openCtxAt}
|
||||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
deleteVideo={deleteVideo}
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
keepVideo={keepVideo}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => openCtx(j, e)}
|
|
||||||
>
|
|
||||||
{/* Thumb */}
|
|
||||||
<div
|
|
||||||
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
|
||||||
ref={registerTeaserHost(k)}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
openCtx(j, e)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FinishedVideoPreview
|
|
||||||
job={j}
|
|
||||||
getFileName={baseName}
|
|
||||||
durationSeconds={durations[k]}
|
|
||||||
onDuration={handleDuration}
|
|
||||||
variant="fill"
|
|
||||||
showPopover={false}
|
|
||||||
blur={blurPreviews}
|
|
||||||
animated={teaserKey === k}
|
|
||||||
animatedMode="clips"
|
|
||||||
animatedTrigger="always"
|
|
||||||
clipSeconds={1}
|
|
||||||
thumbSamples={18}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient overlay bottom */}
|
|
||||||
<div
|
|
||||||
className="
|
|
||||||
pointer-events-none absolute inset-x-0 bottom-0 h-16
|
|
||||||
bg-gradient-to-t from-black/65 to-transparent
|
|
||||||
transition-opacity duration-150
|
|
||||||
group-hover:opacity-0 group-focus-within:opacity-0
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom text */}
|
|
||||||
<div
|
|
||||||
className="
|
|
||||||
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
|
|
||||||
transition-opacity duration-150
|
|
||||||
group-hover:opacity-0 group-focus-within:opacity-0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div className="truncate text-xs font-semibold">{model}</div>
|
|
||||||
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
|
|
||||||
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
|
||||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
|
||||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick keep */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
'absolute right-12 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
|
||||||
'bg-black/55 text-white hover:bg-black/70',
|
|
||||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
|
||||||
].join(' ')}
|
|
||||||
aria-label="Behalten"
|
|
||||||
title="Behalten (nach keep verschieben)"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
void keepVideo(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Quick delete */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
'absolute right-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
|
||||||
'bg-black/55 text-white hover:bg-black/70',
|
|
||||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
|
||||||
].join(' ')}
|
|
||||||
aria-label="Video löschen"
|
|
||||||
title="Video löschen"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
void deleteVideo(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* More / Context */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
|
||||||
'bg-black/55 text-white hover:bg-black/70',
|
|
||||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
|
||||||
].join(' ')}
|
|
||||||
aria-label="Aktionen"
|
|
||||||
title="Aktionen"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
openCtxAt(j, r.left, r.bottom + 6)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⋯
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Optional: status line (below thumb) */}
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
<span className="truncate">
|
|
||||||
Status:{' '}
|
|
||||||
{j.status === 'failed' ? (
|
|
||||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
|
||||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">{j.status}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{/* kurzer Hinweis: Hot Prefix */}
|
|
||||||
{baseName(j.output || '').startsWith('HOT ') ? (
|
|
||||||
<span className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
|
||||||
HOT
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@ -1528,16 +1094,36 @@ export default function FinishedDownloads({
|
|||||||
onClose={() => setCtx(null)}
|
onClose={() => setCtx(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rows.length > visibleCount ? (
|
<Pagination
|
||||||
<div className="mt-3 flex justify-center">
|
page={page}
|
||||||
<Button
|
pageSize={pageSize}
|
||||||
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
|
totalItems={doneTotal}
|
||||||
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
onPageChange={(p) => {
|
||||||
>
|
// 1) Inline-Playback + aktiven Teaser sofort stoppen
|
||||||
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
flushSync(() => {
|
||||||
</Button>
|
setInlinePlay(null)
|
||||||
</div>
|
setTeaserKey(null)
|
||||||
) : null}
|
})
|
||||||
|
|
||||||
|
// 2) alle aktuell sichtbaren Previews "freigeben" (damit keine Datei mehr offen ist)
|
||||||
|
for (const j of visibleRows) {
|
||||||
|
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 } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional: zurück nach oben, damit neue Seite "sauber" startet
|
||||||
|
window.scrollTo({ top: 0, behavior: 'auto' })
|
||||||
|
|
||||||
|
// 3) Seite wechseln (App lädt dann neue doneJobs)
|
||||||
|
onPageChange(p)
|
||||||
|
}}
|
||||||
|
showSummary={false}
|
||||||
|
prevLabel="Zurück"
|
||||||
|
nextLabel="Weiter"
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
420
frontend/src/components/ui/FinishedDownloadsCardsView.tsx
Normal file
420
frontend/src/components/ui/FinishedDownloadsCardsView.tsx
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import Card from './Card'
|
||||||
|
import type { RecordJob } from '../../types'
|
||||||
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
|
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
|
import {
|
||||||
|
TrashIcon,
|
||||||
|
FireIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
BookmarkSquareIcon,
|
||||||
|
StarIcon as StarOutlineIcon,
|
||||||
|
HeartIcon as HeartOutlineIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { StarIcon as StarSolidIcon, HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid'
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
type InlinePlayState = { key: string; nonce: number } | null
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: RecordJob[]
|
||||||
|
isSmall: boolean
|
||||||
|
|
||||||
|
blurPreviews?: boolean
|
||||||
|
durations: Record<string, number>
|
||||||
|
teaserKey: string | null
|
||||||
|
inlinePlay: InlinePlayState
|
||||||
|
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
|
||||||
|
|
||||||
|
deletingKeys: Set<string>
|
||||||
|
keepingKeys: Set<string>
|
||||||
|
removingKeys: Set<string>
|
||||||
|
|
||||||
|
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
keyFor: (j: RecordJob) => string
|
||||||
|
baseName: (p: string) => string
|
||||||
|
stripHotPrefix: (s: string) => string
|
||||||
|
modelNameFromOutput: (output?: string) => string
|
||||||
|
runtimeOf: (job: RecordJob) => string
|
||||||
|
sizeBytesOf: (job: RecordJob) => number | null
|
||||||
|
formatBytes: (bytes?: number | null) => string
|
||||||
|
lower: (s: string) => string
|
||||||
|
|
||||||
|
// callbacks/actions
|
||||||
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
|
openPlayer: (job: RecordJob) => void
|
||||||
|
startInline: (key: string) => void
|
||||||
|
tryAutoplayInline: (domId: string) => boolean
|
||||||
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||||
|
|
||||||
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
|
||||||
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
|
||||||
|
openCtx: (job: RecordJob, e: React.MouseEvent) => void
|
||||||
|
openCtxAt: (job: RecordJob, x: number, y: number) => void
|
||||||
|
|
||||||
|
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
||||||
|
|
||||||
|
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null }>
|
||||||
|
|
||||||
|
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinishedDownloadsCardsView({
|
||||||
|
rows,
|
||||||
|
isSmall,
|
||||||
|
|
||||||
|
blurPreviews,
|
||||||
|
durations,
|
||||||
|
teaserKey,
|
||||||
|
inlinePlay,
|
||||||
|
setInlinePlay,
|
||||||
|
|
||||||
|
deletingKeys,
|
||||||
|
keepingKeys,
|
||||||
|
removingKeys,
|
||||||
|
|
||||||
|
swipeRefs,
|
||||||
|
|
||||||
|
keyFor,
|
||||||
|
baseName,
|
||||||
|
stripHotPrefix,
|
||||||
|
modelNameFromOutput,
|
||||||
|
runtimeOf,
|
||||||
|
sizeBytesOf,
|
||||||
|
formatBytes,
|
||||||
|
lower,
|
||||||
|
|
||||||
|
onOpenPlayer,
|
||||||
|
openPlayer,
|
||||||
|
startInline,
|
||||||
|
tryAutoplayInline,
|
||||||
|
registerTeaserHost,
|
||||||
|
|
||||||
|
handleDuration,
|
||||||
|
|
||||||
|
deleteVideo,
|
||||||
|
keepVideo,
|
||||||
|
|
||||||
|
openCtx,
|
||||||
|
openCtxAt,
|
||||||
|
|
||||||
|
releasePlayingFile,
|
||||||
|
|
||||||
|
modelsByKey,
|
||||||
|
|
||||||
|
onToggleHot,
|
||||||
|
onToggleFavorite,
|
||||||
|
onToggleLike,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((j) => {
|
||||||
|
const k = keyFor(j)
|
||||||
|
const inlineActive = inlinePlay?.key === k
|
||||||
|
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||||
|
|
||||||
|
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||||
|
|
||||||
|
const model = modelNameFromOutput(j.output)
|
||||||
|
const fileRaw = baseName(j.output || '')
|
||||||
|
const dur = runtimeOf(j)
|
||||||
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
|
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
|
|
||||||
|
const cardInner = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={[
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
busy && 'pointer-events-none',
|
||||||
|
deletingKeys.has(k) &&
|
||||||
|
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
|
||||||
|
keepingKeys.has(k) &&
|
||||||
|
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
|
||||||
|
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => openCtx(j, e)}
|
||||||
|
>
|
||||||
|
<Card noBodyPadding className="overflow-hidden">
|
||||||
|
{/* Preview */}
|
||||||
|
<div
|
||||||
|
id={inlineDomId}
|
||||||
|
ref={registerTeaserHost(k)}
|
||||||
|
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isSmall) return
|
||||||
|
startInline(k)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FinishedVideoPreview
|
||||||
|
job={j}
|
||||||
|
getFileName={baseName}
|
||||||
|
durationSeconds={durations[k]}
|
||||||
|
onDuration={handleDuration}
|
||||||
|
className="w-full h-full"
|
||||||
|
showPopover={false}
|
||||||
|
blur={blurPreviews}
|
||||||
|
animated={teaserKey === k}
|
||||||
|
animatedMode="teaser"
|
||||||
|
animatedTrigger="always"
|
||||||
|
inlineVideo={inlineActive ? 'always' : false}
|
||||||
|
inlineNonce={inlineNonce}
|
||||||
|
inlineControls={inlineActive}
|
||||||
|
inlineLoop={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradient overlay bottom */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
|
||||||
|
'transition-opacity duration-150',
|
||||||
|
inlineActive ? 'opacity-0' : 'opacity-100',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay bottom */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3',
|
||||||
|
'transition-opacity duration-150',
|
||||||
|
inlineActive ? 'opacity-0' : 'opacity-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{model}</div>
|
||||||
|
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(fileRaw) || '—'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
|
{fileRaw.startsWith('HOT ') ? (
|
||||||
|
<span className="rounded-md bg-amber-500/25 px-2 py-1 text-[11px] font-semibold text-white">
|
||||||
|
HOT
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSmall && inlinePlay?.key === k && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setInlinePlay((prev) => ({ key: k, nonce: prev?.key === k ? prev.nonce + 1 : 1 }))
|
||||||
|
}}
|
||||||
|
title="Von vorne starten"
|
||||||
|
aria-label="Von vorne starten"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions top-right */}
|
||||||
|
<div className="absolute right-2 top-2 flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const iconBtn =
|
||||||
|
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||||
|
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||||
|
|
||||||
|
const isHot = fileRaw.startsWith('HOT ')
|
||||||
|
const modelKey = modelNameFromOutput(j.output)
|
||||||
|
const flags = modelsByKey[lower(modelKey)]
|
||||||
|
const isFav = Boolean(flags?.favorite)
|
||||||
|
const isLiked = flags?.liked === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isSmall && (
|
||||||
|
<>
|
||||||
|
{/* Keep */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title="Behalten (nach keep verschieben)"
|
||||||
|
aria-label="Behalten"
|
||||||
|
disabled={busy}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void keepVideo(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookmarkSquareIcon className="size-5 text-emerald-300" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title="Löschen"
|
||||||
|
aria-label="Löschen"
|
||||||
|
disabled={busy}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void deleteVideo(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-5 text-red-300" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HOT */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
|
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
|
disabled={busy || !onToggleHot}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
// wichtig gegen File-Lock beim Rename:
|
||||||
|
await releasePlayingFile(fileRaw, { close: true })
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
await onToggleHot?.(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Favorite */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
disabled={busy || !onToggleFavorite}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await onToggleFavorite?.(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const Icon = isFav ? StarSolidIcon : StarOutlineIcon
|
||||||
|
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} />
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Like */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||||
|
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||||
|
disabled={busy || !onToggleLike}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await onToggleLike?.(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
|
||||||
|
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
title="Aktionen"
|
||||||
|
aria-label="Aktionen"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
openCtxAt(j, r.left, r.bottom + 6)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EllipsisVerticalIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="min-w-0 truncate">
|
||||||
|
Dauer: <span className="font-medium">{dur}</span>
|
||||||
|
<span className="mx-2 opacity-60">•</span>
|
||||||
|
Größe: <span className="font-medium">{size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{j.output ? (
|
||||||
|
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400" title={j.output}>
|
||||||
|
{j.output}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Mobile: SwipeCard, Desktop: normale Card
|
||||||
|
return isSmall ? (
|
||||||
|
<SwipeCard
|
||||||
|
ref={(h) => {
|
||||||
|
if (h) swipeRefs.current.set(k, h)
|
||||||
|
else swipeRefs.current.delete(k)
|
||||||
|
}}
|
||||||
|
key={k}
|
||||||
|
enabled
|
||||||
|
disabled={busy}
|
||||||
|
ignoreFromBottomPx={110}
|
||||||
|
onTap={() => {
|
||||||
|
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
|
flushSync(() => startInline(k))
|
||||||
|
if (!tryAutoplayInline(domId)) {
|
||||||
|
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSwipeLeft={() => deleteVideo(j)}
|
||||||
|
onSwipeRight={() => keepVideo(j)}
|
||||||
|
>
|
||||||
|
{cardInner}
|
||||||
|
</SwipeCard>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={k}>{cardInner}</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
frontend/src/components/ui/FinishedDownloadsGalleryView.tsx
Normal file
231
frontend/src/components/ui/FinishedDownloadsGalleryView.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import type { RecordJob } from '../../types'
|
||||||
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
|
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: RecordJob[]
|
||||||
|
blurPreviews?: boolean
|
||||||
|
durations: Record<string, number>
|
||||||
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
|
||||||
|
keyFor: (j: RecordJob) => string
|
||||||
|
baseName: (p: string) => string
|
||||||
|
stripHotPrefix: (s: string) => string
|
||||||
|
modelNameFromOutput: (output?: string) => string
|
||||||
|
runtimeOf: (job: RecordJob) => string
|
||||||
|
sizeBytesOf: (job: RecordJob) => number | null
|
||||||
|
formatBytes: (bytes?: number | null) => string
|
||||||
|
|
||||||
|
deletingKeys: Set<string>
|
||||||
|
keepingKeys: Set<string>
|
||||||
|
removingKeys: Set<string>
|
||||||
|
deletedKeys: Set<string>
|
||||||
|
|
||||||
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||||
|
|
||||||
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
|
openCtx: (job: RecordJob, e: React.MouseEvent) => void
|
||||||
|
openCtxAt: (job: RecordJob, x: number, y: number) => void
|
||||||
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinishedDownloadsGalleryView({
|
||||||
|
rows,
|
||||||
|
blurPreviews,
|
||||||
|
durations,
|
||||||
|
handleDuration,
|
||||||
|
|
||||||
|
keyFor,
|
||||||
|
baseName,
|
||||||
|
stripHotPrefix,
|
||||||
|
modelNameFromOutput,
|
||||||
|
runtimeOf,
|
||||||
|
sizeBytesOf,
|
||||||
|
formatBytes,
|
||||||
|
|
||||||
|
deletingKeys,
|
||||||
|
keepingKeys,
|
||||||
|
removingKeys,
|
||||||
|
deletedKeys,
|
||||||
|
|
||||||
|
registerTeaserHost,
|
||||||
|
|
||||||
|
onOpenPlayer,
|
||||||
|
openCtx,
|
||||||
|
openCtxAt,
|
||||||
|
deleteVideo,
|
||||||
|
keepVideo,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{rows.map((j) => {
|
||||||
|
const k = keyFor(j)
|
||||||
|
const model = modelNameFromOutput(j.output)
|
||||||
|
const file = baseName(j.output || '')
|
||||||
|
const dur = runtimeOf(j)
|
||||||
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
|
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||||
|
const deleted = deletedKeys.has(k)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={k}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={[
|
||||||
|
'group relative overflow-hidden rounded-lg 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)
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => openCtx(j, e)}
|
||||||
|
>
|
||||||
|
{/* Thumb */}
|
||||||
|
<div
|
||||||
|
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
||||||
|
ref={registerTeaserHost(k)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
openCtx(j, e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FinishedVideoPreview
|
||||||
|
job={j}
|
||||||
|
getFileName={baseName}
|
||||||
|
durationSeconds={durations[k]}
|
||||||
|
onDuration={handleDuration}
|
||||||
|
variant="fill"
|
||||||
|
showPopover={false}
|
||||||
|
blur={blurPreviews}
|
||||||
|
animated={true}
|
||||||
|
animatedMode="teaser"
|
||||||
|
animatedTrigger="always"
|
||||||
|
clipSeconds={1}
|
||||||
|
thumbSamples={18}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradient overlay bottom */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
pointer-events-none absolute inset-x-0 bottom-0 h-16
|
||||||
|
bg-gradient-to-t from-black/65 to-transparent
|
||||||
|
transition-opacity duration-150
|
||||||
|
group-hover:opacity-0 group-focus-within:opacity-0
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom text */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
|
||||||
|
transition-opacity duration-150
|
||||||
|
group-hover:opacity-0 group-focus-within:opacity-0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="truncate text-xs font-semibold">{model}</div>
|
||||||
|
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
|
||||||
|
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
||||||
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick keep */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'absolute right-12 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||||
|
'bg-black/55 text-white hover:bg-black/70',
|
||||||
|
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||||
|
].join(' ')}
|
||||||
|
aria-label="Behalten"
|
||||||
|
title="Behalten (nach keep verschieben)"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void keepVideo(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick delete */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'absolute right-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||||
|
'bg-black/55 text-white hover:bg-black/70',
|
||||||
|
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||||
|
].join(' ')}
|
||||||
|
aria-label="Video löschen"
|
||||||
|
title="Video löschen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void deleteVideo(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* More / Context */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||||
|
'bg-black/55 text-white hover:bg-black/70',
|
||||||
|
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||||
|
].join(' ')}
|
||||||
|
aria-label="Aktionen"
|
||||||
|
title="Aktionen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
openCtxAt(j, r.left, r.bottom + 6)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* status line */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="truncate">
|
||||||
|
Status: <span className="font-medium">{j.status}</span>
|
||||||
|
</span>
|
||||||
|
{baseName(j.output || '').startsWith('HOT ') ? (
|
||||||
|
<span className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||||
|
HOT
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/ui/FinishedDownloadsTableView.tsx
Normal file
44
frontend/src/components/ui/FinishedDownloadsTableView.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import Table, { type Column, type SortState } from './Table'
|
||||||
|
import type { RecordJob } from '../../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: RecordJob[]
|
||||||
|
columns: Column<RecordJob>[]
|
||||||
|
getRowKey: (j: RecordJob) => string
|
||||||
|
sort: SortState
|
||||||
|
onSortChange: (s: SortState) => void
|
||||||
|
onRowClick: (job: RecordJob) => void
|
||||||
|
onRowContextMenu: (job: RecordJob, e: React.MouseEvent) => void
|
||||||
|
rowClassName?: (job: RecordJob) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinishedDownloadsTableView({
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
getRowKey,
|
||||||
|
sort,
|
||||||
|
onSortChange,
|
||||||
|
onRowClick,
|
||||||
|
onRowContextMenu,
|
||||||
|
rowClassName,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={getRowKey}
|
||||||
|
striped
|
||||||
|
fullWidth
|
||||||
|
stickyHeader
|
||||||
|
compact
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
onRowContextMenu={onRowContextMenu}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ import HoverPopover from './HoverPopover'
|
|||||||
|
|
||||||
type Variant = 'thumb' | 'fill'
|
type Variant = 'thumb' | 'fill'
|
||||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||||
type AnimatedMode = 'frames' | 'clips'
|
type AnimatedMode = 'frames' | 'clips' | 'teaser'
|
||||||
type AnimatedTrigger = 'always' | 'hover'
|
type AnimatedTrigger = 'always' | 'hover'
|
||||||
|
|
||||||
export type FinishedVideoPreviewProps = {
|
export type FinishedVideoPreviewProps = {
|
||||||
@ -15,10 +15,11 @@ export type FinishedVideoPreviewProps = {
|
|||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
onDuration?: (job: RecordJob, seconds: number) => void
|
onDuration?: (job: RecordJob, seconds: number) => void
|
||||||
|
|
||||||
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
|
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
|
||||||
animated?: boolean
|
animated?: boolean
|
||||||
animatedMode?: AnimatedMode
|
animatedMode?: AnimatedMode
|
||||||
animatedTrigger?: AnimatedTrigger
|
animatedTrigger?: AnimatedTrigger
|
||||||
|
active?: boolean
|
||||||
|
|
||||||
/** nur für frames */
|
/** nur für frames */
|
||||||
autoTickMs?: number
|
autoTickMs?: number
|
||||||
@ -60,6 +61,7 @@ export default function FinishedVideoPreview({
|
|||||||
animated = false,
|
animated = false,
|
||||||
animatedMode = 'frames',
|
animatedMode = 'frames',
|
||||||
animatedTrigger = 'always',
|
animatedTrigger = 'always',
|
||||||
|
active,
|
||||||
|
|
||||||
autoTickMs = 15000,
|
autoTickMs = 15000,
|
||||||
thumbStepSec,
|
thumbStepSec,
|
||||||
@ -102,22 +104,63 @@ export default function FinishedVideoPreview({
|
|||||||
? 'hover'
|
? 'hover'
|
||||||
: 'never'
|
: 'never'
|
||||||
|
|
||||||
|
// ✅ id = Dateiname ohne Endung (genau wie du willst)
|
||||||
const previewId = useMemo(() => {
|
const previewId = useMemo(() => {
|
||||||
if (!file) return ''
|
if (!file) return ''
|
||||||
const dot = file.lastIndexOf('.')
|
const dot = file.lastIndexOf('.')
|
||||||
return dot > 0 ? file.slice(0, dot) : file
|
return dot > 0 ? file.slice(0, dot) : file
|
||||||
}, [file])
|
}, [file])
|
||||||
|
|
||||||
|
// Vollvideo (für Inline-Playback + Duration-Metadaten)
|
||||||
const videoSrc = useMemo(
|
const videoSrc = useMemo(
|
||||||
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
|
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
|
||||||
[file]
|
[file]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ✅ Teaser-Video (vorgerendert)
|
||||||
|
const isActive = active !== undefined ? Boolean(active) : true
|
||||||
|
|
||||||
|
const teaserSrc = useMemo(
|
||||||
|
() => (previewId ? `/api/generated/teaser?id=${encodeURIComponent(previewId)}` : ''),
|
||||||
|
[previewId]
|
||||||
|
)
|
||||||
|
|
||||||
const hasDuration =
|
const hasDuration =
|
||||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||||
|
|
||||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||||
|
|
||||||
|
const inlineRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const clipsRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
const hardStop = (v: HTMLVideoElement | null) => {
|
||||||
|
if (!v) return
|
||||||
|
try { v.pause() } catch {}
|
||||||
|
try {
|
||||||
|
v.removeAttribute('src')
|
||||||
|
// @ts-ignore
|
||||||
|
v.src = ''
|
||||||
|
v.load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onRelease = (ev: any) => {
|
||||||
|
const f = String(ev?.detail?.file ?? '')
|
||||||
|
if (!f || f !== file) return
|
||||||
|
hardStop(inlineRef.current)
|
||||||
|
hardStop(teaserMp4Ref.current)
|
||||||
|
hardStop(clipsRef.current)
|
||||||
|
}
|
||||||
|
window.addEventListener('player:release', onRelease as EventListener)
|
||||||
|
window.addEventListener('player:close', onRelease as EventListener)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('player:release', onRelease as EventListener)
|
||||||
|
window.removeEventListener('player:close', onRelease as EventListener)
|
||||||
|
}
|
||||||
|
}, [file])
|
||||||
|
|
||||||
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
|
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
@ -173,6 +216,7 @@ export default function FinishedVideoPreview({
|
|||||||
)}&v=${encodeURIComponent(String(localTick))}`
|
)}&v=${encodeURIComponent(String(localTick))}`
|
||||||
}, [previewId, thumbTimeSec, localTick])
|
}, [previewId, thumbTimeSec, localTick])
|
||||||
|
|
||||||
|
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
|
||||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||||
setMetaLoaded(true)
|
setMetaLoaded(true)
|
||||||
if (!onDuration) return
|
if (!onDuration) return
|
||||||
@ -191,7 +235,27 @@ export default function FinishedVideoPreview({
|
|||||||
videoOk &&
|
videoOk &&
|
||||||
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||||
|
|
||||||
// --- Teaser Clip Zeiten (nur clips)
|
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
|
||||||
|
const teaserActive =
|
||||||
|
animated &&
|
||||||
|
inView &&
|
||||||
|
!document.hidden &&
|
||||||
|
videoOk &&
|
||||||
|
!showingInlineVideo &&
|
||||||
|
(animatedTrigger === 'always' || hovered) &&
|
||||||
|
(
|
||||||
|
// ✅ neuer schneller Modus
|
||||||
|
(animatedMode === 'teaser' && Boolean(teaserSrc)) ||
|
||||||
|
// Legacy: clips nur wenn Duration bekannt
|
||||||
|
(animatedMode === 'clips' && hasDuration)
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
|
||||||
|
const wantsHover =
|
||||||
|
inlineMode === 'hover' ||
|
||||||
|
(animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
|
||||||
|
|
||||||
|
// --- Legacy "clips" Logik (wenn du es noch nutzt)
|
||||||
const clipTimes = useMemo(() => {
|
const clipTimes = useMemo(() => {
|
||||||
if (!animated) return []
|
if (!animated) return []
|
||||||
if (animatedMode !== 'clips') return []
|
if (animatedMode !== 'clips') return []
|
||||||
@ -214,31 +278,18 @@ 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])
|
||||||
|
|
||||||
// --- Teaser aktiv? (nur inView, nicht inline, optional nur hover)
|
|
||||||
const teaserActive =
|
|
||||||
animated &&
|
|
||||||
animatedMode === 'clips' &&
|
|
||||||
inView &&
|
|
||||||
!document.hidden &&
|
|
||||||
videoOk &&
|
|
||||||
clipTimes.length > 0 &&
|
|
||||||
!showingInlineVideo &&
|
|
||||||
(animatedTrigger === 'always' || hovered)
|
|
||||||
|
|
||||||
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
|
|
||||||
const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover')
|
|
||||||
|
|
||||||
// --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop)
|
|
||||||
const teaserRef = useRef<HTMLVideoElement | null>(null)
|
const teaserRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const clipIdxRef = useRef(0)
|
const clipIdxRef = useRef(0)
|
||||||
const clipStartRef = useRef(0)
|
const clipStartRef = useRef(0)
|
||||||
|
|
||||||
|
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = teaserRef.current
|
const v = teaserRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
|
|
||||||
if (!teaserActive) {
|
if (!(teaserActive && animatedMode === 'clips')) {
|
||||||
v.pause()
|
// bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv
|
||||||
|
if (!teaserActive) v.pause()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +322,6 @@ export default function FinishedVideoPreview({
|
|||||||
v.addEventListener('loadedmetadata', onLoaded)
|
v.addEventListener('loadedmetadata', onLoaded)
|
||||||
v.addEventListener('timeupdate', onTimeUpdate)
|
v.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
|
||||||
// Wenn metadata schon da ist:
|
|
||||||
if (v.readyState >= 1) start()
|
if (v.readyState >= 1) start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -279,7 +329,7 @@ export default function FinishedVideoPreview({
|
|||||||
v.removeEventListener('timeupdate', onTimeUpdate)
|
v.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
v.pause()
|
v.pause()
|
||||||
}
|
}
|
||||||
}, [teaserActive, clipTimesKey, clipSeconds])
|
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
|
||||||
|
|
||||||
const previewNode = (
|
const previewNode = (
|
||||||
<div
|
<div
|
||||||
@ -314,18 +364,33 @@ export default function FinishedVideoPreview({
|
|||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onError={() => setVideoOk(false)}
|
onError={() => setVideoOk(false)}
|
||||||
/>
|
/>
|
||||||
) : teaserActive ? (
|
) : teaserActive && animatedMode === 'teaser' ? (
|
||||||
/* 2) Teaser Clips (1s Segmente) */
|
/* 2a) ✅ Teaser MP4 (vorgerendert) */
|
||||||
<video
|
<video
|
||||||
ref={teaserRef}
|
ref={teaserRef}
|
||||||
key={`teaser-${previewId}-${clipTimesKey}`}
|
key={`teaser-mp4-${previewId}`}
|
||||||
|
src={teaserSrc}
|
||||||
|
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
poster={thumbSrc || undefined}
|
||||||
|
// ❗️kein onLoadedMetadata -> sonst würdest du Teaser-Länge als Dauer speichern
|
||||||
|
onError={() => setVideoOk(false)}
|
||||||
|
/>
|
||||||
|
) : teaserActive && animatedMode === 'clips' ? (
|
||||||
|
/* 2b) Legacy: Teaser Clips (1s Segmente) aus Vollvideo */
|
||||||
|
<video
|
||||||
|
ref={teaserRef}
|
||||||
|
key={`clips-${previewId}-${clipTimesKey}`}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
poster={thumbSrc || undefined}
|
poster={thumbSrc || undefined}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
|
||||||
onError={() => setVideoOk(false)}
|
onError={() => setVideoOk(false)}
|
||||||
/>
|
/>
|
||||||
) : thumbSrc && thumbOk ? (
|
) : thumbSrc && thumbOk ? (
|
||||||
|
|||||||
@ -584,25 +584,19 @@ export default function Player({
|
|||||||
>
|
>
|
||||||
<div ref={containerRef} className="absolute inset-0" />
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
|
|
||||||
{/* Top overlay: inline-like header + actions + window controls */}
|
{/* HOT Badge: immer oben links (auch im inline/mini Player) */}
|
||||||
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
|
{(isHot || isHotFile) ? (
|
||||||
<div className="min-w-0">
|
<div className="absolute left-2 top-2 z-20 pointer-events-none">
|
||||||
<div className="player-ui max-w-[70vw] sm:max-w-[360px] rounded-md bg-black/45 px-2.5 py-1.5 text-white backdrop-blur">
|
<span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
HOT
|
||||||
<div className="truncate text-sm font-semibold">{model}</div>
|
</span>
|
||||||
{isHotFile ? (
|
|
||||||
<span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
|
|
||||||
HOT
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-[11px] text-white/80">
|
|
||||||
{file || title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Top overlay: inline-like header + actions + window controls */}
|
||||||
|
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-end gap-2">
|
||||||
<div className="flex items-center gap-1 pointer-events-auto">
|
<div className="flex items-center gap-1 pointer-events-auto">
|
||||||
|
|
||||||
{/* Inline-like actions (Hot/Fav/Like/Delete) */}
|
{/* Inline-like actions (Hot/Fav/Like/Delete) */}
|
||||||
{footerRight}
|
{footerRight}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,43 @@ type Props = {
|
|||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phaseLabel = (p?: string) => {
|
||||||
|
switch (p) {
|
||||||
|
case 'stopping':
|
||||||
|
return 'Stop wird angefordert…'
|
||||||
|
case 'remuxing':
|
||||||
|
return 'Remux zu MP4…'
|
||||||
|
case 'moving':
|
||||||
|
return 'Verschiebe nach Done…'
|
||||||
|
case 'finalizing':
|
||||||
|
return 'Finalisiere…'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCell({ job }: { job: RecordJob }) {
|
||||||
|
const label = phaseLabel((job as any).phase)
|
||||||
|
const progress = Number((job as any).progress ?? 0)
|
||||||
|
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100 && !!label
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate">
|
||||||
|
<span className="font-medium">{job.status}</span>
|
||||||
|
{label ? <span className="text-gray-600 dark:text-gray-300"> • {label}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBar ? (
|
||||||
|
<div className="mt-1 h-1.5 w-40 overflow-hidden rounded bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div className="h-full" style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const baseName = (p: string) =>
|
const baseName = (p: string) =>
|
||||||
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
|
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
|
||||||
|
|
||||||
@ -95,8 +132,11 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
|||||||
key: 'output',
|
key: 'output',
|
||||||
header: 'Datei',
|
header: 'Datei',
|
||||||
cell: (j) => baseName(j.output || ''),
|
cell: (j) => baseName(j.output || ''),
|
||||||
|
},{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: (j) => <StatusCell job={j} />,
|
||||||
},
|
},
|
||||||
{ key: 'status', header: 'Status' },
|
|
||||||
{
|
{
|
||||||
key: 'runtime',
|
key: 'runtime',
|
||||||
header: 'Dauer',
|
header: 'Dauer',
|
||||||
@ -107,18 +147,25 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
|||||||
header: 'Aktion',
|
header: 'Aktion',
|
||||||
srOnlyHeader: true,
|
srOnlyHeader: true,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: (j) => (
|
cell: (j) => {
|
||||||
<Button
|
const phase = (j as any).phase as string | undefined
|
||||||
size="md"
|
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
|
||||||
variant="primary"
|
|
||||||
onClick={(e) => {
|
return (
|
||||||
e.stopPropagation()
|
<Button
|
||||||
onStopJob(j.id)
|
size="sm"
|
||||||
}}
|
variant="primary"
|
||||||
>
|
disabled={isStoppingOrFinalizing}
|
||||||
Stop
|
onClick={(e) => {
|
||||||
</Button>
|
e.stopPropagation()
|
||||||
),
|
if (isStoppingOrFinalizing) return
|
||||||
|
onStopJob(j.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStoppingOrFinalizing ? 'Stoppe…' : 'Stop'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [onStopJob])
|
}, [onStopJob])
|
||||||
@ -205,7 +252,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
Status: <span className="font-medium">{j.status}</span>
|
<StatusCell job={j} />
|
||||||
<span className="mx-2 opacity-60">•</span>
|
<span className="mx-2 opacity-60">•</span>
|
||||||
Dauer: <span className="font-medium">{dur}</span>
|
Dauer: <span className="font-medium">{dur}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user