updated UI

This commit is contained in:
Linrador 2025-12-27 00:54:17 +01:00
parent 82cd87c92e
commit bd6b2a50a6
12 changed files with 1130 additions and 628 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css">
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
</head>
<body>
<div id="root"></div>

View File

@ -11,9 +11,17 @@ import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button'
import ButtonGroup from './ButtonGroup'
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline'
import {
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
TrashIcon,
EllipsisVerticalIcon,
BookmarkSquareIcon,
} from '@heroicons/react/24/outline'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
@ -378,89 +386,194 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}, [])
const columns: Column<RecordJob>[] = [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => (
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[keyFor(j)]}
onDuration={handleDuration}
/>
),
{
key: 'preview',
header: 'Vorschau',
srOnlyHeader: true,
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
return (
<div
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
/>
</div>
)
},
{
key: 'model',
header: 'Modelname',
sortable: true,
sortValue: (j) => modelNameFromOutput(j.output),
cell: (j) => {
const name = modelNameFromOutput(j.output)
return (
<span className="truncate" title={name}>
{name}
</span>
)
},
},
{
key: 'video',
header: 'Video',
sortable: true,
sortValue: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
},
{
key: 'output',
header: 'Datei',
sortable: true,
sortValue: (j) => baseName(j.output || ''),
cell: (j) => baseName(j.output || ''),
cell: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate font-medium text-gray-900 dark:text-white" title={model}>
{model}
</div>
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
<div className="truncate text-xs text-gray-500 dark:text-gray-400" title={file}>
{file || '—'}
</div>
</div>
)
},
{
key: 'status',
header: 'Status',
sortable: true,
sortValue: (j) => (j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9),
cell: (j) => {
if (j.status !== 'failed') return j.status
},
{
key: 'status',
header: 'Status',
sortable: true,
sortValue: (j) =>
j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9,
cell: (j) => {
const base =
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset'
if (j.status === 'failed') {
const code = httpCodeFromError(j.error)
const label = code ? `failed (${code})` : 'failed'
return (
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
<span
className={`${base} bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/30`}
title={j.error || ''}
>
{label}
</span>
)
},
},
{
key: 'runtime',
header: 'Dauer',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => runtimeOf(j),
},
{
key: 'actions',
header: 'Aktion',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k)
}
if (j.status === 'finished') {
return (
<Button
<span
className={`${base} bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30`}
>
finished
</span>
)
}
if (j.status === 'stopped') {
return (
<span
className={`${base} bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30`}
>
stopped
</span>
)
}
return (
<span className={`${base} bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10`}>
{j.status}
</span>
)
},
},
{
key: 'runtime',
header: 'Dauer',
align: 'right',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
key: 'actions',
header: 'Aktionen',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k)
const iconBtn =
'inline-flex items-center justify-center rounded-md p-1.5 ' +
'hover:bg-gray-100/70 dark:hover:bg-white/5 ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
return (
<div className="flex items-center justify-end gap-1">
{/* Keep */}
<button
type="button"
className={iconBtn}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
</button>
{/* Delete */}
<button
type="button"
className={iconBtn}
title="Löschen"
aria-label="Löschen"
disabled={busy}
variant='soft'
color='red'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
aria-label="Video löschen"
title="Video löschen"
>
{busy ? '…' : 'Löschen'}
</Button>
)
},
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</button>
{/* More */}
<button
type="button"
className={iconBtn}
title="Mehr Aktionen"
aria-label="Mehr Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
<EllipsisVerticalIcon className="size-5 text-gray-500 dark:text-gray-300" />
</button>
</div>
)
},
]
},
]
if (rows.length === 0) {
return (
@ -475,33 +588,43 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return (
<>
{/* Toolbar */}
<div className="mb-3 flex items-center justify-end">
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="sm"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: <span className="hidden sm:inline">Tabelle</span>,
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: <span className="hidden sm:inline">Cards</span>,
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: <span className="hidden sm:inline">Galerie</span>,
srLabel: 'Galerie',
},
]}
/>
<div className="mb-3 flex items-center justify-between gap-2">
{/* Mobile title */}
<div className="sm:hidden min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads <span className="text-gray-500 dark:text-gray-400">({rows.length})</span>
</div>
</div>
{/* Views */}
<div className="shrink-0">
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="sm"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: <span className="hidden sm:inline">Tabelle</span>,
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: <span className="hidden sm:inline">Cards</span>,
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: <span className="hidden sm:inline">Galerie</span>,
srLabel: 'Galerie',
},
]}
/>
</div>
</div>
{/* ✅ Cards */}
@ -556,80 +679,121 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}}
onContextMenu={(e) => openCtx(j, e)}
>
<Card
header={
<div className="flex items-start justify-between gap-3">
<Card noBodyPadding className="overflow-hidden">
{/* Preview */}
<div className="relative aspect-video bg-black/5 dark:bg-white/5">
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
onDuration={handleDuration}
className="w-full h-full"
showPopover={false}
/>
{/* dunkler Verlauf unten für Text */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent" />
{/* Overlay bottom */}
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
<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-1">
<Button
aria-label="Video löschen"
title="Video löschen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) {
void h.swipeLeft() // ✅ führt Swipe + deleteVideo aus
} else {
void deleteVideo(j)
}
}}
>
🗑
</Button>
<button
type="button"
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
<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}
<span className="rounded-md bg-black/40 px-2 py-1 text-[11px] font-semibold text-white">
{dur}
</span>
</div>
</div>
}
>
<div className="flex gap-3">
<div
className="shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
onDuration={handleDuration}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
{/* 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'
return (
<>
<button
type="button"
className={iconBtn}
title="Behalten"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) void h.swipeRight()
else void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
</button>
<button
type="button"
className={iconBtn}
title="Löschen"
aria-label="Löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const h = swipeRefs.current.get(k)
if (h) void h.swipeLeft()
else void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</button>
<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>
</div>
{j.output ? (
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">{j.output}</div>
) : null}
</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>
@ -648,6 +812,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
striped
fullWidth
stickyHeader
compact
sort={sort}
onSortChange={setSort}
onRowClick={onOpenPlayer}
@ -655,13 +820,13 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
rowClassName={(j) => {
const k = keyFor(j)
return [
'transition-opacity duration-300',
'transition-all duration-300',
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse',
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
removingKeys.has(k) && 'opacity-0',
]
.filter(Boolean)
.join(' ')
].filter(Boolean).join(' ')
}}
/>
)}
@ -670,67 +835,150 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{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 busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
return (
<div
key={keyFor(j)}
key={k}
role="button"
tabIndex={0}
className="cursor-pointer"
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)}
>
<Card
header={
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
</div>
<button
type="button"
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
</div>
}
{/* Thumb */}
<div
className="relative aspect-video bg-black/5 dark:bg-white/5"
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<div
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[keyFor(j)]}
onDuration={handleDuration}
/>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
onDuration={handleDuration}
variant="fill"
showPopover={false}
inlineVideo="hover"
/>
{/* 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" />
{/* Bottom text */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
<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">{file || '—'}</span>
<span className="shrink-0 rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
</div>
</div>
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{j.status}</span>
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{runtimeOf(j)}</span>
{/* 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>
</Card>
</div>
</div>
)
})}

View File

@ -1,17 +1,35 @@
// FinishedVideoPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type Props = {
job: RecordJob
getFileName: (path: string) => string
durationSeconds?: number
onDuration?: (job: RecordJob, seconds: number) => void
animated?: boolean // ✅ neu
autoTickMs?: number // ✅ neu
animated?: boolean
autoTickMs?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant
/** optionales Zusatz-Styling */
className?: string
showPopover?: boolean
/**
* inline video:
* - false: nur Bild
* - true/'always': immer inline abspielen (wenn inView)
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
*/
inlineVideo?: InlineVideoMode
}
export default function FinishedVideoPreview({
@ -21,10 +39,15 @@ export default function FinishedVideoPreview({
onDuration,
animated = false,
autoTickMs = 15000,
variant = 'thumb',
className,
showPopover = true,
inlineVideo = false,
}: Props) {
const file = getFileName(job.output || '')
const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
// ✅ nur animieren, wenn sichtbar (Viewport)
@ -32,6 +55,9 @@ export default function FinishedVideoPreview({
const [inView, setInView] = useState(false)
const [localTick, setLocalTick] = useState(0)
// ✅ für hover-play
const [hovered, setHovered] = useState(false)
useEffect(() => {
const el = rootRef.current
if (!el) return
@ -94,9 +120,82 @@ export default function FinishedVideoPreview({
}
if (!videoSrc) {
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
const showingInlineVideo =
inlineMode !== 'never' &&
inView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
const previewNode = (
<div
ref={rootRef}
className={[
'rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative',
sizeClass,
className ?? '',
].join(' ')}
// ✅ hover only relevant for inlineMode==='hover'
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
>
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
{showingInlineVideo ? (
<video
src={videoSrc}
className="w-full h-full object-cover bg-black pointer-events-none"
muted
playsInline
preload="metadata"
autoPlay
loop
poster={thumbSrc || undefined}
onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)}
/>
) : thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading="lazy"
alt={file}
className="w-full h-full object-cover"
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video
src={videoSrc}
preload="metadata"
muted
playsInline
className="hidden"
onLoadedMetadata={handleLoadedMetadata}
/>
)}
</div>
)
// ✅ Gallery: kein HoverPopover
if (!showPopover) return previewNode
return (
<HoverPopover
content={(open) =>
@ -120,31 +219,7 @@ export default function FinishedVideoPreview({
)
}
>
<div ref={rootRef} className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
{thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading="lazy"
alt={file}
className="w-full h-full object-cover"
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) */}
{inView && onDuration && !hasDuration && !metaLoaded && (
<video
src={videoSrc}
preload="metadata"
muted
playsInline
className="hidden"
onLoadedMetadata={handleLoadedMetadata}
/>
)}
</div>
{previewNode}
</HoverPopover>
)
}

View File

@ -4,8 +4,6 @@
import * as React from 'react'
import type { RecordJob } from '../../types'
import Card from './Card'
import Button from './Button'
import videojs from 'video.js'
import type VideoJsPlayer from 'video.js/dist/types/player'
import 'video.js/dist/video-js.css'
@ -207,172 +205,265 @@ export default function Player({
} catch {}
}, [])
const mini = !expanded
const [miniHover, setMiniHover] = React.useState(false)
const [canHover, setCanHover] = React.useState(false)
const [progressActive, setProgressActive] = React.useState(false)
const progressTimerRef = React.useRef<number | null>(null)
const stopProgress = React.useCallback(() => {
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
progressTimerRef.current = null
setProgressActive(false)
}, [])
const kickProgress = React.useCallback(() => {
setProgressActive(true)
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
progressTimerRef.current = window.setTimeout(() => {
setProgressActive(false)
progressTimerRef.current = null
}, 500) // <- hier kannst du 300..800ms tunen
}, [])
React.useEffect(() => {
if (expanded && canHover) kickProgress()
}, [expanded, canHover, kickProgress])
React.useEffect(() => {
return () => {
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
}
}, [])
React.useEffect(() => {
if (!expanded) setProgressActive(false)
}, [expanded])
React.useEffect(() => {
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
const update = () => setCanHover(Boolean(mq?.matches))
update()
mq?.addEventListener?.('change', update)
return () => mq?.removeEventListener?.('change', update)
}, [])
// optional: beim Expand Hover-State zurücksetzen
React.useEffect(() => {
if (!mini) setMiniHover(false)
}, [mini])
const liftMiniOverlay = mini && (canHover ? miniHover : true)
if (!mounted) return null
const mini = !expanded
const overlayBtn =
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
'backdrop-blur hover:bg-black/55 transition ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
const overlayBtnDanger =
'inline-flex items-center justify-center rounded-md bg-red-600/35 p-2 text-white ' +
'backdrop-blur hover:bg-red-600/55 transition ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
const footerRight = (
<div className="flex items-center gap-1">
<Button
variant={isHot ? 'soft' : 'secondary'}
size="md"
className={cn(
'px-2 py-1',
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
<button
type="button"
className={overlayBtn}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
onClick={async () => {
// 1) Stream freigeben (wichtig für Windows Rename)
onClick={async (e) => {
e.stopPropagation()
releaseMedia()
// 2) kurz warten, bis Browser/HTTP wirklich zu ist
await new Promise((r) => setTimeout(r, 150))
// 3) Rename (App aktualisiert danach job.output -> media.src ändert sich)
await onToggleHot?.(job)
// 4) Optional: nach Rename wieder starten (falls du willst)
await new Promise((r) => setTimeout(r, 0))
const p = playerRef.current
if (p && !(p as any).isDisposed?.()) {
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') {
;(ret as Promise<void>).catch(() => {})
}
if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
}
}}
disabled={!onToggleHot}
>
<FireIcon className="h-4 w-4" />
</Button>
<FireIcon className={cn('h-5 w-5', isHot ? 'text-amber-300' : 'text-white')} />
</button>
<Button
variant={isFavorite ? 'soft' : 'secondary'}
size="md"
className={cn(
'px-2 py-1',
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
<button
type="button"
className={overlayBtn}
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
onClick={() => onToggleFavorite?.(job)}
onClick={(e) => {
e.stopPropagation()
onToggleFavorite?.(job)
}}
disabled={!onToggleFavorite}
>
<HeartIcon className="h-4 w-4" />
</Button>
<HeartIcon className={cn('h-5 w-5', isFavorite ? 'text-pink-300' : 'text-white')} />
</button>
<Button
variant={isLiked ? 'soft' : 'secondary'}
size="md"
className={cn(
'px-2 py-1',
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
<button
type="button"
className={overlayBtn}
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
onClick={() => onToggleLike?.(job)}
onClick={(e) => {
e.stopPropagation()
onToggleLike?.(job)
}}
disabled={!onToggleLike}
>
<HandThumbUpIcon className="h-4 w-4" />
</Button>
<HandThumbUpIcon className={cn('h-5 w-5', isLiked ? 'text-indigo-200' : 'text-white')} />
</button>
<Button
variant="secondary"
size="md"
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
<button
type="button"
className={overlayBtnDanger}
title="Löschen"
aria-label="Löschen"
onClick={async () => {
onClick={async (e) => {
e.stopPropagation()
releaseMedia()
// optional: Player schließen -> dispose() läuft im Cleanup und gibt endgültig frei
onClose()
// kurzer Moment, bis der Browser den Stream wirklich abbricht
await new Promise((r) => setTimeout(r, 150))
await onDelete?.(job)
}}
disabled={!onDelete}
>
<TrashIcon className="h-4 w-4" />
</Button>
<TrashIcon className="h-5 w-5" />
</button>
</div>
)
return createPortal(
return createPortal(
<>
{expanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
className="fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px] transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
)}
<Card
edgeToEdgeMobile
noBodyPadding
className={cn(
'fixed z-50 shadow-xl border flex flex-col',
'fixed z-50 flex flex-col shadow-2xl ring-1 ring-black/10 dark:ring-white/10',
expanded
? 'inset-6'
: 'left-0 right-0 bottom-0 w-full rounded-none sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[380px]',
? 'inset-4 sm:inset-6'
: 'left-0 right-0 bottom-0 w-full rounded-none pb-[env(safe-area-inset-bottom)] sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[420px]',
className ?? ''
)}
noBodyPadding
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
header={
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
{title}
>
{/* Video Stage */}
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
<div
className={cn(
'relative w-full h-full',
mini && 'vjs-mini',
// ✅ activity-based controls für expanded UND mini (nur wenn Hover möglich)
canHover && 'vjs-controls-on-activity',
canHover && progressActive && 'vjs-controls-active'
)}
onMouseEnter={
mini
? () => {
setMiniHover(true)
if (canHover) kickProgress()
}
: expanded && canHover
? kickProgress
: undefined
}
onMouseMove={canHover ? kickProgress : undefined}
onMouseLeave={() => {
if (mini) setMiniHover(false)
if (canHover) stopProgress()
}}
onFocusCapture={expanded && canHover ? () => setProgressActive(true) : undefined}
onBlurCapture={
expanded && canHover
? (e) => {
const next = e.relatedTarget as Node | null
if (!next || !(e.currentTarget as HTMLElement).contains(next)) stopProgress()
}
: undefined
}
>
<div ref={containerRef} className="absolute inset-0" />
{/* Top overlay: title + 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-[320px] truncate rounded-md bg-black/45 px-2.5 py-1.5 text-xs font-semibold text-white backdrop-blur">
{title}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
</button>
<button
type="button"
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
onClick={onClose}
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="secondary"
size="md"
className="px-2 py-1"
onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
</Button>
<Button
variant="secondary"
size="md"
color='red'
className="px-2 py-1"
onClick={onClose}
title="Schließen"
aria-label="Schließen"
>
<XMarkIcon className="h-5 w-5" />
</Button>
</div>
</div>
}
footer={
<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: <span className="font-medium">{job.status}</span>
{job.output ? <span className="ml-2 opacity-70"> {job.output}</span> : null}
</div>
{/* Bottom overlay: mini actions + status */}
{!expanded && (
<>
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20'
)}
/>
{/* Buttons primär im Mini-Player */}
{mini ? footerRight : null}
</div>
}
grayBody={false}
>
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
<div className={cn('w-full h-full min-h-0', mini && 'vjs-mini')}>
<div ref={containerRef} className="w-full h-full" />
<div
className={cn(
'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2',
'transition-all duration-200 ease-out',
liftMiniOverlay ? 'bottom-7' : 'bottom-2'
)}
>
<div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur">
<span className="font-semibold text-white">{job.status}</span>
{job.output ? <span className="ml-2 opacity-80"> {job.output}</span> : null}
</div>
{footerRight}
</div>
</>
)}
</div>
</div>
</Card>

View File

@ -1,6 +1,7 @@
'use client'
import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
@ -62,11 +63,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onSwipeRight,
className,
leftAction = {
label: <span className="inline-flex items-center gap-2 font-semibold"> Behalten</span>,
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
<span>Behalten</span>
</span>
),
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
},
rightAction = {
label: <span className="inline-flex items-center gap-2 font-semibold"> Löschen</span>,
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<TrashIcon className="h-6 w-6" aria-hidden="true" />
<span>Löschen</span>
</span>
),
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
},
thresholdPx = 120,
@ -87,11 +98,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}>({ id: null, x: 0, y: 0, dragging: false })
const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
const [animMs, setAnimMs] = React.useState<number>(0)
const reset = React.useCallback(() => {
setAnimMs(snapMs)
setDx(0)
setArmedDir(null)
window.setTimeout(() => setAnimMs(0), snapMs)
}, [snapMs])
@ -102,6 +115,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// rausfliegen lassen
setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
setDx(dir === 'right' ? w + 40 : -(w + 40))
let ok: boolean | void = true
@ -116,6 +130,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) {
setAnimMs(snapMs)
setArmedDir(null)
setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs)
return false
@ -138,19 +153,30 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return (
<div className={cn('relative overflow-hidden rounded-lg', className)}>
{/* Background actions (100% je Richtung) */}
<div className="absolute inset-0 pointer-events-none">
{dx !== 0 ? (
{/* Background actions (100% je Richtung, animiert) */}
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
<div
className={cn(
'h-full w-full flex items-center',
dx > 0 ? leftAction.className : rightAction.className,
dx > 0 ? 'justify-start pl-4' : 'justify-end pr-4'
)}
className={cn(
'absolute inset-0 transition-opacity duration-200 ease-out',
dx === 0 ? 'opacity-0' : 'opacity-100',
dx > 0 ? leftAction.className : rightAction.className
)}
/>
<div
className={cn(
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
)}
style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1,
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
paddingLeft: dx > 0 ? 16 : 0,
paddingRight: dx > 0 ? 0 : 16,
}}
>
{dx > 0 ? leftAction.label : rightAction.label}
{dx > 0 ? leftAction.label : rightAction.label}
</div>
) : null}
</div>
{/* Foreground (moves) */}
@ -184,6 +210,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
setAnimMs(0)
setDx(ddx)
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
@ -215,7 +247,19 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
reset()
}}
>
{children}
<div className="relative">
<div className="relative z-10">{children}</div>
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
<div
className={cn(
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg',
armedDir === 'right' && 'bg-emerald-500/20 opacity-100',
armedDir === 'left' && 'bg-red-500/20 opacity-100',
armedDir === null && 'opacity-0'
)}
/>
</div>
</div>
</div>
)

View File

@ -206,11 +206,12 @@ export default function Table<T>({
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10'
)}
>
<table className="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
<table className="relative min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead
className={cn(
card && 'bg-gray-50 dark:bg-gray-800/75',
stickyHeader && 'sticky top-0 z-10 backdrop-blur-sm backdrop-filter'
card && 'bg-gray-50/90 dark:bg-gray-800/70',
stickyHeader &&
'sticky top-0 z-10 backdrop-blur-sm shadow-sm'
)}
>
<tr>
@ -240,7 +241,7 @@ export default function Table<T>({
aria-sort={ariaSort as any}
className={cn(
headY,
'px-3 text-sm font-semibold text-gray-900 dark:text-gray-200',
'px-3 text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
alignTd(col.align),
col.widthClassName,
col.headerClassName
@ -253,7 +254,7 @@ export default function Table<T>({
type="button"
onClick={nextSort}
className={cn(
'group inline-flex w-full items-center gap-2 select-none',
'group inline-flex w-full items-center gap-2 select-none rounded-md px-1.5 py-1 -my-1 hover:bg-gray-100/70 dark:hover:bg-white/5',
justifyForAlign(col.align)
)}
>
@ -313,6 +314,7 @@ export default function Table<T>({
className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && 'cursor-pointer',
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
rowClassName?.(row, rowIndex)
)}
onClick={() => onRowClick?.(row)}

View File

@ -15,14 +15,56 @@
-moz-osx-font-smoothing: grayscale;
}
.plyr-mini .plyr__controls [data-plyr="rewind"],
.plyr-mini .plyr__controls [data-plyr="fast-forward"],
.plyr-mini .plyr__controls [data-plyr="volume"],
.plyr-mini .plyr__controls [data-plyr="settings"],
.plyr-mini .plyr__controls [data-plyr="pip"],
.plyr-mini .plyr__controls [data-plyr="airplay"],
.plyr-mini .plyr__time--duration {
display: none !important;
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
.vjs-mini .video-js .vjs-control-bar{
z-index: 40; /* über Overlays */
background: rgba(0,0,0,.65);
backdrop-filter: blur(8px);
}
/* Progressbar deutlich höher */
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
height: 10px;
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
.vjs-mini .video-js .vjs-play-progress{
border-radius: 9999px;
background: rgba(99,102,241,.95);
}
.vjs-mini .video-js .vjs-load-progress{
border-radius: 9999px;
background: rgba(255,255,255,.25);
}
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
.vjs-controls-on-activity .video-js .vjs-control-bar{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
.vjs-controls-on-activity .player-ui{
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
.vjs-controls-on-activity.vjs-controls-active .player-ui,
.vjs-controls-on-activity:focus-within .player-ui{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
@media (prefers-color-scheme: light) {