This commit is contained in:
Linrador 2026-01-01 18:22:11 +01:00
parent c751430af5
commit ca237ef2da
10 changed files with 1969 additions and 753 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
backend/recorder_settings.json backend/recorder_settings.json
records records
.DS_Store .DS_Store
backend/generated

File diff suppressed because it is too large Load Diff

View File

@ -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}

View File

@ -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"
/>
</> </>
) )

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View File

@ -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 ? (

View File

@ -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">
<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"> <span className="shrink-0 rounded-md bg-amber-500/25 px-2 py-0.5 text-[11px] font-semibold text-white">
HOT HOT
</span> </span>
</div>
) : null} ) : null}
</div>
<div className="truncate text-[11px] text-white/80">
{file || title}
</div>
</div>
</div>
{/* 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}

View File

@ -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) => {
const phase = (j as any).phase as string | undefined
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
return (
<Button <Button
size="md" size="sm"
variant="primary" variant="primary"
disabled={isStoppingOrFinalizing}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (isStoppingOrFinalizing) return
onStopJob(j.id) onStopJob(j.id)
}} }}
> >
Stop {isStoppingOrFinalizing ? 'Stoppe…' : 'Stop'}
</Button> </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>