// frontend\src\components\ui\Player.tsx
'use client'

import * as React from 'react'
import type { RecordJob } from '../../types'
import Card from './Card'
import videojs from 'video.js'
import type VideoJsPlayer from 'video.js/dist/types/player'
import 'video.js/dist/video-js.css'
import { createPortal } from 'react-dom'
import {
  ArrowsPointingOutIcon,
  ArrowsPointingInIcon,
  XMarkIcon,
} from '@heroicons/react/24/outline'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveHlsVideo from './LiveHlsVideo'

// ✅ Video.js Gear Menu (nur Quality)
function ensureGearControlRegistered() {
  const vjsAny = videojs as any
  if (vjsAny.__gearControlRegistered) return
  vjsAny.__gearControlRegistered = true

  const MenuButton = videojs.getComponent('MenuButton')
  const MenuItem = videojs.getComponent('MenuItem')

  class GearMenuItem extends (MenuItem as any) {
    private _onSelect?: () => void
    constructor(player: any, options: any) {
      super(player, options)
      this._onSelect = options?.onSelect
      this.on('click', () => this._onSelect?.())
    }
  }

  class GearMenuButton extends (MenuButton as any) {
    constructor(player: any, options: any) {
      super(player, options)
      this.controlText('Settings')

      // Icon ersetzen: ⚙️ SVG in den Placeholder
      const el = this.el() as HTMLElement
      const ph = el.querySelector('.vjs-icon-placeholder') as HTMLElement | null
      if (ph) {
        ph.innerHTML = `
          <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
            <path fill="currentColor" d="M19.14,12.94c0.04-0.31,0.06-0.63,0.06-0.94s-0.02-0.63-0.06-0.94l2.03-1.58
            c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.11-0.2-0.36-0.28-0.57-0.2l-2.39,0.96c-0.5-0.38-1.04-0.7-1.64-0.94
            L14.4,2.81C14.37,2.59,14.18,2.42,13.95,2.42h-3.9c-0.23,0-0.42,0.17-0.45,0.39L9.27,5.37
            C8.67,5.61,8.13,5.93,7.63,6.31L5.24,5.35c-0.21-0.08-0.46,0-0.57,0.2L2.75,8.87
            C2.64,9.07,2.69,9.34,2.87,9.48l2.03,1.58C4.86,11.37,4.84,11.69,4.84,12s0.02,0.63,0.06,0.94L2.87,14.52
            c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.11,0.2,0.36,0.28,0.57,0.2l2.39-0.96c0.5,0.38,1.04,0.7,1.64,0.94
            l0.33,2.56c0.03,0.22,0.22,0.39,0.45,0.39h3.9c0.23,0,0.42-0.17,0.45-0.39l0.33-2.56c0.6-0.24,1.14-0.56,1.64-0.94
            l2.39,0.96c0.21,0.08,0.46,0,0.57-0.2l1.92-3.32c0.11-0.2,0.06-0.47-0.12-0.61L19.14,12.94z M12,15.5
            c-1.93,0-3.5-1.57-3.5-3.5s1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5S13.93,15.5,12,15.5z"/>
          </svg>
        `
      }

      // ✅ Nur Quality refreshen
      const p: any = this.player()
      p.on('gear:refresh', () => this.update())
    }

    update() {
      try {
        super.update?.()
      } catch {}

      const p: any = this.player()
      const curQ = String(p.options_?.__gearQuality ?? 'auto')
      const autoApplied = String(p.options_?.__autoAppliedQuality ?? '')

      const items = (this.items || []) as any[]
      for (const it of items) {
        const kind = it?.options_?.__kind
        const val = it?.options_?.__value
        if (kind === 'quality') it.selected?.(String(val) === curQ)
      }

      // ✅ Auto-Label: "Auto (720p)" wenn Auto aktiv
      try {
        for (const it of items) {
          if (it?.options_?.__kind !== 'quality') continue
          if (String(it?.options_?.__value) !== 'auto') continue
          const el = it.el?.() as HTMLElement | null
          const text = el?.querySelector?.('.vjs-menu-item-text') as HTMLElement | null
          if (!text) continue

          if (curQ === 'auto' && autoApplied && autoApplied !== 'auto') {
            text.textContent = `Auto (${autoApplied})`
          } else {
            text.textContent = 'Auto'
          }
        }
      } catch {}
    }

    createItems() {
      const player: any = this.player()
      const items: any[] = []

      // ✅ KEIN "Quality" Header mehr

      const qualities = (player.options_?.gearQualities || [
        'auto',
        '1080p',
        '720p',
        '480p',
      ]) as string[]
      const currentQ = String(player.options_?.__gearQuality ?? 'auto')

      for (const q of qualities) {
        const label = q === 'auto' ? 'Auto' : q === '2160p' ? '4K' : q

        items.push(
          new GearMenuItem(player, {
            label,
            selectable: true,
            selected: currentQ === q,
            __kind: 'quality',
            __value: q,
            onSelect: () => {
              player.options_.__gearQuality = q
              player.trigger({ type: 'gear:quality', quality: q })
              player.trigger('gear:refresh')
            },
          })
        )
      }

      return items
    }
  }

  // ✅ Typing-Fix
  const VjsComponent = videojs.getComponent('Component') as any
  videojs.registerComponent(
    'GearMenuButton',
    GearMenuButton as unknown as typeof VjsComponent
  )

  // ✅ CSS nur 1x injizieren
  if (!vjsAny.__gearControlCssInjected) {
    vjsAny.__gearControlCssInjected = true
    const css = document.createElement('style')
    css.textContent = `
      #player-root .vjs-gear-menu .vjs-icon-placeholder {
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }

      /* ✅ Menübreite nicht aufblasen */
      #player-root .vjs-gear-menu .vjs-menu {
        min-width: 0 !important;
        width: fit-content !important;
      }

      /* ✅ die UL auch nicht breit ziehen */
      #player-root .vjs-gear-menu .vjs-menu-content {
        width: fit-content !important;
        min-width: 0 !important;
        padding: 2px 0 !important;
      }

      /* ✅ Items kompakter */
      #player-root .vjs-gear-menu .vjs-menu-content .vjs-menu-item {
        padding: 4px 10px !important;
        line-height: 1.1 !important;
      }

      /* ✅ Gear-Button als Anker */
      #player-root .vjs-gear-menu {
        position: relative !important;
      }

      /* ✅ Popup wirklich über dem Gear-Icon zentrieren */
      #player-root .vjs-gear-menu .vjs-menu {
        position: absolute !important;

        left: 0% !important;
        right: 0% !important;

        transform: translateX(-50%) !important;
        transform-origin: 50% 100% !important;

        z-index: 9999 !important;
      }

      /* ✅ Manche Skins setzen am UL noch Layout/Width – neutralisieren */
      #player-root .vjs-gear-menu .vjs-menu-content {
        width: max-content !important;
        min-width: 0 !important;
      }

      /* ✅ Menü horizontal zentrieren über dem Gear-Icon */
      #player-root .vjs-gear-menu .vjs-menu {
        z-index: 9999 !important;
      }
    `
    document.head.appendChild(css)
  }
}

const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const lower = (s: string) => (s || '').trim().toLowerCase()

type StoredModelFlagsLite = {
  tags?: string
  favorite?: boolean
  liked?: boolean | null
  watching?: boolean | null
}

// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = (raw?: string): string[] => {
  const s = String(raw ?? '').trim()
  if (!s) return []

  const parts = s
    .split(/[\n,;|]+/g)
    .map((p) => p.trim())
    .filter(Boolean)

  // stable dedupe (case-insensitive), aber original casing behalten
  const seen = new Set<string>()
  const out: string[] = []
  for (const p of parts) {
    const k = p.toLowerCase()
    if (seen.has(k)) continue
    seen.add(k)
    out.push(p)
  }

  // alphabetisch (case-insensitive)
  out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
  return out
}

function formatDuration(ms: number): string {
  if (!Number.isFinite(ms) || ms <= 0) return '—'
  const totalSec = Math.floor(ms / 1000)
  const h = Math.floor(totalSec / 3600)
  const m = Math.floor((totalSec % 3600) / 60)
  const s = totalSec % 60
  if (h > 0) return `${h}h ${m}m`
  if (m > 0) return `${m}m ${s}s`
  return `${s}s`
}

function formatBytes(bytes?: number | null): string {
  if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
  let v = bytes
  let i = 0
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024
    i++
  }
  const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
  return `${v.toFixed(digits)} ${units[i]}`
}

function formatDateTime(v?: string | number | Date | null): string {
  if (!v) return '—'
  const d = v instanceof Date ? v : new Date(v)
  const t = d.getTime()
  if (!Number.isFinite(t)) return '—'
  return d.toLocaleString(undefined, {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  })
}

const pickNum = (...vals: any[]): number | null => {
  for (const v of vals) {
    const n = typeof v === 'string' ? Number(v) : v
    if (typeof n === 'number' && Number.isFinite(n) && n > 0) return n
  }
  return null
}

function formatFps(n?: number | null): string {
  if (!n || !Number.isFinite(n)) return '—'
  const digits = n >= 10 ? 0 : 2
  return `${n.toFixed(digits)} fps`
}

