2026-02-20 18:18:59 +01:00

2539 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M19.14,12.94c0.04-0.31,0.06-0.63,0.06-0.94s-0.02-0.63-0.06-0.94l2.03-1.58
c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.11-0.2-0.36-0.28-0.57-0.2l-2.39,0.96c-0.5-0.38-1.04-0.7-1.64-0.94
L14.4,2.81C14.37,2.59,14.18,2.42,13.95,2.42h-3.9c-0.23,0-0.42,0.17-0.45,0.39L9.27,5.37
C8.67,5.61,8.13,5.93,7.63,6.31L5.24,5.35c-0.21-0.08-0.46,0-0.57,0.2L2.75,8.87
C2.64,9.07,2.69,9.34,2.87,9.48l2.03,1.58C4.86,11.37,4.84,11.69,4.84,12s0.02,0.63,0.06,0.94L2.87,14.52
c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.11,0.2,0.36,0.28,0.57,0.2l2.39-0.96c0.5,0.38,1.04,0.7,1.64,0.94
l0.33,2.56c0.03,0.22,0.22,0.39,0.45,0.39h3.9c0.23,0,0.42-0.17,0.45-0.39l0.33-2.56c0.6-0.24,1.14-0.56,1.64-0.94
l2.39,0.96c0.21,0.08,0.46,0,0.57-0.2l1.92-3.32c0.11-0.2,0.06-0.47-0.12-0.61L19.14,12.94z M12,15.5
c-1.93,0-3.5-1.57-3.5-3.5s1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5S13.93,15.5,12,15.5z"/>
</svg>
`
}
// ✅ 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<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)
}
}
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<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
}
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<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
// ✅ 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<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/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<string>('auto')
const [appliedQuality, setAppliedQuality] = React.useState<string>('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<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
const videoNodeRef = React.useRef<HTMLVideoElement | null>(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<string>('')
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<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 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 <body>, 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<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
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<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 = () => 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<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)
// 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 = 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<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 = 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 = (
<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 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 = (
<div
className={cn(
'relative overflow-visible',
expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
)}
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')}>
{isRunning ? (
<div className="absolute inset-0 bg-black">
<LiveHlsVideo src={liveHlsSrc} muted={startMuted} className="w-full h-full object-contain" />
<div className="absolute right-2 bottom-2 z-[60] 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 ref={containerRef} className="absolute inset-0" />
)}
{/* ✅ Top overlay */}
<div className={cn('absolute inset-x-2 z-30', 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>
{/* Bottom overlay: Gradient */}
<div
className={cn(
'player-ui pointer-events-none absolute inset-x-0 z-40',
'bg-gradient-to-t from-black/70 to-transparent',
'transition-all duration-200 ease-out',
expanded ? 'h-28' : 'h-24'
)}
style={{ bottom: overlayBottom }}
/>
<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>
) : null}
{sizeLabel !== '—' ? <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span> : 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">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewSrc}
alt="Vorschau"
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
onError={() => {
if (previewSrc !== previewB) setPreviewSrc(previewB)
}}
/>
</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 cardEl = (
<Card
edgeToEdgeMobile
noBodyPadding
className={cn(
'relative 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
return createPortal(
<>
<style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */
.is-live-download .vjs-progress-control {
display: none !important;
}
`}</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,
}}
>
{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>
)}
</>,
portalTarget
)
}