// frontend\src\components\ui\ModelDetails.tsx 'use client' import { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from 'react' import type { RecordJob } from '../../types' import Modal from './Modal' import Button from './Button' import RecordJobActions from './RecordJobActions' import Tabs, { type TabItem } from './Tabs' import { ArrowTopRightOnSquareIcon, ArrowPathIcon, HeartIcon as HeartOutlineIcon, StarIcon as StarOutlineIcon, VideoCameraIcon, CakeIcon, IdentificationIcon, LanguageIcon, LinkIcon, MapPinIcon, PhotoIcon, SparklesIcon, UsersIcon, 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' import { buildSpriteFrameStyle, parseJobMeta, readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' import ModelGenderIcon from './ModelGenderIcon' import CountryFlag, { normalizeCountryName } from './CountryFlag' 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 fmtBirthDate(v: string | Date | null | undefined) { if (!v) return '' const raw = String(v).trim() if (!raw) return '' const d = typeof v === 'string' ? new Date(v) : v if (!Number.isNaN(d.getTime())) { return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', }) } return raw } 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 ) } function heroOverlayPill(cls: string) { return cn( 'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold text-white', 'ring-1 backdrop-blur-md shadow-sm', '[text-shadow:0_1px_1px_rgba(0,0,0,0.45)]', cls ) } function heroStatusPill(show: string) { switch (show) { case 'public': return heroOverlayPill('bg-emerald-500/85 ring-emerald-300/25') case 'private': return heroOverlayPill('bg-fuchsia-500/85 ring-fuchsia-300/25') case 'hidden': return heroOverlayPill('bg-slate-900/70 ring-white/15') case 'away': return heroOverlayPill('bg-amber-500/85 ring-amber-300/25') case 'offline': return heroOverlayPill('bg-gray-700/85 ring-white/15') default: return heroOverlayPill('bg-black/55 ring-white/15') } } function heroNewPill() { return heroOverlayPill('bg-amber-500/90 ring-amber-300/25') } const previewBlurCls = (blur?: boolean) => blur ? 'blur-md scale-[1.03] brightness-90' : '' // ------ 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 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 roomStatus?: string isOnline?: boolean chatRoomUrl?: string imageUrl?: string } // ------ 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 roomStatus?: string isOnline?: boolean chatRoomUrl?: string imageUrl?: string lastOnlineAt?: string lastOfflineAt?: string lastRoomSyncAt?: 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 | { newFile?: string } | 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] = useState([]) const [room, setRoom] = useState(null) const [roomMeta, setRoomMeta] = useState | null>(null) const [bio, setBio] = useState(null) const [bioMeta, setBioMeta] = useState | null>(null) const [bioLoading, setBioLoading] = useState(false) const [done, setDone] = useState([]) const [doneLoading, setDoneLoading] = useState(false) const [running, setRunning] = useState([]) const [runningLoading, setRunningLoading] = useState(false) const runningReqSeqRef = useRef(0) const [, setBioRefreshSeq] = useState(0) const [imgViewer, setImgViewer] = useState<{ src: string; alt?: string } | null>(null) const [, setRunningHover] = useState(false) const [stopPending, setStopPending] = useState(false) const [doneTotalCount, setDoneTotalCount] = useState(0) const [donePage, setDonePage] = useState(1) const DONE_PAGE_SIZE = 4 const key = normalizeModelKey(modelKey) type TabKey = 'info' | 'downloads' | 'running' const [tab, setTab] = useState('info') const bioReqRef = useRef(null) // ===== Gallery UI State (wie FinishedDownloadsGalleryView) ===== const [durations, setDurations] = useState>({}) const [hoverTeaserKey, setHoverTeaserKey] = useState(null) const [teaserKey, setTeaserKey] = useState(null) const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = useState(null) const [scrubIndexByKey, setScrubIndexByKey] = useState>({}) const [hoveredThumbKey, setHoveredThumbKey] = useState(null) const lower = useCallback((s: string) => String(s ?? '').toLowerCase(), []) const deletingKeys = useMemo(() => new Set(), []) const keepingKeys = useMemo(() => new Set(), []) const removingKeys = useMemo(() => new Set(), []) const deletedKeys = useMemo(() => new Set(), []) useEffect(() => { if (!open) { bioReqRef.current?.abort() bioReqRef.current = null return } // nur beim Öffnen / Modelwechsel zurücksetzen setRoom(null) setRoomMeta(null) setBio(null) setBioMeta(null) setBioLoading(false) setDone([]) setDoneTotalCount(0) setDoneLoading(false) if (!Array.isArray(runningJobs)) { setRunning([]) setRunningLoading(false) } setStopPending(false) bioReqRef.current?.abort() bioReqRef.current = null }, [open, key]) // ✅ 1) Beim Öffnen sofort aus Cache rendern 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]) useEffect(() => { if (!open) return setStopPending(false) }, [open, key]) const refreshBio = 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]) useEffect(() => { if (open) return bioReqRef.current?.abort() bioReqRef.current = null }, [open]) const refetchModels = 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 = 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 = useRef(refetchDone) useEffect(() => { refetchDoneRef.current = refetchDone }, [refetchDone]) 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 = 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) useEffect(() => { if (!open) setBioRefreshSeq(0) }, [open]) const runningList = useMemo(() => { return Array.isArray(runningJobs) ? runningJobs : running }, [runningJobs, running]) useEffect(() => { if (!open) return setDonePage(1) }, [open, modelKey]) useEffect(() => { if (!open) return void refetchModels() }, [open, refetchModels]) // Done downloads (inkl. keep//) -> serverseitig paginiert laden useEffect(() => { if (!open || !key) return void refetchDone() }, [open, key, refetchDone]) // Running jobs 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 = useMemo(() => { if (!key) return null return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null }, [models, key]) const storedRoomFromSnap = 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 = 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 onlineStatusFetchedAt = model?.lastRoomSyncAt || model?.lastSeenOnlineAt || effectiveRoomMeta?.fetchedAt const doneMatches = done const runningMatches = 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 titleUsername = useMemo(() => { const raw = String( effectiveRoom?.username || model?.modelKey || key || '—' ).trim() if (!raw) return '—' return raw.startsWith('@') ? raw : `@${raw}` }, [effectiveRoom?.username, model?.modelKey, key]) const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || '' const heroImgFull = effectiveRoom?.image_url || heroImg const profileUsername = useMemo(() => { const raw = String( effectiveRoom?.username || model?.modelKey || key || '' ).trim() return raw.replace(/^@/, '').replace(/^\/+|\/+$/g, '') }, [effectiveRoom?.username, model?.modelKey, key]) const profileHref = useMemo(() => { if (!profileUsername) return '' return `https://chaturbate.com/${encodeURIComponent(profileUsername)}` }, [profileUsername]) const currentOnlineState = useMemo(() => { const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase() const isOnline = Boolean(model?.isOnline) const lastSeenOnline = model?.lastSeenOnline if (roomStatus === 'offline') return 'offline' if (lastSeenOnline === false) return 'offline' if (!isOnline && roomStatus === '') return '' if (roomStatus) return roomStatus return (effectiveRoom?.current_show || '').trim().toLowerCase() }, [model?.roomStatus, model?.isOnline, model?.lastSeenOnline, effectiveRoom?.current_show]) const showLabel = currentOnlineState const showPill = showLabel === 'public' ? 'Public' : showLabel === 'private' ? 'Private' : showLabel === 'hidden' ? 'Hidden' : showLabel === 'away' ? 'Away' : showLabel === 'offline' ? 'Offline' : showLabel || '' const bioLocation = (bio?.location || '').trim() const bioFollowers = bio?.follower_count const bioAge = bio?.display_age const bioLast = bio?.last_broadcast ? fmtDateTime(bio.last_broadcast) : '—' const about = stripHtmlToText(bio?.about_me) const wish = stripHtmlToText(bio?.wish_list) const effectiveLocationParts = useMemo(() => { const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim() const rawCountry = normalizeCountryName(effectiveRoom?.country) if (!rawLocation && !rawCountry) { return { locationText: '', countryText: '', } } const locationParts = rawLocation .split(',') .map((x) => x.trim()) .filter(Boolean) const lastPartRaw = locationParts[locationParts.length - 1] ?? '' const lastPartCountry = normalizeCountryName(lastPartRaw) // Fall 1: country separat vorhanden if (rawCountry) { if (locationParts.length >= 2 && lastPartCountry.toLowerCase() === rawCountry.toLowerCase()) { return { locationText: locationParts.slice(0, -1).join(', ').trim(), countryText: rawCountry, } } if (rawLocation.toLowerCase() === rawCountry.toLowerCase()) { return { locationText: rawLocation, countryText: '', } } return { locationText: rawLocation, countryText: rawCountry, } } // Fall 2: country NICHT separat vorhanden -> aus letztem location-Teil ableiten if (locationParts.length >= 2 && lastPartCountry && lastPartCountry !== lastPartRaw ? true : [ 'United States', 'United Kingdom', 'Germany', 'Austria', 'Switzerland', 'Netherlands', 'France', 'Italy', 'Spain', 'Poland', 'Romania', 'Hungary', 'Czechia', 'Slovakia', 'Ukraine', 'Russia', 'Turkey', 'Brazil', 'Colombia', 'Mexico', 'Canada', 'Japan', 'China', 'South Korea', 'North Korea', ].includes(lastPartCountry)) { return { locationText: locationParts.slice(0, -1).join(', ').trim(), countryText: lastPartCountry, } } return { locationText: rawLocation, countryText: '', } }, [effectiveRoom?.location, bioLocation, effectiveRoom?.country]) const effectiveLocationText = effectiveLocationParts.locationText const effectiveCountryText = effectiveLocationParts.countryText const effectiveLocationSubText = useMemo(() => { return effectiveCountryText || '' }, [effectiveCountryText]) const effectiveLocationTitle = useMemo(() => { if (effectiveLocationText && effectiveLocationSubText) { return `${effectiveLocationText} · ${effectiveLocationSubText}` } if (effectiveLocationText) return effectiveLocationText if (effectiveLocationSubText) return effectiveLocationSubText return '—' }, [effectiveLocationText, effectiveLocationSubText]) const effectiveAge = typeof bioAge === 'number' && Number.isFinite(bioAge) ? bioAge : typeof effectiveRoom?.age === 'number' && Number.isFinite(effectiveRoom.age) ? effectiveRoom.age : null const effectiveBirthday = useMemo(() => { const bioBirthday = String(bio?.display_birthday ?? '').trim() if (bioBirthday) return bioBirthday const roomBirthday = String(effectiveRoom?.birthday ?? '').trim() if (roomBirthday) return roomBirthday return '' }, [bio?.display_birthday, effectiveRoom?.birthday]) const effectiveAgeText = useMemo(() => { return effectiveAge != null ? String(effectiveAge) : '' }, [effectiveAge]) const effectiveBirthdayText = useMemo(() => { return fmtBirthDate(effectiveBirthday) }, [effectiveBirthday]) const effectiveAgeTitle = useMemo(() => { if (effectiveAgeText && effectiveBirthdayText) { return `${effectiveAgeText} · ${effectiveBirthdayText}` } if (effectiveAgeText) return effectiveAgeText if (effectiveBirthdayText) return effectiveBirthdayText return '—' }, [effectiveAgeText, effectiveBirthdayText]) 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 = 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: ReactNode label: string value: ReactNode }) => (
{icon} {label}
{value}
) const keyFor = 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 = 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 = useCallback( (job: RecordJob, segmentIndex: number, _segmentCount: number) => { const sprite = readPreviewSpriteInfo((job as any)?.meta) const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5 const startAtSec = Math.max(0, Math.floor(segmentIndex) * step) onOpenPlayer?.(job, startAtSec) }, [onOpenPlayer] ) const handleToggleFavoriteModel = 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 = 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 = 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]) useEffect(() => { if (!open) { setHoverTeaserKey(null) setTeaserKey(null) setHoveredModelPreviewKey(null) setHoveredThumbKey(null) setScrubIndexByKey({}) setDurations({}) } }, [open, key]) const handleDuration = useCallback( (job: RecordJob, seconds: number) => { const k = keyFor(job) setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds })) }, [keyFor] ) const handleHoverPreviewKeyChange = useCallback( (k: string | null) => { setHoverTeaserKey(k) if (teaserPlayback === 'hover') setTeaserKey(k) }, [teaserPlayback] ) const runtimeOf = 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 = 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 = useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), []) const setScrubIndexForKey = 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 = useCallback( (key: string) => setScrubIndexForKey(key, undefined), [setScrubIndexForKey] ) 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 }, ] const mobileFlagBtnBase = 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm transition hover:scale-[1.03] active:scale-[0.98]' const mobileFlagBtnInactive = 'bg-white/95 text-gray-700 ring-gray-300 shadow-md hover:bg-white dark:bg-black/35 dark:text-white dark:ring-white/20 dark:hover:bg-black/45' const hasLocationText = Boolean(effectiveLocationText) const hasLocationSubText = Boolean(effectiveLocationSubText) const showLocationTwoLines = hasLocationText && hasLocationSubText const effectiveLocationDisplayText = effectiveLocationText || effectiveLocationSubText || effectiveLocationTitle || '—' return ( {profileHref ? ( e.stopPropagation()} > {titleUsername} ) : (
{titleUsername}
)} } width="max-w-6xl" layout="split" scroll="right" className="z-[120]" leftWidthClass="lg:w-[320px]" rightBodyClassName="pt-0 sm:pt-2" titleRight={ tab === 'info' ? ( ) : null } left={
{/* ===================== */} {/* DESKTOP (sm:block) */} {/* ===================== */}
{/* Image */}
{heroImg ? ( ) : (
)}
{/* Pills */}
{showPill ? ( {showPill} ) : null} {effectiveRoom?.is_new ? ( NEW ) : null}
{/* Title */}
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
{profileHref ? ( e.stopPropagation()} > {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''} ) : (
{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
{effectiveLocationText || '—'} {effectiveLocationSubText ? ( {effectiveLocationSubText} ) : null}
Sprache
{effectiveRoom?.spoken_languages || '—'}
Online
{fmtHms(effectiveRoom?.seconds_online)}
Alter
{effectiveBirthdayText ? (
{effectiveAgeText || '—'} {effectiveBirthdayText}
) : ( {effectiveAgeText || '—'} )}
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 ? (
{}} className="w-full" />
) : (
Keine Tags vorhanden.
)}
} rightHeader={
{/* ===================== */} {/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */} {/* ===================== */}
{/* HERO Header (mobile) */}
{/* Hero Background */}
{heroImg ? ( ) : (
)} {/* Gradient overlay */}
{/* Top row: name/username links + action icons rechts */}
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} {showPill ? ( {showPill} ) : null} {effectiveRoom?.is_new ? ( NEW ) : null}
{profileHref ? ( e.stopPropagation()} > {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''} ) : (
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
)}
{/* Watched */} {/* Favorite */} {/* Like */}
{/* Bottom overlay content */}
{/* Quick stats im Hero */}
{showLocationTwoLines ? ( {effectiveLocationText} {effectiveLocationSubText} ) : ( {effectiveLocationDisplayText} )} {effectiveBirthdayText ? ( {effectiveAgeText || '—'} {effectiveBirthdayText} ) : ( {effectiveAgeText || '—'} )} {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'} {fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
{/* Tags (mobile, compact row) */} {allTags.length ? (
Tags ({allTags.length})
{}} className="w-full" expandMode="dropdown" dropdownMaxHeightClassName="max-h-[50vh]" />
) : null}
{/* ===================== */} {/* (dein bisheriger Header) Row 1: Meta + Actions */} {/* ===================== */}
{/* Meta */}
{key ? (
{onlineStatusFetchedAt ? ( Online-Stand: {fmtDateTime(onlineStatusFetchedAt)} ) : null} {bioMeta?.fetchedAt ? ( · Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)} ) : null} {model?.lastSeenOnlineAt ? ( · Zuletzt gesehen:{' '} {model.lastSeenOnline == null ? '—' : model.lastSeenOnline ? 'online' : 'offline'} {' '} ({fmtDateTime(model.lastSeenOnlineAt)}) ) : 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)} 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 = heroImgFull || heroImg || undefined // -------- Sprite/Scrubber Setup (nur wenn echte Sprite-Metadaten existieren) -------- const sprite = readPreviewSpriteInfo((j as any)?.meta) const spriteUrl = sprite.url const hasScrubberUi = sprite.hasScrubberUi const hasSpriteScrubber = sprite.hasSpriteScrubber const scrubberCount = hasScrubberUi ? sprite.count : 0 const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0 const hasScrubber = hasScrubberUi const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) const spriteFrameStyle = buildSpriteFrameStyle({ spriteUrl, spriteCols: sprite.cols, spriteRows: sprite.rows, spriteCount: sprite.count, activeIndex: activeScrubIndex, }) 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'} forceActive={ !hideTeaserUnderOverlay && ( teaserPlayback === 'all' || (teaserPlayback === 'hover' && hoveredThumbKey === k) ) } teaserPreloadEnabled />
{/* 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 = heroImgFull || heroImg || undefined // -------- Sprite/Scrubber Setup (nur wenn echte Sprite-Metadaten existieren) -------- const sprite = readPreviewSpriteInfo((j as any)?.meta) const spriteUrl = sprite.url const hasScrubberUi = sprite.hasScrubberUi const scrubberCount = hasScrubberUi ? sprite.count : 0 const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) const spriteFrameStyle = buildSpriteFrameStyle({ spriteUrl, spriteCols: sprite.cols, spriteRows: sprite.rows, spriteCount: sprite.count, activeIndex: activeScrubIndex, }) 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'} forceActive={ !hideTeaserUnderOverlay && ( teaserPlayback === 'all' || (teaserPlayback === 'hover' && hoveredThumbKey === k) ) } teaserPreloadEnabled />
) : 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}
) }