function formatResolution(h?: number | null): string {
  if (!h || !Number.isFinite(h)) return '—'
  return `${Math.round(h)}p`
}

function parseDateFromOutput(output?: string): Date | null {
  const fileRaw = baseName(output || '')
  const file = stripHotPrefix(fileRaw)

  if (!file) return null

  const stem = file.replace(/\.[^.]+$/, '')
  // model_MM_DD_YYYY__HH-MM-SS
  const m = stem.match(/_(\d{1,2})_(\d{1,2})_(\d{4})__(\d{1,2})-(\d{2})-(\d{2})$/)
  if (!m) return null

  const mm = Number(m[1])
  const dd = Number(m[2])
  const yyyy = Number(m[3])
  const hh = Number(m[4])
  const mi = Number(m[5])
  const ss = Number(m[6])

  if (![mm, dd, yyyy, hh, mi, ss].every((n) => Number.isFinite(n))) return null
  return new Date(yyyy, mm - 1, dd, hh, mi, ss)
}

const modelNameFromOutput = (output?: string) => {
  const fileRaw = baseName(output || '')
  const file = stripHotPrefix(fileRaw)
  if (!file) return '—'

  const stem = file.replace(/\.[^.]+$/, '')
  const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
  if (m?.[1]) return m[1]

  const i = stem.lastIndexOf('_')
  return i > 0 ? stem.slice(0, i) : stem
}

const sizeBytesOf = (job: RecordJob): number | null => {
  const anyJob = job as any
  const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}

function cn(...parts: Array<string | false | null | undefined>) {
  return parts.filter(Boolean).join(' ')
}

function useMediaQuery(query: string) {
  const [matches, setMatches] = React.useState(false)

  React.useEffect(() => {
    if (typeof window === 'undefined') return
    const mql = window.matchMedia(query)
    const onChange = () => setMatches(mql.matches)
    onChange()

    if (mql.addEventListener) mql.addEventListener('change', onChange)
    else mql.addListener(onChange)

    return () => {
      if (mql.removeEventListener) mql.removeEventListener('change', onChange)
      else mql.removeListener(onChange)
    }
  }, [query])

  return matches
}

function installAbsoluteTimelineShim(p: any) {
  if (!p || p.__absTimelineShimInstalled) return
  p.__absTimelineShimInstalled = true

  // Originale Methoden sichern
  p.__origCurrentTime = p.currentTime.bind(p)
  p.__origDuration = p.duration.bind(p)

  // Helper: relative Zeit (innerhalb des aktuell geladenen Segments) setzen,
  // OHNE server-seek auszulösen.
  p.__setRelativeTime = (rel: number) => {
    try {
      p.__origCurrentTime(Math.max(0, rel || 0))
    } catch {}
  }

  // currentTime(): GET => absolute Zeit, SET => absolute Zeit (-> server seek falls vorhanden)
  p.currentTime = function (v?: number) {
    const off = Number(this.__timeOffsetSec ?? 0) || 0

    // SET (Seekbar / API)
    if (typeof v === 'number' && Number.isFinite(v)) {
      const abs = Math.max(0, v)

      // Wenn wir server-seek können: als absolute Zeit interpretieren
      if (typeof this.__serverSeekAbs === 'function') {
        this.__serverSeekAbs(abs)
        return abs
      }

      // Fallback: innerhalb aktueller Datei relativ setzen
      return this.__origCurrentTime(Math.max(0, abs - off))
    }

    // GET
    const rel = Number(this.__origCurrentTime() ?? 0) || 0
    return Math.max(0, off + rel)
  }

  // duration(): immer volle Original-Dauer zurückgeben, wenn bekannt
  p.duration = function () {
    const full = Number(this.__fullDurationSec ?? 0) || 0
    if (full > 0) return full

    // Fallback: offset + segment-dauer
    const off = Number(this.__timeOffsetSec ?? 0) || 0
    const relDur = Number(this.__origDuration() ?? 0) || 0
    return Math.max(0, off + relDur)
  }
}

function gearQualitiesForHeight(h?: number | null): string[] {
  const srcH = typeof h === 'number' && Number.isFinite(h) && h > 0 ? Math.round(h) : 0
  const ladder = [2160, 1440, 1080, 720, 480, 360, 240] // gewünschte Stufen

  const toQ = (n: number) => `${n}p`
  const uniq = (arr: string[]) => Array.from(new Set(arr))

  // Wenn unbekannt: trotzdem Ladder anbieten + Auto
  if (!srcH) {
    return ['auto', ...ladder.map(toQ)]
  }

  // native Höhe (immer anzeigen, auch wenn nicht genau Ladder)
  const allowed = ladder.filter((x) => x <= srcH + 8)

  // native immer rein (und dann absteigend sortiert)
  const allHeights = Array.from(new Set([...allowed, srcH])).sort((a, b) => b - a)

  return uniq(['auto', ...allHeights.map(toQ)])
}

export type PlayerProps = {
  job: RecordJob
  expanded: boolean
  onClose: () => void
  onToggleExpand: () => void
  className?: string
  modelKey?: string

  // ✅ neu: ModelStore für Tags wie FinishedDownloads
  modelsByKey?: Record<string, StoredModelFlagsLite>

  // states für Buttons
  isHot?: boolean
  isFavorite?: boolean
  isLiked?: boolean
  isWatching?: boolean

  // actions
  onKeep?: (job: RecordJob) => void | Promise<void>
  onDelete?: (job: RecordJob) => void | Promise<void>
  onToggleHot?: (
  job: RecordJob
  ) =>
    | void
    | { ok?: boolean; oldFile?: string; newFile?: string }
    | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
  onToggleFavorite?: (job: RecordJob) => void | Promise<void>
  onToggleLike?: (job: RecordJob) => void | Promise<void>
  onToggleWatch?: (job: RecordJob) => void | Promise<void>

  // ✅ neu: laufenden Download stoppen
  onStopJob?: (id: string) => void | Promise<void>

  startMuted?: boolean
}

