781 lines
31 KiB
TypeScript
781 lines
31 KiB
TypeScript
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
|
||
'use client'
|
||
|
||
import * as React from 'react'
|
||
import type { RecordJob } from '../../types'
|
||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||
import {
|
||
StarIcon as StarSolidIcon,
|
||
HeartIcon as HeartSolidIcon,
|
||
EyeIcon as EyeSolidIcon,
|
||
} from '@heroicons/react/24/solid'
|
||
import RecordJobActions from './RecordJobActions'
|
||
import TagOverflowRow from './TagOverflowRow'
|
||
import { isHotName, stripHotPrefix } from './hotName'
|
||
import { formatResolution } from './formatters'
|
||
import PreviewScrubber from './PreviewScrubber'
|
||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||
import {
|
||
shouldAnimateTeaser,
|
||
shouldEnableTeaserAudio,
|
||
shouldObserveTeasers,
|
||
} from './teaserPlayback'
|
||
|
||
type ModelFlags = {
|
||
favorite?: boolean
|
||
liked?: boolean | null
|
||
watching?: boolean | null
|
||
tags?: string
|
||
|
||
// ✅ Optional für Model-Hover-Preview
|
||
image?: string
|
||
imageUrl?: string | null
|
||
|
||
// ✅ Optional für stashapp-artigen Preview-Scrubber
|
||
previewScrubberPath?: string
|
||
previewScrubberCount?: number
|
||
}
|
||
|
||
type FinishedPostworkBadge = {
|
||
file: string
|
||
assetId?: string
|
||
queue: 'postwork' | 'enrich'
|
||
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
|
||
phase?: string
|
||
label?: string
|
||
position?: number
|
||
waiting?: number
|
||
running?: number
|
||
maxParallel?: number
|
||
ts: number
|
||
}
|
||
|
||
type Props = {
|
||
rows: RecordJob[]
|
||
isLoading?: boolean
|
||
blurPreviews?: boolean
|
||
durations: Record<string, number>
|
||
postworkByFile: Record<string, FinishedPostworkBadge>
|
||
teaserState: FinishedDownloadsTeaserState
|
||
|
||
handleDuration: (job: RecordJob, seconds: number) => void
|
||
|
||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||
jobForDetails: (job: RecordJob) => RecordJob
|
||
|
||
keyFor: (j: RecordJob) => string
|
||
baseName: (p: 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
|
||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||
|
||
lower: (s: string) => string
|
||
|
||
modelsByKey: Record<string, ModelFlags>
|
||
activeTagSet: Set<string>
|
||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||
onToggleTagFilter: (tag: string) => void
|
||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||
|
||
forcePreviewMuted?: boolean
|
||
previewActivationNonce: number
|
||
}
|
||
|
||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||
for (const v of values) {
|
||
if (typeof v === 'string') {
|
||
const s = v.trim()
|
||
if (s) return s
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function parseJobMeta(metaRaw: unknown): any | null {
|
||
if (!metaRaw) return null
|
||
if (typeof metaRaw === 'string') {
|
||
try {
|
||
return JSON.parse(metaRaw)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
if (typeof metaRaw === 'object') return metaRaw
|
||
return null
|
||
}
|
||
|
||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||
// ms -> s Heuristik wie in FinishedVideoPreview
|
||
return value > 24 * 60 * 60 ? value / 1000 : value
|
||
}
|
||
|
||
function clamp(n: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, n))
|
||
}
|
||
|
||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||
|
||
function chooseSpriteGrid(count: number): [number, number] {
|
||
if (count <= 1) return [1, 1]
|
||
|
||
const targetRatio = 16 / 9
|
||
let bestCols = 1
|
||
let bestRows = count
|
||
let bestWaste = Number.POSITIVE_INFINITY
|
||
let bestRatioScore = Number.POSITIVE_INFINITY
|
||
|
||
for (let c = 1; c <= count; c++) {
|
||
const r = Math.max(1, Math.ceil(count / c))
|
||
const waste = c * r - count
|
||
const ratio = c / r
|
||
const ratioScore = Math.abs(ratio - targetRatio)
|
||
|
||
if (
|
||
waste < bestWaste ||
|
||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
||
) {
|
||
bestWaste = waste
|
||
bestRatioScore = ratioScore
|
||
bestCols = c
|
||
bestRows = r
|
||
}
|
||
}
|
||
|
||
return [bestCols, bestRows]
|
||
}
|
||
|
||
export default function FinishedDownloadsGalleryView({
|
||
rows,
|
||
isLoading,
|
||
blurPreviews,
|
||
durations,
|
||
postworkByFile,
|
||
teaserState,
|
||
|
||
handleDuration,
|
||
handleScrubberClickIndex,
|
||
jobForDetails,
|
||
keyFor,
|
||
baseName,
|
||
modelNameFromOutput,
|
||
runtimeOf,
|
||
sizeBytesOf,
|
||
formatBytes,
|
||
|
||
deletingKeys,
|
||
keepingKeys,
|
||
removingKeys,
|
||
|
||
registerTeaserHost,
|
||
|
||
onHoverPreviewKeyChange,
|
||
onOpenPlayer,
|
||
deleteVideo,
|
||
keepVideo,
|
||
onToggleHot,
|
||
lower,
|
||
modelsByKey,
|
||
activeTagSet,
|
||
onToggleTagFilter,
|
||
onToggleFavorite,
|
||
onToggleLike,
|
||
onToggleWatch,
|
||
onSplit,
|
||
onAddToDownloads,
|
||
forcePreviewMuted,
|
||
previewActivationNonce,
|
||
}: Props) {
|
||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||
|
||
|
||
|
||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
||
const registerTeaserHostIfNeeded = React.useCallback(
|
||
(key: string) => (el: HTMLDivElement | null) => {
|
||
if (!observeTeasers) {
|
||
registerTeaserHost(key)(null)
|
||
return
|
||
}
|
||
registerTeaserHost(key)(el)
|
||
},
|
||
[registerTeaserHost, observeTeasers]
|
||
)
|
||
|
||
const parseTags = (raw?: string): string[] => {
|
||
const s = String(raw ?? '').trim()
|
||
if (!s) return []
|
||
const parts = s
|
||
.split(/[\n,;|]+/g)
|
||
.map((p) => p.trim())
|
||
.filter(Boolean)
|
||
|
||
const seen = new Set<string>()
|
||
const out: string[] = []
|
||
for (const p of parts) {
|
||
const k = p.toLowerCase()
|
||
if (seen.has(k)) continue
|
||
seen.add(k)
|
||
out.push(p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||
const w =
|
||
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
|
||
? (j as any).meta.videoWidth
|
||
: 0) ||
|
||
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
|
||
? (j as any).videoWidth
|
||
: 0)
|
||
const h =
|
||
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
|
||
? (j as any).meta.videoHeight
|
||
: 0) ||
|
||
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
|
||
? (j as any).videoHeight
|
||
: 0)
|
||
|
||
if (w > 0 && h > 0) return { w, h }
|
||
return null
|
||
}, [])
|
||
|
||
// ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
|
||
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
||
|
||
// ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
|
||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
||
|
||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
||
|
||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
||
setScrubIndexByKey((prev) => {
|
||
if (index === undefined) {
|
||
if (!(key in prev)) return prev
|
||
const next = { ...prev }
|
||
delete next[key]
|
||
return next
|
||
}
|
||
|
||
if (prev[key] === index) return prev
|
||
return { ...prev, [key]: index }
|
||
})
|
||
}, [])
|
||
|
||
const clearScrubIndex = React.useCallback((key: string) => {
|
||
setScrubIndexForKey(key, undefined)
|
||
}, [setScrubIndexForKey])
|
||
|
||
return (
|
||
<div className="relative">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||
{rows.map((j) => {
|
||
const k = keyFor(j)
|
||
|
||
const isPreviewActive =
|
||
teaserState.activeKey === k || teaserState.hoverKey === k
|
||
|
||
// Sound nur bei Hover auf genau diesem Teaser
|
||
const allowSound = shouldEnableTeaserAudio({
|
||
state: teaserState,
|
||
itemKey: k,
|
||
})
|
||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||
|
||
const model = modelNameFromOutput(j.output)
|
||
const modelKey = lower(model)
|
||
const flags = modelsByKey[modelKey]
|
||
const isFav = Boolean(flags?.favorite)
|
||
const isLiked = flags?.liked === true
|
||
const isWatching = Boolean(flags?.watching)
|
||
|
||
const tags = parseTags(flags?.tags)
|
||
|
||
const fileRaw = baseName(j.output || '')
|
||
const isHot = isHotName(fileRaw)
|
||
const file = stripHotPrefix(fileRaw)
|
||
|
||
const postworkBadge =
|
||
postworkByFile[fileRaw] ??
|
||
postworkByFile[stripHotPrefix(fileRaw)]
|
||
const showPostworkBadge =
|
||
postworkBadge &&
|
||
(postworkBadge.state === 'queued' ||
|
||
postworkBadge.state === 'running' ||
|
||
postworkBadge.state === 'done' ||
|
||
postworkBadge.state === 'error')
|
||
|
||
const dur = runtimeOf(j)
|
||
const size = formatBytes(sizeBytesOf(j))
|
||
|
||
const resObj = resolutionObjOf(j)
|
||
const resLabel = formatResolution(resObj)
|
||
|
||
const isRemoving = removingKeys.has(k)
|
||
const isDeleting = deletingKeys.has(k)
|
||
const isKeeping = keepingKeys.has(k)
|
||
const busy = isDeleting || isKeeping || isRemoving
|
||
|
||
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
|
||
const modelImageSrc = firstNonEmptyString(
|
||
flags?.image,
|
||
flags?.imageUrl,
|
||
(j as any)?.meta?.modelImage,
|
||
(j as any)?.meta?.modelImageUrl
|
||
)
|
||
|
||
// Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
|
||
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
|
||
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
|
||
|
||
// meta robust lesen (Objekt oder JSON-String)
|
||
const meta = parseJobMeta((j as any)?.meta)
|
||
|
||
// ------------------------------------------------------------
|
||
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
|
||
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
|
||
// ------------------------------------------------------------
|
||
const spritePathRaw = firstNonEmptyString(
|
||
meta?.previewSprite?.path,
|
||
(meta as any)?.previewSpritePath,
|
||
flags?.previewScrubberPath,
|
||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||
)
|
||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||
|
||
const spriteStepSecondsRaw =
|
||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||
|
||
const spriteStepSeconds =
|
||
typeof spriteStepSecondsRaw === 'number' &&
|
||
Number.isFinite(spriteStepSecondsRaw) &&
|
||
spriteStepSecondsRaw > 0
|
||
? spriteStepSecondsRaw
|
||
: DEFAULT_SPRITE_STEP_SECONDS
|
||
|
||
// ✅ Fallback-Dauer (meta -> job -> durations cache)
|
||
const durationForSprite =
|
||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
||
normalizeDurationSeconds(durations[k])
|
||
|
||
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
|
||
const inferredSpriteCountFromDuration =
|
||
typeof durationForSprite === 'number' && durationForSprite > 0
|
||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
||
: undefined
|
||
|
||
const spriteCountRaw =
|
||
meta?.previewSprite?.count ??
|
||
(meta as any)?.previewSpriteCount ??
|
||
flags?.previewScrubberCount ??
|
||
inferredSpriteCountFromDuration
|
||
|
||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||
|
||
const spriteCount =
|
||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
||
? Math.max(0, Math.floor(spriteCountRaw))
|
||
: 0
|
||
|
||
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
|
||
const [inferredCols, inferredRows] =
|
||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
||
|
||
const spriteCols =
|
||
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
|
||
? Math.max(0, Math.floor(spriteColsRaw))
|
||
: inferredCols
|
||
|
||
const spriteRows =
|
||
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
|
||
? Math.max(0, Math.floor(spriteRowsRaw))
|
||
: inferredRows
|
||
|
||
// Optionaler Cache-Buster (wenn du sowas in meta hast)
|
||
const spriteVersion =
|
||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||
? meta.updatedAtUnix
|
||
: undefined) ??
|
||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
|
||
? (meta as any).fileModUnix
|
||
: undefined) ??
|
||
0
|
||
|
||
const spriteUrl =
|
||
spritePath && spriteVersion
|
||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
||
: spritePath || undefined
|
||
|
||
const hasScrubberUi =
|
||
Boolean(spriteUrl) &&
|
||
spriteCount > 1
|
||
|
||
const hasSpriteScrubber =
|
||
hasScrubberUi &&
|
||
spriteCols > 0 &&
|
||
spriteRows > 0
|
||
|
||
// Finales Scrubber-Setup
|
||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
||
const hasScrubber = hasScrubberUi
|
||
|
||
const activeScrubIndex = scrubIndexByKey[k]
|
||
|
||
const scrubProgressRatio =
|
||
typeof activeScrubIndex === 'number' && scrubberCount > 1
|
||
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
|
||
: undefined
|
||
|
||
// Sprite-Overlay-Frame (kein Request pro Move)
|
||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||
? (() => {
|
||
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
|
||
const col = idx % spriteCols
|
||
const row = Math.floor(idx / spriteCols)
|
||
|
||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
||
|
||
return {
|
||
backgroundImage: `url("${spriteUrl}")`,
|
||
backgroundRepeat: 'no-repeat',
|
||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
||
backgroundPosition: `${posX}% ${posY}%`,
|
||
}
|
||
})()
|
||
: undefined
|
||
|
||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||
|
||
const hideTeaserUnderOverlay =
|
||
showModelPreviewInThumb || showScrubberSpriteInThumb
|
||
|
||
return (
|
||
<div key={k} className="relative">
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
||
onClick={() => onOpenPlayer(j)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||
}}
|
||
>
|
||
<div
|
||
className={[
|
||
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||
'bg-white dark:bg-gray-900/40',
|
||
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
|
||
'will-change-[transform,opacity]',
|
||
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||
isDeleting &&
|
||
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||
isKeeping &&
|
||
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||
isRemoving
|
||
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
|
||
: 'opacity-100 translate-y-0 scale-100',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
>
|
||
{/* Thumb */}
|
||
<div
|
||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||
ref={registerTeaserHostIfNeeded(k)}
|
||
onMouseEnter={() => {
|
||
setHoveredThumbKey(k)
|
||
onHoverPreviewKeyChange?.(k)
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||
onHoverPreviewKeyChange?.(null)
|
||
clearScrubIndex(k)
|
||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||
}}
|
||
>
|
||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||
<div className="absolute inset-0">
|
||
<FinishedVideoPreview
|
||
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
||
job={j}
|
||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||
onDuration={handleDuration}
|
||
variant="fill"
|
||
showPopover={false}
|
||
blur={blurPreviews}
|
||
animated={shouldAnimateTeaser({
|
||
state: teaserState,
|
||
itemKey: k,
|
||
disabled: hideTeaserUnderOverlay,
|
||
})}
|
||
animatedMode="teaser"
|
||
animatedTrigger="always"
|
||
muted={previewMuted}
|
||
popoverMuted={previewMuted}
|
||
scrubProgressRatio={scrubProgressRatio}
|
||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||
forceActive={isPreviewActive}
|
||
activationNonce={isPreviewActive ? previewActivationNonce : 0}
|
||
teaserPreloadEnabled
|
||
/>
|
||
</div>
|
||
|
||
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
||
{hasSpriteScrubber && spriteUrl ? (
|
||
<img
|
||
src={spriteUrl}
|
||
alt=""
|
||
className="hidden"
|
||
loading="lazy"
|
||
decoding="async"
|
||
aria-hidden="true"
|
||
/>
|
||
) : null}
|
||
|
||
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
|
||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||
<div
|
||
className="h-full w-full"
|
||
style={spriteFrameStyle}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
|
||
{showModelPreviewInThumb && modelImageSrc ? (
|
||
<div className="absolute inset-0 z-[6]">
|
||
<img
|
||
src={modelImageSrc}
|
||
alt={model ? `${model} preview` : 'Model preview'}
|
||
className="h-full w-full object-cover"
|
||
draggable={false}
|
||
/>
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
||
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
||
MODEL PREVIEW
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
||
{hasScrubber && hoveredThumbKey === k ? (
|
||
<div
|
||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<PreviewScrubber
|
||
className="pointer-events-auto px-1"
|
||
imageCount={scrubberCount}
|
||
activeIndex={activeScrubIndex}
|
||
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
||
onIndexClick={(index) => {
|
||
setScrubIndexForKey(k, index)
|
||
handleScrubberClickIndex(j, index, scrubberCount)
|
||
}}
|
||
stepSeconds={scrubberStepSeconds}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Meta-Overlay im Video: unten rechts */}
|
||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||
<div
|
||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
||
.filter(Boolean)
|
||
.join(' • ')}
|
||
>
|
||
<span>{dur}</span>
|
||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||
{resLabel ? <span>{resLabel}</span> : null}
|
||
<span aria-hidden="true">•</span>
|
||
<span>{size}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer / Meta */}
|
||
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||
{/* ✅ stashapp-like: Dateiname zuerst */}
|
||
<div className="min-w-0">
|
||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||
{isHot ? (
|
||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||
HOT
|
||
</span>
|
||
) : null}
|
||
|
||
<span
|
||
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"
|
||
title={stripHotPrefix(file) || '—'}
|
||
>
|
||
{stripHotPrefix(file) || '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
|
||
<div className="mt-1 flex items-center justify-between gap-2">
|
||
<div className="min-w-0 flex items-center gap-2">
|
||
<span
|
||
className={[
|
||
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
|
||
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
|
||
onMouseEnter={(e) => {
|
||
e.stopPropagation()
|
||
if (modelImageSrc) setHoveredModelPreviewKey(k)
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.stopPropagation()
|
||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{model || '—'}
|
||
</span>
|
||
|
||
{showPostworkBadge ? (
|
||
<span
|
||
className={[
|
||
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
|
||
postworkBadge.state === 'running'
|
||
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
|
||
: postworkBadge.state === 'queued'
|
||
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
|
||
: postworkBadge.state === 'done'
|
||
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
|
||
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
|
||
].join(' ')}
|
||
title={
|
||
postworkBadge.label ||
|
||
(postworkBadge.state === 'running'
|
||
? 'Nacharbeiten laufen'
|
||
: postworkBadge.state === 'queued'
|
||
? 'Warte auf Nacharbeiten'
|
||
: postworkBadge.state === 'done'
|
||
? 'Nacharbeiten abgeschlossen'
|
||
: 'Nacharbeiten fehlgeschlagen')
|
||
}
|
||
>
|
||
<span className="truncate">
|
||
{postworkBadge.label ||
|
||
(postworkBadge.state === 'running'
|
||
? 'Nacharbeiten laufen…'
|
||
: postworkBadge.state === 'queued'
|
||
? 'Warte auf Nacharbeiten…'
|
||
: postworkBadge.state === 'done'
|
||
? 'Nacharbeiten abgeschlossen'
|
||
: 'Nacharbeiten fehlgeschlagen')}
|
||
</span>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="shrink-0 flex items-center gap-1.5">
|
||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
||
<div
|
||
className="mt-2 shrink-0"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="w-full">
|
||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||
<RecordJobActions
|
||
job={jobForDetails(j)}
|
||
variant="table"
|
||
busy={busy}
|
||
collapseToMenu
|
||
compact={true}
|
||
isHot={isHot}
|
||
isFavorite={isFav}
|
||
isLiked={isLiked}
|
||
isWatching={isWatching}
|
||
onToggleWatch={onToggleWatch}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleHot={onToggleHot}
|
||
onKeep={keepVideo}
|
||
onDelete={deleteVideo}
|
||
onSplit={onSplit}
|
||
onAddToDownloads={onAddToDownloads}
|
||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||
className="w-full gap-1.5"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<TagOverflowRow
|
||
rowKey={k}
|
||
tags={tags}
|
||
activeTagSet={activeTagSet}
|
||
lower={lower}
|
||
onToggleTagFilter={onToggleTagFilter}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{isLoading && rows.length === 0 ? (
|
||
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
||
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade…</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
} |