2026-05-12 17:25:55 +02:00

2903 lines
90 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 {
useEffect,
useRef,
useState,
useMemo,
useCallback,
useLayoutEffect,
type PointerEvent as ReactPointerEvent,
} 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,
ListBulletIcon,
} from '@heroicons/react/24/outline'
import {
RatingSegmentsPanel,
readRatingSegments,
} from './RatingOverlay'
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'
import { formatResolution, formatFps, formatDateTime, formatBytes, formatDuration } from './formatters'
import LoadingSpinner from './LoadingSpinner'
function buildChaturbateCookieHeader(): string {
try {
const raw = window.localStorage.getItem('record_cookies')
if (!raw) return ''
const obj = JSON.parse(raw) as Record<string, string>
return Object.entries(obj ?? {})
.map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const)
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
} catch {
return ''
}
}
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
}
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 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 pickPlayerMetaRaw(job: RecordJob, fetched?: unknown): unknown {
const j = job as any
const f = fetched as any
const candidates = [
f?.metaRaw,
f?.meta,
f?.analysis || f?.ai || f?.preview || f?.previewSprite || f?.preview_sprite ? f : null,
j?.metaRaw,
j?.meta,
j?.analysis || j?.ai || j?.preview || j?.previewSprite || j?.preview_sprite ? j : null,
]
for (const v of candidates) {
if (typeof v === 'string' && v.trim()) return v
if (v && typeof v === 'object') return v
}
return null
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
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 | { newFile?: string } | Promise<void | { newFile?: string }>
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 = useMemo(
() => baseName(job.output?.trim() || '') || job.id,
[job.output, job.id]
)
const fileRaw = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const playName = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
const [playerMetaRaw, setPlayerMetaRaw] = useState<unknown | null>(() =>
pickPlayerMetaRaw(job)
)
const PLAYER_SEGMENTS_OPEN_KEY = 'player_segments_open_v1'
const [segmentsPanelOpen, setSegmentsPanelOpen] = useState<boolean>(() => {
if (typeof window === 'undefined') return true
return window.localStorage.getItem(PLAYER_SEGMENTS_OPEN_KEY) !== '0'
})
useEffect(() => {
try {
window.localStorage.setItem(
PLAYER_SEGMENTS_OPEN_KEY,
segmentsPanelOpen ? '1' : '0'
)
} catch {}
}, [segmentsPanelOpen])
const [fullDurationSec, setFullDurationSec] = useState<number>(() => {
return (
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
)
})
const [metaReady, setMetaReady] = useState<boolean>(() => {
// live ist egal, finished: erst mal false (wir holen gleich)
return job.status === 'running'
})
const [metaDims, setMetaDims] = useState<{ h: number; fps: number | null }>(() => ({
h: 0,
fps: null,
}))
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isLive = job.status === 'running'
const [liveMuted, setLiveMuted] = useState(startMuted)
const [liveVolume, setLiveVolume] = useState(startMuted ? 0 : 1)
const [livePreparing, setLivePreparing] = useState(false)
const [livePreparedSrc, setLivePreparedSrc] = useState('')
const [liveReady, setLiveReady] = useState(false)
// ✅ 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 = useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
const previewId = useMemo(
() => (isLive ? job.id : finishedStem || job.id),
[isLive, job.id, finishedStem]
)
useEffect(() => {
if (isLive) 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()
setPlayerMetaRaw((prev: unknown | null) => pickPlayerMetaRaw(job, j) ?? prev)
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()
}
}, [isLive, fullDurationSec, job.output])
const isHotFile = fileRaw.startsWith('HOT ')
const model = useMemo(() => {
const k = (modelKey || '').trim()
return k ? k : modelNameFromOutput(job.output)
}, [modelKey, job.output])
const file = useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
const runtimeLabel = 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 = 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 = useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model])
const tags = useMemo(() => {
const flags = modelsByKey?.[effectiveModelKey]
return parseTags(flags?.tags)
}, [modelsByKey, effectiveModelKey])
// Vorschaubild oben
const previewA = 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 = useMemo(
() => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
[previewId]
)
const [previewSrc, setPreviewSrc] = useState(previewA)
useEffect(() => {
if (!isLive) {
setLivePreparing(false)
setLivePreparedSrc('')
setLiveReady(false)
return
}
let cancelled = false
const ac = new AbortController()
const prepareLive = async () => {
setLivePreparing(true)
setLiveReady(false)
setLivePreparedSrc('')
const cookieHeader = buildChaturbateCookieHeader()
try {
const prepareUrl = apiUrl(
`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1&prepare=1`
)
const res = await apiFetch(prepareUrl, {
method: 'GET',
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (cancelled || ac.signal.aborted) return
await res.json().catch(() => null)
if (cancelled || ac.signal.aborted) return
setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`)
} catch {
if (cancelled || ac.signal.aborted) return
// best effort: Stream trotzdem versuchen
setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`)
} finally {
if (!cancelled && !ac.signal.aborted) {
setLivePreparing(false)
}
}
}
void prepareLive()
return () => {
cancelled = true
ac.abort()
}
}, [isLive, previewId, liveHlsSrc])
useEffect(() => {
setPreviewSrc(previewA)
}, [previewA])
const fps = 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 [intrSize, setIntrSize] = useState<{ w: number; h: number } | null>(null)
const videoW = useMemo(
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
)
const videoH = useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const resolutionLabel = useMemo(
() =>
formatResolution(
intrSize ??
(videoW && videoH ? { w: videoW, h: videoH } : null)
),
[intrSize, videoW, videoH]
)
const fpsLabel = useMemo(() => formatFps(fps), [fps])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const buildVideoSrc = 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 = useMemo(() => {
// ✅ Live wird NICHT mehr über Video.js gespielt
if (isLive) 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' }
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
const containerRef = useRef<HTMLDivElement | null>(null)
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
const setVideoContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el
setContainerEl(el)
}, [])
const playerRef = useRef<VideoJsPlayer | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
const mobileSegmentsScrollRef = useRef<HTMLDivElement | null>(null)
const mobileHeaderTouchYRef = useRef<number | null>(null)
const mobileSegmentsTouchYRef = useRef<number | null>(null)
const [mounted, setMounted] = useState(false)
const updateIntrinsicDims = useCallback(() => {
const p: any = playerRef.current
if (!p || p.isDisposed?.()) return
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 0
const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
setIntrSize({ w, h })
}
}, [])
const captureGhostFrame = 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 = useMemo(() => {
return baseName(job.output?.trim() || '') || job.id
}, [job.output, job.id])
const normalizedStartAtSec = 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 = useRef<string>('')
useEffect(() => {
if (isLive) {
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()
}
}, [isLive, playbackKey, job.output])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = useState(0)
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] = useState(30)
const [portalTarget, setPortalTarget] = 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
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])
useEffect(() => setMounted(true), [])
useEffect(() => {
if (!mounted) return
let el = document.getElementById('player-root') as HTMLElement | null
if (!el) {
el = document.createElement('div')
el.id = 'player-root'
}
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
if (el.parentElement !== host) {
host.appendChild(el)
}
el.style.position = 'relative'
el.style.zIndex = '2147483647'
setPortalTarget(el)
}, [mounted, isDesktop])
useEffect(() => {
const p: any = playerRef.current
if (!p || p.isDisposed?.()) return
if (isLive) 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, isLive])
useLayoutEffect(() => {
if (!mounted) return
if (!containerEl) return
if (isLive) return
if (!metaReady) return
// Falls der Player schon existiert, wurde nur der React-Container ersetzt.
// Dann hängen wir das bestehende Video.js-Element einfach in den neuen Container.
const existingPlayer = playerRef.current as any
if (existingPlayer && !existingPlayer.isDisposed?.()) {
const playerEl = existingPlayer.el?.() as HTMLElement | null
if (playerEl && playerEl.parentElement !== containerEl) {
containerEl.replaceChildren(playerEl)
}
requestAnimationFrame(() => {
try {
existingPlayer.trigger?.('resize')
existingPlayer.resize?.()
existingPlayer.play?.()?.catch?.(() => {})
} catch {}
})
return
}
const videoEl = document.createElement('video')
videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
videoEl.setAttribute('playsinline', 'true')
videoEl.setAttribute('webkit-playsinline', 'true')
containerEl.replaceChildren(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 },
nativeVideoTracks: true,
nativeAudioTracks: true,
nativeTextTracks: 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 () => {
// Wichtig:
// NICHT disposen, nur weil containerEl gewechselt hat.
// Dispose nur beim echten Component-Unmount machen wir separat unten.
}
}, [mounted, containerEl, startMuted, isLive, metaReady, updateIntrinsicDims])
useEffect(() => {
return () => {
try {
const p = playerRef.current as any
if (p && !p.isDisposed?.()) {
p.dispose()
}
} catch {}
playerRef.current = null
videoNodeRef.current = null
}
}, [])
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 = 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 = 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 {}
}
}, [])
const ratingSegments = useMemo(
() => readRatingSegments(playerMetaRaw),
[playerMetaRaw]
)
const hasRatingSegments = ratingSegments.length > 0
useEffect(() => {
if (!mounted) return
if (!isLive && !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, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
useEffect(() => {
if (!mounted) return
if (isLive) 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,
isLive,
metaReady,
media.src,
playbackKey,
normalizedStartAtSec,
seekPlayerToAbsolute,
])
useEffect(() => {
const p = playerRef.current as any
if (!p || p.isDisposed?.()) return
const triggerResize = () => {
try {
p.trigger('resize')
p.resize?.()
} catch {}
}
triggerResize()
const r1 = requestAnimationFrame(triggerResize)
const r2 = requestAnimationFrame(() => {
requestAnimationFrame(triggerResize)
})
return () => {
cancelAnimationFrame(r1)
cancelAnimationFrame(r2)
}
}, [expanded, segmentsPanelOpen, isDesktop])
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])
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 = useRef<{ w: number; h: number } | null>(null)
useEffect(() => {
if (typeof window === 'undefined') return
const { w, h } = getViewport()
prevViewportRef.current = { w, h }
}, [])
const getVideoAspectRatio = 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(
intrSize?.h,
(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, intrSize])
const clampMiniRect = useCallback(
(r: { x: number; y: number; w: number; h: number }) => {
if (typeof window === 'undefined') return r
const ratio = getVideoAspectRatio()
const BAR_H = isLive ? 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, isLive]
)
const loadRect = 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] = useState<WinRect>(() => loadRect())
const isNarrowMini = miniDesktop && win.w < 380
const saveRect = useCallback((r: WinRect) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(WIN_KEY, JSON.stringify(r))
} catch {}
}, [])
const winRef = useRef(win)
useEffect(() => {
winRef.current = win
}, [win])
useEffect(() => {
if (!miniDesktop) return
setWin(loadRect())
}, [miniDesktop, loadRect])
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 = useRef<number | null>(null)
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] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [snapPreviewRect, setSnapPreviewRect] = useState<WinRect | null>(null)
const [ghostFrameSrc, setGhostFrameSrc] = useState<string | null>(null)
const ghostFrameCanvasRef = useRef<HTMLCanvasElement | null>(null)
// pointermove sehr häufig -> 1x pro Frame committen
const dragRafRef = useRef<number | null>(null)
const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
const HOLD_TO_DRAG_MS = 220
const holdDragTimerRef = useRef<number | null>(null)
const suppressClickUntilRef = useRef(0)
const isNoDragTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false
return Boolean(
target.closest(
[
'button',
'a',
'input',
'textarea',
'select',
'label',
'[role="button"]',
'[role="menu"]',
'[data-no-player-drag]',
'.vjs-control-bar',
'.vjs-control',
'.vjs-menu',
'.vjs-modal-dialog',
'.vjs-big-play-button',
].join(',')
)
)
}
const applySnap = 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 = useCallback(
(ev: globalThis.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 = 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 startDragAt = useCallback(
(clientX: number, clientY: number) => {
if (!miniDesktop) return false
if (isResizing) return false
const start = winRef.current
draggingRef.current = { sx: clientX, sy: clientY, start }
setIsDragging(true)
const frame = captureGhostFrame()
setGhostFrameSrc(frame)
setSnapPreviewRect(applySnap(start))
window.addEventListener('pointermove', onDragMove)
window.addEventListener('pointerup', endDrag)
return true
},
[
miniDesktop,
isResizing,
captureGhostFrame,
applySnap,
onDragMove,
endDrag,
]
)
const beginVideoHoldDrag = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
if (!miniDesktop) return
if (isResizing) return
if (e.button !== 0) return
if (e.pointerType !== 'mouse') return
if (isNoDragTarget(e.target)) return
const startX = e.clientX
const startY = e.clientY
let didStartDrag = false
let cleanup = () => {}
const onMoveBeforeHold = () => {
// Absichtlich leer:
// Bewegung vor Ablauf des Hold-Timers soll den Drag nicht abbrechen.
}
const onUpBeforeHold = () => {
cleanup()
}
cleanup = () => {
if (holdDragTimerRef.current != null) {
window.clearTimeout(holdDragTimerRef.current)
holdDragTimerRef.current = null
}
window.removeEventListener('pointermove', onMoveBeforeHold)
window.removeEventListener('pointerup', onUpBeforeHold)
}
window.addEventListener('pointermove', onMoveBeforeHold)
window.addEventListener('pointerup', onUpBeforeHold)
holdDragTimerRef.current = window.setTimeout(() => {
cleanup()
// Wenn die Maus in der Hold-Zeit schon minimal bewegt wurde,
// starten wir trotzdem am ursprünglichen Punkt, damit die Position sauber bleibt.
didStartDrag = startDragAt(startX, startY)
if (didStartDrag) {
suppressClickUntilRef.current = Date.now() + 1000
}
}, HOLD_TO_DRAG_MS)
},
[miniDesktop, isResizing, startDragAt]
)
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame
const resizeRafRef = useRef<number | null>(null)
const pendingRectRef = useRef<WinRect | null>(null)
const resizingRef = useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null)
const onResizeMove = useCallback(
(ev: globalThis.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 = 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 = useCallback(
(dir: ResizeDir) => (e: ReactPointerEvent<HTMLDivElement>) => {
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 [stopPending, setStopPending] = useState(false)
useEffect(() => {
if (job.status !== 'running') setStopPending(false)
}, [job.id, job.status])
useEffect(() => {
if (typeof window === 'undefined') return
if (typeof document === 'undefined') return
if (isDesktop) return
const shouldLock =
!isLive &&
hasRatingSegments &&
segmentsPanelOpen
if (!shouldLock) return
const html = document.documentElement
const body = document.body
const scrollY = window.scrollY || window.pageYOffset || 0
const prevHtmlOverflow = html.style.overflow
const prevHtmlOverscrollBehavior = html.style.overscrollBehavior
const prevBodyOverflow = body.style.overflow
const prevBodyPosition = body.style.position
const prevBodyTop = body.style.top
const prevBodyLeft = body.style.left
const prevBodyRight = body.style.right
const prevBodyWidth = body.style.width
const prevBodyTouchAction = body.style.touchAction
const prevBodyOverscrollBehavior = body.style.overscrollBehavior
html.style.overflow = 'hidden'
html.style.overscrollBehavior = 'none'
body.style.overflow = 'hidden'
body.style.position = 'fixed'
body.style.top = `-${scrollY}px`
body.style.left = '0'
body.style.right = '0'
body.style.width = '100%'
body.style.touchAction = 'none'
body.style.overscrollBehavior = 'none'
return () => {
html.style.overflow = prevHtmlOverflow
html.style.overscrollBehavior = prevHtmlOverscrollBehavior
body.style.overflow = prevBodyOverflow
body.style.position = prevBodyPosition
body.style.top = prevBodyTop
body.style.left = prevBodyLeft
body.style.right = prevBodyRight
body.style.width = prevBodyWidth
body.style.touchAction = prevBodyTouchAction
body.style.overscrollBehavior = prevBodyOverscrollBehavior
window.scrollTo(0, scrollY)
}
}, [isDesktop, isLive, hasRatingSegments, segmentsPanelOpen])
if (!mounted) return null
if (!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 || !isLive || isStoppingLike || stopPending
const footerRight = (
<div className="flex items-center gap-1 min-w-0">
{isLive ? (
<>
<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', 'training']}
className="gap-1 min-w-0 flex-1"
/>
)}
</div>
)
const fullSize = expanded || miniDesktop
const metaBottom = isLive
? `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'
)}
onPointerDownCapture={beginVideoHoldDrag}
onClickCapture={(e) => {
if (Date.now() >= suppressClickUntilRef.current) return
suppressClickUntilRef.current = 0
e.preventDefault()
e.stopPropagation()
}}
>
<div
className={cn(
'relative w-full h-full player-video-frame',
miniDesktop && 'vjs-mini',
expanded && !isLive && 'vjs-expanded-player'
)}
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
>
{isLive ? (
<div className="absolute inset-0 bg-black">
<LiveVideo
src={livePreparedSrc}
muted={liveMuted}
volume={liveVolume}
onLoadingStart={() => {
setLiveReady(false)
}}
onReady={() => {
setLiveReady(true)
}}
onVolumeChange={(nextVolume, nextMuted) => {
setLiveVolume(nextVolume)
setLiveMuted(nextMuted)
}}
className="w-full h-full object-cover object-center"
/>
{(livePreparing || !liveReady) ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner />
<div className="text-sm font-medium">
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
</div>
</div>
) : null}
<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-white/90 px-2.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-black/10 hover:bg-white dark:bg-black/65 dark:text-white dark:ring-white/10 dark: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={setVideoContainerRef} 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] items-start gap-2">
<div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
{!isLive && hasRatingSegments ? (
<button
type="button"
className={cn(
overlayBtn,
segmentsPanelOpen && 'shadow-sm'
)}
style={
segmentsPanelOpen
? {
backgroundColor: '#4f46e5',
color: '#ffffff',
}
: undefined
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSegmentsPanelOpen((v) => !v)
}}
aria-pressed={segmentsPanelOpen}
aria-label={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
title={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
>
<ListBulletIcon
className={cn(
'h-5 w-5 transition-transform duration-200 ease-out',
segmentsPanelOpen ? 'scale-110' : ''
)}
style={{
color: segmentsPanelOpen ? '#ffffff' : undefined,
stroke: 'currentColor',
}}
/>
</button>
) : null}
<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}
{!isLive ? (
<>
<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 { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
const sidePanel = (
<div className="w-[360px] shrink-0 border-r border-white/10 bg-black/40 text-white">
<div className="h-full min-h-0 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">
{isLive ? (
<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={isLive ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete', 'training']}
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>
{!isLive && hasRatingSegments ? (
<div className="player-sidebar-segments min-h-[280px] flex-1 overflow-hidden rounded-lg bg-black/30 text-white shadow-sm ring-1 ring-black/20">
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={seekPlayerToAbsolute}
maxHeight={Math.max(280, vh - 260)}
className="h-full max-h-full w-full !bg-transparent !text-white !border-0 !ring-0"
/>
</div>
) : null}
</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>
{/* 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}
{!isLive && 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 SEGMENTS_PANEL_GAP = 10
const SEGMENTS_PANEL_MIN_W = 260
const SEGMENTS_PANEL_MAX_W = 340
const playerOnLeft = miniDesktop
? win.x + win.w / 2 < vw / 2
: false
const segmentPanelAvailableW = playerOnLeft
? vw - (win.x + win.w) - MARGIN - SEGMENTS_PANEL_GAP
: win.x - MARGIN - SEGMENTS_PANEL_GAP
const segmentPanelW = Math.min(
SEGMENTS_PANEL_MAX_W,
Math.max(SEGMENTS_PANEL_MIN_W, segmentPanelAvailableW)
)
const canRenderSegmentsPanel =
miniDesktop &&
!isLive &&
hasRatingSegments &&
!isDragging &&
!isResizing &&
segmentPanelAvailableW >= SEGMENTS_PANEL_MIN_W
const segmentsPanelEl = canRenderSegmentsPanel ? (
<div
aria-hidden={!segmentsPanelOpen}
className={cn(
'player-segments-panel absolute top-0 bottom-0 z-20 hidden overflow-hidden sm:block',
'transition-[opacity,transform,filter] duration-200 ease-[cubic-bezier(.2,.9,.2,1)] will-change-transform',
playerOnLeft ? 'origin-left' : 'origin-right',
segmentsPanelOpen
? 'pointer-events-auto translate-x-0 scale-100 opacity-100 blur-0'
: cn(
'pointer-events-none scale-[0.985] opacity-0 blur-[1px]',
playerOnLeft ? 'translate-x-3' : '-translate-x-3'
)
)}
style={{
width: segmentPanelW,
height: win.h,
maxHeight: win.h,
left: playerOnLeft
? `calc(100% + ${SEGMENTS_PANEL_GAP}px)`
: undefined,
right: !playerOnLeft
? `calc(100% + ${SEGMENTS_PANEL_GAP}px)`
: undefined,
}}
>
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={segmentsPanelOpen ? seekPlayerToAbsolute : undefined}
maxHeight={win.h}
className="h-full max-h-full w-full"
/>
</div>
) : null
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
const MOBILE_PLAYER_GAP = 10
const isMobileExpanded = expanded && !isDesktop
const MOBILE_EXPANDED_MARGIN_X = 8
const MOBILE_EXPANDED_TOP = 8
const MOBILE_EXPANDED_BOTTOM = 8
const MOBILE_EXPANDED_GAP = 10
const MOBILE_EXPANDED_SEGMENTS_H = 'min(38dvh, 360px)'
const mobilePlayerBottom = `calc(${bottomInset}px + env(safe-area-inset-bottom))`
const mobileExpandedSafeBottom =
`calc(${bottomInset}px + env(safe-area-inset-bottom) + ${MOBILE_EXPANDED_BOTTOM}px)`
const mobileExpandedSegmentsVisible =
isMobileExpanded && !isLive && hasRatingSegments && segmentsPanelOpen
const mobileExpandedPlayerH = mobileExpandedSegmentsVisible
? `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px - ${MOBILE_EXPANDED_SEGMENTS_H} - ${MOBILE_EXPANDED_GAP}px)`
: `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px)`
const mobileSegmentsBottom = isMobileExpanded
? mobileExpandedSafeBottom
: `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)`
const mobileSegmentsMaxHeight = isMobileExpanded
? MOBILE_EXPANDED_SEGMENTS_H
: `calc(100dvh - ${mobileSegmentsBottom} - 12px)`
const mobileSegmentsSheetEl =
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
<div
className={cn(
`
fixed inset-x-2 z-[2147483647]
flex min-h-0 flex-col overflow-hidden rounded-2xl
border border-white/10 bg-white shadow-2xl ring-1 ring-black/10
dark:bg-gray-950 dark:ring-white/10
sm:hidden
`,
isMobileExpanded &&
`
bg-white/95 backdrop-blur-md
dark:bg-gray-950/95
`
)}
style={{
bottom: mobileSegmentsBottom,
maxHeight: mobileSegmentsMaxHeight,
height: mobileSegmentsMaxHeight,
touchAction: 'none',
overscrollBehavior: 'contain',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="shrink-0 flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10"
style={{
touchAction: 'none',
}}
onTouchStart={(e) => {
mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null
e.stopPropagation()
}}
onTouchMove={(e) => {
const y = e.touches[0]?.clientY ?? null
const lastY = mobileHeaderTouchYRef.current
if (y != null && lastY != null) {
const dy = lastY - y
mobileSegmentsScrollRef.current?.scrollBy({ top: dy })
mobileHeaderTouchYRef.current = y
}
e.preventDefault()
e.stopPropagation()
}}
onTouchEnd={(e) => {
mobileHeaderTouchYRef.current = null
e.stopPropagation()
}}
onWheel={(e) => {
mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY })
e.preventDefault()
e.stopPropagation()
}}
>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Interessante Stellen
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{ratingSegments.length} Segmente
</div>
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSegmentsPanelOpen(false)
}}
aria-label="Segmente schließen"
title="Schließen"
>
×
</button>
</div>
<div
ref={mobileSegmentsScrollRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 [-webkit-overflow-scrolling:touch]"
style={{
touchAction: 'none',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
}}
onTouchStart={(e) => {
mobileSegmentsTouchYRef.current = e.touches[0]?.clientY ?? null
e.stopPropagation()
}}
onTouchMove={(e) => {
const el = mobileSegmentsScrollRef.current
const y = e.touches[0]?.clientY ?? null
const lastY = mobileSegmentsTouchYRef.current
e.preventDefault()
e.stopPropagation()
if (!el || y == null || lastY == null) return
const dy = lastY - y
el.scrollTop += dy
mobileSegmentsTouchYRef.current = y
}}
onTouchEnd={(e) => {
mobileSegmentsTouchYRef.current = null
e.stopPropagation()
}}
onWheel={(e) => {
const el = mobileSegmentsScrollRef.current
e.preventDefault()
e.stopPropagation()
if (!el) return
el.scrollTop += e.deltaY
}}
>
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={(seconds) => {
seekPlayerToAbsolute(seconds)
}}
maxHeight={mobileSegmentsMaxHeight}
className="min-h-0 shadow-none"
/>
</div>
</div>
) : null
const expandedRect = isMobileExpanded
? {
left: ox + MOBILE_EXPANDED_MARGIN_X,
top: oy + MOBILE_EXPANDED_TOP,
width: Math.max(0, vw - MOBILE_EXPANDED_MARGIN_X * 2),
height: mobileExpandedPlayerH,
}
: {
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;
}
.player-sidebar-segments {
background: rgba(0, 0, 0, 0.3) !important;
color: white !important;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.22);
}
.player-sidebar-segments > * {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.08) !important;
box-shadow: none !important;
}
.player-sidebar-segments [class*="ring-white"],
.player-sidebar-segments [class*="border-white"] {
--tw-ring-color: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.player-sidebar-segments [class*="bg-white"] {
background-color: rgba(255, 255, 255, 0.06) !important;
}
.player-sidebar-segments [class*="text-gray-"],
.player-sidebar-segments [class*="text-slate-"],
.player-sidebar-segments [class*="text-zinc-"],
.player-sidebar-segments [class*="text-neutral-"],
.player-sidebar-segments [class*="text-stone-"],
.player-sidebar-segments [class*="text-black"] {
color: rgba(255, 255, 255, 0.92) !important;
}
.player-sidebar-segments [class*="text-gray-4"],
.player-sidebar-segments [class*="text-gray-5"],
.player-sidebar-segments [class*="text-gray-6"],
.player-sidebar-segments [class*="text-slate-4"],
.player-sidebar-segments [class*="text-slate-5"],
.player-sidebar-segments [class*="text-slate-6"],
.player-sidebar-segments [class*="text-zinc-4"],
.player-sidebar-segments [class*="text-zinc-5"],
.player-sidebar-segments [class*="text-zinc-6"] {
color: rgba(255, 255, 255, 0.68) !important;
}
.player-sidebar-segments svg {
color: rgba(255, 255, 255, 0.85) !important;
}
.player-video-frame .video-js {
width: 100% !important;
height: 100% !important;
}
.player-video-frame .video-js .vjs-tech {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
object-position: center center !important;
}
.player-video-frame .video-js.vjs-fill,
.player-video-frame .video-js .vjs-tech {
position: absolute !important;
inset: 0 !important;
}
/* Maximierter Finished-Player: CurrentTime / EndTime in der Video.js-Controlbar anzeigen */
.vjs-expanded-player .video-js .vjs-current-time,
.vjs-expanded-player .video-js .vjs-time-divider,
.vjs-expanded-player .video-js .vjs-duration {
display: flex !important;
align-items: center !important;
flex: 0 0 auto !important;
}
.vjs-expanded-player .video-js .vjs-time-control {
min-width: auto !important;
padding-left: 0.35em !important;
padding-right: 0.35em !important;
font-variant-numeric: tabular-nums;
}
.vjs-expanded-player .video-js .vjs-current-time-display,
.vjs-expanded-player .video-js .vjs-duration-display {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vjs-expanded-player .video-js .vjs-time-divider {
padding-left: 0.15em !important;
padding-right: 0.15em !important;
}
.vjs-expanded-player .video-js .vjs-progress-control {
min-width: 120px;
}
`}</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}
{!isMobileExpanded ? segmentsPanelEl : null}
{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>
{isMobileExpanded ? mobileSegmentsSheetEl : null}
</>
) : (
<>
{mobileSegmentsSheetEl}
<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: mobilePlayerBottom,
}}
>
{cardEl}
</div>
</>
)}
</>
)
return createPortal(content, portalTarget)
}