// frontend\src\components\ui\Player.tsx 'use client' import * as React from 'react' import type { RecordJob } from '../../types' import Card from './Card' import videojs from 'video.js' import type VideoJsPlayer from 'video.js/dist/types/player' import 'video.js/dist/video-js.css' import { createPortal } from 'react-dom' import { ArrowsPointingOutIcon, ArrowsPointingInIcon, XMarkIcon, } from '@heroicons/react/24/outline' import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy' import RecordJobActions from './RecordJobActions' import Button from './Button' import { apiUrl, apiFetch } from '../../lib/api' import LiveHlsVideo from './LiveHlsVideo' const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) const lower = (s: string) => (s || '').trim().toLowerCase() type StoredModelFlagsLite = { tags?: string favorite?: boolean liked?: boolean | null watching?: boolean | null } // Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt) 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) // stable dedupe (case-insensitive), aber original casing behalten const seen = new Set() const out: string[] = [] for (const p of parts) { const k = p.toLowerCase() if (seen.has(k)) continue seen.add(k) out.push(p) } // alphabetisch (case-insensitive) out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) return out } function formatDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '—' const totalSec = Math.floor(ms / 1000) const h = Math.floor(totalSec / 3600) const m = Math.floor((totalSec % 3600) / 60) const s = totalSec % 60 if (h > 0) return `${h}h ${m}m` if (m > 0) return `${m}m ${s}s` return `${s}s` } function formatBytes(bytes?: number | null): string { if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2 return `${v.toFixed(digits)} ${units[i]}` } function formatDateTime(v?: string | number | Date | null): string { if (!v) return '—' const d = v instanceof Date ? v : new Date(v) const t = d.getTime() if (!Number.isFinite(t)) return '—' return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } const pickNum = (...vals: any[]): number | null => { for (const v of vals) { const n = typeof v === 'string' ? Number(v) : v if (typeof n === 'number' && Number.isFinite(n) && n > 0) return n } return null } function formatFps(n?: number | null): string { if (!n || !Number.isFinite(n)) return '—' const digits = n >= 10 ? 0 : 2 return `${n.toFixed(digits)} fps` } function formatResolution(h?: number | null): string { if (!h || !Number.isFinite(h)) return '—' return `${Math.round(h)}p` } function parseDateFromOutput(output?: string): Date | null { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return null const stem = file.replace(/\.[^.]+$/, '') // model_MM_DD_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) return null const mm = Number(m[1]) const dd = Number(m[2]) const yyyy = Number(m[3]) const hh = Number(m[4]) const mi = Number(m[5]) const ss = Number(m[6]) if (![mm, dd, yyyy, hh, mi, ss].every((n) => Number.isFinite(n))) return null return new Date(yyyy, mm - 1, dd, hh, mi, ss) } const modelNameFromOutput = (output?: string) => { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') 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 } const sizeBytesOf = (job: RecordJob): number | null => { const anyJob = job as any const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null } function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } function useMediaQuery(query: string) { const [matches, setMatches] = React.useState(false) React.useEffect(() => { if (typeof window === 'undefined') return const mql = window.matchMedia(query) const onChange = () => setMatches(mql.matches) onChange() if (mql.addEventListener) mql.addEventListener('change', onChange) else mql.addListener(onChange) return () => { if (mql.removeEventListener) mql.removeEventListener('change', onChange) else mql.removeListener(onChange) } }, [query]) return matches } function installAbsoluteTimelineShim(p: any) { if (!p || p.__absTimelineShimInstalled) return p.__absTimelineShimInstalled = true // Originale Methoden sichern p.__origCurrentTime = p.currentTime.bind(p) p.__origDuration = p.duration.bind(p) // Helper: relative Zeit (innerhalb des aktuell geladenen Segments) setzen, // OHNE server-seek auszulösen. p.__setRelativeTime = (rel: number) => { try { p.__origCurrentTime(Math.max(0, rel || 0)) } catch {} } // currentTime(): GET => absolute Zeit, SET => absolute Zeit (-> server seek falls vorhanden) p.currentTime = function (v?: number) { const off = Number(this.__timeOffsetSec ?? 0) || 0 // SET (Seekbar / API) if (typeof v === 'number' && Number.isFinite(v)) { const abs = Math.max(0, v) // Wenn wir server-seek können: als absolute Zeit interpretieren if (typeof this.__serverSeekAbs === 'function') { this.__serverSeekAbs(abs) return abs } // Fallback: innerhalb aktueller Datei relativ setzen return this.__origCurrentTime(Math.max(0, abs - off)) } // GET const rel = Number(this.__origCurrentTime() ?? 0) || 0 return Math.max(0, off + rel) } // duration(): immer volle Original-Dauer zurückgeben, wenn bekannt p.duration = function () { const full = Number(this.__fullDurationSec ?? 0) || 0 if (full > 0) return full // Fallback: offset + segment-dauer const off = Number(this.__timeOffsetSec ?? 0) || 0 const relDur = Number(this.__origDuration() ?? 0) || 0 return Math.max(0, off + relDur) } } export type PlayerProps = { job: RecordJob expanded: boolean onClose: () => void onToggleExpand: () => void className?: string modelKey?: string // ✅ neu: ModelStore für Tags wie FinishedDownloads modelsByKey?: Record // states für Buttons isHot?: boolean isFavorite?: boolean isLiked?: boolean isWatching?: boolean // actions onKeep?: (job: RecordJob) => void | Promise onDelete?: (job: RecordJob) => void | Promise onToggleHot?: ( job: RecordJob ) => | void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise // ✅ neu: laufenden Download stoppen onStopJob?: (id: string) => void | Promise startMuted?: boolean startAtSec?: number } export default function Player({ job, expanded, onClose, onToggleExpand, modelKey, modelsByKey, isHot = false, isFavorite = false, isLiked = false, isWatching = false, onKeep, onDelete, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onStopJob, startMuted = DEFAULT_PLAYER_START_MUTED, startAtSec = 0 }: PlayerProps) { const title = React.useMemo( () => baseName(job.output?.trim() || '') || job.id, [job.output, job.id] ) const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output]) const playName = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output]) const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job]) const anyJob = job as any const [fullDurationSec, setFullDurationSec] = React.useState(() => { return ( Number((job as any)?.meta?.durationSeconds) || Number((job as any)?.durationSeconds) || 0 ) }) const [metaReady, setMetaReady] = React.useState(() => { // live ist egal, finished: erst mal false (wir holen gleich) return job.status === 'running' }) const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({ h: 0, fps: null, })) // ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running") const isRunning = job.status === 'running' const [hlsReady, setHlsReady] = React.useState(false) const isLive = isRunning && hlsReady // ✅ Backend erwartet "id=" (nicht "name=") // running: echte job.id (jobs-map lookup) // finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped) const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName]) const previewId = React.useMemo( () => (isRunning ? job.id : finishedStem || job.id), [isRunning, job.id, finishedStem] ) React.useEffect(() => { if (isRunning) return if (fullDurationSec > 0) return const fileName = baseName(job.output?.trim() || '') if (!fileName) return let alive = true const ctrl = new AbortController() ;(async () => { try { // ✅ Backend-Endpoint existiert bei dir bereits: /api/record/done/meta // Ich gebe hier file mit, weil du finished oft darüber mapst. const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`) const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' }) if (!res.ok) return const j = await res.json() const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0 if (!alive || dur <= 0) return setFullDurationSec(dur) // ✅ Video.js Duration-Shim nachträglich füttern + UI refreshen const p: any = playerRef.current if (p && !p.isDisposed?.()) { try { p.__fullDurationSec = dur p.trigger?.('durationchange') p.trigger?.('timeupdate') } catch {} } } catch {} })() return () => { alive = false ctrl.abort() } }, [isRunning, fullDurationSec, job.output]) const isHotFile = fileRaw.startsWith('HOT ') const model = React.useMemo(() => { const k = (modelKey || '').trim() return k ? k : modelNameFromOutput(job.output) }, [modelKey, job.output]) const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw]) const runtimeLabel = React.useMemo(() => { const sec = Number(fullDurationSec || 0) || 0 return sec > 0 ? formatDuration(sec * 1000) : '—' }, [fullDurationSec]) // Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit const dateLabel = React.useMemo(() => { const fromName = parseDateFromOutput(job.output) if (fromName) return formatDateTime(fromName) const fallback = anyJob.startedAt ?? anyJob.endedAt ?? anyJob.createdAt ?? anyJob.fileCreatedAt ?? anyJob.ctime ?? null const d = fallback ? new Date(fallback) : null return formatDateTime(d && Number.isFinite(d.getTime()) ? d : null) }, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime]) // ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey) const effectiveModelKey = React.useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model]) const tags = React.useMemo(() => { const flags = modelsByKey?.[effectiveModelKey] return parseTags(flags?.tags) }, [modelsByKey, effectiveModelKey]) // Vorschaubild oben const previewA = React.useMemo( () => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=thumbs.webp`), [previewId] ) // ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben const liveHlsSrc = React.useMemo( () => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&play=1&file=index_hq.m3u8`), [previewId] ) const [previewSrc, setPreviewSrc] = React.useState(previewA) React.useEffect(() => { setPreviewSrc(previewA) }, [previewA]) const videoH = React.useMemo( () => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height), [metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height] ) const fps = React.useMemo( () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate), [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate] ) const [intrH, setIntrH] = React.useState(null) const resolutionLabel = React.useMemo(() => formatResolution(intrH ?? videoH), [intrH, videoH]) const fpsLabel = React.useMemo(() => formatFps(fps), [fps]) React.useEffect(() => { const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [onClose]) const hlsIndexUrl = React.useMemo(() => { const u = `/api/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1` return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u) }, [previewId, isRunning]) React.useEffect(() => { if (!isRunning) { setHlsReady(false) return } let alive = true const ctrl = new AbortController() setHlsReady(false) const poll = async () => { for (let i = 0; i < 120 && alive && !ctrl.signal.aborted; i++) { try { const res = await apiFetch(hlsIndexUrl, { method: 'GET', cache: 'no-store', signal: ctrl.signal, headers: { 'cache-control': 'no-cache' }, }) if (res.ok) { const text = await res.text() // ✅ muss wirklich wie eine m3u8 aussehen und mindestens 1 Segment enthalten const hasM3u = text.includes('#EXTM3U') const hasSegment = /#EXTINF:/i.test(text) || /\.ts(\?|$)/i.test(text) || /\.m4s(\?|$)/i.test(text) if (hasM3u && hasSegment) { if (alive) setHlsReady(true) return } } } catch {} await new Promise((r) => setTimeout(r, 500)) } } poll() return () => { alive = false ctrl.abort() } }, [isRunning, hlsIndexUrl]) const buildVideoSrc = React.useCallback( (params: { file?: string; id?: string }) => { const qp = new URLSearchParams() if (params.file) qp.set('file', params.file) if (params.id) qp.set('id', params.id) return apiUrl(`/api/record/video?${qp.toString()}`) }, [] ) const media = React.useMemo(() => { // ✅ Live wird NICHT mehr über Video.js gespielt if (isRunning) return { src: '', type: '' } // ✅ Warten bis meta.json existiert + Infos geladen if (!metaReady) return { src: '', type: '' } const file = baseName(job.output?.trim() || '') if (file) { const ext = file.toLowerCase().split('.').pop() const type = ext === 'mp4' ? 'video/mp4' : ext === 'ts' ? 'video/mp2t' : 'application/octet-stream' return { src: buildVideoSrc({ file }), type } } return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' } }, [isRunning, metaReady, job.output, job.id, buildVideoSrc]) const containerRef = React.useRef(null) const playerRef = React.useRef(null) const videoNodeRef = React.useRef(null) const [mounted, setMounted] = React.useState(false) const updateIntrinsicDims = React.useCallback(() => { const p: any = playerRef.current if (!p || p.isDisposed?.()) return const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0 if (typeof h === 'number' && h > 0 && Number.isFinite(h)) { setIntrH(h) } }, []) const captureGhostFrame = React.useCallback(() => { try { // Bevorzugt das echte HTMLVideoElement von Video.js const v = (playerRef.current as any)?.tech?.(true)?.el?.() || (playerRef.current as any)?.el?.()?.querySelector?.('video.vjs-tech') || videoNodeRef.current if (!v || !(v instanceof HTMLVideoElement)) return null // Muss Daten haben const vw = Number(v.videoWidth || 0) const vh = Number(v.videoHeight || 0) if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return null // Canvas wiederverwenden let canvas = ghostFrameCanvasRef.current if (!canvas) { canvas = document.createElement('canvas') ghostFrameCanvasRef.current = canvas } // Kleine Größe reicht für Ghost (Performance) const MAX_W = 640 const scale = Math.min(1, MAX_W / vw) const cw = Math.max(1, Math.round(vw * scale)) const ch = Math.max(1, Math.round(vh * scale)) if (canvas.width !== cw) canvas.width = cw if (canvas.height !== ch) canvas.height = ch const ctx = canvas.getContext('2d', { alpha: false }) if (!ctx) return null // Frame zeichnen ctx.drawImage(v, 0, 0, cw, ch) // toDataURL kann bei CORS/tainted canvas werfen return canvas.toDataURL('image/jpeg', 0.78) } catch { return null } }, []) const playbackKey = React.useMemo(() => { return baseName(job.output?.trim() || '') || job.id }, [job.output, job.id]) const normalizedStartAtSec = React.useMemo(() => { const n = Number(startAtSec) return Number.isFinite(n) && n >= 0 ? n : 0 }, [startAtSec]) // Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben const appliedStartSeekRef = React.useRef('') React.useEffect(() => { if (isRunning) { setMetaReady(true) return } const fileName = baseName(job.output?.trim() || '') if (!fileName) { // wenn kein file → fail-open setMetaReady(true) return } let alive = true const ctrl = new AbortController() setMetaReady(false) ;(async () => { // Poll bis metaExists=true (oder fail-open nach N Versuchen) for (let i = 0; i < 80 && alive && !ctrl.signal.aborted; i++) { try { const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`) const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' }) if (res.ok) { const j = await res.json() const exists = Boolean(j?.metaExists) const dur = Number(j?.durationSeconds || 0) || 0 const h = Number(j?.height || 0) || 0 const fps = Number(j?.fps || 0) || 0 // ✅ Infos neu in den Player-State übernehmen if (dur > 0) { setFullDurationSec(dur) const p: any = playerRef.current if (p && !p.isDisposed?.()) { try { p.__fullDurationSec = dur p.trigger?.('durationchange') p.trigger?.('timeupdate') } catch {} } } if (h > 0) { setMetaDims({ h, fps: fps > 0 ? fps : null }) } if (exists) { setMetaReady(true) return } } } catch {} await new Promise((r) => setTimeout(r, 250)) } // fail-open (damit der Player nicht “für immer” blockiert) if (alive) setMetaReady(true) })() return () => { alive = false ctrl.abort() } }, [isRunning, playbackKey, job.output]) // ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender const [, setVvTick] = React.useState(0) React.useEffect(() => { if (typeof window === 'undefined') return const vv = window.visualViewport if (!vv) return const bump = () => setVvTick((x) => x + 1) bump() vv.addEventListener('resize', bump) vv.addEventListener('scroll', bump) window.addEventListener('resize', bump) window.addEventListener('orientationchange', bump) return () => { vv.removeEventListener('resize', bump) vv.removeEventListener('scroll', bump) window.removeEventListener('resize', bump) window.removeEventListener('orientationchange', bump) } }, []) const [controlBarH, setControlBarH] = React.useState(30) const [portalTarget, setPortalTarget] = React.useState(null) const mini = !expanded type WinRect = { x: number; y: number; w: number; h: number } type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' const isDesktop = useMediaQuery('(min-width: 640px)') const miniDesktop = mini && isDesktop const usePortal = expanded || miniDesktop const WIN_KEY = 'player_window_v1' const DEFAULT_W = 420 const DEFAULT_H = 280 const MARGIN = 12 const MIN_W = 320 const MIN_H = 200 React.useEffect(() => { if (!mounted) return const p = playerRef.current if (!p || (p as any).isDisposed?.()) return const root = p.el() as HTMLElement | null if (!root) return const bar = root.querySelector('.vjs-control-bar') as HTMLElement | null if (!bar) return const update = () => { const h = Math.round(bar.getBoundingClientRect().height || 0) if (h > 0) setControlBarH(h) } update() let ro: ResizeObserver | null = null if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(update) ro.observe(bar) } window.addEventListener('resize', update) return () => { window.removeEventListener('resize', update) ro?.disconnect() } }, [mounted, expanded]) React.useEffect(() => setMounted(true), []) React.useEffect(() => { if (!usePortal) { setPortalTarget(null) return } let el = document.getElementById('player-root') as HTMLElement | null if (!el) { el = document.createElement('div') el.id = 'player-root' } // Desktop / Expanded: im Top-Layer (Dialog) oder body let host: HTMLElement | null = null if (isDesktop) { const dialogs = Array.from(document.querySelectorAll('dialog[open]')) as HTMLElement[] host = dialogs.length ? dialogs[dialogs.length - 1] : null } host = host ?? document.body host.appendChild(el) el.style.position = 'relative' el.style.zIndex = '2147483647' setPortalTarget(el) }, [isDesktop, usePortal]) React.useEffect(() => { const p: any = playerRef.current if (!p || p.isDisposed?.()) return if (isRunning) return // live nutzt Video.js nicht installAbsoluteTimelineShim(p) const fileName = baseName(job.output?.trim() || '') if (!fileName) return // volle Dauer: nimm was du hast (durationSeconds ist bei finished normalerweise da) const knownFull = Number(fullDurationSec || 0) || 0 if (knownFull > 0) p.__fullDurationSec = knownFull // absolute server-seek p.__serverSeekAbs = (absSec: number) => { const abs = Math.max(0, Number(absSec) || 0) try { p.__origCurrentTime?.(abs) try { p.trigger?.('timeupdate') } catch {} } catch { try { p.currentTime?.(abs) } catch {} } } return () => { try { delete p.__serverSeekAbs } catch {} } }, [job.output, isRunning]) React.useLayoutEffect(() => { if (!mounted) return if (!containerRef.current) return if (playerRef.current) return if (isRunning) return // ✅ neu: für Live keinen Video.js mounten if (!metaReady) return const videoEl = document.createElement('video') videoEl.className = 'video-js vjs-big-play-centered w-full h-full' videoEl.setAttribute('playsinline', 'true') containerRef.current.appendChild(videoEl) videoNodeRef.current = videoEl const p = videojs(videoEl, { autoplay: true, muted: startMuted, controls: true, preload: 'metadata', playsinline: true, responsive: true, fluid: false, fill: true, liveui: false, html5: { vhs: { lowLatencyMode: true }, }, inactivityTimeout: 0, controlBar: { skipButtons: { backward: 10, forward: 10 }, volumePanel: { inline: false }, children: [ 'skipBackward', 'playToggle', 'skipForward', 'volumePanel', 'currentTimeDisplay', 'timeDivider', 'durationDisplay', 'progressControl', 'spacer', 'playbackRateMenuButton', 'fullscreenToggle', ], }, playbackRates: [0.5, 1, 1.25, 1.5, 2], }) playerRef.current = p p.one('loadedmetadata', () => { updateIntrinsicDims() }) p.userActive(true) p.on('userinactive', () => p.userActive(true)) return () => { try { if (playerRef.current) { playerRef.current.dispose() playerRef.current = null } } finally { if (videoNodeRef.current) { videoNodeRef.current.remove() videoNodeRef.current = null } } } }, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims]) React.useEffect(() => { const p = playerRef.current if (!p || (p as any).isDisposed?.()) return const el = p.el() as HTMLElement | null if (!el) return el.classList.toggle('is-live-download', Boolean(isLive)) }, [isLive]) const releaseMedia = React.useCallback(() => { const p = playerRef.current if (!p || (p as any).isDisposed?.()) return try { p.pause() ;(p as any).reset?.() } catch {} try { p.src({ src: '', type: 'video/mp4' } as any) ;(p as any).load?.() } catch {} }, []) const seekPlayerToAbsolute = React.useCallback((absSec: number) => { const p: any = playerRef.current if (!p || p.isDisposed?.()) return const target = Math.max(0, Number(absSec) || 0) try { // Shim ist installiert -> p.currentTime(...) interpretiert absolute Zeit korrekt const dur = Number(p.duration?.() ?? 0) const maxSeek = Number.isFinite(dur) && dur > 0 ? Math.max(0, dur - 0.05) : target p.currentTime(Math.min(target, maxSeek)) p.trigger?.('timeupdate') } catch { try { p.currentTime(target) } catch {} } }, []) React.useEffect(() => { if (!mounted) return if (!isRunning && !metaReady) { releaseMedia() return } const p = playerRef.current if (!p || (p as any).isDisposed?.()) return const t = p.currentTime() || 0 p.muted(startMuted) if (!media.src) { try { p.pause() ;(p as any).reset?.() p.error(null as any) // Video.js Error-State leeren } catch {} return } ;(p as any).__timeOffsetSec = 0 // volle Dauer kennen wir bei finished meistens schon: const knownFull = Number(fullDurationSec || 0) || 0 ;(p as any).__fullDurationSec = knownFull // ✅ NICHT neu setzen, wenn Source identisch ist (verhindert "cancelled" durch unnötige Reloads) const curSrc = String((p as any).currentSrc?.() || '') // ✅ immer zurücksetzen, sobald der Effekt für diese media.src läuft // (auch wenn wir die gleiche Source behalten) appliedStartSeekRef.current = '' if (curSrc && curSrc === media.src) { const ret = p.play?.() if (ret && typeof (ret as any).catch === 'function') (ret as Promise).catch(() => {}) return } p.src({ src: media.src, type: media.type }) const tryPlay = () => { const ret = p.play?.() if (ret && typeof (ret as any).catch === 'function') { ;(ret as Promise).catch(() => {}) } } p.one('loadedmetadata', () => { if ((p as any).isDisposed?.()) return updateIntrinsicDims() // ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration()) try { const knownFull = Number(fullDurationSec || 0) || 0 if (knownFull > 0) (p as any).__fullDurationSec = knownFull } catch {} try { p.playbackRate(1) } catch {} const isHls = /mpegurl/i.test(media.type) // ✅ initiale Position wiederherstellen, aber RELATIV (ohne server-seek) if (t > 0 && !isHls) { try { const off = Number((p as any).__timeOffsetSec ?? 0) || 0 const rel = Math.max(0, t - off) ;(p as any).__setRelativeTime?.(rel) } catch {} } try { p.trigger?.('timeupdate') } catch {} tryPlay() }) tryPlay() }, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia]) React.useEffect(() => { if (!mounted) return if (isRunning) return // Live spielt nicht über Video.js if (!metaReady) return if (!media.src) return const p: any = playerRef.current if (!p || p.isDisposed?.()) return // Nur seeken, wenn wirklich eine Startzeit angefordert wurde if (!(normalizedStartAtSec > 0)) { appliedStartSeekRef.current = '' return } const seekSig = `${playbackKey}|${media.src}|${normalizedStartAtSec.toFixed(3)}` if (appliedStartSeekRef.current === seekSig) return let cancelled = false const apply = () => { if (cancelled) return const pp: any = playerRef.current if (!pp || pp.isDisposed?.()) return // ✅ nur seeken, wenn die AKTUELLE source wirklich geladen ist const currentSrc = String(pp.currentSrc?.() || '') if (!currentSrc || currentSrc !== media.src) return // readyState >= 1 => metadata verfügbar const techEl = pp.tech?.(true)?.el?.() || pp.el?.()?.querySelector?.('video.vjs-tech') const readyState = techEl instanceof HTMLVideoElement ? Number(techEl.readyState || 0) : 0 if (readyState < 1) return seekPlayerToAbsolute(normalizedStartAtSec) appliedStartSeekRef.current = seekSig try { const ret = pp.play?.() if (ret && typeof ret.catch === 'function') ret.catch(() => {}) } catch {} } // ✅ Erst versuchen (falls schon geladen) apply() if (appliedStartSeekRef.current === seekSig) return // ✅ Dann auf Events warten (neue Source lädt noch) const onLoaded = () => apply() p.one?.('loadedmetadata', onLoaded) p.one?.('canplay', onLoaded) p.one?.('durationchange', onLoaded) // Extra fallback (manche Browser/Event-Reihenfolgen zickig) const t1 = window.setTimeout(apply, 0) const t2 = window.setTimeout(apply, 120) return () => { cancelled = true window.clearTimeout(t1) window.clearTimeout(t2) try { p.off?.('loadedmetadata', onLoaded) } catch {} try { p.off?.('canplay', onLoaded) } catch {} try { p.off?.('durationchange', onLoaded) } catch {} } }, [ mounted, isRunning, metaReady, media.src, playbackKey, normalizedStartAtSec, seekPlayerToAbsolute, ]) React.useEffect(() => { if (!mounted) return const p = playerRef.current if (!p || (p as any).isDisposed?.()) return const onErr = () => { if (job.status === 'running') setHlsReady(false) } p.on('error', onErr) return () => { try { p.off('error', onErr) } catch {} } }, [mounted, job.status]) React.useEffect(() => { const p = playerRef.current if (!p || (p as any).isDisposed?.()) return queueMicrotask(() => p.trigger('resize')) }, [expanded]) React.useEffect(() => { const onRelease = (ev: Event) => { const detail = (ev as CustomEvent<{ file?: string }>).detail const file = (detail?.file ?? '').trim() if (!file) return const current = baseName(job.output?.trim() || '') if (current && current === file) releaseMedia() } window.addEventListener('player:release', onRelease as EventListener) return () => window.removeEventListener('player:release', onRelease as EventListener) }, [job.output, releaseMedia]) React.useEffect(() => { const onCloseIfFile = (ev: Event) => { const detail = (ev as CustomEvent<{ file?: string }>).detail const file = (detail?.file ?? '').trim() if (!file) return const current = baseName(job.output?.trim() || '') if (current && current === file) { releaseMedia() onClose() } } window.addEventListener('player:close', onCloseIfFile as EventListener) return () => window.removeEventListener('player:close', onCloseIfFile as EventListener) }, [job.output, releaseMedia, onClose]) const getViewport = () => { if (typeof window === 'undefined') return { w: 0, h: 0, ox: 0, oy: 0, bottomInset: 0 } const vv = window.visualViewport if (vv && Number.isFinite(vv.width) && Number.isFinite(vv.height)) { const w = Math.floor(vv.width) const h = Math.floor(vv.height) const ox = Math.floor(vv.offsetLeft || 0) const oy = Math.floor(vv.offsetTop || 0) // Space below the visual viewport (Safari bottom bar / keyboard) const bottomInset = Math.max(0, Math.floor(window.innerHeight - (vv.height + vv.offsetTop))) return { w, h, ox, oy, bottomInset } } const de = document.documentElement const w = de?.clientWidth || window.innerWidth const h = de?.clientHeight || window.innerHeight return { w, h, ox: 0, oy: 0, bottomInset: 0 } } const prevViewportRef = React.useRef<{ w: number; h: number } | null>(null) React.useEffect(() => { if (typeof window === 'undefined') return const { w, h } = getViewport() prevViewportRef.current = { w, h } }, []) const getVideoAspectRatio = React.useCallback(() => { // bevorzugt echtes Video (intrinsic), dann Meta, fallback 16:9 const anyJob = job as any const w = pickNum( (playerRef.current as any)?.videoWidth?.(), anyJob.videoWidth, anyJob.width, anyJob.meta?.width ) ?? 0 const h = pickNum( intrH, (playerRef.current as any)?.videoHeight?.(), anyJob.videoHeight, anyJob.height, anyJob.meta?.height ) ?? 0 if (w > 0 && h > 0) return w / h // Fallback wenn nur Höhe bekannt ist // (lieber stabiler fallback als kaputt) return 16 / 9 }, [job, intrH]) const clampMiniRect = React.useCallback( (r: { x: number; y: number; w: number; h: number }) => { if (typeof window === 'undefined') return r const ratio = getVideoAspectRatio() const BAR_H = 30 // gewünschter fixer Platz unter dem Video für die Controlbar const { w: vw, h: vh } = getViewport() const maxW = vw - MARGIN * 2 // Höhe darf nur so groß werden, dass Video + 30px reinpasst const maxVideoH = Math.max(1, vh - MARGIN * 2 - BAR_H) const minVideoH = Math.max(1, MIN_H - BAR_H) let w = Math.max(MIN_W, Math.min(r.w, maxW)) let videoH = w / ratio if (videoH < minVideoH) { videoH = minVideoH w = videoH * ratio } if (videoH > maxVideoH) { videoH = maxVideoH w = videoH * ratio } // final width nochmal an viewport clampen if (w > maxW) { w = maxW videoH = w / ratio } const h = Math.round(videoH + BAR_H) const x = Math.max(MARGIN, Math.min(r.x, vw - w - MARGIN)) const y = Math.max(MARGIN, Math.min(r.y, vh - h - MARGIN)) return { x, y, w: Math.round(w), h } }, [getVideoAspectRatio] ) const loadRect = React.useCallback(() => { if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H } try { const raw = window.localStorage.getItem(WIN_KEY) if (raw) { const v = JSON.parse(raw) as Partial<{ x: number; y: number; w: number; h: number }> if (typeof v.x === 'number' && typeof v.y === 'number' && typeof v.w === 'number' && typeof v.h === 'number') { return clampMiniRect({ x: v.x, y: v.y, w: v.w, h: v.h }) } } } catch {} const { w: vw, h: vh } = getViewport() const w = DEFAULT_W const h = DEFAULT_H // wird gleich in clampMiniRect korrekt berechnet const x = Math.max(MARGIN, vw - w - MARGIN) const y = Math.max(MARGIN, vh - h - MARGIN) return clampMiniRect({ x, y, w, h }) }, [clampMiniRect]) const [win, setWin] = React.useState(() => loadRect()) const isNarrowMini = miniDesktop && win.w < 380 const saveRect = React.useCallback((r: WinRect) => { if (typeof window === 'undefined') return try { window.localStorage.setItem(WIN_KEY, JSON.stringify(r)) } catch {} }, []) const winRef = React.useRef(win) React.useEffect(() => { winRef.current = win }, [win]) React.useEffect(() => { if (!miniDesktop) return setWin(loadRect()) }, [miniDesktop, loadRect]) React.useEffect(() => { if (!miniDesktop) return const onResize = () => { const prev = prevViewportRef.current const { w: newVw, h: newVh } = getViewport() setWin((r) => { // Falls wir keinen vorherigen Viewport haben: einfach clampen if (!prev) { return clampMiniRect(r) } const EDGE_SNAP = 24 // ✅ Kantenabstand gegen den VORHERIGEN Viewport prüfen const leftDist = r.x - MARGIN const rightDist = (prev.w - MARGIN) - (r.x + r.w) const bottomDist = (prev.h - MARGIN) - (r.y + r.h) const wasDockedLeft = Math.abs(leftDist) <= EDGE_SNAP const wasDockedRight = Math.abs(rightDist) <= EDGE_SNAP const wasDockedBottom = Math.abs(bottomDist) <= EDGE_SNAP let next = clampMiniRect(r) if (wasDockedBottom) { next = { ...next, y: Math.max(MARGIN, newVh - next.h - MARGIN) } } if (wasDockedRight) { next = { ...next, x: Math.max(MARGIN, newVw - next.w - MARGIN) } } else if (wasDockedLeft) { next = { ...next, x: MARGIN } } return clampMiniRect(next) }) // ✅ neuen Viewport für das nächste Resize merken prevViewportRef.current = { w: newVw, h: newVh } } // Falls der Effect neu aktiviert wird (z.B. Wechsel auf Desktop), initialen Viewport setzen prevViewportRef.current = (() => { const { w, h } = getViewport() return { w, h } })() window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [miniDesktop, clampMiniRect]) // Video.js resize triggern, wenn sich Fenstergröße ändert const vjsResizeRafRef = React.useRef(null) React.useEffect(() => { const p = playerRef.current if (!p || (p as any).isDisposed?.()) return if (vjsResizeRafRef.current != null) cancelAnimationFrame(vjsResizeRafRef.current) vjsResizeRafRef.current = requestAnimationFrame(() => { vjsResizeRafRef.current = null try { p.trigger('resize') } catch {} }) return () => { if (vjsResizeRafRef.current != null) { cancelAnimationFrame(vjsResizeRafRef.current) vjsResizeRafRef.current = null } } }, [miniDesktop, win.w, win.h]) const [isResizing, setIsResizing] = React.useState(false) const [isDragging, setIsDragging] = React.useState(false) const [snapPreviewRect, setSnapPreviewRect] = React.useState(null) const [ghostFrameSrc, setGhostFrameSrc] = React.useState(null) const ghostFrameCanvasRef = React.useRef(null) // pointermove sehr häufig -> 1x pro Frame committen const dragRafRef = React.useRef(null) const pendingPosRef = React.useRef<{ x: number; y: number } | null>(null) const draggingRef = React.useRef(null) const applySnap = React.useCallback((r: WinRect): WinRect => { const { w: vw, h: vh } = getViewport() const leftEdge = MARGIN const rightEdge = vw - r.w - MARGIN const bottomEdge = vh - r.h - MARGIN const centerX = r.x + r.w / 2 const dockLeft = centerX < vw / 2 return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge } }, []) const onDragMove = React.useCallback( (ev: PointerEvent) => { const s = draggingRef.current if (!s) return const dx = ev.clientX - s.sx const dy = ev.clientY - s.sy const start = s.start const next = clampMiniRect({ x: start.x + dx, y: start.y + dy, w: start.w, h: start.h }) pendingPosRef.current = { x: next.x, y: next.y } // ✅ Snap-Vorschau (wohin beim Loslassen gedockt wird) setSnapPreviewRect(applySnap(next)) if (dragRafRef.current == null) { dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = null const p = pendingPosRef.current if (!p) return setWin((cur) => ({ ...cur, x: p.x, y: p.y })) }) } }, [clampMiniRect, applySnap] ) const endDrag = React.useCallback(() => { if (!draggingRef.current) return setIsDragging(false) setSnapPreviewRect(null) if (dragRafRef.current != null) { cancelAnimationFrame(dragRafRef.current) dragRafRef.current = null } draggingRef.current = null window.removeEventListener('pointermove', onDragMove) window.removeEventListener('pointerup', endDrag) setWin((cur) => { const snapped = applySnap(clampMiniRect(cur)) queueMicrotask(() => saveRect(snapped)) return snapped }) }, [onDragMove, applySnap, clampMiniRect, saveRect]) const beginDrag = React.useCallback( (e: React.PointerEvent) => { if (!miniDesktop) return if (isResizing) return if (e.button !== 0) return e.preventDefault() e.stopPropagation() const start = winRef.current draggingRef.current = { sx: e.clientX, sy: e.clientY, start } setIsDragging(true) // ✅ möglichst echten aktuellen Frame für Ghost verwenden (Fallback bleibt previewSrc) const frame = captureGhostFrame() setGhostFrameSrc(frame) // ✅ sofortige Vorschau anzeigen setSnapPreviewRect(applySnap(start)) window.addEventListener('pointermove', onDragMove) window.addEventListener('pointerup', endDrag) }, [miniDesktop, isResizing, onDragMove, endDrag, applySnap, captureGhostFrame] ) // pointermove kommt extrem oft -> wir committen max. 1x pro Frame const resizeRafRef = React.useRef(null) const pendingRectRef = React.useRef(null) const resizingRef = React.useRef(null) const onResizeMove = React.useCallback( (ev: PointerEvent) => { const s = resizingRef.current if (!s) return const dx = ev.clientX - s.sx const dy = ev.clientY - s.sy const ratio = s.ratio const fromW = s.dir.includes('w') const fromE = s.dir.includes('e') const fromN = s.dir.includes('n') const fromS = s.dir.includes('s') let w = s.start.w let h = s.start.h let x = s.start.x let y = s.start.y const { w: vw, h: vh } = getViewport() const EDGE_SNAP = 24 const startRight = s.start.x + s.start.w const startBottom = s.start.y + s.start.h const anchoredRight = Math.abs(vw - MARGIN - startRight) <= EDGE_SNAP const anchoredBottom = Math.abs(vh - MARGIN - startBottom) <= EDGE_SNAP const fitFromW = (newW: number) => { newW = Math.max(MIN_W, newW) let newH = newW / ratio if (newH < MIN_H) { newH = MIN_H newW = newH * ratio } return { newW, newH } } const fitFromH = (newH: number) => { newH = Math.max(MIN_H, newH) let newW = newH * ratio if (newW < MIN_W) { newW = MIN_W newH = newW / ratio } return { newW, newH } } const isCorner = (fromE || fromW) && (fromN || fromS) if (isCorner) { const useWidth = Math.abs(dx) >= Math.abs(dy) if (useWidth) { const rawW = fromE ? s.start.w + dx : s.start.w - dx const { newW, newH } = fitFromW(rawW) w = newW h = newH } else { const rawH = fromS ? s.start.h + dy : s.start.h - dy const { newW, newH } = fitFromH(rawH) w = newW h = newH } if (fromW) x = s.start.x + (s.start.w - w) if (fromN) y = s.start.y + (s.start.h - h) } else if (fromE || fromW) { const rawW = fromE ? s.start.w + dx : s.start.w - dx const { newW, newH } = fitFromW(rawW) w = newW h = newH if (fromW) x = s.start.x + (s.start.w - w) y = anchoredBottom ? s.start.y + (s.start.h - h) : s.start.y } else if (fromN || fromS) { const rawH = fromS ? s.start.h + dy : s.start.h - dy const { newW, newH } = fitFromH(rawH) w = newW h = newH if (fromN) y = s.start.y + (s.start.h - h) if (anchoredRight) x = s.start.x + (s.start.w - w) else x = s.start.x } const next = clampMiniRect({ x, y, w, h }) pendingRectRef.current = next if (resizeRafRef.current == null) { resizeRafRef.current = requestAnimationFrame(() => { resizeRafRef.current = null const r = pendingRectRef.current if (r) setWin(r) }) } }, [clampMiniRect] ) const endResize = React.useCallback(() => { if (!resizingRef.current) return setIsResizing(false) setSnapPreviewRect(null) setGhostFrameSrc(null) if (resizeRafRef.current != null) { cancelAnimationFrame(resizeRafRef.current) resizeRafRef.current = null } resizingRef.current = null window.removeEventListener('pointermove', onResizeMove) window.removeEventListener('pointerup', endResize) saveRect(winRef.current) }, [onResizeMove, saveRect]) const beginResize = React.useCallback( (dir: ResizeDir) => (e: React.PointerEvent) => { if (!miniDesktop) return if (e.button !== 0) return e.preventDefault() e.stopPropagation() const start = winRef.current resizingRef.current = { dir, sx: e.clientX, sy: e.clientY, start, ratio: start.w / start.h } setIsResizing(true) window.addEventListener('pointermove', onResizeMove) window.addEventListener('pointerup', endResize) }, [miniDesktop, onResizeMove, endResize] ) const [canHover, setCanHover] = React.useState(false) const [chromeHover, setChromeHover] = React.useState(false) React.useEffect(() => { const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)') const update = () => setCanHover(Boolean(mq?.matches)) update() mq?.addEventListener?.('change', update) return () => mq?.removeEventListener?.('change', update) }, []) const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing) const [stopPending, setStopPending] = React.useState(false) React.useEffect(() => { if (job.status !== 'running') setStopPending(false) }, [job.id, job.status]) if (!mounted) return null if (usePortal && !portalTarget) return null const overlayBtn = 'inline-flex items-center justify-center rounded-md p-2 transition ' + 'bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 active:scale-[0.98] ' + 'dark:bg-black/45 dark:text-white dark:ring-white/10 dark:hover:bg-black/60 ' + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500' const phaseRaw = String((job as any).phase ?? '') const phase = phaseRaw.toLowerCase() const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving' const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending const footerRight = (
{isRunning ? ( <> onToggleWatch(j) : undefined} onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined} onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined} order={['watch', 'favorite', 'like', 'details']} className="gap-1 min-w-0 flex-1" /> ) : ( onToggleWatch(j) : undefined} onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined} onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined} onToggleHot={ onToggleHot ? async (j) => { releaseMedia() await new Promise((r) => setTimeout(r, 150)) await onToggleHot(j) await new Promise((r) => setTimeout(r, 0)) const p = playerRef.current if (p && !(p as any).isDisposed?.()) { const ret = p.play?.() if (ret && typeof (ret as any).catch === 'function') { ;(ret as Promise).catch(() => {}) } } } : undefined } onKeep={ onKeep ? async (j) => { releaseMedia() onClose() await new Promise((r) => setTimeout(r, 150)) await onKeep(j) } : undefined } onDelete={ onDelete ? async (j) => { releaseMedia() onClose() await new Promise((r) => setTimeout(r, 150)) await onDelete(j) } : undefined } order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']} className="gap-1 min-w-0 flex-1" /> )}
) const fullSize = expanded || miniDesktop const metaBottom = isRunning ? `calc(4px + env(safe-area-inset-bottom))` : `calc(${controlBarH + 2}px + env(safe-area-inset-bottom))` const topOverlayTop = miniDesktop ? 'top-2' : 'top-2' const showSideInfo = expanded && isDesktop const videoChrome = (
{ if (!miniDesktop || !canHover) return setChromeHover(true) }} onMouseLeave={() => { if (!miniDesktop || !canHover) return setChromeHover(false) }} >
{isRunning ? (
Live
) : (
)} {/* ✅ Top overlay */}
{showSideInfo ? null : footerRight}
{miniDesktop ? ( ) : null}
{model}
{file || title} {isHot || isHotFile ? ( HOT ) : null}
{resolutionLabel !== '—' ? ( {resolutionLabel} ) : null} {!isRunning ? ( {runtimeLabel} ) : null} {sizeLabel !== '—' ? {sizeLabel} : null}
) const sidePanel = (
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */} {/* eslint-disable-next-line @next/next/no-img-element */} { // Wenn Snapshot ungültig wäre, fällt ghostFrameSrc || previewSrc automatisch auf previewSrc zurück, // sobald ghostFrameSrc null ist. Hier kein State-Zwang nötig. }} />
{model}
{file || title}
{isRunning ? ( ) : null} onToggleWatch(j) : undefined} onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined} onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined} onToggleHot={ onToggleHot ? async (j) => { releaseMedia() await new Promise((r) => setTimeout(r, 150)) await onToggleHot(j) await new Promise((r) => setTimeout(r, 0)) const p = playerRef.current if (p && !(p as any).isDisposed?.()) { const ret = p.play?.() if (ret && typeof (ret as any).catch === 'function') { ;(ret as Promise).catch(() => {}) } } } : undefined } onKeep={ onKeep ? async (j) => { releaseMedia() onClose() await new Promise((r) => setTimeout(r, 150)) await onKeep(j) } : undefined } onDelete={ onDelete ? async (j) => { releaseMedia() onClose() await new Promise((r) => setTimeout(r, 150)) await onDelete(j) } : undefined } order={isRunning ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']} className="flex items-center justify-start gap-1" />
Status
{job.status}
Auflösung
{resolutionLabel}
FPS
{fpsLabel}
Laufzeit
{runtimeLabel}
Größe
{sizeLabel}
Datum
{dateLabel}
{tags.length ? (
{tags.map((t) => ( {t} ))}
) : ( )}
) const snapGhostEl = miniDesktop && isDragging && snapPreviewRect ? (