2903 lines
90 KiB
TypeScript
2903 lines
90 KiB
TypeScript
// 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)
|
||
}
|