updated
This commit is contained in:
parent
c751430af5
commit
ca237ef2da
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
backend/recorder_settings.json
|
||||
records
|
||||
.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() {
|
||||
|
||||
const DONE_PAGE_SIZE = 8
|
||||
|
||||
const [sourceUrl, setSourceUrl] = useState('')
|
||||
const [, setParsed] = useState<ParsedModel | null>(null)
|
||||
const [, setParseError] = useState<string | null>(null)
|
||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||||
const [donePage, setDonePage] = useState(1)
|
||||
const [doneCount, setDoneCount] = useState<number>(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(() => {
|
||||
if (sourceUrl.trim() === '') {
|
||||
setParsed(null)
|
||||
@ -371,7 +380,10 @@ export default function App() {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
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 : [])
|
||||
} catch {
|
||||
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
|
||||
@ -400,7 +412,7 @@ export default function App() {
|
||||
window.clearInterval(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [selectedTab])
|
||||
}, [selectedTab, donePage])
|
||||
|
||||
|
||||
function isChaturbate(url: string): boolean {
|
||||
@ -785,6 +797,10 @@ export default function App() {
|
||||
<FinishedDownloads
|
||||
jobs={jobs}
|
||||
doneJobs={doneJobs}
|
||||
doneTotal={doneCount}
|
||||
page={donePage}
|
||||
pageSize={DONE_PAGE_SIZE}
|
||||
onPageChange={setDonePage}
|
||||
onOpenPlayer={openPlayer}
|
||||
onDeleteJob={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// frontend/src/components/ui/FinishedDownloads.tsx
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -28,7 +27,10 @@ import {
|
||||
} from '@heroicons/react/24/solid'
|
||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
|
||||
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
|
||||
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
|
||||
import Pagination from './Pagination'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
@ -39,6 +41,10 @@ type Props = {
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleFavorite?: (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()
|
||||
@ -148,9 +154,11 @@ export default function FinishedDownloads({
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
doneTotal,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange
|
||||
}: 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 teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||
@ -572,10 +580,6 @@ export default function FinishedDownloads({
|
||||
return arr
|
||||
}, [rows, sortMode, durations])
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE)
|
||||
}, [rows.length])
|
||||
|
||||
useEffect(() => {
|
||||
const onExternalDelete = (ev: Event) => {
|
||||
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 visibleRows = viewRows
|
||||
.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||
.slice(0, visibleCount)
|
||||
const visibleRows = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
||||
|
||||
useEffect(() => {
|
||||
const active = view === 'cards' || view === 'gallery'
|
||||
const active = view === 'cards'
|
||||
if (!active) { setTeaserKey(null); return }
|
||||
|
||||
// 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"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={true}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -993,337 +998,50 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ Cards */}
|
||||
{view === 'cards' && (
|
||||
<div className="space-y-3">
|
||||
{visibleRows.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 file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const statusNode =
|
||||
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>
|
||||
)
|
||||
|
||||
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)} // <- 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>
|
||||
<FinishedDownloadsCardsView
|
||||
rows={visibleRows}
|
||||
isSmall={isSmall}
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
teaserKey={teaserKey}
|
||||
inlinePlay={inlinePlay}
|
||||
setInlinePlay={setInlinePlay}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
swipeRefs={swipeRefs}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
stripHotPrefix={stripHotPrefix}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openPlayer={openPlayer}
|
||||
startInline={startInline}
|
||||
tryAutoplayInline={tryAutoplayInline}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
handleDuration={handleDuration}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
openCtx={openCtx}
|
||||
openCtxAt={openCtxAt}
|
||||
releasePlayingFile={releasePlayingFile}
|
||||
modelsByKey={modelsByKey}
|
||||
onToggleHot={onToggleHot}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Tabelle */}
|
||||
{view === 'table' && (
|
||||
<Table
|
||||
<FinishedDownloadsTableView
|
||||
rows={visibleRows}
|
||||
columns={columns}
|
||||
getRowKey={(j) => keyFor(j)}
|
||||
striped
|
||||
fullWidth
|
||||
stickyHeader
|
||||
compact
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onRowClick={onOpenPlayer}
|
||||
@ -1342,182 +1060,30 @@ export default function FinishedDownloads({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Galerie */}
|
||||
{view === 'gallery' && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{visibleRows.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={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>
|
||||
<FinishedDownloadsGalleryView
|
||||
rows={visibleRows}
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
handleDuration={handleDuration}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
stripHotPrefix={stripHotPrefix}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
deletedKeys={deletedKeys}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openCtx={openCtx}
|
||||
openCtxAt={openCtxAt}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContextMenu
|
||||
@ -1528,16 +1094,36 @@ export default function FinishedDownloads({
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
|
||||
{rows.length > visibleCount ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
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"
|
||||
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
||||
>
|
||||
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalItems={doneTotal}
|
||||
onPageChange={(p) => {
|
||||
// 1) Inline-Playback + aktiven Teaser sofort stoppen
|
||||
flushSync(() => {
|
||||
setInlinePlay(null)
|
||||
setTeaserKey(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 InlineVideoMode = false | true | 'always' | 'hover'
|
||||
type AnimatedMode = 'frames' | 'clips'
|
||||
type AnimatedMode = 'frames' | 'clips' | 'teaser'
|
||||
type AnimatedTrigger = 'always' | 'hover'
|
||||
|
||||
export type FinishedVideoPreviewProps = {
|
||||
@ -15,10 +15,11 @@ export type FinishedVideoPreviewProps = {
|
||||
durationSeconds?: number
|
||||
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
|
||||
animatedMode?: AnimatedMode
|
||||
animatedTrigger?: AnimatedTrigger
|
||||
active?: boolean
|
||||
|
||||
/** nur für frames */
|
||||
autoTickMs?: number
|
||||
@ -60,6 +61,7 @@ export default function FinishedVideoPreview({
|
||||
animated = false,
|
||||
animatedMode = 'frames',
|
||||
animatedTrigger = 'always',
|
||||
active,
|
||||
|
||||
autoTickMs = 15000,
|
||||
thumbStepSec,
|
||||
@ -102,22 +104,63 @@ export default function FinishedVideoPreview({
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
// ✅ id = Dateiname ohne Endung (genau wie du willst)
|
||||
const previewId = useMemo(() => {
|
||||
if (!file) return ''
|
||||
const dot = file.lastIndexOf('.')
|
||||
return dot > 0 ? file.slice(0, dot) : file
|
||||
}, [file])
|
||||
|
||||
// Vollvideo (für Inline-Playback + Duration-Metadaten)
|
||||
const videoSrc = useMemo(
|
||||
() => (file ? `/api/record/video?file=${encodeURIComponent(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 =
|
||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
@ -173,6 +216,7 @@ export default function FinishedVideoPreview({
|
||||
)}&v=${encodeURIComponent(String(localTick))}`
|
||||
}, [previewId, thumbTimeSec, localTick])
|
||||
|
||||
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
setMetaLoaded(true)
|
||||
if (!onDuration) return
|
||||
@ -191,7 +235,27 @@ export default function FinishedVideoPreview({
|
||||
videoOk &&
|
||||
(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(() => {
|
||||
if (!animated) return []
|
||||
if (animatedMode !== 'clips') return []
|
||||
@ -214,31 +278,18 @@ export default function FinishedVideoPreview({
|
||||
|
||||
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 clipIdxRef = useRef(0)
|
||||
const clipStartRef = useRef(0)
|
||||
|
||||
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
||||
useEffect(() => {
|
||||
const v = teaserRef.current
|
||||
if (!v) return
|
||||
|
||||
if (!teaserActive) {
|
||||
v.pause()
|
||||
if (!(teaserActive && animatedMode === 'clips')) {
|
||||
// bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv
|
||||
if (!teaserActive) v.pause()
|
||||
return
|
||||
}
|
||||
|
||||
@ -271,7 +322,6 @@ export default function FinishedVideoPreview({
|
||||
v.addEventListener('loadedmetadata', onLoaded)
|
||||
v.addEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
// Wenn metadata schon da ist:
|
||||
if (v.readyState >= 1) start()
|
||||
|
||||
return () => {
|
||||
@ -279,7 +329,7 @@ export default function FinishedVideoPreview({
|
||||
v.removeEventListener('timeupdate', onTimeUpdate)
|
||||
v.pause()
|
||||
}
|
||||
}, [teaserActive, clipTimesKey, clipSeconds])
|
||||
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
@ -314,18 +364,33 @@ export default function FinishedVideoPreview({
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : teaserActive ? (
|
||||
/* 2) Teaser Clips (1s Segmente) */
|
||||
) : teaserActive && animatedMode === 'teaser' ? (
|
||||
/* 2a) ✅ Teaser MP4 (vorgerendert) */
|
||||
<video
|
||||
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}
|
||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : thumbSrc && thumbOk ? (
|
||||
|
||||
@ -584,25 +584,19 @@ export default function Player({
|
||||
>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{/* Top overlay: inline-like header + actions + window controls */}
|
||||
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{model}</div>
|
||||
{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>
|
||||
{/* HOT Badge: immer oben links (auch im inline/mini Player) */}
|
||||
{(isHot || isHotFile) ? (
|
||||
<div className="absolute left-2 top-2 z-20 pointer-events-none">
|
||||
<span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
|
||||
HOT
|
||||
</span>
|
||||
</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">
|
||||
|
||||
{/* Inline-like actions (Hot/Fav/Like/Delete) */}
|
||||
{footerRight}
|
||||
|
||||
|
||||
@ -21,6 +21,43 @@ type Props = {
|
||||
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) =>
|
||||
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
|
||||
|
||||
@ -95,8 +132,11 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
||||
key: 'output',
|
||||
header: 'Datei',
|
||||
cell: (j) => baseName(j.output || ''),
|
||||
},{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
cell: (j) => <StatusCell job={j} />,
|
||||
},
|
||||
{ key: 'status', header: 'Status' },
|
||||
{
|
||||
key: 'runtime',
|
||||
header: 'Dauer',
|
||||
@ -107,18 +147,25 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
||||
header: 'Aktion',
|
||||
srOnlyHeader: true,
|
||||
align: 'right',
|
||||
cell: (j) => (
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStopJob(j.id)
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
),
|
||||
cell: (j) => {
|
||||
const phase = (j as any).phase as string | undefined
|
||||
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={isStoppingOrFinalizing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isStoppingOrFinalizing) return
|
||||
onStopJob(j.id)
|
||||
}}
|
||||
>
|
||||
{isStoppingOrFinalizing ? 'Stoppe…' : 'Stop'}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}, [onStopJob])
|
||||
@ -205,7 +252,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
Dauer: <span className="font-medium">{dur}</span>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user