export default function Player({
  job,
  expanded,
  onClose,
  onToggleExpand,
  modelKey,
  modelsByKey,
  isHot = false,
  isFavorite = false,
  isLiked = false,
  isWatching = false,
  onKeep,
  onDelete,
  onToggleHot,
  onToggleFavorite,
  onToggleLike,
  onToggleWatch,
  onStopJob,
  startMuted = DEFAULT_PLAYER_START_MUTED,
}: PlayerProps) {
  const title = React.useMemo(
    () => baseName(job.output?.trim() || '') || job.id,
    [job.output, job.id]
  )

  const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
  const playName = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
  const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
  const anyJob = job as any

  const [fullDurationSec, setFullDurationSec] = React.useState<number>(() => {
    return (
      Number((job as any)?.meta?.durationSeconds) ||
      Number((job as any)?.durationSeconds) ||
      0
    )
  })

  const [metaReady, setMetaReady] = React.useState<boolean>(() => {
    // live ist egal, finished: erst mal false (wir holen gleich)
    return job.status === 'running'
  })

  const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({
    h: 0,
    fps: null,
  }))

  // ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
  const isRunning = job.status === 'running'
  const [hlsReady, setHlsReady] = React.useState(false)
  const isLive = isRunning && hlsReady

  // ✅ Backend erwartet "id=" (nicht "name=")
  // running: echte job.id (jobs-map lookup)
  // finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
  const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])

  const previewId = React.useMemo(
    () => (isRunning ? job.id : finishedStem || job.id),
    [isRunning, job.id, finishedStem]
  )

  React.useEffect(() => {
    if (isRunning) return
    if (fullDurationSec > 0) return

    const fileName = baseName(job.output?.trim() || '')
    if (!fileName) return

    let alive = true
    const ctrl = new AbortController()

    ;(async () => {
      try {
        // ✅ Backend-Endpoint existiert bei dir bereits: /api/record/done/meta
        // Ich gebe hier file mit, weil du finished oft darüber mapst.
        const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
        const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })
        if (!res.ok) return

        const j = await res.json()
        const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
        if (!alive || dur <= 0) return

        setFullDurationSec(dur)

        // ✅ Video.js Duration-Shim nachträglich füttern + UI refreshen
        const p: any = playerRef.current
        if (p && !p.isDisposed?.()) {
          try {
            p.__fullDurationSec = dur
            p.trigger?.('durationchange')
            p.trigger?.('timeupdate')
          } catch {}
        }
      } catch {}
    })()

    return () => {
      alive = false
      ctrl.abort()
    }
  }, [isRunning, fullDurationSec, job.output])

  const isHotFile = fileRaw.startsWith('HOT ')
  const model = React.useMemo(() => {
    const k = (modelKey || '').trim()
    return k ? k : modelNameFromOutput(job.output)
  }, [modelKey, job.output])
  const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])

  const runtimeLabel = React.useMemo(() => {
    const sec = Number(fullDurationSec || 0) || 0
    return sec > 0 ? formatDuration(sec * 1000) : '—'
  }, [fullDurationSec])

  // Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
  const dateLabel = React.useMemo(() => {
    const fromName = parseDateFromOutput(job.output)
    if (fromName) return formatDateTime(fromName)

    const fallback =
      anyJob.startedAt ??
      anyJob.endedAt ??
      anyJob.createdAt ??
      anyJob.fileCreatedAt ??
      anyJob.ctime ??
      null

    const d = fallback ? new Date(fallback) : null
    return formatDateTime(d && Number.isFinite(d.getTime()) ? d : null)
  }, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime])

  // ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey)
  const effectiveModelKey = React.useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model])

  const tags = React.useMemo(() => {
    const flags = modelsByKey?.[effectiveModelKey]
    return parseTags(flags?.tags)
  }, [modelsByKey, effectiveModelKey])

  // Vorschaubild oben
  const previewA = React.useMemo(
    () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
    [previewId]
  )

  const previewB = React.useMemo(
    () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.webp`),
    [previewId]
  )

  // ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
  const liveHlsSrc = React.useMemo(
    () => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&play=1&file=index_hq.m3u8`),
    [previewId]
  )

  const [previewSrc, setPreviewSrc] = React.useState(previewA)

  React.useEffect(() => {
    setPreviewSrc(previewA)
  }, [previewA])

  const videoH = React.useMemo(
    () => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
    [metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
  )

  const fps = React.useMemo(
    () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
    [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
  )

  const [intrH, setIntrH] = React.useState<number | null>(null)

  const resolutionLabel = React.useMemo(() => formatResolution(intrH ?? videoH), [intrH, videoH])
  const fpsLabel = React.useMemo(() => formatFps(fps), [fps])

  React.useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [onClose])

  const hlsIndexUrl = React.useMemo(() => {
    const u = `/api/record/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1`
    return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
  }, [previewId, isRunning])

  React.useEffect(() => {
    if (!isRunning) {
      setHlsReady(false)
      return
    }

    let alive = true
    const ctrl = new AbortController()
    setHlsReady(false)

    const poll = async () => {
      for (let i = 0; i < 120 && alive && !ctrl.signal.aborted; i++) {
        try {
          const res = await apiFetch(hlsIndexUrl, {
            method: 'GET',
            cache: 'no-store',
            signal: ctrl.signal,
            headers: { 'cache-control': 'no-cache' },
          })

          if (res.ok) {
            const text = await res.text()

            // ✅ muss wirklich wie eine m3u8 aussehen und mindestens 1 Segment enthalten
            const hasM3u = text.includes('#EXTM3U')
            const hasSegment =
              /#EXTINF:/i.test(text) || /\.ts(\?|$)/i.test(text) || /\.m4s(\?|$)/i.test(text)

            if (hasM3u && hasSegment) {
              if (alive) setHlsReady(true)
              return
            }
          }
        } catch {}
        await new Promise((r) => setTimeout(r, 500))
      }
    }

    poll()
    return () => {
      alive = false
      ctrl.abort()
    }
  }, [isRunning, hlsIndexUrl])

  const buildVideoSrc = React.useCallback(
    (params: { file?: string; id?: string; quality?: string; startSec?: number }) => {
      const q = String(params.quality || 'auto').trim()

      const startSec =
        typeof params.startSec === 'number' && Number.isFinite(params.startSec) && params.startSec > 0
          ? Math.floor(params.startSec)
          : 0

      // ✅ query params sauber bauen
      const qp = new URLSearchParams()
      if (params.file) qp.set('file', params.file)
      if (params.id) qp.set('id', params.id)

      const qToH = (qq: string): number => {
        const m = String(qq).match(/(\d{3,4})p/i)
        return m ? Number(m[1]) : 0
      }

      // Quelle (für Downscale-Erkennung)
      const sourceH =
        typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0

      if (q && q !== 'auto') {
        qp.set('quality', q)

        // ✅ stream=1 nur für "Start bei 0" (schneller Start ohne Full-Transcode)
        // Bei startSec>0 wollen wir i.d.R. Segment-Cache + Range (besseres Seek/Quality-Switch)
        const targetH = qToH(q)
        if (startSec === 0 && sourceH && targetH && targetH < sourceH - 8) {
          qp.set('stream', '1')
        }
      }

      if (startSec > 0) {
        qp.set('t', String(startSec)) // oder qp.set('start', String(startSec))
      }

      return apiUrl(`/api/record/video?${qp.toString()}`)
    },
    [videoH]
  )

  // ✅ requested = UI (Auto oder fix), applied = echte Source-Qualität
  const [requestedQuality, setRequestedQuality] = React.useState<string>('auto')
  const [appliedQuality, setAppliedQuality] = React.useState<string>('auto')

  const requestedQualityRef = React.useRef(requestedQuality)
  React.useEffect(() => {
    requestedQualityRef.current = requestedQuality
  }, [requestedQuality])

  const appliedQualityRef = React.useRef(appliedQuality)
  React.useEffect(() => {
    appliedQualityRef.current = appliedQuality
  }, [appliedQuality])

  const media = React.useMemo(() => {
    // ✅ Live wird NICHT mehr über Video.js gespielt
    if (isRunning) return { src: '', type: '' }

    // ✅ Warten bis meta.json existiert + Infos geladen
    if (!metaReady) return { src: '', type: '' }

    const file = baseName(job.output?.trim() || '')

    if (file) {
      const ext = file.toLowerCase().split('.').pop()
      const type =
        ext === 'mp4' ? 'video/mp4' : ext === 'ts' ? 'video/mp2t' : 'application/octet-stream'
      return { src: buildVideoSrc({ file, quality: appliedQuality }), type }
    }

    return { src: buildVideoSrc({ id: job.id, quality: appliedQuality }), type: 'video/mp4' }
  }, [isRunning, metaReady, job.output, job.id, appliedQuality, buildVideoSrc])

  const containerRef = React.useRef<HTMLDivElement | null>(null)
  const playerRef = React.useRef<VideoJsPlayer | null>(null)
  const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
  const skipNextMediaSrcRef = React.useRef(false)

  const [mounted, setMounted] = React.useState(false)
  const [playerReadyTick, setPlayerReadyTick] = React.useState(0)

  const updateIntrinsicDims = React.useCallback(() => {
    const p: any = playerRef.current
    if (!p || p.isDisposed?.()) return
    const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
    if (typeof h === 'number' && h > 0 && Number.isFinite(h)) {
      setIntrH(h)
    }
  }, [])

  // pro Datei einmal default setzen (damit beim nächsten Video wieder sinnvoll startet)
  const playbackKey = React.useMemo(() => {
    // finished: Dateiname; running ist eh LiveHlsVideo, also egal
    return baseName(job.output?.trim() || '') || job.id
  }, [job.output, job.id])

  const defaultQuality = React.useMemo(() => {
    const h = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0
    return h ? `${h}p` : 'auto'
  }, [videoH])

  const lastPlaybackKeyRef = React.useRef<string>('')

  React.useEffect(() => {
    if (lastPlaybackKeyRef.current !== playbackKey) {
      lastPlaybackKeyRef.current = playbackKey
      setRequestedQuality(defaultQuality)
      setAppliedQuality(defaultQuality)
      setIntrH(null)

      // player-Optionen syncen, falls schon gemountet
      const p: any = playerRef.current
      if (p && !p.isDisposed?.()) {
        try {
          p.options_.__gearQuality = defaultQuality
          p.options_.__autoAppliedQuality = defaultQuality
          p.trigger?.('gear:refresh')
        } catch {}
      }
    }
  }, [playbackKey, defaultQuality])

  React.useEffect(() => {
    if (isRunning) {
      setMetaReady(true)
      return
    }

    const fileName = baseName(job.output?.trim() || '')
    if (!fileName) {
      // wenn kein file → fail-open
      setMetaReady(true)
      return
    }

    let alive = true
    const ctrl = new AbortController()

    setMetaReady(false)

    ;(async () => {
      // Poll bis metaExists=true (oder fail-open nach N Versuchen)
      for (let i = 0; i < 80 && alive && !ctrl.signal.aborted; i++) {
        try {
          const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
          const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })

          if (res.ok) {
            const j = await res.json()

            const exists = Boolean(j?.metaExists)
            const dur = Number(j?.durationSeconds || 0) || 0
            const h = Number(j?.height || 0) || 0
            const fps = Number(j?.fps || 0) || 0

            // ✅ Infos neu in den Player-State übernehmen
            if (dur > 0) {
              setFullDurationSec(dur)
              const p: any = playerRef.current
              if (p && !p.isDisposed?.()) {
                try {
                  p.__fullDurationSec = dur
                  p.trigger?.('durationchange')
                  p.trigger?.('timeupdate')
                } catch {}
              }
            }

            if (h > 0) {
              setMetaDims({ h, fps: fps > 0 ? fps : null })
            }

            if (exists) {
              setMetaReady(true)
              return
            }
          }
        } catch {}

        await new Promise((r) => setTimeout(r, 250))
      }

      // fail-open (damit der Player nicht “für immer” blockiert)
      if (alive) setMetaReady(true)
    })()

    return () => {
      alive = false
      ctrl.abort()
    }
  }, [isRunning, playbackKey, job.output])

  // ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
  const [, setVvTick] = React.useState(0)

  React.useEffect(() => {
    if (typeof window === 'undefined') return
    const vv = window.visualViewport
    if (!vv) return

    const bump = () => setVvTick((x) => x + 1)

    bump()
    vv.addEventListener('resize', bump)
    vv.addEventListener('scroll', bump)
    window.addEventListener('resize', bump)
    window.addEventListener('orientationchange', bump)

    return () => {
      vv.removeEventListener('resize', bump)
      vv.removeEventListener('scroll', bump)
      window.removeEventListener('resize', bump)
      window.removeEventListener('orientationchange', bump)
    }
  }, [])

  const [controlBarH, setControlBarH] = React.useState(56)
  const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null)

  const mini = !expanded

  type WinRect = { x: number; y: number; w: number; h: number }
  type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'

  const isDesktop = useMediaQuery('(min-width: 640px)')
  const miniDesktop = mini && isDesktop

  const WIN_KEY = 'player_window_v1'

  const DEFAULT_W = 420
  const DEFAULT_H = 280
  const MARGIN = 12
  const MIN_W = 320
  const MIN_H = 200

  React.useEffect(() => {
    if (!mounted) return
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    const root = p.el() as HTMLElement | null
    if (!root) return

    const bar = root.querySelector('.vjs-control-bar') as HTMLElement | null
    if (!bar) return

    const update = () => {
      const h = Math.round(bar.getBoundingClientRect().height || 0)
      if (h > 0) setControlBarH(h)
    }

    update()

    let ro: ResizeObserver | null = null
    if (typeof ResizeObserver !== 'undefined') {
      ro = new ResizeObserver(update)
      ro.observe(bar)
    }

    window.addEventListener('resize', update)
    return () => {
      window.removeEventListener('resize', update)
      ro?.disconnect()
    }
  }, [mounted, expanded])

  // ✅ zentrale Umschalt-Funktion (Quality Switch, inkl. t= Segment)
  const applyQualitySwitch = React.useCallback(
    (p: any, fileName: string, nextQ: string) => {
      if (isRunning) return

      installAbsoluteTimelineShim(p)

      const wasPaused = Boolean(p.paused?.())

      // absolute Zeit (Shim)
      const absNow = Number(p.currentTime?.() ?? 0) || 0
      const startSec = absNow > 0 ? Math.floor(absNow / 2) * 2 : 0
      const rel = absNow - startSec

      p.__timeOffsetSec = startSec

      // media-effect darf nicht direkt danach drüberbügeln
      skipNextMediaSrcRef.current = true

      // ✅ applied wechseln
      setAppliedQuality(nextQ)

      // fürs Label "Auto (720p)"
      p.options_.__autoAppliedQuality = nextQ

      const nextSrc = buildVideoSrc({
        file: fileName,
        quality: nextQ,
        startSec,
      })

      const knownFull = Number(fullDurationSec || 0) || 0
      if (knownFull > 0) p.__fullDurationSec = knownFull

      try {
        const prev = p.__onLoadedMetaQSwitch
        if (prev) p.off('loadedmetadata', prev)
      } catch {}

      const onLoadedMeta = () => {
        updateIntrinsicDims()

        if (!(Number(p.__fullDurationSec) > 0)) {
          try {
            const segDur = Number(p.__origDuration?.() ?? 0) || 0
            if (segDur > 0) p.__fullDurationSec = startSec + segDur
          } catch {}
        }

        try {
          p.__setRelativeTime?.(rel)
        } catch {}
        try {
          p.trigger?.('timeupdate')
        } catch {}

        // Auto-label refresh
        try {
          p.trigger?.('gear:refresh')
        } catch {}

        if (!wasPaused) {
          const ret = p.play?.()
          if (ret && typeof ret.catch === 'function') ret.catch(() => {})
        }
      }

      p.__onLoadedMetaQSwitch = onLoadedMeta
      p.one('loadedmetadata', onLoadedMeta)

      try {
        p.src({ src: nextSrc, type: 'video/mp4' })
      } catch {}
      try {
        p.load?.()
      } catch {}
    },
    [isRunning, buildVideoSrc, updateIntrinsicDims, fullDurationSec]
  )

  // ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten
  React.useEffect(() => {
    const p = playerRef.current as any
    if (!p || p.isDisposed?.()) return

    installAbsoluteTimelineShim(p)

    const onQ = (ev: any) => {
      const q = String(ev?.quality ?? 'auto').trim()
      if (isRunning) return

      const fileName = baseName(job.output?.trim() || '')
      if (!fileName) return

      // requested immer updaten (UI)
      setRequestedQuality(q)

      // Auto: applied bleibt erstmal wie aktuell
      if (q === 'auto') {
        try {
          p.options_.__gearQuality = 'auto'
          p.options_.__autoAppliedQuality = appliedQualityRef.current || defaultQuality
          p.trigger?.('gear:refresh')
        } catch {}
        return
      }

      // Manual: direkt umschalten
      try {
        p.options_.__gearQuality = q
        p.options_.__autoAppliedQuality = q
        p.trigger?.('gear:refresh')
      } catch {}

      applyQualitySwitch(p, fileName, q)
    }

    p.on('gear:quality', onQ)
    return () => {
      try {
        p.off('gear:quality', onQ)
      } catch {}
    }
  }, [playerReadyTick, job.output, isRunning, applyQualitySwitch, defaultQuality])

  // ✅ Auto-Controller: buffer/stall-basiert hoch/runter
  React.useEffect(() => {
    if (!mounted) return
    if (isRunning) return
    if (requestedQuality !== 'auto') return

    const p: any = playerRef.current
    if (!p || p.isDisposed?.()) return

    installAbsoluteTimelineShim(p)

    const fileName = baseName(job.output?.trim() || '')
    if (!fileName) return

    const getLadder = (): string[] => {
      const q = (p.options_?.gearQualities || gearQualitiesForHeight(intrH ?? videoH)) as string[]
      return q.filter((x) => x && x !== 'auto')
    }

    const qToNum = (qq: string) => {
      const m = String(qq).match(/(\d{3,4})p/i)
      return m ? Number(m[1]) : 0
    }

    const sortDesc = (arr: string[]) => [...arr].sort((a, b) => qToNum(b) - qToNum(a))

    const pickLower = (cur: string, ladder: string[]) => {
      const L = sortDesc(ladder)
      const curN = qToNum(cur)
      for (let i = 0; i < L.length; i++) {
        const n = qToNum(L[i])
        if (n > 0 && n < curN - 8) return L[i]
      }
      return L[L.length - 1] || cur
    }

    const pickHigher = (cur: string, ladder: string[]) => {
      const L = sortDesc(ladder)
      const curN = qToNum(cur)
      for (let i = L.length - 1; i >= 0; i--) {
        const n = qToNum(L[i])
        if (n > curN + 8) return L[i]
      }
      return L[0] || cur
    }

    let lastStallAt = 0
    let lastSwitchAt = 0
    let stableSince = 0

    const onStall = () => {
      lastStallAt = Date.now()
    }
    p.on('waiting', onStall)
    p.on('stalled', onStall)

    const getBufferAheadSec = () => {
      try {
        const relNow = Number(p.__origCurrentTime?.() ?? 0) || 0
        const buf = p.buffered?.()
        if (!buf || buf.length <= 0) return 0
        const end = buf.end(buf.length - 1)
        return Math.max(0, (Number(end) || 0) - relNow)
      } catch {
        return 0
      }
    }

    const tick = () => {
      const now = Date.now()
      if (now - lastSwitchAt < 8000) return // hysteresis

      const ladder = getLadder()
      if (!ladder.length) return

      const cur = appliedQualityRef.current && appliedQualityRef.current !== 'auto' ? appliedQualityRef.current : ladder[0]
      const ahead = getBufferAheadSec()
      const recentlyStalled = now - lastStallAt < 2500

      // downgrade schnell, wenn buffer knapp / stall
      if (ahead < 4 || recentlyStalled) {
        const next = pickLower(cur, ladder)
        if (next && next !== cur) {
          lastSwitchAt = now
          stableSince = 0
          try {
            p.options_.__autoAppliedQuality = next
            p.trigger?.('gear:refresh')
          } catch {}
          applyQualitySwitch(p, fileName, next)
        }
        return
      }

      // stable => upgrade langsam
      if (ahead > 20 && now - lastStallAt > 20000) {
        if (!stableSince) stableSince = now
        if (now - stableSince > 20000) {
          const next = pickHigher(cur, ladder)
          if (next && next !== cur) {
            lastSwitchAt = now
            stableSince = now
            try {
              p.options_.__autoAppliedQuality = next
              p.trigger?.('gear:refresh')
            } catch {}
            applyQualitySwitch(p, fileName, next)
          }
        }
      } else {
        stableSince = 0
      }
    }

    const id = window.setInterval(tick, 2000)
    tick()

    return () => {
      window.clearInterval(id)
      try {
        p.off('waiting', onStall)
      } catch {}
      try {
        p.off('stalled', onStall)
      } catch {}
    }
  }, [mounted, isRunning, requestedQuality, job.output, intrH, videoH, applyQualitySwitch])

  React.useEffect(() => setMounted(true), [])

  React.useEffect(() => {
    let el = document.getElementById('player-root') as HTMLElement | null
    if (!el) {
      el = document.createElement('div')
      el.id = 'player-root'
    }

    // ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt
    // ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
    let host: HTMLElement | null = null

    if (isDesktop) {
      const dialogs = Array.from(document.querySelectorAll('dialog[open]')) as HTMLElement[]
      host = dialogs.length ? dialogs[dialogs.length - 1] : null
    }

    host = host ?? document.body
    host.appendChild(el)

    el.style.position = 'relative'
    el.style.zIndex = '2147483647'

    setPortalTarget(el)
  }, [isDesktop])

  React.useEffect(() => {
    const p: any = playerRef.current
    if (!p || p.isDisposed?.()) return
    if (isRunning) return // live nutzt Video.js nicht

    installAbsoluteTimelineShim(p)

    const fileName = baseName(job.output?.trim() || '')
    if (!fileName) return

    // volle Dauer: nimm was du hast (durationSeconds ist bei finished normalerweise da)
    const knownFull = Number(fullDurationSec || 0) || 0
    if (knownFull > 0) p.__fullDurationSec = knownFull

    // absolute server-seek
    p.__serverSeekAbs = (absSec: number) => {
      const abs = Math.max(0, Number(absSec) || 0)

      // ✅ effektive Quality: bei Auto die applied nehmen
      const effectiveQ =
        requestedQualityRef.current === 'auto'
          ? String(appliedQualityRef.current || 'auto').trim()
          : String(requestedQualityRef.current || 'auto').trim()

      // helper: "1080p" -> 1080
      const qToH = (qq: string): number => {
        const m = String(qq).match(/(\d{3,4})p/i)
        return m ? Number(m[1]) : 0
      }

      // Quelle (wie in buildVideoSrc-Logik): wenn target < source => Downscale
      const sourceH =
        typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0
      const targetH = qToH(effectiveQ)

      const needsDownscale =
        effectiveQ !== 'auto' && sourceH > 0 && targetH > 0 && targetH < sourceH - 8

      // ✅ 1) NICHT runterskalieren => NUR client seek (kein FFmpeg)
      if (!needsDownscale) {
        const wasPaused = Boolean(p.paused?.())
        const off = Number(p.__timeOffsetSec ?? 0) || 0
        const curSrc = String(p.currentSrc?.() || '')
        const isSegmented = off > 0 || curSrc.includes('t=') || curSrc.includes('start=')

        if (isSegmented) {
          p.__timeOffsetSec = 0

          const nextSrc = buildVideoSrc({
            file: fileName,
            quality: effectiveQ,
            // startSec absichtlich NICHT setzen
          })

          const onMeta = () => {
            updateIntrinsicDims()
            try {
              p.__origCurrentTime?.(abs)
              try {
                p.trigger?.('timeupdate')
              } catch {}
            } catch {
              try {
                p.currentTime?.(abs)
              } catch {}
            }
            if (!wasPaused) {
              const ret = p.play?.()
              if (ret && typeof ret.catch === 'function') ret.catch(() => {})
            }
          }

          try {
            p.one('loadedmetadata', onMeta)
          } catch {}
          try {
            p.src({ src: nextSrc, type: 'video/mp4' })
          } catch {}
          try {
            p.load?.()
          } catch {}
          return
        }

        try {
          p.__origCurrentTime?.(abs)
          try {
            p.trigger?.('timeupdate')
          } catch {}
        } catch {
          try {
            p.currentTime?.(abs)
          } catch {}
        }
        return
      }

      // ✅ 2) Downscale aktiv => Server-Seek (Segment)
      const wasPaused = Boolean(p.paused?.())
      const startSec = Math.floor(abs / 2) * 2
      const rel = abs - startSec

      p.__timeOffsetSec = startSec

      const nextSrc = buildVideoSrc({
        file: fileName,
        quality: effectiveQ,
        startSec,
      })

      const onMeta = () => {
        updateIntrinsicDims()
        p.__setRelativeTime?.(rel)
        try {
          p.trigger?.('timeupdate')
        } catch {}
        if (!wasPaused) {
          const ret = p.play?.()
          if (ret && typeof ret.catch === 'function') ret.catch(() => {})
        }
      }

      try {
        p.one('loadedmetadata', onMeta)
      } catch {}
      try {
        p.src({ src: nextSrc, type: 'video/mp4' })
      } catch {}
      try {
        p.load?.()
      } catch {}
    }

    return () => {
      try {
        delete p.__serverSeekAbs
      } catch {}
    }
  }, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, videoH])

  React.useLayoutEffect(() => {
    if (!mounted) return
    if (!containerRef.current) return
    if (playerRef.current) return
    if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
    if (!metaReady) return

    const videoEl = document.createElement('video')
    videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
    videoEl.setAttribute('playsinline', 'true')

    containerRef.current.appendChild(videoEl)
    videoNodeRef.current = videoEl

    ensureGearControlRegistered()

    const initialGearQualities = gearQualitiesForHeight(videoH)

    const initialSelectedQuality = (() => {
      const h = typeof videoH === 'number' && Number.isFinite(videoH) && videoH > 0 ? Math.round(videoH) : 0
      return h ? `${h}p` : 'auto'
    })()

    setRequestedQuality(initialSelectedQuality)
    setAppliedQuality(initialSelectedQuality)

    const p = videojs(videoEl, {
      autoplay: true,
      muted: startMuted,
      controls: true,
      preload: 'metadata',
      playsinline: true,
      responsive: true,
      fluid: false,
      fill: true,
      liveui: false,

      html5: {
        vhs: { lowLatencyMode: true },
      },

      inactivityTimeout: 0,

      gearQualities: initialGearQualities,
      __gearQuality: initialSelectedQuality,
      __autoAppliedQuality: initialSelectedQuality,

      controlBar: {
        skipButtons: { backward: 10, forward: 10 },
        volumePanel: { inline: false },
        children: [
          'skipBackward',
          'playToggle',
          'skipForward',
          'volumePanel',
          'currentTimeDisplay',
          'timeDivider',
          'durationDisplay',
          'progressControl',
          'spacer',
          'playbackRateMenuButton',
          'GearMenuButton',
          'fullscreenToggle',
        ],
      },
      playbackRates: [0.5, 1, 1.25, 1.5, 2],
    })

    playerRef.current = p
    setPlayerReadyTick((x) => x + 1)

    p.one('loadedmetadata', () => {
      updateIntrinsicDims()
      try {
        const h = typeof (p as any).videoHeight === 'function' ? (p as any).videoHeight() : 0
        const next = gearQualitiesForHeight(h)
        ;(p as any).options_.gearQualities = next

        const curReq = String((p as any).options_.__gearQuality ?? 'auto')
        const native =
          typeof h === 'number' && Number.isFinite(h) && h > 0 ? `${Math.round(h)}p` : 'auto'

        const nextReq = next.includes(curReq) ? curReq : next.includes(native) ? native : 'auto'

        ;(p as any).options_.__gearQuality = nextReq

        // state sync
        setRequestedQuality(nextReq)
        if (nextReq !== 'auto') {
          setAppliedQuality(nextReq)
          ;(p as any).options_.__autoAppliedQuality = nextReq
        } else {
          // Auto gewählt: applied falls noch auto -> native
          const curApplied = String(appliedQualityRef.current || 'auto')
          const nextApplied = curApplied !== 'auto' ? curApplied : native !== 'auto' ? native : 'auto'
          setAppliedQuality(nextApplied)
          ;(p as any).options_.__autoAppliedQuality = nextApplied
        }

        p.trigger('gear:refresh')
      } catch {}
    })

    try {
      const gear = (p.getChild('controlBar') as any)?.getChild('GearMenuButton')
      if (gear?.el) gear.el().classList.add('vjs-gear-menu')
    } catch {}

    p.userActive(true)
    p.on('userinactive', () => p.userActive(true))

    return () => {
      try {
        if (playerRef.current) {
          playerRef.current.dispose()
          playerRef.current = null
        }
      } finally {
        if (videoNodeRef.current) {
          videoNodeRef.current.remove()
          videoNodeRef.current = null
        }
      }
    }
  }, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims])

  React.useEffect(() => {
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    const el = p.el() as HTMLElement | null
    if (!el) return

    el.classList.toggle('is-live-download', Boolean(isLive))
  }, [isLive])

  const releaseMedia = React.useCallback(() => {
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    try {
      p.pause()
      ;(p as any).reset?.()
    } catch {}

    try {
      p.src({ src: '', type: 'video/mp4' } as any)
      ;(p as any).load?.()
    } catch {}
  }, [])

  React.useEffect(() => {
    if (!mounted) return
    if (!isRunning && !metaReady) {
      releaseMedia()
      return
    }

    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    // ✅ src wurde gerade manuell (Quality-Wechsel mit t=...) gesetzt -> einmaligen Auto-Apply überspringen
    if (skipNextMediaSrcRef.current) {
      skipNextMediaSrcRef.current = false
      return
    }

    const t = p.currentTime() || 0
    p.muted(startMuted)

    if (!media.src) {
      try {
        p.pause()
        ;(p as any).reset?.()
        p.error(null as any) // Video.js Error-State leeren
      } catch {}
      return
    }

    ;(p as any).__timeOffsetSec = 0

    // volle Dauer kennen wir bei finished meistens schon:
    const knownFull = Number(fullDurationSec || 0) || 0
    ;(p as any).__fullDurationSec = knownFull

    // ✅ NICHT neu setzen, wenn Source identisch ist (verhindert "cancelled" durch unnötige Reloads)
    const curSrc = String((p as any).currentSrc?.() || '')
    if (curSrc && curSrc === media.src) {
      // trotzdem versuchen zu spielen (z.B. wenn nur muted/state geändert wurde)
      const ret = p.play?.()
      if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
      return
    }

    p.src({ src: media.src, type: media.type })

    const tryPlay = () => {
      const ret = p.play?.()
      if (ret && typeof (ret as any).catch === 'function') {
        ;(ret as Promise<void>).catch(() => {})
      }
    }

    p.one('loadedmetadata', () => {
      if ((p as any).isDisposed?.()) return
      updateIntrinsicDims()

      // ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration())
      try {
        const knownFull = Number(fullDurationSec || 0) || 0
        if (knownFull > 0) (p as any).__fullDurationSec = knownFull
      } catch {}

      try {
        p.playbackRate(1)
      } catch {}

      const isHls = /mpegurl/i.test(media.type)

      // ✅ initiale Position wiederherstellen, aber RELATIV (ohne server-seek)
      if (t > 0 && !isHls) {
        try {
          const off = Number((p as any).__timeOffsetSec ?? 0) || 0
          const rel = Math.max(0, t - off)
          ;(p as any).__setRelativeTime?.(rel)
        } catch {}
      }

      try {
        p.trigger?.('timeupdate')
      } catch {}

      tryPlay()
    })

    tryPlay()
  }, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])

  React.useEffect(() => {
    if (!mounted) return
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    const onErr = () => {
      if (job.status === 'running') setHlsReady(false)
    }

    p.on('error', onErr)
    return () => {
      try {
        p.off('error', onErr)
      } catch {}
    }
  }, [mounted, job.status])

  React.useEffect(() => {
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return
    queueMicrotask(() => p.trigger('resize'))
  }, [expanded])

  React.useEffect(() => {
    const onRelease = (ev: Event) => {
      const detail = (ev as CustomEvent<{ file?: string }>).detail
      const file = (detail?.file ?? '').trim()
      if (!file) return

      const current = baseName(job.output?.trim() || '')
      if (current && current === file) releaseMedia()
    }

    window.addEventListener('player:release', onRelease as EventListener)
    return () => window.removeEventListener('player:release', onRelease as EventListener)
  }, [job.output, releaseMedia])

  React.useEffect(() => {
    const onCloseIfFile = (ev: Event) => {
      const detail = (ev as CustomEvent<{ file?: string }>).detail
      const file = (detail?.file ?? '').trim()
      if (!file) return

      const current = baseName(job.output?.trim() || '')
      if (current && current === file) {
        releaseMedia()
        onClose()
      }
    }

    window.addEventListener('player:close', onCloseIfFile as EventListener)
    return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
  }, [job.output, releaseMedia, onClose])

  const getViewport = () => {
    if (typeof window === 'undefined') return { w: 0, h: 0, ox: 0, oy: 0, bottomInset: 0 }

    const vv = window.visualViewport
    if (vv && Number.isFinite(vv.width) && Number.isFinite(vv.height)) {
      const w = Math.floor(vv.width)
      const h = Math.floor(vv.height)

      const ox = Math.floor(vv.offsetLeft || 0)
      const oy = Math.floor(vv.offsetTop || 0)

      // Space below the visual viewport (Safari bottom bar / keyboard)
      const bottomInset = Math.max(0, Math.floor(window.innerHeight - (vv.height + vv.offsetTop)))

      return { w, h, ox, oy, bottomInset }
    }

    const de = document.documentElement
    const w = de?.clientWidth || window.innerWidth
    const h = de?.clientHeight || window.innerHeight
    return { w, h, ox: 0, oy: 0, bottomInset: 0 }
  }

  const clampRect = React.useCallback((r: { x: number; y: number; w: number; h: number }, ratio?: number) => {
    if (typeof window === 'undefined') return r

    const { w: vw, h: vh } = getViewport()
    const maxW = vw - MARGIN * 2
    const maxH = vh - MARGIN * 2

    let w = r.w
    let h = r.h

    if (ratio && Number.isFinite(ratio) && ratio > 0.1) {
      w = Math.max(MIN_W, w)
      h = w / ratio

      if (h < MIN_H) {
        h = MIN_H
        w = h * ratio
      }

      if (w > maxW) {
        w = maxW
        h = w / ratio
      }
      if (h > maxH) {
        h = maxH
        w = h * ratio
      }
    } else {
      w = Math.max(MIN_W, Math.min(w, maxW))
      h = Math.max(MIN_H, Math.min(h, maxH))
    }

    const x = Math.max(MARGIN, Math.min(r.x, vw - w - MARGIN))
    const y = Math.max(MARGIN, Math.min(r.y, vh - h - MARGIN))

    return { x, y, w, h }
  }, [])

  const loadRect = React.useCallback(() => {
    if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H }

    try {
      const raw = window.localStorage.getItem(WIN_KEY)
      if (raw) {
        const v = JSON.parse(raw) as Partial<{ x: number; y: number; w: number; h: number }>
        if (typeof v.x === 'number' && typeof v.y === 'number' && typeof v.w === 'number' && typeof v.h === 'number') {
          return clampRect({ x: v.x, y: v.y, w: v.w, h: v.h }, v.w / v.h)
        }
      }
    } catch {}

    const { w: vw, h: vh } = getViewport()
    const w = DEFAULT_W
    const h = DEFAULT_H

    const x = Math.max(MARGIN, vw - w - MARGIN)
    const y = Math.max(MARGIN, vh - h - MARGIN)
    return clampRect({ x, y, w, h }, w / h)
  }, [clampRect])

  const [win, setWin] = React.useState<WinRect>(() => loadRect())
  const isNarrowMini = miniDesktop && win.w < 380

  const saveRect = React.useCallback((r: WinRect) => {
    if (typeof window === 'undefined') return
    try {
      window.localStorage.setItem(WIN_KEY, JSON.stringify(r))
    } catch {}
  }, [])

  const winRef = React.useRef(win)
  React.useEffect(() => {
    winRef.current = win
  }, [win])

  React.useEffect(() => {
    if (!miniDesktop) return
    setWin(loadRect())
  }, [miniDesktop, loadRect])

  React.useEffect(() => {
    if (!miniDesktop) return
    const onResize = () => setWin((r) => clampRect(r, r.w / r.h))
    window.addEventListener('resize', onResize)
    return () => window.removeEventListener('resize', onResize)
  }, [miniDesktop, clampRect])

  // Video.js resize triggern, wenn sich Fenstergröße ändert
  const vjsResizeRafRef = React.useRef<number | null>(null)
  React.useEffect(() => {
    const p = playerRef.current
    if (!p || (p as any).isDisposed?.()) return

    if (vjsResizeRafRef.current != null) cancelAnimationFrame(vjsResizeRafRef.current)
    vjsResizeRafRef.current = requestAnimationFrame(() => {
      vjsResizeRafRef.current = null
      try {
        p.trigger('resize')
      } catch {}
    })

    return () => {
      if (vjsResizeRafRef.current != null) {
        cancelAnimationFrame(vjsResizeRafRef.current)
        vjsResizeRafRef.current = null
      }
    }
  }, [miniDesktop, win.w, win.h])

  const [isResizing, setIsResizing] = React.useState(false)
  const [isDragging, setIsDragging] = React.useState(false)

  // pointermove sehr häufig -> 1x pro Frame committen
  const dragRafRef = React.useRef<number | null>(null)
  const pendingPosRef = React.useRef<{ x: number; y: number } | null>(null)
  const draggingRef = React.useRef<null | { sx: number; sy: number; start: WinRect }>(null)

  const applySnap = React.useCallback((r: WinRect): WinRect => {
    const { w: vw, h: vh } = getViewport()
    const leftEdge = MARGIN
    const rightEdge = vw - r.w - MARGIN
    const bottomEdge = vh - r.h - MARGIN
    const centerX = r.x + r.w / 2
    const dockLeft = centerX < vw / 2
    return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge }
  }, [])

  const onDragMove = React.useCallback(
    (ev: PointerEvent) => {
      const s = draggingRef.current
      if (!s) return

      const dx = ev.clientX - s.sx
      const dy = ev.clientY - s.sy

      const start = s.start
      const next = clampRect({ x: start.x + dx, y: start.y + dy, w: start.w, h: start.h })

      pendingPosRef.current = { x: next.x, y: next.y }

      if (dragRafRef.current == null) {
        dragRafRef.current = requestAnimationFrame(() => {
          dragRafRef.current = null
          const p = pendingPosRef.current
          if (!p) return
          setWin((cur) => ({ ...cur, x: p.x, y: p.y }))
        })
      }
    },
    [clampRect]
  )

  const endDrag = React.useCallback(() => {
    if (!draggingRef.current) return
    setIsDragging(false)

    if (dragRafRef.current != null) {
      cancelAnimationFrame(dragRafRef.current)
      dragRafRef.current = null
    }

    draggingRef.current = null
    window.removeEventListener('pointermove', onDragMove)
    window.removeEventListener('pointerup', endDrag)

    setWin((cur) => {
      const snapped = applySnap(clampRect(cur))
      queueMicrotask(() => saveRect(snapped))
      return snapped
    })
  }, [onDragMove, applySnap, clampRect, saveRect])

  const beginDrag = React.useCallback(
    (e: React.PointerEvent) => {
      if (!miniDesktop) return
      if (isResizing) return
      if (e.button !== 0) return

      e.preventDefault()
      e.stopPropagation()

      const start = winRef.current
      draggingRef.current = { sx: e.clientX, sy: e.clientY, start }
      setIsDragging(true)

      window.addEventListener('pointermove', onDragMove)
      window.addEventListener('pointerup', endDrag)
    },
    [miniDesktop, isResizing, onDragMove, endDrag]
  )

  // pointermove kommt extrem oft -> wir committen max. 1x pro Frame
  const resizeRafRef = React.useRef<number | null>(null)
  const pendingRectRef = React.useRef<WinRect | null>(null)
  const resizingRef = React.useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null)

  const onResizeMove = React.useCallback(
    (ev: PointerEvent) => {
      const s = resizingRef.current
      if (!s) return

      const dx = ev.clientX - s.sx
      const dy = ev.clientY - s.sy
      const ratio = s.ratio

      const fromW = s.dir.includes('w')
      const fromE = s.dir.includes('e')
      const fromN = s.dir.includes('n')
      const fromS = s.dir.includes('s')

      let w = s.start.w
      let h = s.start.h
      let x = s.start.x
      let y = s.start.y

      const { w: vw, h: vh } = getViewport()
      const EDGE_SNAP = 24

      const startRight = s.start.x + s.start.w
      const startBottom = s.start.y + s.start.h

      const anchoredRight = Math.abs(vw - MARGIN - startRight) <= EDGE_SNAP
      const anchoredBottom = Math.abs(vh - MARGIN - startBottom) <= EDGE_SNAP

      const fitFromW = (newW: number) => {
        newW = Math.max(MIN_W, newW)
        let newH = newW / ratio
        if (newH < MIN_H) {
          newH = MIN_H
          newW = newH * ratio
        }
        return { newW, newH }
      }

      const fitFromH = (newH: number) => {
        newH = Math.max(MIN_H, newH)
        let newW = newH * ratio
        if (newW < MIN_W) {
          newW = MIN_W
          newH = newW / ratio
        }
        return { newW, newH }
      }

      const isCorner = (fromE || fromW) && (fromN || fromS)

      if (isCorner) {
        const useWidth = Math.abs(dx) >= Math.abs(dy)
        if (useWidth) {
          const rawW = fromE ? s.start.w + dx : s.start.w - dx
          const { newW, newH } = fitFromW(rawW)
          w = newW
          h = newH
        } else {
          const rawH = fromS ? s.start.h + dy : s.start.h - dy
          const { newW, newH } = fitFromH(rawH)
          w = newW
          h = newH
        }
        if (fromW) x = s.start.x + (s.start.w - w)
        if (fromN) y = s.start.y + (s.start.h - h)
      } else if (fromE || fromW) {
        const rawW = fromE ? s.start.w + dx : s.start.w - dx
        const { newW, newH } = fitFromW(rawW)
        w = newW
        h = newH
        if (fromW) x = s.start.x + (s.start.w - w)
        y = anchoredBottom ? s.start.y + (s.start.h - h) : s.start.y
      } else if (fromN || fromS) {
        const rawH = fromS ? s.start.h + dy : s.start.h - dy
        const { newW, newH } = fitFromH(rawH)
        w = newW
        h = newH
        if (fromN) y = s.start.y + (s.start.h - h)
        if (anchoredRight) x = s.start.x + (s.start.w - w)
        else x = s.start.x
      }

      const next = clampRect({ x, y, w, h }, ratio)
      pendingRectRef.current = next

      if (resizeRafRef.current == null) {
        resizeRafRef.current = requestAnimationFrame(() => {
          resizeRafRef.current = null
          const r = pendingRectRef.current
          if (r) setWin(r)
        })
      }
    },
    [clampRect]
  )

  const endResize = React.useCallback(() => {
    if (!resizingRef.current) return
    setIsResizing(false)

    if (resizeRafRef.current != null) {
      cancelAnimationFrame(resizeRafRef.current)
      resizeRafRef.current = null
    }

    resizingRef.current = null
    window.removeEventListener('pointermove', onResizeMove)
    window.removeEventListener('pointerup', endResize)
    saveRect(winRef.current)
  }, [onResizeMove, saveRect])

  const beginResize = React.useCallback(
    (dir: ResizeDir) => (e: React.PointerEvent) => {
      if (!miniDesktop) return
      if (e.button !== 0) return
      e.preventDefault()
      e.stopPropagation()

      const start = winRef.current
      resizingRef.current = { dir, sx: e.clientX, sy: e.clientY, start, ratio: start.w / start.h }
      setIsResizing(true)

      window.addEventListener('pointermove', onResizeMove)
      window.addEventListener('pointerup', endResize)
    },
    [miniDesktop, onResizeMove, endResize]
  )

  const [canHover, setCanHover] = React.useState(false)
  const [chromeHover, setChromeHover] = React.useState(false)

  React.useEffect(() => {
    const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
    const update = () => setCanHover(Boolean(mq?.matches))
    update()
    mq?.addEventListener?.('change', update)
    return () => mq?.removeEventListener?.('change', update)
  }, [])

  const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing)
  const [stopPending, setStopPending] = React.useState(false)

  React.useEffect(() => {
    if (job.status !== 'running') setStopPending(false)
  }, [job.id, job.status])

  if (!mounted || !portalTarget) return null

  const overlayBtn =
    'inline-flex items-center justify-center rounded-md p-2 transition ' +
    'bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 active:scale-[0.98] ' +
    'dark:bg-black/45 dark:text-white dark:ring-white/10 dark:hover:bg-black/60 ' +
    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'

  const phaseRaw = String((job as any).phase ?? '')
  const phase = phaseRaw.toLowerCase()
  const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
  const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending

  const footerRight = (
    <div className="flex items-center gap-1 min-w-0">
      {isRunning ? (
        <>
          <Button
            variant="primary"
            color="red"
            size="sm"
            rounded="md"
            disabled={stopDisabled}
            title={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
            aria-label={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
            onClick={async (e) => {
              e.preventDefault()
              e.stopPropagation()
              if (stopDisabled) return
              try {
                setStopPending(true)
                await onStopJob?.(job.id)
              } finally {
                setStopPending(false)
              }
            }}
            className={cn('shadow-none shrink-0', miniDesktop && isNarrowMini && 'px-2')}
          >
            <span className="whitespace-nowrap">
              {isStoppingLike || stopPending ? 'Stoppe…' : miniDesktop && isNarrowMini ? 'Stop' : 'Stoppen'}
            </span>
          </Button>

          <RecordJobActions
            job={job}
            variant="overlay"
            collapseToMenu
            busy={isStoppingLike || stopPending}
            isFavorite={isFavorite}
            isLiked={isLiked}
            isWatching={isWatching}
            onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
            onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
            onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
            order={['watch', 'favorite', 'like', 'details']}
            className="gap-1 min-w-0 flex-1"
          />
        </>
      ) : (
        <RecordJobActions
          job={job}
          variant="overlay"
          collapseToMenu
          isHot={isHot || isHotFile}
          isFavorite={isFavorite}
          isLiked={isLiked}
          isWatching={isWatching}
          onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
          onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
          onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
          onToggleHot={
            onToggleHot
              ? async (j) => {
                  releaseMedia()
                  await new Promise((r) => setTimeout(r, 150))
                  await onToggleHot(j)
                  await new Promise((r) => setTimeout(r, 0))
                  const p = playerRef.current
                  if (p && !(p as any).isDisposed?.()) {
                    const ret = p.play?.()
                    if (ret && typeof (ret as any).catch === 'function') {
                      ;(ret as Promise<void>).catch(() => {})
                    }
                  }
                }
              : undefined
          }
          onKeep={
            onKeep
              ? async (j) => {
                  releaseMedia()
                  onClose()
                  await new Promise((r) => setTimeout(r, 150))
                  await onKeep(j)
                }
              : undefined
          }
          onDelete={
            onDelete
              ? async (j) => {
                  releaseMedia()
                  onClose()
                  await new Promise((r) => setTimeout(r, 150))
                  await onDelete(j)
                }
              : undefined
          }
          order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']}
          className="gap-1 min-w-0 flex-1"
        />
      )}
    </div>
  )

  const fullSize = expanded || miniDesktop
  const liveBottom = `env(safe-area-inset-bottom)`
  const vjsBottom = `calc(${controlBarH}px + env(safe-area-inset-bottom))`

  const overlayBottom = isRunning ? liveBottom : vjsBottom
  const metaBottom = isRunning
    ? `calc(8px + env(safe-area-inset-bottom))`
    : `calc(${controlBarH + 8}px + env(safe-area-inset-bottom))`

  const topOverlayTop = miniDesktop ? 'top-4' : 'top-2'
  const showSideInfo = expanded && isDesktop

  const videoChrome = (
    <div
      className={cn(
        'relative overflow-visible',
        expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
      )}
      onMouseEnter={() => {
        if (!miniDesktop || !canHover) return
        setChromeHover(true)
      }}
      onMouseLeave={() => {
        if (!miniDesktop || !canHover) return
        setChromeHover(false)
      }}
    >
      <div className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}>
        {isRunning ? (
          <div className="absolute inset-0 bg-black">
            <LiveHlsVideo src={liveHlsSrc} muted={startMuted} className="w-full h-full object-contain" />

            <div className="absolute right-2 bottom-2 z-[60] pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
              <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
              Live
            </div>
          </div>
        ) : (
          <div ref={containerRef} className="absolute inset-0" />
        )}

        {/* ✅ Top overlay */}
        <div className={cn('absolute inset-x-2 z-30', topOverlayTop)}>
          <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-start gap-2">
            <div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>

            {miniDesktop ? (
              <button
                type="button"
                aria-label="Player-Fenster verschieben"
                title="Ziehen zum Verschieben"
                onPointerDown={beginDrag}
                onClick={(e) => {
                  e.preventDefault()
                  e.stopPropagation()
                }}
                className={cn(
                  overlayBtn,
                  'px-3 gap-1 cursor-grab active:cursor-grabbing select-none',
                  dragUiActive ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none -translate-y-1',
                  isDragging && 'scale-[0.98] opacity-90'
                )}
              >
                <span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
                <span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
                <span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
              </button>
            ) : null}

            <div className="shrink-0 flex items-center gap-1 pointer-events-auto">
              <button
                type="button"
                className={overlayBtn}
                onClick={onToggleExpand}
                aria-label={expanded ? 'Minimieren' : 'Maximieren'}
                title={expanded ? 'Minimieren' : 'Maximieren'}
              >
                {expanded ? <ArrowsPointingInIcon className="h-5 w-5" /> : <ArrowsPointingOutIcon className="h-5 w-5" />}
              </button>

              <button type="button" className={overlayBtn} onClick={onClose} aria-label="Schließen" title="Schließen">
                <XMarkIcon className="h-5 w-5" />
              </button>
            </div>
          </div>
        </div>

        {/* Bottom overlay: Gradient */}
        <div
          className={cn(
            'player-ui pointer-events-none absolute inset-x-0 z-40',
            'bg-gradient-to-t from-black/70 to-transparent',
            'transition-all duration-200 ease-out',
            expanded ? 'h-28' : 'h-24'
          )}
          style={{ bottom: overlayBottom }}
        />

        <div
          className={cn(
            'player-ui pointer-events-none absolute inset-x-2 z-50',
            'flex items-end justify-between gap-2',
            'transition-all duration-200 ease-out'
          )}
          style={{ bottom: metaBottom }}
        >
          <div className="min-w-0">
            <div className="truncate text-sm font-semibold text-white">{model}</div>
            <div className="truncate text-[11px] text-white/80">
              <span className="inline-flex items-center gap-1 min-w-0 align-middle">
                <span className="truncate">{file || title}</span>

                {isHot || isHotFile ? (
                  <span className="shrink-0 rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-white">HOT</span>
                ) : null}
              </span>
            </div>
          </div>

          <div className="shrink-0 flex items-center gap-1.5 text-[11px] text-white">
            {resolutionLabel !== '—' ? (
              <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
            ) : null}

            {!isRunning ? (
              <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{runtimeLabel}</span>
            ) : null}

            {sizeLabel !== '—' ? <span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{sizeLabel}</span> : null}
          </div>
        </div>
      </div>
    </div>
  )

  const sidePanel = (
    <div className="w-[360px] shrink-0 border-r border-white/10 bg-black/40 text-white">
      <div className="h-full p-4 flex flex-col gap-3 overflow-y-auto">
        <div className="rounded-lg overflow-hidden ring-1 ring-white/10 bg-black/30">
          <div className="relative aspect-video">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={previewSrc}
              alt="Vorschau"
              className="absolute inset-0 h-full w-full object-cover"
              loading="lazy"
              onError={() => {
                if (previewSrc !== previewB) setPreviewSrc(previewB)
              }}
            />
          </div>
        </div>

        <div className="space-y-1">
          <div className="text-lg font-semibold truncate">{model}</div>
          <div className="text-xs text-white/70 break-all">{file || title}</div>
        </div>

        <div className="pointer-events-auto">
          <div className="flex items-center justify-center gap-2 flex-wrap">
            {isRunning ? (
              <Button
                variant="primary"
                color="red"
                size="sm"
                rounded="md"
                disabled={stopDisabled}
                title={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
                aria-label={isStoppingLike || stopPending ? 'Stoppe…' : 'Stop'}
                onClick={async (e) => {
                  e.preventDefault()
                  e.stopPropagation()
                  if (stopDisabled) return
                  try {
                    setStopPending(true)
                    await onStopJob?.(job.id)
                  } finally {
                    setStopPending(false)
                  }
                }}
                className="shadow-none"
              >
                {isStoppingLike || stopPending ? 'Stoppe…' : 'Stoppen'}
              </Button>
            ) : null}

            <RecordJobActions
              job={job}
              variant="table"
              collapseToMenu={false}
              busy={isStoppingLike || stopPending}
              isHot={isHot || isHotFile}
              isFavorite={isFavorite}
              isLiked={isLiked}
              isWatching={isWatching}
              onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
              onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
              onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
              onToggleHot={
                onToggleHot
                  ? async (j) => {
                      releaseMedia()
                      await new Promise((r) => setTimeout(r, 150))
                      await onToggleHot(j)
                      await new Promise((r) => setTimeout(r, 0))
                      const p = playerRef.current
                      if (p && !(p as any).isDisposed?.()) {
                        const ret = p.play?.()
                        if (ret && typeof (ret as any).catch === 'function') {
                          ;(ret as Promise<void>).catch(() => {})
                        }
                      }
                    }
                  : undefined
              }
              onKeep={
                onKeep
                  ? async (j) => {
                      releaseMedia()
                      onClose()
                      await new Promise((r) => setTimeout(r, 150))
                      await onKeep(j)
                    }
                  : undefined
              }
              onDelete={
                onDelete
                  ? async (j) => {
                      releaseMedia()
                      onClose()
                      await new Promise((r) => setTimeout(r, 150))
                      await onDelete(j)
                    }
                  : undefined
              }
              order={isRunning ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
              className="flex items-center justify-start gap-1"
            />
          </div>
        </div>

        <div className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
          <div className="text-white/60">Status</div>
          <div className="font-medium">{job.status}</div>

          <div className="text-white/60">Auflösung</div>
          <div className="font-medium">{resolutionLabel}</div>

          <div className="text-white/60">FPS</div>
          <div className="font-medium">{fpsLabel}</div>

          <div className="text-white/60">Laufzeit</div>
          <div className="font-medium">{runtimeLabel}</div>

          <div className="text-white/60">Größe</div>
          <div className="font-medium">{sizeLabel}</div>

          <div className="text-white/60">Datum</div>
          <div className="font-medium">{dateLabel}</div>

          <div className="col-span-2">
            {tags.length ? (
              <div className="flex flex-wrap gap-1.5">
                {tags.map((t) => (
                  <span key={t} className="rounded bg-white/10 px-2 py-0.5 text-xs text-white/90">
                    {t}
                  </span>
                ))}
              </div>
            ) : (
              <span className="text-white/50">—</span>
            )}
          </div>
        </div>
      </div>
    </div>
  )

  const cardEl = (
    <Card
      edgeToEdgeMobile
      noBodyPadding
      className={cn(
        'relative flex flex-col shadow-2xl ring-1 ring-black/10 dark:ring-white/10',
        'w-full',
        fullSize ? 'h-full' : 'h-[220px] max-h-[40vh]',
        expanded ? 'rounded-2xl' : miniDesktop ? 'rounded-lg' : 'rounded-none'
      )}
      bodyClassName="flex flex-col flex-1 min-h-0 p-0"
    >
      <div className="flex flex-1 min-h-0">
        {showSideInfo ? sidePanel : null}
        {videoChrome}
      </div>
    </Card>
  )

  const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()

  const expandedRect = {
    left: ox + 16,
    top: oy + 16,
    width: Math.max(0, vw - 32),
    height: Math.max(0, vh - 32),
  }

  const wrapStyle = expanded
    ? expandedRect
    : miniDesktop
      ? { left: win.x, top: win.y, width: win.w, height: win.h }
      : undefined

  return createPortal(
    <>
      <style>{`
        /* Live-Download: Progress/Seek-Bar ausblenden */
        .is-live-download .vjs-progress-control {
          display: none !important;
        }
      `}</style>

      {expanded || miniDesktop ? (
        <div
          className={cn(
            'fixed z-[2147483647]',
            !isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
          )}
          style={{
            ...(wrapStyle as any),
            willChange: isResizing ? 'left, top, width, height' : undefined,
          }}
        >
          {cardEl}

          {miniDesktop ? (
            <div className="pointer-events-none absolute inset-0">
              <div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
              <div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
              <div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
              <div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />

              <div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
              <div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
              <div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
              <div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
            </div>
          ) : null}
        </div>
      ) : (
        <div
          className="
            fixed z-[2147483647] inset-x-0 w-full
            shadow-2xl
            md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
          "
          style={{
            bottom: `calc(${bottomInset}px + env(safe-area-inset-bottom))`,
          }}
        >
          {cardEl}
        </div>
      )}
    </>,
    portalTarget
  )
}
