// 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' // ✅ Video.js Gear Menu (nur Quality) function ensureGearControlRegistered() { const vjsAny = videojs as any if (vjsAny.__gearControlRegistered) return vjsAny.__gearControlRegistered = true const MenuButton = videojs.getComponent('MenuButton') const MenuItem = videojs.getComponent('MenuItem') class GearMenuItem extends (MenuItem as any) { private _onSelect?: () => void constructor(player: any, options: any) { super(player, options) this._onSelect = options?.onSelect this.on('click', () => this._onSelect?.()) } } class GearMenuButton extends (MenuButton as any) { constructor(player: any, options: any) { super(player, options) this.controlText('Settings') // Icon ersetzen: ⚙️ SVG in den Placeholder const el = this.el() as HTMLElement const ph = el.querySelector('.vjs-icon-placeholder') as HTMLElement | null if (ph) { ph.innerHTML = ` ` } // ✅ Nur Quality refreshen const p: any = this.player() p.on('gear:refresh', () => this.update()) } update() { try { super.update?.() } catch {} const p: any = this.player() const curQ = String(p.options_?.__gearQuality ?? 'auto') const autoApplied = String(p.options_?.__autoAppliedQuality ?? '') const items = (this.items || []) as any[] for (const it of items) { const kind = it?.options_?.__kind const val = it?.options_?.__value if (kind === 'quality') it.selected?.(String(val) === curQ) } // ✅ Auto-Label: "Auto (720p)" wenn Auto aktiv try { for (const it of items) { if (it?.options_?.__kind !== 'quality') continue if (String(it?.options_?.__value) !== 'auto') continue const el = it.el?.() as HTMLElement | null const text = el?.querySelector?.('.vjs-menu-item-text') as HTMLElement | null if (!text) continue if (curQ === 'auto' && autoApplied && autoApplied !== 'auto') { text.textContent = `Auto (${autoApplied})` } else { text.textContent = 'Auto' } } } catch {} } createItems() { const player: any = this.player() const items: any[] = [] // ✅ KEIN "Quality" Header mehr const qualities = (player.options_?.gearQualities || [ 'auto', '1080p', '720p', '480p', ]) as string[] const currentQ = String(player.options_?.__gearQuality ?? 'auto') for (const q of qualities) { const label = q === 'auto' ? 'Auto' : q === '2160p' ? '4K' : q items.push( new GearMenuItem(player, { label, selectable: true, selected: currentQ === q, __kind: 'quality', __value: q, onSelect: () => { player.options_.__gearQuality = q player.trigger({ type: 'gear:quality', quality: q }) player.trigger('gear:refresh') }, }) ) } return items } } // ✅ Typing-Fix const VjsComponent = videojs.getComponent('Component') as any videojs.registerComponent( 'GearMenuButton', GearMenuButton as unknown as typeof VjsComponent ) // ✅ CSS nur 1x injizieren if (!vjsAny.__gearControlCssInjected) { vjsAny.__gearControlCssInjected = true const css = document.createElement('style') css.textContent = ` #player-root .vjs-gear-menu .vjs-icon-placeholder { display: inline-flex; align-items: center; justify-content: center; } /* ✅ Menübreite nicht aufblasen */ #player-root .vjs-gear-menu .vjs-menu { min-width: 0 !important; width: fit-content !important; } /* ✅ die UL auch nicht breit ziehen */ #player-root .vjs-gear-menu .vjs-menu-content { width: fit-content !important; min-width: 0 !important; padding: 2px 0 !important; } /* ✅ Items kompakter */ #player-root .vjs-gear-menu .vjs-menu-content .vjs-menu-item { padding: 4px 10px !important; line-height: 1.1 !important; } /* ✅ Gear-Button als Anker */ #player-root .vjs-gear-menu { position: relative !important; } /* ✅ Popup wirklich über dem Gear-Icon zentrieren */ #player-root .vjs-gear-menu .vjs-menu { position: absolute !important; left: 0% !important; right: 0% !important; transform: translateX(-50%) !important; transform-origin: 50% 100% !important; z-index: 9999 !important; } /* ✅ Manche Skins setzen am UL noch Layout/Width – neutralisieren */ #player-root .vjs-gear-menu .vjs-menu-content { width: max-content !important; min-width: 0 !important; } /* ✅ Menü horizontal zentrieren über dem Gear-Icon */ #player-root .vjs-gear-menu .vjs-menu { z-index: 9999 !important; } ` document.head.appendChild(css) } } 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) } } function gearQualitiesForHeight(h?: number | null): string[] { const srcH = typeof h === 'number' && Number.isFinite(h) && h > 0 ? Math.round(h) : 0 const ladder = [2160, 1440, 1080, 720, 480, 360, 240] // gewünschte Stufen const toQ = (n: number) => `${n}p` const uniq = (arr: string[]) => Array.from(new Set(arr)) // Wenn unbekannt: trotzdem Ladder anbieten + Auto if (!srcH) { return ['auto', ...ladder.map(toQ)] } // native Höhe (immer anzeigen, auch wenn nicht genau Ladder) const allowed = ladder.filter((x) => x <= srcH + 8) // native immer rein (und dann absteigend sortiert) const allHeights = Array.from(new Set([...allowed, srcH])).sort((a, b) => b - a) return uniq(['auto', ...allHeights.map(toQ)]) } 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 } 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, }: 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/record/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`), [previewId] ) const previewB = React.useMemo( () => apiUrl(`/api/record/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/record/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/record/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; quality?: string; startSec?: number }) => { const q = String(params.quality || 'auto').trim() const startSec = typeof params.startSec === 'number' && Number.isFinite(params.startSec) && params.startSec > 0 ? Math.floor(params.startSec) : 0 // ✅ query params sauber bauen const qp = new URLSearchParams() if (params.file) qp.set('file', params.file) if (params.id) qp.set('id', params.id) const qToH = (qq: string): number => { const m = String(qq).match(/(\d{3,4})p/i) return m ? Number(m[1]) : 0 } // Quelle (für Downscale-Erkennung) const sourceH = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0 if (q && q !== 'auto') { qp.set('quality', q) // ✅ stream=1 nur für "Start bei 0" (schneller Start ohne Full-Transcode) // Bei startSec>0 wollen wir i.d.R. Segment-Cache + Range (besseres Seek/Quality-Switch) const targetH = qToH(q) if (startSec === 0 && sourceH && targetH && targetH < sourceH - 8) { qp.set('stream', '1') } } if (startSec > 0) { qp.set('t', String(startSec)) // oder qp.set('start', String(startSec)) } return apiUrl(`/api/record/video?${qp.toString()}`) }, [videoH] ) // ✅ requested = UI (Auto oder fix), applied = echte Source-Qualität const [requestedQuality, setRequestedQuality] = React.useState('auto') const [appliedQuality, setAppliedQuality] = React.useState('auto') const requestedQualityRef = React.useRef(requestedQuality) React.useEffect(() => { requestedQualityRef.current = requestedQuality }, [requestedQuality]) const appliedQualityRef = React.useRef(appliedQuality) React.useEffect(() => { appliedQualityRef.current = appliedQuality }, [appliedQuality]) 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, quality: appliedQuality }), type } } return { src: buildVideoSrc({ id: job.id, quality: appliedQuality }), type: 'video/mp4' } }, [isRunning, metaReady, job.output, job.id, appliedQuality, buildVideoSrc]) const containerRef = React.useRef(null) const playerRef = React.useRef(null) const videoNodeRef = React.useRef(null) const skipNextMediaSrcRef = React.useRef(false) const [mounted, setMounted] = React.useState(false) const [playerReadyTick, setPlayerReadyTick] = React.useState(0) 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) } }, []) // pro Datei einmal default setzen (damit beim nächsten Video wieder sinnvoll startet) const playbackKey = React.useMemo(() => { // finished: Dateiname; running ist eh LiveHlsVideo, also egal return baseName(job.output?.trim() || '') || job.id }, [job.output, job.id]) const defaultQuality = React.useMemo(() => { const h = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0 return h ? `${h}p` : 'auto' }, [videoH]) const lastPlaybackKeyRef = React.useRef('') React.useEffect(() => { if (lastPlaybackKeyRef.current !== playbackKey) { lastPlaybackKeyRef.current = playbackKey setRequestedQuality(defaultQuality) setAppliedQuality(defaultQuality) setIntrH(null) // player-Optionen syncen, falls schon gemountet const p: any = playerRef.current if (p && !p.isDisposed?.()) { try { p.options_.__gearQuality = defaultQuality p.options_.__autoAppliedQuality = defaultQuality p.trigger?.('gear:refresh') } catch {} } } }, [playbackKey, defaultQuality]) 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(56) 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 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]) // ✅ zentrale Umschalt-Funktion (Quality Switch, inkl. t= Segment) const applyQualitySwitch = React.useCallback( (p: any, fileName: string, nextQ: string) => { if (isRunning) return installAbsoluteTimelineShim(p) const wasPaused = Boolean(p.paused?.()) // absolute Zeit (Shim) const absNow = Number(p.currentTime?.() ?? 0) || 0 const startSec = absNow > 0 ? Math.floor(absNow / 2) * 2 : 0 const rel = absNow - startSec p.__timeOffsetSec = startSec // media-effect darf nicht direkt danach drüberbügeln skipNextMediaSrcRef.current = true // ✅ applied wechseln setAppliedQuality(nextQ) // fürs Label "Auto (720p)" p.options_.__autoAppliedQuality = nextQ const nextSrc = buildVideoSrc({ file: fileName, quality: nextQ, startSec, }) const knownFull = Number(fullDurationSec || 0) || 0 if (knownFull > 0) p.__fullDurationSec = knownFull try { const prev = p.__onLoadedMetaQSwitch if (prev) p.off('loadedmetadata', prev) } catch {} const onLoadedMeta = () => { updateIntrinsicDims() if (!(Number(p.__fullDurationSec) > 0)) { try { const segDur = Number(p.__origDuration?.() ?? 0) || 0 if (segDur > 0) p.__fullDurationSec = startSec + segDur } catch {} } try { p.__setRelativeTime?.(rel) } catch {} try { p.trigger?.('timeupdate') } catch {} // Auto-label refresh try { p.trigger?.('gear:refresh') } catch {} if (!wasPaused) { const ret = p.play?.() if (ret && typeof ret.catch === 'function') ret.catch(() => {}) } } p.__onLoadedMetaQSwitch = onLoadedMeta p.one('loadedmetadata', onLoadedMeta) try { p.src({ src: nextSrc, type: 'video/mp4' }) } catch {} try { p.load?.() } catch {} }, [isRunning, buildVideoSrc, updateIntrinsicDims, fullDurationSec] ) // ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten React.useEffect(() => { const p = playerRef.current as any if (!p || p.isDisposed?.()) return installAbsoluteTimelineShim(p) const onQ = (ev: any) => { const q = String(ev?.quality ?? 'auto').trim() if (isRunning) return const fileName = baseName(job.output?.trim() || '') if (!fileName) return // requested immer updaten (UI) setRequestedQuality(q) // Auto: applied bleibt erstmal wie aktuell if (q === 'auto') { try { p.options_.__gearQuality = 'auto' p.options_.__autoAppliedQuality = appliedQualityRef.current || defaultQuality p.trigger?.('gear:refresh') } catch {} return } // Manual: direkt umschalten try { p.options_.__gearQuality = q p.options_.__autoAppliedQuality = q p.trigger?.('gear:refresh') } catch {} applyQualitySwitch(p, fileName, q) } p.on('gear:quality', onQ) return () => { try { p.off('gear:quality', onQ) } catch {} } }, [playerReadyTick, job.output, isRunning, applyQualitySwitch, defaultQuality]) // ✅ Auto-Controller: buffer/stall-basiert hoch/runter React.useEffect(() => { if (!mounted) return if (isRunning) return if (requestedQuality !== 'auto') return const p: any = playerRef.current if (!p || p.isDisposed?.()) return installAbsoluteTimelineShim(p) const fileName = baseName(job.output?.trim() || '') if (!fileName) return const getLadder = (): string[] => { const q = (p.options_?.gearQualities || gearQualitiesForHeight(intrH ?? videoH)) as string[] return q.filter((x) => x && x !== 'auto') } const qToNum = (qq: string) => { const m = String(qq).match(/(\d{3,4})p/i) return m ? Number(m[1]) : 0 } const sortDesc = (arr: string[]) => [...arr].sort((a, b) => qToNum(b) - qToNum(a)) const pickLower = (cur: string, ladder: string[]) => { const L = sortDesc(ladder) const curN = qToNum(cur) for (let i = 0; i < L.length; i++) { const n = qToNum(L[i]) if (n > 0 && n < curN - 8) return L[i] } return L[L.length - 1] || cur } const pickHigher = (cur: string, ladder: string[]) => { const L = sortDesc(ladder) const curN = qToNum(cur) for (let i = L.length - 1; i >= 0; i--) { const n = qToNum(L[i]) if (n > curN + 8) return L[i] } return L[0] || cur } let lastStallAt = 0 let lastSwitchAt = 0 let stableSince = 0 const onStall = () => { lastStallAt = Date.now() } p.on('waiting', onStall) p.on('stalled', onStall) const getBufferAheadSec = () => { try { const relNow = Number(p.__origCurrentTime?.() ?? 0) || 0 const buf = p.buffered?.() if (!buf || buf.length <= 0) return 0 const end = buf.end(buf.length - 1) return Math.max(0, (Number(end) || 0) - relNow) } catch { return 0 } } const tick = () => { const now = Date.now() if (now - lastSwitchAt < 8000) return // hysteresis const ladder = getLadder() if (!ladder.length) return const cur = appliedQualityRef.current && appliedQualityRef.current !== 'auto' ? appliedQualityRef.current : ladder[0] const ahead = getBufferAheadSec() const recentlyStalled = now - lastStallAt < 2500 // downgrade schnell, wenn buffer knapp / stall if (ahead < 4 || recentlyStalled) { const next = pickLower(cur, ladder) if (next && next !== cur) { lastSwitchAt = now stableSince = 0 try { p.options_.__autoAppliedQuality = next p.trigger?.('gear:refresh') } catch {} applyQualitySwitch(p, fileName, next) } return } // stable => upgrade langsam if (ahead > 20 && now - lastStallAt > 20000) { if (!stableSince) stableSince = now if (now - stableSince > 20000) { const next = pickHigher(cur, ladder) if (next && next !== cur) { lastSwitchAt = now stableSince = now try { p.options_.__autoAppliedQuality = next p.trigger?.('gear:refresh') } catch {} applyQualitySwitch(p, fileName, next) } } } else { stableSince = 0 } } const id = window.setInterval(tick, 2000) tick() return () => { window.clearInterval(id) try { p.off('waiting', onStall) } catch {} try { p.off('stalled', onStall) } catch {} } }, [mounted, isRunning, requestedQuality, job.output, intrH, videoH, applyQualitySwitch]) React.useEffect(() => setMounted(true), []) React.useEffect(() => { let el = document.getElementById('player-root') as HTMLElement | null if (!el) { el = document.createElement('div') el.id = 'player-root' } // ✅ Mobile: immer in , damit "fixed bottom-0" am echten Viewport hängt // ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt 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]) 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) // ✅ effektive Quality: bei Auto die applied nehmen const effectiveQ = requestedQualityRef.current === 'auto' ? String(appliedQualityRef.current || 'auto').trim() : String(requestedQualityRef.current || 'auto').trim() // helper: "1080p" -> 1080 const qToH = (qq: string): number => { const m = String(qq).match(/(\d{3,4})p/i) return m ? Number(m[1]) : 0 } // Quelle (wie in buildVideoSrc-Logik): wenn target < source => Downscale const sourceH = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0 const targetH = qToH(effectiveQ) const needsDownscale = effectiveQ !== 'auto' && sourceH > 0 && targetH > 0 && targetH < sourceH - 8 // ✅ 1) NICHT runterskalieren => NUR client seek (kein FFmpeg) if (!needsDownscale) { const wasPaused = Boolean(p.paused?.()) const off = Number(p.__timeOffsetSec ?? 0) || 0 const curSrc = String(p.currentSrc?.() || '') const isSegmented = off > 0 || curSrc.includes('t=') || curSrc.includes('start=') if (isSegmented) { p.__timeOffsetSec = 0 const nextSrc = buildVideoSrc({ file: fileName, quality: effectiveQ, // startSec absichtlich NICHT setzen }) const onMeta = () => { updateIntrinsicDims() try { p.__origCurrentTime?.(abs) try { p.trigger?.('timeupdate') } catch {} } catch { try { p.currentTime?.(abs) } catch {} } if (!wasPaused) { const ret = p.play?.() if (ret && typeof ret.catch === 'function') ret.catch(() => {}) } } try { p.one('loadedmetadata', onMeta) } catch {} try { p.src({ src: nextSrc, type: 'video/mp4' }) } catch {} try { p.load?.() } catch {} return } try { p.__origCurrentTime?.(abs) try { p.trigger?.('timeupdate') } catch {} } catch { try { p.currentTime?.(abs) } catch {} } return } // ✅ 2) Downscale aktiv => Server-Seek (Segment) const wasPaused = Boolean(p.paused?.()) const startSec = Math.floor(abs / 2) * 2 const rel = abs - startSec p.__timeOffsetSec = startSec const nextSrc = buildVideoSrc({ file: fileName, quality: effectiveQ, startSec, }) const onMeta = () => { updateIntrinsicDims() p.__setRelativeTime?.(rel) try { p.trigger?.('timeupdate') } catch {} if (!wasPaused) { const ret = p.play?.() if (ret && typeof ret.catch === 'function') ret.catch(() => {}) } } try { p.one('loadedmetadata', onMeta) } catch {} try { p.src({ src: nextSrc, type: 'video/mp4' }) } catch {} try { p.load?.() } catch {} } return () => { try { delete p.__serverSeekAbs } catch {} } }, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, videoH]) 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 ensureGearControlRegistered() const initialGearQualities = gearQualitiesForHeight(videoH) const initialSelectedQuality = (() => { const h = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0 return h ? `${h}p` : 'auto' })() setRequestedQuality(initialSelectedQuality) setAppliedQuality(initialSelectedQuality) 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, gearQualities: initialGearQualities, __gearQuality: initialSelectedQuality, __autoAppliedQuality: initialSelectedQuality, controlBar: { skipButtons: { backward: 10, forward: 10 }, volumePanel: { inline: false }, children: [ 'skipBackward', 'playToggle', 'skipForward', 'volumePanel', 'currentTimeDisplay', 'timeDivider', 'durationDisplay', 'progressControl', 'spacer', 'playbackRateMenuButton', 'GearMenuButton', 'fullscreenToggle', ], }, playbackRates: [0.5, 1, 1.25, 1.5, 2], }) playerRef.current = p setPlayerReadyTick((x) => x + 1) p.one('loadedmetadata', () => { updateIntrinsicDims() try { const h = typeof (p as any).videoHeight === 'function' ? (p as any).videoHeight() : 0 const next = gearQualitiesForHeight(h) ;(p as any).options_.gearQualities = next const curReq = String((p as any).options_.__gearQuality ?? 'auto') const native = typeof h === 'number' && Number.isFinite(h) && h > 0 ? `${Math.round(h)}p` : 'auto' const nextReq = next.includes(curReq) ? curReq : next.includes(native) ? native : 'auto' ;(p as any).options_.__gearQuality = nextReq // state sync setRequestedQuality(nextReq) if (nextReq !== 'auto') { setAppliedQuality(nextReq) ;(p as any).options_.__autoAppliedQuality = nextReq } else { // Auto gewählt: applied falls noch auto -> native const curApplied = String(appliedQualityRef.current || 'auto') const nextApplied = curApplied !== 'auto' ? curApplied : native !== 'auto' ? native : 'auto' setAppliedQuality(nextApplied) ;(p as any).options_.__autoAppliedQuality = nextApplied } p.trigger('gear:refresh') } catch {} }) try { const gear = (p.getChild('controlBar') as any)?.getChild('GearMenuButton') if (gear?.el) gear.el().classList.add('vjs-gear-menu') } catch {} 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 {} }, []) React.useEffect(() => { if (!mounted) return if (!isRunning && !metaReady) { releaseMedia() return } const p = playerRef.current if (!p || (p as any).isDisposed?.()) return // ✅ src wurde gerade manuell (Quality-Wechsel mit t=...) gesetzt -> einmaligen Auto-Apply überspringen if (skipNextMediaSrcRef.current) { skipNextMediaSrcRef.current = false 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?.() || '') if (curSrc && curSrc === media.src) { // trotzdem versuchen zu spielen (z.B. wenn nur muted/state geändert wurde) 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 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 clampRect = React.useCallback((r: { x: number; y: number; w: number; h: number }, ratio?: number) => { if (typeof window === 'undefined') return r const { w: vw, h: vh } = getViewport() const maxW = vw - MARGIN * 2 const maxH = vh - MARGIN * 2 let w = r.w let h = r.h if (ratio && Number.isFinite(ratio) && ratio > 0.1) { w = Math.max(MIN_W, w) h = w / ratio if (h < MIN_H) { h = MIN_H w = h * ratio } if (w > maxW) { w = maxW h = w / ratio } if (h > maxH) { h = maxH w = h * ratio } } else { w = Math.max(MIN_W, Math.min(w, maxW)) h = Math.max(MIN_H, Math.min(h, maxH)) } 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, h } }, []) 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 clampRect({ x: v.x, y: v.y, w: v.w, h: v.h }, v.w / v.h) } } } catch {} const { w: vw, h: vh } = getViewport() const w = DEFAULT_W const h = DEFAULT_H const x = Math.max(MARGIN, vw - w - MARGIN) const y = Math.max(MARGIN, vh - h - MARGIN) return clampRect({ x, y, w, h }, w / h) }, [clampRect]) 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 = () => setWin((r) => clampRect(r, r.w / r.h)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [miniDesktop, clampRect]) // 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) // 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 = clampRect({ x: start.x + dx, y: start.y + dy, w: start.w, h: start.h }) pendingPosRef.current = { x: next.x, y: next.y } 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 })) }) } }, [clampRect] ) const endDrag = React.useCallback(() => { if (!draggingRef.current) return setIsDragging(false) 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(clampRect(cur)) queueMicrotask(() => saveRect(snapped)) return snapped }) }, [onDragMove, applySnap, clampRect, 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) window.addEventListener('pointermove', onDragMove) window.addEventListener('pointerup', endDrag) }, [miniDesktop, isResizing, onDragMove, endDrag] ) // 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 = clampRect({ x, y, w, h }, ratio) pendingRectRef.current = next if (resizeRafRef.current == null) { resizeRafRef.current = requestAnimationFrame(() => { resizeRafRef.current = null const r = pendingRectRef.current if (r) setWin(r) }) } }, [clampRect] ) const endResize = React.useCallback(() => { if (!resizingRef.current) return setIsResizing(false) 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 || !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 liveBottom = `env(safe-area-inset-bottom)` const vjsBottom = `calc(${controlBarH}px + env(safe-area-inset-bottom))` const overlayBottom = isRunning ? liveBottom : vjsBottom const metaBottom = isRunning ? `calc(8px + env(safe-area-inset-bottom))` : `calc(${controlBarH + 8}px + env(safe-area-inset-bottom))` const topOverlayTop = miniDesktop ? 'top-4' : '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}
{/* Bottom overlay: Gradient */}
{model}
{file || title} {isHot || isHotFile ? ( HOT ) : null}
{resolutionLabel !== '—' ? ( {resolutionLabel} ) : null} {!isRunning ? ( {runtimeLabel} ) : null} {sizeLabel !== '—' ? {sizeLabel} : null}
) const sidePanel = (
{/* eslint-disable-next-line @next/next/no-img-element */} Vorschau { if (previewSrc !== previewB) setPreviewSrc(previewB) }} />
{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 cardEl = (
{showSideInfo ? sidePanel : null} {videoChrome}
) const { w: vw, h: vh, ox, oy, bottomInset } = getViewport() const expandedRect = { left: ox + 16, top: oy + 16, width: Math.max(0, vw - 32), height: Math.max(0, vh - 32), } const wrapStyle = expanded ? expandedRect : miniDesktop ? { left: win.x, top: win.y, width: win.w, height: win.h } : undefined return createPortal( <> {expanded || miniDesktop ? (
{cardEl} {miniDesktop ? (
) : null}
) : (
{cardEl}
)} , portalTarget ) }