// 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)
}
