2026-03-16 12:46:38 +01:00

2294 lines
72 KiB
TypeScript

// 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,
SpeakerXMarkIcon,
SpeakerWaveIcon,
} 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 LiveVideo from './LiveVideo'
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<string>()
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<string | false | null | undefined>) {
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<string, StoredModelFlagsLite>
// states für Buttons
isHot?: boolean
isFavorite?: boolean
isLiked?: boolean
isWatching?: boolean
// actions
onKeep?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (
job: RecordJob
) =>
| void
| { ok?: boolean; oldFile?: string; newFile?: string }
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
// ✅ neu: laufenden Download stoppen
onStopJob?: (id: string) => void | Promise<void>
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<number>(() => {
return (
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
)
})
const [metaReady, setMetaReady] = React.useState<boolean>(() => {
// 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
const [liveMuted, setLiveMuted] = React.useState(startMuted)
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
// ✅ 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=preview.jpg`),
[previewId]
)
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
const liveHlsSrc = React.useMemo(
() => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
[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<number | null>(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)}&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<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
const videoNodeRef = React.useRef<HTMLVideoElement | null>(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<string>('')
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<HTMLElement | null>(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<void>).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<void>).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 = isRunning ? 0 : 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, isRunning]
)
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<WinRect>(() => 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<number | null>(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<WinRect | null>(null)
const [ghostFrameSrc, setGhostFrameSrc] = React.useState<string | null>(null)
const ghostFrameCanvasRef = React.useRef<HTMLCanvasElement | null>(null)
// pointermove sehr häufig -> 1x pro Frame committen
const dragRafRef = React.useRef<number | null>(null)
const pendingPosRef = React.useRef<{ x: number; y: number } | null>(null)
const draggingRef = React.useRef<null | { sx: number; sy: number; start: WinRect }>(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<number | null>(null)
const pendingRectRef = React.useRef<WinRect | null>(null)
const resizingRef = React.useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(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 = (
<div className="flex items-center gap-1 min-w-0">
{isRunning ? (
<>
<Button
variant="primary"
color="red"
size="sm"
rounded="md"
disabled={stopDisabled}
title={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
aria-label={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
if (stopDisabled) return
try {
setStopPending(true)
await onStopJob?.(job.id)
} finally {
setStopPending(false)
}
}}
className={cn('shadow-none shrink-0', miniDesktop && isNarrowMini && 'px-2')}
>
<span className="whitespace-nowrap">
{isStoppingLike || stopPending ? 'Stoppe…' : miniDesktop && isNarrowMini ? 'Stop' : 'Stoppen'}
</span>
</Button>
<RecordJobActions
job={job}
variant="overlay"
collapseToMenu
busy={isStoppingLike || stopPending}
isFavorite={isFavorite}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch ? (j) => 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"
/>
</>
) : (
<RecordJobActions
job={job}
variant="overlay"
collapseToMenu
isHot={isHot || isHotFile}
isFavorite={isFavorite}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch ? (j) => 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<void>).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"
/>
)}
</div>
)
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 = (
<div
className={cn(
'relative overflow-visible flex-1 min-h-0'
)}
onMouseEnter={() => {
if (!miniDesktop || !canHover) return
setChromeHover(true)
}}
onMouseLeave={() => {
if (!miniDesktop || !canHover) return
setChromeHover(false)
}}
>
<div
className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
>
{isRunning ? (
<div className="absolute inset-0 bg-black">
<LiveVideo
src={liveHlsSrc}
muted={liveMuted}
volume={liveVolume}
onVolumeChange={(nextVolume, nextMuted) => {
setLiveVolume(nextVolume)
setLiveMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom"
/>
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
<button
type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75"
title={liveMuted ? 'Ton an' : 'Stumm'}
aria-label={liveMuted ? 'Ton an' : 'Stumm'}
onClick={() => {
if (liveMuted) {
setLiveMuted(false)
if (liveVolume <= 0) setLiveVolume(1)
} else {
setLiveMuted(true)
}
}}
>
<span className="text-[13px] leading-none">
{liveMuted ? (
<SpeakerXMarkIcon className="h-4 w-4" />
) : (
<SpeakerWaveIcon className="h-4 w-4" />
)}
</span>
</button>
<div className="pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
</div>
</div>
) : (
<div ref={containerRef} className="absolute inset-0" />
)}
{/* ✅ Top overlay */}
<div
className={cn(
'absolute inset-x-0 z-30 pointer-events-none',
topOverlayTop === 'top-2' ? 'top-0' : 'top-0'
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/35 to-transparent" />
<div className={cn('absolute inset-x-2', topOverlayTop)}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-start gap-2">
<div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>
{miniDesktop ? (
<button
type="button"
aria-label="Player-Fenster verschieben"
title="Ziehen zum Verschieben"
onPointerDown={beginDrag}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className={cn(
overlayBtn,
'px-3 gap-1 cursor-grab active:cursor-grabbing select-none',
dragUiActive ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none -translate-y-1',
isDragging && 'scale-[0.98] opacity-90'
)}
>
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
</button>
) : null}
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
<button
type="button"
className={overlayBtn}
onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? <ArrowsPointingInIcon className="h-5 w-5" /> : <ArrowsPointingOutIcon className="h-5 w-5" />}
</button>
<button type="button" className={overlayBtn} onClick={onClose} aria-label="Schließen" title="Schließen">
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-2 z-50',
'flex items-end justify-between gap-2',
'transition-all duration-200 ease-out'
)}
style={{ bottom: metaBottom }}
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{model}</div>
<div className="truncate text-[11px] text-white/80">
<span className="inline-flex items-center gap-1 min-w-0 align-middle">
<span className="truncate">{file || title}</span>
{isHot || isHotFile ? (
<span className="shrink-0 rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-white">HOT</span>
) : null}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white">
{resolutionLabel !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
) : null}
{!isRunning ? (
<>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
{runtimeLabel}
</span>
{sizeLabel !== '—' ? (
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
{sizeLabel}
</span>
) : null}
</>
) : null}
</div>
</div>
</div>
</div>
)
const sidePanel = (
<div className="w-[360px] shrink-0 border-r border-white/10 bg-black/40 text-white">
<div className="h-full p-4 flex flex-col gap-3 overflow-y-auto">
<div className="rounded-lg overflow-hidden ring-1 ring-white/10 bg-black/30">
<div className="relative aspect-video">
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewSrc}
alt=""
className="absolute inset-0 h-full w-full object-contain opacity-80"
draggable={false}
onError={() => {
// 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.
}}
/>
</div>
</div>
<div className="space-y-1">
<div className="text-lg font-semibold truncate">{model}</div>
<div className="text-xs text-white/70 break-all">{file || title}</div>
</div>
<div className="pointer-events-auto">
<div className="flex items-center justify-center gap-2 flex-wrap">
{isRunning ? (
<Button
variant="primary"
color="red"
size="sm"
rounded="md"
disabled={stopDisabled}
title={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
aria-label={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
if (stopDisabled) return
try {
setStopPending(true)
await onStopJob?.(job.id)
} finally {
setStopPending(false)
}
}}
className="shadow-none"
>
{isStoppingLike || stopPending ? 'Stoppe…' : 'Stoppen'}
</Button>
) : null}
<RecordJobActions
job={job}
variant="table"
collapseToMenu={false}
busy={isStoppingLike || stopPending}
isHot={isHot || isHotFile}
isFavorite={isFavorite}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch ? (j) => 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<void>).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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
<div className="text-white/60">Status</div>
<div className="font-medium">{job.status}</div>
<div className="text-white/60">Auflösung</div>
<div className="font-medium">{resolutionLabel}</div>
<div className="text-white/60">FPS</div>
<div className="font-medium">{fpsLabel}</div>
<div className="text-white/60">Laufzeit</div>
<div className="font-medium">{runtimeLabel}</div>
<div className="text-white/60">Größe</div>
<div className="font-medium">{sizeLabel}</div>
<div className="text-white/60">Datum</div>
<div className="font-medium">{dateLabel}</div>
<div className="col-span-2">
{tags.length ? (
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<span key={t} className="rounded bg-white/10 px-2 py-0.5 text-xs text-white/90">
{t}
</span>
))}
</div>
) : (
<span className="text-white/50"></span>
)}
</div>
</div>
</div>
</div>
)
const snapGhostEl =
miniDesktop && isDragging && snapPreviewRect ? (
<div
className="pointer-events-none absolute z-0 player-snap-ghost overflow-hidden rounded-lg border-2 border-dashed border-white/70 bg-black/55 shadow-2xl dark:border-white/60"
style={{
left: snapPreviewRect.x - win.x,
top: snapPreviewRect.y - win.y,
width: snapPreviewRect.w,
height: snapPreviewRect.h,
}}
aria-hidden="true"
>
{/* Video/Preview Fläche */}
<div className="absolute inset-0 bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ghostFrameSrc || previewSrc}
alt=""
className="absolute inset-0 h-full w-full object-contain opacity-80"
draggable={false}
onError={() => {
// kein State-Update nötig hier; echter Player kümmert sich schon um fallback
}}
/>
{/* Top gradient */}
<div className="absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/55 to-transparent" />
{/* Fake top controls / grip */}
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-80">
<div className="h-8 w-8 rounded-md bg-white/15 ring-1 ring-white/20" />
<div className="h-8 w-8 rounded-md bg-white/15 ring-1 ring-white/20" />
</div>
{/* Mini "drag grip" Andeutung */}
<div className="absolute top-2 left-2 h-8 rounded-md bg-white/12 px-2 flex items-center gap-1 ring-1 ring-white/15">
<span className="h-1 w-1 rounded-full bg-white/50" />
<span className="h-1 w-1 rounded-full bg-white/50" />
<span className="h-1 w-1 rounded-full bg-white/50" />
</div>
{/* Bottom meta bar (angelehnt an echten Player) */}
<div className="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white/95">{model}</div>
<div className="truncate text-[11px] text-white/75">
{file || title}
{(isHot || isHotFile) ? (
<span className="ml-1.5 rounded bg-amber-500/30 px-1.5 py-0.5 text-white">HOT</span>
) : null}
</div>
</div>
<div className="shrink-0 flex items-center gap-1 text-[10px] text-white/90">
{resolutionLabel !== '—' ? (
<span className="rounded bg-black/45 px-1.5 py-0.5">{resolutionLabel}</span>
) : null}
{!isRunning && runtimeLabel !== '—' ? (
<span className="rounded bg-black/45 px-1.5 py-0.5">{runtimeLabel}</span>
) : null}
</div>
</div>
{/* Dock-Hinweis */}
<div className="absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white/8 to-transparent" />
</div>
</div>
) : null
const cardEl = (
<Card
edgeToEdgeMobile
noBodyPadding
className={cn(
'relative z-10 flex flex-col shadow-2xl ring-1 ring-black/10 dark:ring-white/10',
'w-full',
fullSize ? 'h-full' : 'h-[220px] max-h-[40vh]',
expanded ? 'rounded-2xl' : miniDesktop ? 'rounded-lg' : 'rounded-none'
)}
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
>
<div className="flex flex-1 min-h-0">
{showSideInfo ? sidePanel : null}
{videoChrome}
</div>
</Card>
)
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
const content = (
<>
<style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */
.is-live-download .vjs-progress-control {
display: none !important;
}
/* ---------- Video.js Layout-Fix (Finished Player) ----------
Ziel:
- Video endet immer direkt über der Controlbar
- Aspect Ratio bleibt erhalten
- Resize stabil (kein "Wandern")
*/
/* Container schwarz, falls Letterboxing sichtbar wird */
.vjs-mini .video-js,
.vjs-mini .video-js .vjs-tech,
.vjs-mini .video-js .vjs-poster {
background-color: transparent !important;
}
/* ECHTES Video (<video class="vjs-tech">) */
.vjs-mini .video-js .vjs-tech {
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
/* Seitenverhältnis beibehalten + unten an die Controlbar "anlehnen" */
object-fit: contain !important;
object-position: center top !important;
}
/* Poster genauso behandeln (falls sichtbar) */
.vjs-mini .video-js .vjs-poster {
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
background-position: center bottom !important;
background-size: contain !important;
background-repeat: no-repeat !important;
}
/* Untertitel-/Texttrack-Layer darf ebenfalls nicht in die Controlbar laufen */
.vjs-mini .video-js .vjs-text-track-display {
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: var(--vjs-controlbar-h, 30px) !important;
inset: 0 0 var(--vjs-controlbar-h, 30px) 0 !important;
}
/* Sicherheitsnetz: Controlbar immer wirklich unten */
.vjs-mini .video-js .vjs-control-bar {
bottom: 0 !important;
}
.player-snap-ghost {
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.player-snap-ghost::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
pointer-events: none;
}
`}</style>
{expanded || miniDesktop ? (
<div
className={cn(
'fixed z-[2147483647]',
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
)}
style={{
...(wrapStyle as any),
willChange: isResizing ? 'left, top, width, height' : undefined,
}}
>
{snapGhostEl}
{cardEl}
{miniDesktop ? (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
</div>
) : null}
</div>
) : (
<div
className="
fixed z-[2147483647] inset-x-0 w-full
shadow-2xl
md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
"
style={{
bottom: `calc(${bottomInset}px + env(safe-area-inset-bottom))`,
}}
>
{cardEl}
</div>
)}
</>
)
if (usePortal) {
return createPortal(content, portalTarget!)
}
return content
}