// frontend\src\components\ui\ModelDetails.tsx 'use client' import * as React from 'react' import type { RecordJob } from '../../types' import Modal from './Modal' import Button from './Button' import TagBadge from './TagBadge' import RecordJobActions from './RecordJobActions' import Tabs, { type TabItem } from './Tabs' import { ArrowTopRightOnSquareIcon, CalendarDaysIcon, ArrowPathIcon, HeartIcon as HeartOutlineIcon, StarIcon as StarOutlineIcon, IdentificationIcon, LanguageIcon, LinkIcon, MapPinIcon, PhotoIcon, SparklesIcon, UsersIcon, ClockIcon, EyeIcon as EyeOutlineIcon, } from '@heroicons/react/24/outline' import { HeartIcon as HeartSolidIcon, StarIcon as StarSolidIcon, EyeIcon as EyeSolidIcon, } from '@heroicons/react/24/solid' import FinishedVideoPreview from './FinishedVideoPreview' import TagOverflowRow from './TagOverflowRow' import PreviewScrubber from './PreviewScrubber' import { formatResolution } from './formatters' import Pagination from './Pagination' import LiveVideo from './LiveVideo' function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } function isRunningJob(job: RecordJob): boolean { const s = String((job as any)?.status ?? '').toLowerCase() const ended = Boolean((job as any)?.endedAt ?? (job as any)?.completedAt) return !ended && (s === 'running' || s === 'postwork') } const MD_CACHE_TTL_MS = 10 * 60 * 1000 // 10 min type OnlineCacheEntry = { at: number room: ChaturbateRoom | null meta: Pick | null } type BioCacheEntry = { at: number bio: BioContext | null meta: Pick | null } // In-Memory (über Modal-Open/Close hinweg, solange Tab offen) const mdOnlineMem = new Map() const mdBioMem = new Map() function isFresh(at: number) { return Number.isFinite(at) && Date.now() - at <= MD_CACHE_TTL_MS } function ssGet(key: string): T | null { try { if (typeof window === 'undefined') return null const raw = window.sessionStorage.getItem(key) if (!raw) return null return JSON.parse(raw) as T } catch { return null } } function ssSet(key: string, value: any) { try { if (typeof window === 'undefined') return window.sessionStorage.setItem(key, JSON.stringify(value)) } catch { // ignore } } function ssKeyOnline(modelKey: string) { return `md:cb:online:${modelKey}` } function ssKeyBio(modelKey: string) { return `md:cb:bio:${modelKey}` } const nf = new Intl.NumberFormat('de-DE') function fmtInt(n: number | undefined | null) { if (n == null || !Number.isFinite(n)) return '—' return nf.format(n) } function fmtBytes(n: number | undefined | null) { if (n == null || !Number.isFinite(n)) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = n let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const digits = i === 0 ? 0 : i === 1 ? 0 : 1 return `${v.toFixed(digits)} ${units[i]}` } function fmtHms(totalSeconds: number | undefined | null) { if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds < 0) return '—' const s = Math.floor(totalSeconds) const hh = Math.floor(s / 3600) const mm = Math.floor((s % 3600) / 60) const ss = s % 60 if (hh > 0) return `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}` return `${mm}:${String(ss).padStart(2, '0')}` } function fmtDateTime(v: string | Date | null | undefined) { if (!v) return '—' const d = typeof v === 'string' ? new Date(v) : v if (Number.isNaN(d.getTime())) return String(v) return d.toLocaleString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } function splitTags(v?: string | null) { if (!v) return [] return v .split(',') .map((t) => t.trim()) .filter(Boolean) } function baseName(path: string) { return (path || '').split(/[\\/]/).pop() || '' } function shortDate(v: string | Date | null | undefined) { if (!v) return '—' const d = typeof v === 'string' ? new Date(v) : v if (Number.isNaN(d.getTime())) return String(v) // kompakter als fmtDateTime (ohne Sekunden) return d.toLocaleDateString('de-DE', { year: '2-digit', month: '2-digit', day: '2-digit' }) } function stripHotPrefix(name: string) { return name.startsWith('HOT ') ? name.slice(4) : name } function isHotName(name: string) { return String(name || '').startsWith('HOT ') } function replaceBaseName(path: string, newBase: string) { const p = String(path || '') if (!p) return p // ersetzt nur das letzte Segment (funktioniert für / und \) return p.replace(/([\\/])[^\\/]*$/, `$1${newBase}`) } function toggleHotFileName(file: string) { return isHotName(file) ? stripHotPrefix(file) : `HOT ${file}` } function modelNameFromOutput(output?: string) { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') // match: _DD_MM_YYYY__HH-MM-SS const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) if (m?.[1]) return m[1] const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } function stripHtmlToText(input?: string | null) { if (!input) return '' // simpel & sicher: tags weg (kein dangerouslySetInnerHTML) return String(input) .replace(/[\s\S]*?<\/script>/gi, '') .replace(/[\s\S]*?<\/style>/gi, '') .replace(/<\/?[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() } function errorSummary(input?: string | null) { const s = String(input ?? '').trim() if (!s) return '' // bevorzugt: HTTP-Code rausziehen (kommt bei dir oft vor) const m = s.match(/HTTP\s+(\d{3})/i) if (m?.[1]) return `HTTP ${m[1]}` // wenn HTML/doctype drin: nur "HTML response" anzeigen const lower = s.toLowerCase() if (lower.includes(' 80 ? oneLine.slice(0, 77) + '…' : oneLine } function errorDetails(input?: string | null) { const s = String(input ?? '').trim() if (!s) return '' // HTML zu Text strippen, damit es lesbar/kopierbar wird const t = stripHtmlToText(s) // hart begrenzen, damit es nicht eskaliert return t.length > 2000 ? t.slice(0, 2000) + '…' : t } function absCbUrl(u?: string | null) { if (!u) return '' const s = String(u).trim() if (!s) return '' if (s.startsWith('http://') || s.startsWith('https://')) return s if (s.startsWith('/')) return `https://chaturbate.com${s}` return `https://chaturbate.com/${s}` } function pill(cls: string) { return cn( 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset', cls ) } const previewBlurCls = (blur?: boolean) => blur ? 'blur-md scale-[1.03] brightness-90' : '' 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] } // ------ API types (Chaturbate online) ------ type ChaturbateRoom = { gender?: string location?: string country?: string current_show?: string username?: string room_subject?: string tags?: string[] is_new?: boolean num_users?: number num_followers?: number spoken_languages?: string display_name?: string birthday?: string is_hd?: boolean age?: number seconds_online?: number image_url?: string image_url_360x270?: string chat_room_url?: string chat_room_url_revshare?: string iframe_embed?: string iframe_embed_revshare?: string slug?: string } type OnlineResp = { enabled?: boolean fetchedAt?: string count?: number lastError?: string rooms?: ChaturbateRoom[] } // ------ API types (Chaturbate biocontext proxy) ------ type BioPhotoSet = { id: number name: string cover_url?: string tokens?: number is_video?: boolean user_can_access?: boolean user_has_purchased?: boolean fan_club_only?: boolean label_text?: string label_color?: string video_has_sound?: boolean video_ready?: boolean } type BioSocial = { id: number title_name: string image_url?: string link?: string popup_link?: boolean tokens?: number purchased?: boolean label_text?: string label_color?: string } type BioContext = { follower_count?: number location?: string real_name?: string body_decorations?: string last_broadcast?: string smoke_drink?: string body_type?: string display_birthday?: string about_me?: string wish_list?: string time_since_last_broadcast?: string fan_club_cost?: number performer_has_fanclub?: boolean fan_club_is_member?: boolean fan_club_join_url?: string needs_supporter_to_pm?: boolean interested_in?: string[] display_age?: number sex?: string subgender?: string room_status?: string // offline/online photo_sets?: BioPhotoSet[] social_medias?: BioSocial[] is_broadcaster_or_staff?: boolean } type BioResp = { enabled?: boolean fetchedAt?: string lastError?: string model?: string bio?: BioContext | null } // ------ props ------ // ------ API types (local model store) ------ // /api/models liefert StoredModel aus dem models_store type StoredModel = { id: string modelKey: string tags?: string | null lastSeenOnline?: boolean | null lastSeenOnlineAt?: string favorite?: boolean watching?: boolean liked?: boolean | null hot?: boolean keep?: boolean createdAt?: string updatedAt?: string cbOnlineJson?: string | null cbOnlineFetchedAt?: string | null cbOnlineLastError?: string | null } type Props = { open: boolean modelKey: string | null onClose: () => void onOpenPlayer?: (job: RecordJob, startAtSec?: number) => void cookies?: Record runningJobs?: RecordJob[] blurPreviews?: boolean teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean onToggleWatch?: (job: RecordJob) => void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleHot?: ( job: RecordJob ) => | void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise onDelete?: (job: RecordJob) => void | Promise onKeep?: (job: RecordJob) => void | Promise onStopJob?: (id: string) => void | Promise } function normalizeModelKey(raw: string | null | undefined): string { let s = String(raw ?? '').trim() if (!s) return '' // falls jemand eine URL reinreicht s = s.replace(/^https?:\/\//i, '') // falls host/path drin ist -> letzten Segment nehmen if (s.includes('/')) { const parts = s.split('/').filter(Boolean) s = parts[parts.length - 1] || s } // falls host:model drin ist -> nach ":" nehmen if (s.includes(':')) { s = s.split(':').pop() || s } return s.trim().toLowerCase() } function buildChaturbateCookieHeader(cookies?: Record): string { const c = cookies ?? {} const cf = c['cf_clearance'] || c['cf-clearance'] || c['cfclearance'] || '' const sess = // ✅ wichtig: deine App nutzt "sessionId" c['sessionId'] || c['sessionid'] || c['session_id'] || c['session-id'] || '' const parts: string[] = [] if (cf) parts.push(`cf_clearance=${cf}`) if (sess) parts.push(`sessionid=${sess}`) // upstream erwartet sessionid=... return parts.join('; ') } export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, cookies, runningJobs, blurPreviews, teaserPlayback = 'hover', teaserAudio = false, onToggleWatch, onToggleFavorite, onToggleLike, onToggleHot, onDelete, onKeep, onStopJob }: Props) { //const isDesktop = useMediaQuery('(min-width: 640px)') const [models, setModels] = React.useState([]) const [room, setRoom] = React.useState(null) const [roomMeta, setRoomMeta] = React.useState | null>(null) const [bio, setBio] = React.useState(null) const [bioMeta, setBioMeta] = React.useState | null>(null) const [bioLoading, setBioLoading] = React.useState(false) const [done, setDone] = React.useState([]) const [doneLoading, setDoneLoading] = React.useState(false) const [running, setRunning] = React.useState([]) const [runningLoading, setRunningLoading] = React.useState(false) const runningReqSeqRef = React.useRef(0) const [, setBioRefreshSeq] = React.useState(0) const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null) const [, setRunningHover] = React.useState(false) const [stopPending, setStopPending] = React.useState(false) const [doneTotalCount, setDoneTotalCount] = React.useState(0) const [donePage, setDonePage] = React.useState(1) const DONE_PAGE_SIZE = 4 const key = normalizeModelKey(modelKey) type TabKey = 'info' | 'downloads' | 'running' const [tab, setTab] = React.useState('info') const bioReqRef = React.useRef(null) // ===== Gallery UI State (wie FinishedDownloadsGalleryView) ===== const [durations, setDurations] = React.useState>({}) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) const [teaserKey, setTeaserKey] = React.useState(null) const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState(null) const [scrubIndexByKey, setScrubIndexByKey] = React.useState>({}) const [hoveredThumbKey, setHoveredThumbKey] = React.useState(null) const lower = React.useCallback((s: string) => String(s ?? '').toLowerCase(), []) const deletingKeys = React.useMemo(() => new Set(), []) const keepingKeys = React.useMemo(() => new Set(), []) const removingKeys = React.useMemo(() => new Set(), []) const deletedKeys = React.useMemo(() => new Set(), []) // ✅ 1) Beim Öffnen sofort aus Cache rendern React.useEffect(() => { if (!open || !key) return // Online const memOnline = mdOnlineMem.get(key) const ssOnline = ssGet(ssKeyOnline(key)) const onlineHit = (memOnline && isFresh(memOnline.at) ? memOnline : null) || (ssOnline && isFresh(ssOnline.at) ? ssOnline : null) if (onlineHit) { setRoom(onlineHit.room ?? null) setRoomMeta(onlineHit.meta ?? null) } // Bio const memBio = mdBioMem.get(key) const ssBio = ssGet(ssKeyBio(key)) const bioHit = (memBio && isFresh(memBio.at) ? memBio : null) || (ssBio && isFresh(ssBio.at) ? ssBio : null) if (bioHit) { setBio(bioHit.bio ?? null) setBioMeta(bioHit.meta ?? null) // bioLoading NICHT anfassen – Fetch kann trotzdem laufen } }, [open, key]) React.useEffect(() => { if (!open) return setStopPending(false) }, [open, key]) const refreshBio = React.useCallback(async () => { if (!key) return // vorherigen abbrechen bioReqRef.current?.abort() const ac = new AbortController() bioReqRef.current = ac setBioLoading(true) try { const cookieHeader = buildChaturbateCookieHeader(cookies) const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1` const r = await fetch(url, { cache: 'no-store', signal: ac.signal, headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined, }) if (!r.ok) { const text = await r.text().catch(() => '') throw new Error(text || `HTTP ${r.status}`) } const data = (await r.json().catch(() => null)) as BioResp const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError } const nextBio = (data?.bio as BioContext) ?? null setBioMeta(meta) setBio(nextBio) const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta } mdBioMem.set(key, entry) ssSet(ssKeyBio(key), entry) } catch (e: any) { if (e?.name === 'AbortError') return setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' }) } finally { setBioLoading(false) } }, [key, cookies]) React.useEffect(() => { if (open) return bioReqRef.current?.abort() bioReqRef.current = null }, [open]) const refetchModels = React.useCallback(async () => { try { const r = await fetch('/api/models', { cache: 'no-store' }) const data = (await r.json().catch(() => null)) as any setModels(Array.isArray(data) ? data : []) } catch { // ignore } }, []) const refetchDone = React.useCallback(async () => { if (!key) return setDoneLoading(true) try { const url = `/api/record/done?model=${encodeURIComponent(key)}` + `&page=${donePage}&pageSize=${DONE_PAGE_SIZE}` + `&sort=completed_desc&includeKeep=1&withCount=1` const r = await fetch(url, { cache: 'no-store' }) const data = await r.json().catch(() => null) const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] const count = Number(data?.count ?? items.length) setDone(items) setDoneTotalCount(Number.isFinite(count) ? count : items.length) } catch { // ignore } finally { setDoneLoading(false) } }, [key, donePage]) const refetchDoneRef = React.useRef(refetchDone) React.useEffect(() => { refetchDoneRef.current = refetchDone }, [refetchDone]) React.useEffect(() => { if (!open) return const es = new EventSource('/api/stream') const onJobs = () => { // optional } const onDone = () => { void refetchDoneRef.current() } es.addEventListener('jobs', onJobs) es.addEventListener('doneChanged', onDone) return () => { es.removeEventListener('jobs', onJobs) es.removeEventListener('doneChanged', onDone) es.close() } }, [open]) // erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht function jobFromModelKey(key: string): RecordJob { // muss zum Regex in App.tsx passen: _MM_DD_YYYY__HH-MM-SS.ext return { id: `model:${key}`, output: `${key}_01_01_2000__00-00-00.mp4`, status: 'finished', } as any } const openImage = React.useCallback((src?: string | null, alt?: string) => { const s = String(src ?? '').trim() if (!s) return setImgViewer({ src: s, alt }) }, []) // wenn Modal zu geht, wieder "normal" (kein force refresh) React.useEffect(() => { if (!open) setBioRefreshSeq(0) }, [open]) const runningList = React.useMemo(() => { return Array.isArray(runningJobs) ? runningJobs : running }, [runningJobs, running]) React.useEffect(() => { if (!open) return setDonePage(1) }, [open, modelKey]) React.useEffect(() => { if (!open) return void refetchModels() }, [open, refetchModels]) // Done downloads (inkl. keep//) -> serverseitig paginiert laden React.useEffect(() => { if (!open || !key) return void refetchDone() }, [open, key, refetchDone]) // Running jobs React.useEffect(() => { if (!open) return if (Array.isArray(runningJobs)) return const ac = new AbortController() const seq = ++runningReqSeqRef.current setRunningLoading(true) fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal }) .then((r) => r.json()) .then((data: RecordJob[]) => { if (ac.signal.aborted) return if (runningReqSeqRef.current !== seq) return setRunning(Array.isArray(data) ? data : []) }) .catch(() => { if (ac.signal.aborted) return if (runningReqSeqRef.current !== seq) return setRunning([]) }) .finally(() => { if (ac.signal.aborted) return if (runningReqSeqRef.current !== seq) return setRunningLoading(false) }) return () => { ac.abort() } }, [open, runningJobs]) const model = React.useMemo(() => { if (!key) return null return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null }, [models, key]) const storedRoomFromSnap = React.useMemo(() => { const raw = (model as any)?.cbOnlineJson if (!raw || typeof raw !== 'string') return null try { return JSON.parse(raw) as ChaturbateRoom } catch { return null } }, [model]) const storedRoomMeta = React.useMemo(() => { const fetchedAt = (model as any)?.cbOnlineFetchedAt const lastError = (model as any)?.cbOnlineLastError if (!fetchedAt && !lastError) return null return { enabled: true, fetchedAt, lastError } as Pick }, [model]) const effectiveRoom = room ?? storedRoomFromSnap const effectiveRoomMeta = roomMeta ?? storedRoomMeta const doneMatches = done const runningMatches = React.useMemo(() => { if (!key) return [] return runningList.filter((j) => { const m = modelNameFromOutput(j.output) return m !== '—' && m.trim().toLowerCase() === key }) }, [runningList, key]) const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model' const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || '' const heroImgFull = effectiveRoom?.image_url || heroImg const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || '' const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase() const showPill = showLabel ? showLabel === 'public' ? 'Public' : showLabel === 'private' ? 'Private' : showLabel : '' const bioLocation = (bio?.location || '').trim() const bioFollowers = bio?.follower_count const bioAge = bio?.display_age const bioStatus = (bio?.room_status || '').trim() const bioLast = bio?.last_broadcast ? fmtDateTime(bio.last_broadcast) : '—' const about = stripHtmlToText(bio?.about_me) const wish = stripHtmlToText(bio?.wish_list) const storedPresenceLabel = model?.lastSeenOnline == null ? '' : model.lastSeenOnline ? 'online' : 'offline' const effectivePresenceLabel = (bioStatus || showLabel || storedPresenceLabel || '').trim() const socials = Array.isArray(bio?.social_medias) ? bio!.social_medias! : [] const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : [] const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : [] const allTags = React.useMemo(() => { const a = splitTags(model?.tags) const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : [] const map = new Map() for (const t of [...a, ...b]) { const k = String(t).trim().toLowerCase() if (!k) continue if (!map.has(k)) map.set(k, String(t).trim()) } return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de')) }, [model?.tags, effectiveRoom?.tags]) const Stat = ({ icon, label, value, }: { icon: React.ReactNode label: string value: React.ReactNode }) => (
{icon} {label}
{value}
) const keyFor = React.useCallback((j: RecordJob) => { // stabil, auch wenn id fehlt const id = String((j as any)?.id ?? '') const out = String(j.output ?? '') return id ? `${id}::${out}` : out }, []) const handleToggleHot = React.useCallback( async (job: RecordJob) => { const out = job.output || '' const oldFile = baseName(out) if (!oldFile) { await onToggleHot?.(job) return } const newFile = toggleHotFileName(oldFile) // ✅ 1) UI sofort updaten (optimistisch) setDone((prev) => prev.map((j) => { const same = (j.id && job.id && j.id === job.id) || (j.output && job.output && j.output === job.output) if (!same) return j return { ...j, output: replaceBaseName(j.output || '', newFile) } }) ) // ✅ 2) Backend/App Handler await onToggleHot?.(job) // ✅ 3) Server-Truth nachziehen (falls Backend anders renamed) refetchDone() }, [onToggleHot, refetchDone] ) const handleScrubberClickIndex = React.useCallback( (job: RecordJob, segmentIndex: number, _segmentCount: number) => { const metaRaw = (job as any)?.meta const meta = typeof metaRaw === 'string' ? (() => { try { return JSON.parse(metaRaw) } catch { return null } })() : metaRaw const step = typeof meta?.previewSprite?.stepSeconds === 'number' && Number.isFinite(meta.previewSprite.stepSeconds) && meta.previewSprite.stepSeconds > 0 ? meta.previewSprite.stepSeconds : 5 const startAtSec = Math.max(0, Math.floor(segmentIndex) * step) onOpenPlayer?.(job, startAtSec) }, [onOpenPlayer] ) const handleToggleFavoriteModel = React.useCallback(async () => { if (!key) return // ✅ UI sofort (optimistisch) setModels((prev) => prev.map((m) => (m.modelKey || '').toLowerCase() === key ? { ...m, favorite: !Boolean(m.favorite) } : m ) ) // ✅ Backend/App Handler await onToggleFavorite?.(jobFromModelKey(key)) // ✅ Server-Truth nachziehen refetchModels() }, [key, onToggleFavorite, refetchModels]) const handleToggleLikeModel = React.useCallback(async () => { if (!key) return // liked ist bei dir boolean | null -> wir togglen "true <-> false" setModels((prev) => prev.map((m) => (m.modelKey || '').toLowerCase() === key ? { ...m, liked: m.liked === true ? false : true } : m ) ) await onToggleLike?.(jobFromModelKey(key)) refetchModels() }, [key, onToggleLike, refetchModels]) const handleToggleWatchModel = React.useCallback(async () => { if (!key) return // ✅ UI sofort (optimistisch) setModels((prev) => prev.map((m) => (m.modelKey || '').toLowerCase() === key ? { ...m, watching: !Boolean(m.watching) } : m ) ) // ✅ Backend/App Handler await onToggleWatch?.(jobFromModelKey(key)) // ✅ Server-Truth nachziehen refetchModels() }, [key, onToggleWatch, refetchModels]) React.useEffect(() => { if (!open) { setHoverTeaserKey(null) setTeaserKey(null) setHoveredModelPreviewKey(null) setHoveredThumbKey(null) setScrubIndexByKey({}) setDurations({}) } }, [open, key]) const handleDuration = React.useCallback( (job: RecordJob, seconds: number) => { const k = keyFor(job) setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds })) }, [keyFor] ) const handleHoverPreviewKeyChange = React.useCallback( (k: string | null) => { setHoverTeaserKey(k) if (teaserPlayback === 'hover') setTeaserKey(k) }, [teaserPlayback] ) const runtimeOf = React.useCallback( (job: RecordJob) => { const k = keyFor(job) const raw = (job as any)?.durationSeconds ?? (job as any)?.meta?.durationSeconds ?? durations[k] const n = typeof raw === 'number' && Number.isFinite(raw) ? raw > 24 * 60 * 60 ? raw / 1000 : raw : null return fmtHms(n) }, [durations, keyFor] ) const sizeBytesOf = React.useCallback((job: RecordJob) => { const v = (job as any)?.sizeBytes ?? (job as any)?.size ?? (job as any)?.meta?.sizeBytes ?? (job as any)?.meta?.size return typeof v === 'number' && Number.isFinite(v) ? v : null }, []) const formatBytes = React.useCallback((bytes?: number | null) => fmtBytes(bytes ?? 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] ) React.useEffect(() => { if (!open) return setTab('info') }, [open, key]) const tabs: TabItem[] = [ { id: 'info', label: 'Info' }, { id: 'downloads', label: 'Downloads', count: doneTotalCount ? fmtInt(doneTotalCount) : undefined, disabled: doneLoading }, { id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading }, ] return ( void refreshBio()} title="BioContext neu abrufen" > Bio aktualisieren ) : null } left={
{/* ===================== */} {/* DESKTOP (sm:block) */} {/* ===================== */}
{/* Image */}
{heroImg ? ( ) : (
)}
{/* Pills */}
{showPill ? ( {showPill} ) : null} {effectivePresenceLabel ? ( {effectivePresenceLabel} ) : null} {effectiveRoom?.is_hd ? ( HD ) : null} {effectiveRoom?.is_new ? ( NEW ) : null}
{/* Title */}
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
{/* Local flags icons */}
{/* Watched */} {/* Favorite */} {/* Like */}
{/* Summary */}
} label="Viewer" value={fmtInt(effectiveRoom?.num_users)} /> } label="Follower" value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} />
Location
{effectiveRoom?.location || bioLocation || '—'}
Sprache
{effectiveRoom?.spoken_languages || '—'}
Online
{fmtHms(effectiveRoom?.seconds_online)}
Alter
{bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
Last broadcast
{bioLast}
{/* Meta warnings */} {effectiveRoomMeta?.enabled === false ? (
Chaturbate-Online ist aktuell deaktiviert.
) : effectiveRoomMeta?.lastError ? (
Online-Info: {errorSummary(effectiveRoomMeta.lastError)}
Details
                        {errorDetails(effectiveRoomMeta.lastError)}
                      
) : null} {bioMeta?.enabled === false ? (
BioContext ist aktuell deaktiviert.
) : bioMeta?.lastError ? (
BioContext: {bioMeta.lastError}
) : null}
{/* Tags (desktop) */}
Tags
{allTags.length ? (
{allTags.map((t) => ( ))}
) : (
Keine Tags vorhanden.
)}
} rightHeader={
{/* ===================== */} {/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */} {/* ===================== */}
{/* HERO Header (mobile) */}
{/* Hero Background */}
{heroImg ? ( ) : (
)} {/* Gradient overlay */}
{/* Top row: name + action icons */}
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
{/* Watched */} {/* Favorite */} {/* Like */}
{/* Pills bottom-left */}
{showPill ? ( {showPill} ) : null} {effectivePresenceLabel ? ( {effectivePresenceLabel} ) : null} {effectiveRoom?.is_hd ? HD : null} {effectiveRoom?.is_new ? NEW : null}
{/* Room link bottom-right */} {roomUrl ? ( ) : null}
{/* Quick stats row (unter dem Hero) */}
{fmtInt(effectiveRoom?.num_users)} {fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} {fmtHms(effectiveRoom?.seconds_online)} {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'}
{/* Meta warnings (mobile) */} {effectiveRoomMeta?.enabled === false ? (
Chaturbate-Online ist aktuell deaktiviert.
) : effectiveRoomMeta?.lastError ? (
Online-Info: {effectiveRoomMeta.lastError}
) : null} {bioMeta?.enabled === false ? (
BioContext ist aktuell deaktiviert.
) : bioMeta?.lastError ? (
BioContext: {errorSummary(bioMeta.lastError)}
Details
                        {errorDetails(bioMeta.lastError)}
                      
) : null}
{/* Tags (mobile, compact row) */} {allTags.length ? (
Tags ({allTags.length})
{allTags.slice(0, 20).map((t) => ( ))} {allTags.length > 20 ? ( +{allTags.length - 20} ) : null}
) : null}
{/* ===================== */} {/* (dein bisheriger Header) Row 1: Meta + Actions */} {/* ===================== */}
{/* Meta */}
{key ? (
{effectiveRoomMeta?.fetchedAt ? ( Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)} ) : null} {bioMeta?.fetchedAt ? ( · Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)} ) : null} {model?.lastSeenOnlineAt ? ( · Zuletzt gesehen:{' '} {model.lastSeenOnline == null ? '—' : model.lastSeenOnline ? 'online' : 'offline'} {' '} ({fmtDateTime(model.lastSeenOnlineAt)}) ) : null}
) : ( '—' )}
{/* Actions */}
{roomUrl ? ( Room öffnen ) : null}
{/* Row 2: Tabs */}
setTab(id as TabKey)} variant="pillsBrand" ariaLabel="Bereich auswählen" />
} footer={
} > {/* RIGHT content (Tabs) */}
{/* INFO */} {tab === 'info' ? (
{/* Subject */}
Room Subject
{effectiveRoom?.room_subject ? (

{effectiveRoom.room_subject}

) : (

Keine Subject-Info vorhanden.

)}
{/* Bio */}
Bio
{bioLoading ? Lade… : null}
Über mich
{about ? (

{about}

) : ( )}
Wishlist
{wish ? (

{wish}

) : ( )}
Real name:{' '} {bio?.real_name ? stripHtmlToText(bio.real_name) : '—'}
Body type:{' '} {bio?.body_type || '—'}
Smoke/Drink:{' '} {bio?.smoke_drink || '—'}
Sex:{' '} {bio?.sex || '—'}
{interested.length ? (
Interested in
{interested.map((x) => ( {x} ))}
) : null}
{/* Socials */}
Socials
{socials.length ? ( {socials.length} Links ) : null}
{socials.length ? ( ) : (
Keine Social-Medias vorhanden.
)}
{/* Photo sets */}
Photo sets
{photos.length ? ( {photos.length} Sets ) : null}
{photos.length ? (
{photos.slice(0, 6).map((p) => (
{p.cover_url ? ( ) : (
)}
{p.is_video ? ( Video ) : ( Photos )} {p.fan_club_only ? ( FanClub ) : null}
{p.name}
Tokens: {fmtInt(p.tokens)} {p.user_can_access === true ? · Zugriff: ✅ : null}
))}
) : (
Keine Photo-Sets vorhanden.
)}
) : null} {/* DOWNLOADS */} {tab === 'downloads' ? (
Abgeschlossene Downloads
Inkl. Dateien aus /done/keep/
setDonePage(p)} siblingCount={1} boundaryCount={1} showSummary={true} prevLabel="Zurück" nextLabel="Weiter" ariaLabel="Downloads Seiten" className="border-0 bg-transparent px-0 py-0 dark:bg-transparent" />
{doneLoading ? (
Lade Downloads…
) : doneMatches.length === 0 ? (
Keine abgeschlossenen Downloads für dieses Model gefunden.
) : (
{doneMatches.map((j) => { const k = keyFor(j) const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const previewMuted = !allowSound const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) // Auflösung bevorzugt aus meta/videoWidth/videoHeight const meta = parseJobMeta((j as any)?.meta) const resObj = typeof meta?.videoWidth === 'number' && typeof meta?.videoHeight === 'number' && meta.videoWidth > 0 && meta.videoHeight > 0 ? { w: meta.videoWidth, h: meta.videoHeight } : typeof (j as any)?.videoWidth === 'number' && typeof (j as any)?.videoHeight === 'number' && (j as any).videoWidth > 0 && (j as any).videoHeight > 0 ? { w: (j as any).videoWidth, h: (j as any).videoHeight } : null const resLabel = formatResolution(resObj) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const deleted = deletedKeys.has(k) // Model “Flags” (hier: aktuelles Model) const isFav = Boolean(model?.favorite) const isLiked = model?.liked === true const isWatching = Boolean(model?.watching) // Tags: nimm stored + room-tags (wie links), deduped const cardTags = allTags // Model Preview Bild: nimm Hero const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg) // Preview-ID (für sprite fallback) const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim() // -------- Sprite/Scrubber Setup (wie Gallery) -------- const spritePathRaw = firstNonEmptyString( meta?.previewSprite?.path, (meta as any)?.previewSpritePath, 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 const durationForSprite = normalizeDurationSeconds(meta?.durationSeconds) ?? normalizeDurationSeconds((j as any)?.durationSeconds) ?? normalizeDurationSeconds(durations[k]) 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 ?? 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 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 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 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 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 if (deleted) return null return (
onOpenPlayer?.(j)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer?.(j) }} > {/* Thumb */}
{ setHoveredThumbKey(k) handleHoverPreviewKeyChange(k) }} onMouseLeave={() => { setHoveredThumbKey(null) handleHoverPreviewKeyChange(null) clearScrubIndex(k) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) }} >
stripHotPrefix(baseName(p))} durationSeconds={durations[k] ?? (j as any)?.durationSeconds} onDuration={handleDuration} variant="fill" showPopover={false} blur={blurPreviews} animated={ hideTeaserUnderOverlay ? false : teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false } animatedMode="teaser" animatedTrigger="always" muted={previewMuted} popoverMuted={previewMuted} scrubProgressRatio={scrubProgressRatio} preferScrubProgress={typeof activeScrubIndex === 'number'} />
{/* Sprite preload */} {hasSpriteScrubber && spriteUrl ? ( ) : null} {/* Sprite overlay frame */} {showScrubberSpriteInThumb && spriteFrameStyle ? (
{/* Footer */}
{/* filename */}
{isHot ? ( HOT ) : null} {file || '—'}
{/* actions */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
{ await handleToggleHot(job) return true }} onKeep={async (job) => { try { await onKeep?.(job) return true } catch { return false } }} onDelete={async (job) => { try { await onDelete?.(job) return true } catch { return false } }} order={[ 'hot', 'keep', 'delete']} className="w-full gap-1.5" />
{/* tags */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> leer lower={lower} onToggleTagFilter={() => {}} />
) })}
)}
) : null} {/* RUNNING */} {tab === 'running' ? (
Laufender Download
{runningLoading ? Lade… : null}
{runningLoading ? (
Lade…
) : runningMatches.length === 0 ? (
Kein laufender Job.
) : (() => { const j = runningMatches[0] const k = keyFor(j) const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const previewMuted = !allowSound const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) || '—' const meta = parseJobMeta((j as any)?.meta) // Auflösung bevorzugt aus meta/videoWidth/videoHeight const resObj = typeof meta?.videoWidth === 'number' && typeof meta?.videoHeight === 'number' && meta.videoWidth > 0 && meta.videoHeight > 0 ? { w: meta.videoWidth, h: meta.videoHeight } : typeof (j as any)?.videoWidth === 'number' && typeof (j as any)?.videoHeight === 'number' && (j as any).videoWidth > 0 && (j as any).videoHeight > 0 ? { w: (j as any).videoWidth, h: (j as any).videoHeight } : null const resLabel = formatResolution(resObj) // Dauer + Size im gleichen Stil wie Gallery const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) const cardTags = allTags const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg) // Preview-ID (für sprite fallback) const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim() // -------- Sprite/Scrubber Setup (wie Gallery) -------- const spritePathRaw = firstNonEmptyString( meta?.previewSprite?.path, (meta as any)?.previewSpritePath, 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 const durationForSprite = normalizeDurationSeconds(meta?.durationSeconds) ?? normalizeDurationSeconds((j as any)?.durationSeconds) ?? normalizeDurationSeconds(durations[k]) 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 ?? 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 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 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 const scrubberCount = hasScrubberUi ? spriteCount : 0 const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = typeof activeScrubIndex === 'number' && scrubberCount > 1 ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1) : undefined 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 const showLive = isRunningJob(j) return (
{/* Thumb (GROSS) */}
onOpenPlayer?.(j)} onKeyDown={(e) => { if (!onOpenPlayer) return if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} onMouseEnter={() => { setRunningHover(true) setHoveredThumbKey(k) handleHoverPreviewKeyChange(k) }} onMouseLeave={() => { setRunningHover(false) setHoveredThumbKey(null) handleHoverPreviewKeyChange(null) clearScrubIndex(k) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) }} > {/* Clip area */}
{/* Base Preview (nur wenn Live NICHT aktiv) */} {!showLive ? (
stripHotPrefix(baseName(p))} durationSeconds={durations[k] ?? (j as any)?.durationSeconds} onDuration={handleDuration} variant="fill" showPopover={false} blur={blurPreviews} animated={ hideTeaserUnderOverlay ? false : teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false } animatedMode="teaser" animatedTrigger="always" muted={previewMuted} popoverMuted={previewMuted} scrubProgressRatio={scrubProgressRatio} preferScrubProgress={typeof activeScrubIndex === 'number'} />
) : null} {/* Live HLS inline (statt Teaser) */} {showLive ? (
{/* ✅ LIVE badge oben links */}
Live
) : null} {/* Meta overlay bottom right */}
{size}
{/* Footer (wie Gallery) */}
{/* filename */}
{isHot ? ( HOT ) : null} {file}
{/* kleine Meta-Zeile (optional, aber nice) */}
Start:{' '} {fmtDateTime((j as any)?.startedAt as any)} {' · '} Status:{' '} {String((j as any)?.status ?? 'running')}
{/* ✅ actions: Stop Button wie im Player */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
{/* tags */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> {}} />
) })()}
) : null}
{/* Image viewer modal (unverändert) */} setImgViewer(null)} title={imgViewer?.alt || 'Bild'} width="max-w-4xl" layout="single" scroll="body" footer={
{imgViewer?.src ? ( In neuem Tab ) : null}
} >
{imgViewer?.src ? ( {imgViewer.alt ) : null}
) }