"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, ExternalLink, Loader2, AlertTriangle, Cpu, PlayCircle } from "lucide-react";

function formatJobElapsed(sec: number): string {
  const s = Math.max(0, Math.floor(sec));
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
import { useSettings } from "@/components/settings/SettingsProvider";
import { VariantPicker } from "./VariantPicker";
import {
  SubtitlePicker,
  type SubtitleSources,
  type EmbeddedEntry,
  type SidecarEntry,
  type LangIso,
} from "./SubtitlePicker";
import { dispatchVideoStatusRefresh } from "./videoStatusEvents";
import { cn } from "@/lib/utils";

/** One alternate encode of a single part. partIdx is the absolute index
 *  into the on-disk findVideosForCode list — used as the stream URL's
 *  `?part=` value. */
interface VariantOut {
  partIdx: number;
  abs: string;
  rel: string;
  filename: string;
  size: number;
  label: string;
  metadata?: VideoMetadata | null;
}

interface PartOut {
  partIndex: number;
  defaultIdx: number;
  variants: VariantOut[];
}

interface VideoMetadata {
  sizeBytes: number;
  durationSec: number | null;
  videoCodec: string | null;
  videoBFrames: number | null;
  width: number | null;
  height: number | null;
  videoBitrate: number | null;
  playbackMode: string | null;
  probeError: string | null;
}

/**
 * Snapshot dropped/total decoded video frames from a live <video>
 * element via the standard MediaCapabilities-adjacent API. Returns
 * null if the API is unavailable or the element isn't decoding.
 */
function readQuality(video: HTMLVideoElement | null): { dropped: number; total: number } | null {
  if (!video) return null;
  const q = video.getVideoPlaybackQuality?.();
  if (!q) return null;
  return { dropped: q.droppedVideoFrames ?? 0, total: q.totalVideoFrames ?? 0 };
}

/** Threshold check: did this many drops over this many total frames
 *  in the measurement window indicate the native sink can't keep up? */
function isStuttering(dropped: number, total: number): boolean {
  // >5% drop ratio on a meaningful sample, OR >5 absolute drops in
  // the test window. The absolute floor catches cases where the
  // decoder produced very few total frames.
  return (total > 0 && dropped / total > 0.05) || dropped > 5;
}

async function persistMode(code: string, partIdx: number, mode: "direct" | "transcode"): Promise<void> {
  try {
    await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ mode }),
    });
  } catch { /* best effort */ }
}

function formatDuration(sec: number | null | undefined): string | null {
  if (sec == null || !Number.isFinite(sec) || sec <= 0) return null;
  const total = Math.round(sec);
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
  return `${m}:${String(s).padStart(2, "0")}`;
}

function formatBitrate(bps: number | null | undefined): string | null {
  if (bps == null || !Number.isFinite(bps) || bps <= 0) return null;
  if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
  if (bps >= 1_000) return `${Math.round(bps / 1_000)} Kbps`;
  return `${Math.round(bps)} bps`;
}

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

type LangPref = "EN" | "CN" | "JP" | "off";

const SUBTITLE_PREF_KEY = "pinkudex.subtitlePreference";

function readSubtitlePref(): LangPref {
  try {
    const raw = localStorage.getItem(SUBTITLE_PREF_KEY);
    if (raw === "EN" || raw === "CN" || raw === "JP" || raw === "off") return raw;
  } catch { /* ignore */ }
  return "off";
}

function writeSubtitlePref(pref: LangPref): void {
  try { localStorage.setItem(SUBTITLE_PREF_KEY, pref); } catch { /* ignore */ }
}

const SUBTITLE_OFFSET_KEY = (code: string) => `pinkudex.subtitleOffset.${code}`;

function readSubtitleOffset(code: string): number {
  try {
    const raw = localStorage.getItem(SUBTITLE_OFFSET_KEY(code));
    if (raw == null) return 0;
    const n = Number(raw);
    return Number.isFinite(n) ? n : 0;
  } catch { return 0; }
}

function writeSubtitleOffset(code: string, value: number): void {
  try {
    if (value === 0) localStorage.removeItem(SUBTITLE_OFFSET_KEY(code));
    else localStorage.setItem(SUBTITLE_OFFSET_KEY(code), String(value));
  } catch { /* ignore */ }
}

function isoToPref(iso: LangIso | null): LangPref {
  if (iso === "eng") return "EN";
  if (iso === "zho") return "CN";
  if (iso === "jpn") return "JP";
  return "off";
}

function prefToIso(pref: LangPref): LangIso | null {
  if (pref === "EN") return "eng";
  if (pref === "CN") return "zho";
  if (pref === "JP") return "jpn";
  return null;
}

const TOKEN_SPLIT = /[\s._\-\[\]()+,;]+/g;

/** Mirror of detectLanguageFromName from lib/video/subtitles.ts — kept
 *  in the client so Browse can synthesize a sidecar entry locally
 *  without round-tripping the list endpoint. */
function detectLangClient(filename: string): LangIso | null {
  const dot = filename.lastIndexOf(".");
  const stem = dot > 0 ? filename.slice(0, dot) : filename;
  const tokens = stem.toLowerCase().split(TOKEN_SPLIT).filter(Boolean);
  let found: LangIso | null = null;
  let multiple = false;
  for (const t of tokens) {
    let iso: LangIso | null = null;
    if (t === "en" || t === "eng" || t === "english") iso = "eng";
    else if (t === "zh" || t === "zho" || t === "chi" || t === "chs" || t === "cht"
      || t === "chn" || t === "cn" || t === "chinese") iso = "zho";
    else if (t === "ja" || t === "jp" || t === "jpn" || t === "japanese") iso = "jpn";
    if (iso) {
      if (found && found !== iso) { multiple = true; break; }
      found = iso;
    }
  }
  return multiple ? null : found;
}

function langDisplay(iso: LangIso | null): string {
  if (iso === "eng") return "English";
  if (iso === "zho") return "Chinese";
  if (iso === "jpn") return "Japanese";
  return "Unknown";
}

function formatCodec(codec: string | null | undefined): string | null {
  if (!codec) return null;
  const map: Record<string, string> = {
    h264: "H.264",
    hevc: "HEVC",
    h265: "HEVC",
    av1: "AV1",
    vp9: "VP9",
    mpeg4: "MPEG-4",
  };
  return map[codec.toLowerCase()] ?? codec.toUpperCase();
}

function formatVideoSummary(meta: VideoMetadata | null | undefined, compact = false): string | null {
  if (!meta || meta.probeError) return null;
  const parts = [
    meta.width && meta.height ? (compact ? `${meta.height}p` : `${meta.width}x${meta.height}`) : null,
    formatCodec(meta.videoCodec),
    compact ? null : formatBitrate(meta.videoBitrate),
    compact ? null : formatBytes(meta.sizeBytes),
    compact ? null : formatDuration(meta.durationSec),
  ].filter((part): part is string => Boolean(part));
  return parts.length > 0 ? parts.join(" · ") : null;
}

export function VideoPlayerModal({
  code,
  actresses,
  onClose,
}: {
  code: string;
  actresses?: Array<{ id: number; name: string; slug: string }>;
  onClose: () => void;
}) {
  const [parts, setParts] = useState<PartOut[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  /** Absolute stream-URL identifier — see VariantOut.partIdx. */
  const [partIdx, setPartIdx] = useState(0);
  /** Per-part display index → variant index within that part, used so
   *  that switching parts and coming back remembers your variant pick.
   *  In-memory only by design (each session is a fresh decision). */
  const [variantPickByPart, setVariantPickByPart] = useState<Record<number, number>>({});
  const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const { settings } = useSettings();
  const mode = settings.transcodeMode;

  // Resolved playback decision: direct or transcode. For "off"/"always" this
  // is fixed. For auto modes we probe + (optionally) measure first.
  const [resolved, setResolved] = useState<"direct" | "transcode" | null>(
    mode === "off" ? "direct" : mode === "always" ? "transcode" : null,
  );
  const [probing, setProbing] = useState(false);
  // For auto-runtime: we start in direct mode and watch the LIVE main video
  // element for dropped frames. If it stutters, swap to transcode and
  // persist the decision. The badge shows "Measuring" while this runs so
  // the user knows the brief glitch on stuttery files isn't a bug.
  const [measuring, setMeasuring] = useState(false);
  const transcoding = resolved === "transcode";

  // Subtitle state — sources fetched per (code, partIdx). selectedSubId
  // is "off" or one of the source ids ("emb:N" / "side:<b64>"). Browsed
  // sidecars get appended to subSources.sidecar with origin="browsed".
  const [subSources, setSubSources] = useState<SubtitleSources | null>(null);
  const [subSourcesLoading, setSubSourcesLoading] = useState(false);
  const [selectedSubId, setSelectedSubId] = useState<string | "off">("off");
  // Subtitle timing offset in seconds (positive = subs appear later,
  // negative = sooner). Per-code, persisted to localStorage. Applied
  // to native TextTrackCue startTime/endTime client-side so the shift
  // is instant — no server roundtrip.
  const [subOffsetSec, setSubOffsetSec] = useState<number>(() => readSubtitleOffset(code));
  const subAutoApplied = useRef<string | null>(null);

  // Re-read offset when the code changes (e.g. modal navigation between
  // titles). Per-code persistence means each video keeps its own sync.
  useEffect(() => { setSubOffsetSec(readSubtitleOffset(code)); }, [code]);

  // Persist offset on change.
  useEffect(() => { writeSubtitleOffset(code, subOffsetSec); }, [code, subOffsetSec]);

  useEffect(() => {
    if (mode === "off") { setResolved("direct"); setMeasuring(false); return; }
    if (mode === "always") { setResolved("transcode"); setMeasuring(false); return; }
    let cancelled = false;
    setProbing(true);
    setResolved(null);
    setMeasuring(false);

    (async () => {
      try {
        // Probe the SPECIFIC part the user is opening — the file at part 0
        // can be a different codec from part N (e.g. .fixed.mp4 vs HEVC
        // .restored.mp4 sharing the same code).
        const r = await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`);
        const data = await r.json() as {
          codec: string | null;
          bFrames: number | null;
          cachedMode: string | null;
          metadata: VideoMetadata | null;
        };
        if (cancelled) return;
        setMetadata(data.metadata ?? null);

        // auto-runtime: trust a previously persisted decision when present.
        if (mode === "auto-runtime" && (data.cachedMode === "direct" || data.cachedMode === "transcode")) {
          setResolved(data.cachedMode);
          setProbing(false);
          return;
        }

        const triggersBug = data.codec === "h264" && (data.bFrames ?? 0) > 0;

        // auto-predicate: predicate is the final answer, no measurement.
        if (mode === "auto-predicate") {
          setResolved(triggersBug ? "transcode" : "direct");
          setProbing(false);
          return;
        }

        // auto-runtime, first time:
        // - Codec can't trigger the bug (HEVC, AV1, no-B-frame H.264) →
        //   shortcut to direct, persist, no measurement.
        if (!triggersBug) {
          setResolved("direct");
          setProbing(false);
          void persistMode(code, partIdx, "direct");
          return;
        }
        // Codec might trigger the bug → start the LIVE measurement on the
        // real <video> element (a hidden offscreen probe video doesn't
        // actually decode in modern Chromium — battery optimization). The
        // measurement effect below picks this up via `measuring` state.
        setResolved("direct");
        setProbing(false);
        setMeasuring(true);
      } catch (e) {
        if (cancelled) return;
        // Probe failed — fall back to direct so the user still gets video.
        // eslint-disable-next-line no-console
        console.error("[probe]", e);
        setResolved("direct");
        setProbing(false);
      }
    })();
    return () => { cancelled = true; };
  }, [code, mode, partIdx]);

  useEffect(() => {
    if (mode !== "off" && mode !== "always") return;
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch(`/api/video-probe/${encodeURIComponent(code)}?part=${partIdx}`);
        const data = await r.json() as { metadata: VideoMetadata | null };
        if (!cancelled) setMetadata(data.metadata ?? null);
      } catch {
        if (!cancelled) setMetadata(null);
      }
    })();
    return () => { cancelled = true; };
  }, [code, mode, partIdx]);

  // Live measurement effect: when `measuring` is true, the user is watching
  // the actual file via the direct stream. After a brief settle window we
  // sample dropped frames from the live <video>. If it's stuttering, swap
  // to the transcode path (HLS) and persist. Otherwise persist "direct".
  // Only runs in auto-runtime mode; off/always/auto-predicate never set
  // `measuring=true`.
  useEffect(() => {
    if (!measuring) return;
    let cancelled = false;
    let baseline: { dropped: number; total: number } | null = null;

    // Wait for the video to actually start rendering frames before we
    // record the baseline. Without this we may sample before the decoder
    // has emitted anything.
    const startTimer = setTimeout(() => {
      baseline = readQuality(videoRef.current);
    }, 500);

    const decideTimer = setTimeout(() => {
      if (cancelled) return;
      const now = readQuality(videoRef.current);
      const baseDrop = baseline?.dropped ?? 0;
      const baseTotal = baseline?.total ?? 0;
      const dropped = Math.max(0, (now?.dropped ?? 0) - baseDrop);
      const total = Math.max(0, (now?.total ?? 0) - baseTotal);
      const decision = isStuttering(dropped, total) ? "transcode" : "direct";
      setMeasuring(false);
      void persistMode(code, partIdx, decision);
      if (decision === "transcode") setResolved("transcode");
    }, 4000);

    return () => {
      cancelled = true;
      clearTimeout(startTimer);
      clearTimeout(decideTimer);
    };
  }, [measuring, code, partIdx]);

  useEffect(() => {
    let live = true;
    setError(null);
    fetch(`/api/video-files/${encodeURIComponent(code)}`)
      .then((r) => r.json())
      .then((j: { parts?: PartOut[] }) => {
        if (!live) return;
        if (!j.parts || j.parts.length === 0) {
          setError("No video file found for this code.");
          setParts([]);
          return;
        }
        setParts(j.parts);
        // Initial selection: first part's default variant. Stream URL
        // uses the variant's absolute partIdx.
        const first = j.parts[0]!;
        const v = first.variants[first.defaultIdx] ?? first.variants[0]!;
        setPartIdx(v.partIdx);
      })
      .catch((e) => { if (live) setError((e as Error).message); });
    return () => { live = false; };
  }, [code]);

  // Look up the currently playing variant from parts + partIdx.
  const currentLocation = useMemo(() => {
    if (!parts) return null;
    for (let p = 0; p < parts.length; p++) {
      const part = parts[p]!;
      for (let v = 0; v < part.variants.length; v++) {
        if (part.variants[v]!.partIdx === partIdx) return { partDisplay: p, variantIdx: v };
      }
    }
    return null;
  }, [parts, partIdx]);
  const currentFile = currentLocation && parts
    ? parts[currentLocation.partDisplay]!.variants[currentLocation.variantIdx]!
    : null;

  useEffect(() => {
    setMetadata(currentFile?.metadata ?? null);
  }, [currentFile]);

  // Fetch subtitle sources whenever the active variant changes. The
  // auto-apply ref is keyed by code+partIdx so switching variants of
  // the same part doesn't re-yank the user's manual pick.
  const [subRefetchTick, setSubRefetchTick] = useState(0);
  const refetchSources = useCallback(() => setSubRefetchTick((t) => t + 1), []);

  // Tracks abs paths of sidecars the modal has already seen. Used by the
  // auto-select-on-new-generated effect to recognize a freshly produced
  // .whisperjav.srt without confusing it with files present at open.
  const knownSidecarAbsRef = useRef<Set<string>>(new Set());

  useEffect(() => {
    if (!currentFile) return;
    let live = true;
    const key = `${code}:${partIdx}`;
    const isInitial = subAutoApplied.current !== key;
    setSubSourcesLoading(true);
    fetch(`/api/video-subtitles/${encodeURIComponent(code)}?part=${partIdx}`, { cache: "no-store" })
      .then((r) => r.json())
      .then((data: SubtitleSources) => {
        if (!live) return;
        const next: SubtitleSources = {
          embedded: data.embedded ?? [],
          sidecar: data.sidecar ?? [],
        };
        setSubSources(next);
        setSubSourcesLoading(false);
        if (isInitial) {
          subAutoApplied.current = key;
          // Seed the known-sidecars ref so already-present files don't
          // count as fresh on later refetches.
          knownSidecarAbsRef.current = new Set(next.sidecar.map((s) => s.abs));
          const pref = readSubtitlePref();
          const targetIso = prefToIso(pref);
          let chosen: { id: string; language: LangIso | null } | null = null;
          // English wins by default when present — even ahead of a
          // saved preference. The saved pref is mostly populated by
          // the "sole tagged sidecar" auto-persist below, which
          // shouldn't override the user's explicit "default to EN
          // when available" wish. Also match compound-language files
          // (lang=null but label contains "English") since multi-tag
          // filenames like `.ja.pass1.english.srt` collapse to null.
          const isEnglish = (lang: LangIso | null, label: string) =>
            lang === "eng" || /\benglish\b/i.test(label);
          const eng =
            next.embedded.find((e) => e.renderable && isEnglish(e.language, e.label))
            ?? next.sidecar.find((s) => isEnglish(s.language, s.label));
          if (eng) chosen = { id: eng.id, language: eng.language };
          if (!chosen && targetIso) {
            const m =
              next.embedded.find((e) => e.renderable && e.language === targetIso)
              ?? next.sidecar.find((s) => s.language === targetIso);
            if (m) chosen = { id: m.id, language: m.language };
          }
          if (!chosen) {
            const tagged = next.sidecar.filter((s) => s.language != null);
            if (tagged.length === 1) {
              const sole = tagged[0]!;
              chosen = { id: sole.id, language: sole.language };
              if (sole.language) writeSubtitlePref(isoToPref(sole.language));
            }
          }
          setSelectedSubId(chosen ? chosen.id : "off");
        }
      })
      .catch(() => {
        if (!live) return;
        setSubSources({ embedded: [], sidecar: [] });
        setSubSourcesLoading(false);
      });
    return () => { live = false; };
  }, [code, partIdx, currentFile, subRefetchTick]);

  // Auto-select a freshly generated sidecar (filename contains
  // ".whisperjav.") when current selection is "off". Runs whenever
  // subSources changes so it picks up post-job refetches reliably.
  useEffect(() => {
    if (!subSources) return;
    const known = knownSidecarAbsRef.current;
    const fresh = subSources.sidecar.find(
      (s) => !known.has(s.abs) && s.filename.toLowerCase().includes(".whisperjav."),
    );
    // Update known set to include current sidecars so a fresh entry is
    // only recognized once.
    knownSidecarAbsRef.current = new Set(subSources.sidecar.map((s) => s.abs));
    if (fresh) {
      setSelectedSubId((curId) => {
        if (curId !== "off") return curId;
        // Persist language so future opens auto-apply on initial load.
        if (fresh.language) writeSubtitlePref(isoToPref(fresh.language));
        return fresh.id;
      });
    }
  }, [subSources]);

  // Compute the WebVTT URL for the currently selected source. Embedded
  // and sidecar both go through the same track endpoint; "off" yields
  // null which suppresses the <track> element entirely.
  const trackUrl = useMemo(() => {
    if (selectedSubId === "off") return null;
    return `/api/video-subtitles/${encodeURIComponent(code)}/track?part=${partIdx}&src=${encodeURIComponent(selectedSubId)}`;
  }, [code, partIdx, selectedSubId]);

  const trackLang = useMemo<LangIso | null>(() => {
    if (selectedSubId === "off" || !subSources) return null;
    const emb = subSources.embedded.find((e) => e.id === selectedSubId);
    if (emb) return emb.language;
    const side = subSources.sidecar.find((s) => s.id === selectedSubId);
    return side ? side.language : null;
  }, [selectedSubId, subSources]);

  const trackLabel = useMemo(() => {
    if (selectedSubId === "off" || !subSources) return "Subtitles";
    const emb = subSources.embedded.find((e) => e.id === selectedSubId);
    if (emb) return emb.label;
    const side = subSources.sidecar.find((s) => s.id === selectedSubId);
    return side ? side.label : "Subtitles";
  }, [selectedSubId, subSources]);

  const onSubChange = useCallback((id: string | "off") => {
    setSelectedSubId(id);
    if (id === "off") { writeSubtitlePref("off"); return; }
    const emb = subSources?.embedded.find((e) => e.id === id);
    const side = subSources?.sidecar.find((s) => s.id === id);
    const lang = emb?.language ?? side?.language ?? null;
    if (lang) writeSubtitlePref(isoToPref(lang));
  }, [subSources]);

  // ───────────────────────── WhisperJAV generation ─────────────────────────
  const whisperjavEnabled = !!settings.whisperjav?.cliPath;

  /** True when the current sidecar list contains a WhisperJAV-generated
   *  file for the active variant. Drives the Generate/Regenerate label. */
  const hasGenerated = useMemo(() => {
    if (!subSources) return false;
    return subSources.sidecar.some((s) => s.filename.toLowerCase().includes(".whisperjav."));
  }, [subSources]);

  interface WhisperJobState {
    id: string;
    startedAt: number | null;
    enqueuedAt: number;
    status: "queued" | "running" | "completed" | "warning" | "failed" | "cancelled";
    stage: string | null;
    stageIndex: number | null;
    stageTotal: number | null;
    error: string | null;
    etaSec: number | null;
    /** Wall-clock time the etaSec value was sampled. Used to count it
     *  down between polls without re-fetching. */
    etaSampledAt: number | null;
  }
  const [activeJob, setActiveJob] = useState<WhisperJobState | null>(null);
  const [tickNow, setTickNow] = useState(Date.now());

  // Discover any in-flight job on open / variant change so reopening a
  // modal during generation re-attaches to the same job.
  useEffect(() => {
    if (!whisperjavEnabled) return;
    let live = true;
    fetch(`/api/whisperjav-jobs?code=${encodeURIComponent(code)}`)
      .then((r) => r.json())
      .then((data: { jobs: Array<{ id: string; status: string; enqueuedAt: number; startedAt: number | null; stage: string | null; stageIndex: number | null; stageTotal: number | null; error: string | null }> }) => {
        if (!live) return;
        const inflight = (data.jobs ?? []).find((j) => j.status === "queued" || j.status === "running");
        if (inflight) {
          setActiveJob({
            id: inflight.id,
            startedAt: inflight.startedAt,
            enqueuedAt: inflight.enqueuedAt,
            status: inflight.status as WhisperJobState["status"],
            stage: inflight.stage,
            stageIndex: inflight.stageIndex,
            stageTotal: inflight.stageTotal,
            error: inflight.error,
            etaSec: null,
            etaSampledAt: null,
          });
        } else {
          setActiveJob(null);
        }
      })
      .catch(() => { /* ignore */ });
    return () => { live = false; };
  }, [code, whisperjavEnabled, partIdx]);

  // Poll job state every 3s while a job is active. On completion, kick
  // a source refetch so the new .srt appears in Sidecar. Effect deps
  // are scalar so per-tick updates don't tear down the interval.
  const activeJobId = activeJob?.id ?? null;
  const activeJobLive =
    activeJob?.status === "queued" || activeJob?.status === "running";
  useEffect(() => {
    if (!activeJobId || !activeJobLive) return;
    let live = true;
    const tick = async () => {
      try {
        const r = await fetch(`/api/whisperjav-jobs/${encodeURIComponent(activeJobId)}`);
        if (!r.ok) return;
        const data = await r.json() as {
          status: string; startedAt: number | null; enqueuedAt: number;
          stage: string | null; stageIndex: number | null; stageTotal: number | null;
          error: string | null; etaSec: number | null;
        };
        if (!live) return;
        setActiveJob({
          id: activeJobId,
          status: data.status as WhisperJobState["status"],
          startedAt: data.startedAt,
          enqueuedAt: data.enqueuedAt,
          stage: data.stage,
          stageIndex: data.stageIndex,
          stageTotal: data.stageTotal,
          error: data.error,
          etaSec: data.etaSec,
          etaSampledAt: data.etaSec != null ? Date.now() : null,
        });
        if (data.status === "completed" || data.status === "warning") {
          refetchSources();
          // Refresh the client-side video index so card badges update.
          // The has_subtitle DB column refreshes naturally on next
          // settings rescan or page navigation; calling /api/video-
          // rescan here was triggering revalidatePath cascades.
          dispatchVideoStatusRefresh();
        }
      } catch { /* transient — try again next tick */ }
    };
    const interval = setInterval(tick, 3000);
    return () => { live = false; clearInterval(interval); };
  }, [activeJobId, activeJobLive, refetchSources]);

  // Drive the elapsed-seconds display.
  useEffect(() => {
    if (!activeJob || (activeJob.status !== "queued" && activeJob.status !== "running")) return;
    const i = setInterval(() => setTickNow(Date.now()), 1000);
    return () => clearInterval(i);
  }, [activeJob]);

  const runningSummary = useMemo(() => {
    if (!activeJob) return null;
    if (activeJob.status !== "queued" && activeJob.status !== "running") return null;
    const start = activeJob.startedAt ?? activeJob.enqueuedAt;
    const elapsedSec = Math.max(0, (tickNow - start) / 1000);
    const status = activeJob.stage
      ? (activeJob.stageIndex && activeJob.stageTotal
        ? `Step ${activeJob.stageIndex}/${activeJob.stageTotal}: ${activeJob.stage}`
        : activeJob.stage)
      : (activeJob.status === "queued" ? "Queued" : "Starting...");
    // Tick the ETA down between polls so it doesn't stutter — subtract
    // wall-clock time elapsed since the server-sampled value.
    let etaSec: number | null = null;
    if (activeJob.etaSec != null && activeJob.etaSampledAt != null) {
      const drift = (tickNow - activeJob.etaSampledAt) / 1000;
      etaSec = Math.max(0, activeJob.etaSec - drift);
    }
    return { jobId: activeJob.id, elapsedSec, status, etaSec };
  }, [activeJob, tickNow]);

  const onGenerate = useCallback(async (overwrite: boolean) => {
    if (!whisperjavEnabled) return;
    try {
      const r = await fetch("/api/whisperjav-jobs", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ code, partIdx, overwrite }),
      });
      if (r.status === 409) {
        // Already exists — confirm overwrite then retry.
        const data = await r.json() as { abs: string };
        const ok = window.confirm(`Subtitles already exist:\n${data.abs}\n\nRegenerate (overwrite)?`);
        if (!ok) return;
        await onGenerate(true);
        return;
      }
      if (!r.ok) {
        const data = await r.json().catch(() => ({})) as { error?: string };
        window.alert(`Generate failed: ${data.error ?? r.statusText}`);
        return;
      }
      const data = await r.json() as { jobId: string };
      setActiveJob({
        id: data.jobId,
        status: "queued",
        startedAt: null,
        enqueuedAt: Date.now(),
        stage: null,
        stageIndex: null,
        stageTotal: null,
        error: null,
        etaSec: null,
        etaSampledAt: null,
      });
    } catch (e) {
      window.alert(`Generate failed: ${(e as Error).message}`);
    }
  }, [code, partIdx, whisperjavEnabled]);

  /** Two-stage cancel UI: confirm → "Cancelling..." → final status. */
  const [cancelling, setCancelling] = useState(false);
  // Reset when the active job changes (e.g. cancel completes, status
  // flipped, or user kicked off a fresh job).
  useEffect(() => {
    if (!activeJob || activeJob.status !== "running") setCancelling(false);
  }, [activeJob]);

  const onCancelJob = useCallback(async () => {
    if (!activeJob) return;
    if (!window.confirm("Cancel subtitle generation?\n\nProgress will be lost. The video file is unchanged.")) return;
    setCancelling(true);
    try {
      await fetch(`/api/whisperjav-jobs/${encodeURIComponent(activeJob.id)}/cancel`, { method: "POST" });
    } catch { /* status will flip via polling */ }
  }, [activeJob]);

  const onBrowse = useCallback(async () => {
    try {
      const r = await fetch(`/api/pick-file`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ purpose: "subtitle" }),
      });
      const data = await r.json() as { path?: string | null; cancelled?: boolean; error?: string };
      if (data.cancelled || !data.path) return;
      const abs = data.path;
      const filename = abs.split(/[\\/]/).pop() ?? abs;
      const ext = (() => {
        const dot = filename.lastIndexOf(".");
        return dot > 0 ? filename.slice(dot).toLowerCase() : "";
      })();
      const lang = detectLangClient(filename);
      const id = "side:" + btoa(unescape(encodeURIComponent(abs)))
        .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
      const entry: SidecarEntry = {
        id,
        abs,
        filename,
        ext,
        language: lang,
        label: lang ? langDisplay(lang) : "Browsed",
        origin: "manual",
      };
      // Persist the pick so it survives modal close and server restart.
      // Done before optimistic UI update so server-side trust check
      // (isAllowedSubtitlePath via manual_subtitles) is in place by the
      // time the track endpoint is hit.
      try {
        await fetch(`/api/manual-subtitle/${encodeURIComponent(code)}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ partIdx, abs }),
        });
      } catch { /* persistence failed; entry still works for this session via session-trusted set */ }
      setSubSources((cur) => {
        const base = cur ?? { embedded: [], sidecar: [] };
        // Replace any existing entry for the same abs path.
        const sidecar = base.sidecar.filter((s) => s.id !== id);
        sidecar.push(entry);
        return { embedded: base.embedded, sidecar };
      });
      // Keep knownSidecarAbsRef in sync so the auto-select-on-fresh
      // effect doesn't treat this entry as a new generation.
      knownSidecarAbsRef.current = new Set([...knownSidecarAbsRef.current, abs]);
      onSubChange(id);
    } catch { /* picker failed — silently ignore */ }
  }, [code, partIdx, onSubChange]);

  /** Switch to part display index `p`, restoring the user's last
   *  variant pick for that part (or its default). */
  function selectPart(p: number) {
    if (!parts) return;
    const part = parts[p];
    if (!part) return;
    const v = variantPickByPart[p] ?? part.defaultIdx;
    const variant = part.variants[v] ?? part.variants[part.defaultIdx]!;
    setPartIdx(variant.partIdx);
  }

  /** Switch to variant `v` within the current part. */
  function selectVariantInCurrentPart(v: number) {
    if (!parts || !currentLocation) return;
    const part = parts[currentLocation.partDisplay]!;
    const variant = part.variants[v];
    if (!variant) return;
    setVariantPickByPart((cur) => ({ ...cur, [currentLocation.partDisplay]: v }));
    setPartIdx(variant.partIdx);
  }

  // Stable ref so the keyboard listeners never reattach during the
  // modal's lifetime — every reattach is a window where Space could
  // leak through to the page.
  const onCloseRef = useRef(onClose);
  useEffect(() => { onCloseRef.current = onClose; }, [onClose]);

  // Lock body scroll while the modal is open. Without this, Space (or
  // any other scroll-triggering key) on the native video controls'
  // shadow DOM can still nudge the underlying library page even though
  // we have keydown listeners on document.
  useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);

  // Block native HTML5 drag on every element on the page while the modal
  // is open. Even with `draggable={false}` on the <video>, Chrome's
  // shadow-DOM slider thumbs occasionally let drag gestures escape onto
  // whatever's beneath the cursor — usually a card image — and the
  // browser starts dragging that. Adding a body class while the modal
  // is mounted lets a CSS rule (in globals.css) globally disable
  // user-drag, then restores normal behavior on unmount.
  useEffect(() => {
    document.body.classList.add("video-player-active");
    return () => { document.body.classList.remove("video-player-active"); };
  }, []);

  // Persist volume + mute state across plays. Native <video> resets on
  // each new element, so we read on mount and write on every change.
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    try {
      const rawVol = localStorage.getItem("pinkudex.videoVolume");
      const rawMuted = localStorage.getItem("pinkudex.videoMuted");
      const vol = rawVol == null ? null : Number(rawVol);
      if (vol != null && Number.isFinite(vol) && vol >= 0 && vol <= 1) {
        video.volume = vol;
      }
      // Honor anything truthy/falsy in the saved value, not just "0"|"1".
      // Older builds may have stored "true"/"false". Skip only when the
      // key is missing entirely so autoplay's default isn't clobbered
      // for first-time users.
      if (rawMuted != null) {
        const lower = rawMuted.toLowerCase();
        video.muted = !(lower === "0" || lower === "false" || lower === "");
      }
    } catch { /* localStorage may be disabled */ }

    const onVolumeChange = () => {
      try {
        localStorage.setItem("pinkudex.videoVolume", String(video.volume));
        localStorage.setItem("pinkudex.videoMuted", video.muted ? "1" : "0");
      } catch { /* ignore */ }
    };
    video.addEventListener("volumechange", onVolumeChange);
    return () => { video.removeEventListener("volumechange", onVolumeChange); };
    // Re-run when the video element identity changes (new src / new key)
    // so a remounted player still picks up the saved level.
  }, [parts, resolved, partIdx]);

  useEffect(() => {
    const isTypingTarget = (e: Event) => {
      const tgt = e.target as HTMLElement | null;
      return !!(tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable));
    };
    const isSpace = (e: KeyboardEvent) => e.key === " " || e.code === "Space" || e.keyCode === 32;

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") { onCloseRef.current(); return; }
      if (!isSpace(e)) return;
      if (isTypingTarget(e)) return;
      e.preventDefault();
      e.stopImmediatePropagation();
      const v = videoRef.current;
      if (!v) return;
      if (v.paused) v.play().catch(() => {});
      else v.pause();
      if (document.activeElement instanceof HTMLElement && document.activeElement !== v) {
        document.activeElement.blur();
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (!isSpace(e)) return;
      if (isTypingTarget(e)) return;
      e.preventDefault();
      e.stopImmediatePropagation();
    };
    const onKeyPress = (e: KeyboardEvent) => {
      if (!isSpace(e)) return;
      if (isTypingTarget(e)) return;
      e.preventDefault();
      e.stopImmediatePropagation();
    };

    // Attach to BOTH window and document, capture phase, so we win every
    // dispatch order regardless of which target the browser picks.
    window.addEventListener("keydown", onKeyDown, true);
    window.addEventListener("keyup", onKeyUp, true);
    window.addEventListener("keypress", onKeyPress, true);
    document.addEventListener("keydown", onKeyDown, true);
    document.addEventListener("keyup", onKeyUp, true);
    document.addEventListener("keypress", onKeyPress, true);
    return () => {
      window.removeEventListener("keydown", onKeyDown, true);
      window.removeEventListener("keyup", onKeyUp, true);
      window.removeEventListener("keypress", onKeyPress, true);
      document.removeEventListener("keydown", onKeyDown, true);
      document.removeEventListener("keyup", onKeyUp, true);
      document.removeEventListener("keypress", onKeyPress, true);
    };
  }, []);

  // After any pointer release, move keyboard focus OUT of the video
  // entirely. Native video controls are in closed shadow DOM, and when
  // the slider has focus Chrome handles Space at the engine level
  // (slider thumb increment / page scroll fallback) before any JS
  // listener fires. Putting focus on the modal container instead means
  // Space hits our handlers above cleanly.
  useEffect(() => {
    const c = containerRef.current;
    if (!c) return;
    const v = videoRef.current;
    const stealFocus = () => {
      setTimeout(() => {
        const cur = containerRef.current;
        if (!cur) return;
        // Don't yank focus from typing targets if any get added later.
        const a = document.activeElement;
        if (a instanceof HTMLElement && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) return;
        cur.focus({ preventScroll: true });
      }, 0);
    };
    document.addEventListener("pointerup", stealFocus, true);
    document.addEventListener("mouseup", stealFocus, true);
    document.addEventListener("touchend", stealFocus, true);
    if (v) v.addEventListener("seeked", stealFocus);
    stealFocus();
    return () => {
      document.removeEventListener("pointerup", stealFocus, true);
      document.removeEventListener("mouseup", stealFocus, true);
      document.removeEventListener("touchend", stealFocus, true);
      if (v) v.removeEventListener("seeked", stealFocus);
    };
  }, [parts, partIdx]);

  async function reveal() {
    try {
      await fetch(`/api/video-reveal/${encodeURIComponent(code)}?part=${partIdx}`, { method: "POST" });
    } catch {}
  }

  const streamUrl = transcoding
    ? `/api/video-hls/${encodeURIComponent(code)}/playlist?part=${partIdx}`
    : `/api/video-stream/${encodeURIComponent(code)}?part=${partIdx}`;
  const metadataSummary = formatVideoSummary(metadata);

  // Reset any stale error whenever the user switches stream sources.
  // Without this, a fatal HLS error on one part survives a part change
  // (or transcoding toggle) and locks the modal into the error UI even
  // when the new stream loads cleanly.
  useEffect(() => { setError(null); }, [streamUrl]);

  // Chrome ignores `default` on dynamically inserted <track>. Force
  // "showing" once metadata is ready and again when selection or
  // stream changes — a streamUrl swap remounts <video> and resets all
  // textTracks state.
  useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    if (selectedSubId === "off") {
      for (let i = 0; i < v.textTracks.length; i++) {
        const t = v.textTracks[i];
        if (t) t.mode = "disabled";
      }
      return;
    }
    const apply = () => {
      const tracks = v.textTracks;
      for (let i = 0; i < tracks.length; i++) {
        const t = tracks[i];
        if (!t) continue;
        t.mode = i === 0 ? "showing" : "disabled";
      }
    };
    apply();
    v.addEventListener("loadedmetadata", apply);
    return () => v.removeEventListener("loadedmetadata", apply);
  }, [selectedSubId, trackUrl, streamUrl]);

  // Apply the user's timing offset to the active text track. Snapshots
  // each cue's original start/end on first sight so re-applying a
  // different offset stays cumulative-free. Native VTTCue.startTime /
  // endTime are writable in modern Chromium / Firefox.
  useEffect(() => {
    if (selectedSubId === "off") return;
    const v = videoRef.current;
    if (!v) return;

    type ShiftableCue = TextTrackCue & { __pinkOriginalStart?: number; __pinkOriginalEnd?: number };

    const apply = () => {
      const track = v.textTracks[0];
      if (!track || !track.cues) return;
      for (let i = 0; i < track.cues.length; i++) {
        const cue = track.cues[i] as ShiftableCue;
        if (cue.__pinkOriginalStart === undefined) {
          cue.__pinkOriginalStart = cue.startTime;
          cue.__pinkOriginalEnd = cue.endTime;
        }
        const newStart = Math.max(0, cue.__pinkOriginalStart + subOffsetSec);
        const newEnd = Math.max(newStart + 0.001, (cue.__pinkOriginalEnd ?? 0) + subOffsetSec);
        cue.startTime = newStart;
        cue.endTime = newEnd;
      }
    };

    // Cues populate asynchronously after the <track> fetches its VTT.
    // Listen for the track's load + cuechange events plus the video's
    // loadedmetadata so we cover every population path.
    apply();
    const track = v.textTracks[0];
    const onCueChange = () => apply();
    track?.addEventListener("cuechange", onCueChange);
    v.addEventListener("loadedmetadata", apply);
    // Polling fallback: cues sometimes appear after both events have
    // already fired. Cheap walk over a short window.
    const pollUntil = Date.now() + 4000;
    const interval = setInterval(() => {
      apply();
      if (Date.now() > pollUntil) clearInterval(interval);
    }, 200);
    return () => {
      clearInterval(interval);
      track?.removeEventListener("cuechange", onCueChange);
      v.removeEventListener("loadedmetadata", apply);
    };
  }, [selectedSubId, trackUrl, streamUrl, subOffsetSec]);

  // hls.js drives MSE for transcoded HLS streams. Native <video src> works
  // for direct streams. Attach/detach lifecycle is keyed off streamUrl so
  // changing parts or toggling transcoding cleans up the previous session.
  useEffect(() => {
    if (!transcoding) return;
    const video = videoRef.current;
    if (!video) return;
    // Clear any prior fatal-error state before starting a fresh load.
    // Without this reset, switching parts after a fatal HLS error keeps
    // the modal stuck on the error screen even when the new stream is
    // playing fine in the background.
    setError(null);
    let cancelled = false;
    let hls: import("hls.js").default | null = null;
    (async () => {
      const HlsMod = (await import("hls.js")).default;
      if (cancelled) return;
      if (HlsMod.isSupported()) {
        hls = new HlsMod({ enableWorker: true, lowLatencyMode: false });
        hls.loadSource(streamUrl);
        hls.attachMedia(video);
        hls.on(HlsMod.Events.ERROR, (_e, data) => {
          // Effect cleanup may have already fired (mode swap, modal close).
          // Don't surface errors to a player that's about to be torn down.
          if (cancelled) return;
          if (!data.fatal) return;
          // eslint-disable-next-line no-console
          console.error("[hls.js fatal]", JSON.stringify(data, null, 2), data);

          // Try the canonical hls.js recovery pattern for fatal errors
          // before giving up. Network errors are often transient (dev
          // server restart, hot-reload, brief 404 from stale video
          // index) — startLoad pulls the playlist again.
          try {
            if (data.type === HlsMod.ErrorTypes.NETWORK_ERROR) {
              hls?.startLoad();
              return;
            }
            if (data.type === HlsMod.ErrorTypes.MEDIA_ERROR) {
              hls?.recoverMediaError();
              return;
            }
          } catch (e) {
            // eslint-disable-next-line no-console
            console.error("[hls.js recover failed]", e);
          }

          // Recovery either failed or the error type isn't recoverable —
          // build a useful message from whatever fields the payload has.
          const parts: string[] = [];
          if (data.type) parts.push(String(data.type));
          if (data.details) parts.push(String(data.details));
          if (data.response?.code) parts.push(`http ${data.response.code}`);
          if (data.error?.message) parts.push(String(data.error.message));
          if (data.url) parts.push(String(data.url));
          setError(`HLS error: ${parts.length ? parts.join(" · ") : "unknown (likely aborted load or network blip)"}`);
        });
      } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
        // Native HLS (Safari). hls.js not needed.
        video.src = streamUrl;
      } else {
        setError("HLS not supported in this browser");
      }
    })();
    return () => {
      cancelled = true;
      try { hls?.destroy(); } catch { /* ignore */ }
    };
    // `files` in the deps so the effect re-runs once the modal's pre-flight
    // /api/video-files fetch completes and the <video> element actually
    // mounts. Without it, this effect fires once with a null videoRef.
  }, [streamUrl, transcoding, parts]);

  if (typeof document === "undefined") return null;
  return createPortal(
    <div
      ref={containerRef}
      tabIndex={-1}
      className="fixed inset-0 z-[100] grid place-items-center bg-black/85 backdrop-blur-sm p-4 fade-in outline-none"
      onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
    >
      <div className="w-full max-w-[1400px] rounded-2xl bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl overflow-hidden flex flex-col"
        style={{ height: "calc(100vh - 32px)" }}
      >
        <div className="border-b border-[var(--color-glass-border)]">
        <div className="flex items-center justify-between gap-4 px-5 py-3">
          <div className="min-w-0 flex-1">
            <div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Playing</div>
            <div className="flex items-baseline gap-2 min-w-0">
              <span className="font-mono text-[var(--color-cyan)] font-semibold shrink-0">{code}</span>
              {actresses && actresses.length > 0 && (
                <span className="text-sm text-[var(--color-fg-dim)] truncate">
                  {actresses.map((a) => a.name).join(", ")}
                </span>
              )}
            </div>
          </div>
          <div className="flex items-center gap-2">
            {runningSummary && (
              <span
                title={cancelling ? "Cancelling generation..." : (runningSummary.status ?? "Generating subtitles")}
                className={cn(
                  "inline-flex items-center gap-1.5 text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border",
                  cancelling
                    ? "border-red-400/40 bg-red-500/10 text-red-300"
                    : "border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]",
                )}
              >
                <Loader2 className="w-3 h-3 animate-spin shrink-0" />
                {cancelling ? (
                  <>
                    <span>Cancelling</span>
                    <span className="opacity-75">· {formatJobElapsed(runningSummary.elapsedSec)}</span>
                  </>
                ) : (
                  <>
                    <span>Generating</span>
                    <span className="opacity-75">· {formatJobElapsed(runningSummary.elapsedSec)}</span>
                    {runningSummary.etaSec != null && runningSummary.etaSec > 5 && (
                      <span className="opacity-75" title="Estimated time remaining">
                        · ETA {formatJobElapsed(runningSummary.etaSec)}
                      </span>
                    )}
                    {runningSummary.status && (
                      <span className="opacity-75 normal-case truncate max-w-[180px]">· {runningSummary.status}</span>
                    )}
                    <button
                      type="button"
                      onClick={onCancelJob}
                      title="Cancel generation"
                      className="ml-1 p-0.5 rounded hover:bg-red-500/15 text-red-300 cursor-pointer"
                    >
                      <X className="w-3 h-3" />
                    </button>
                  </>
                )}
              </span>
            )}
            {probing && (
              <span
                title="Probing codec metadata"
                className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-fg-muted)]/40 bg-[var(--color-glass)] text-[var(--color-fg-muted)]"
              >
                <Loader2 className="w-3 h-3 animate-spin shrink-0" /> Probing
              </span>
            )}
            {!probing && measuring && (
              <span
                title="Watching dropped-frame count on live playback to decide direct vs transcode"
                className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-amber-400/40 bg-amber-400/10 text-amber-200"
              >
                <Loader2 className="w-3 h-3 animate-spin shrink-0" /> Measuring
              </span>
            )}
            {!probing && !measuring && resolved === "transcode" && (
              <span
                title="Live NVENC transcode (no B-frames) — bypasses Chrome H.264 stutter"
                className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/40 bg-[var(--color-coral)]/10 text-[var(--color-coral)]"
              >
                <span className="relative flex h-3 w-3 shrink-0 items-center justify-center">
                  <span className="absolute inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-coral)] opacity-75 animate-ping" />
                  <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-coral)] shadow-[0_0_10px_var(--color-coral)]" />
                </span>
                Transcoding
              </span>
            )}
            {!probing && !measuring && resolved === "direct" && (
              <span
                title="Streaming the original file directly — no re-encode"
                className="inline-flex items-center justify-center gap-1.5 min-w-[120px] text-[10px] leading-4 uppercase tracking-wider font-mono px-3 py-1.5 rounded-lg border border-[var(--color-mint)]/40 bg-[var(--color-mint)]/10 text-[var(--color-mint)]"
              >
                <PlayCircle className="w-3 h-3 shrink-0" /> Direct Play
              </span>
            )}
            <SubtitlePicker
              sources={subSources}
              selectedId={selectedSubId}
              loading={subSourcesLoading}
              onChange={onSubChange}
              onBrowse={onBrowse}
              generateEnabled={whisperjavEnabled}
              hasGenerated={hasGenerated}
              runningJob={runningSummary}
              onGenerate={onGenerate}
              onCancel={onCancelJob}
              offsetSec={subOffsetSec}
              onOffsetChange={setSubOffsetSec}
            />
            <button
              type="button"
              onClick={reveal}
              title="Reveal in folder"
              className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
            >
              <ExternalLink className="w-3.5 h-3.5" /> Reveal in Folder
            </button>
            <button
              onClick={onClose}
              aria-label="Close"
              className="p-1 rounded hover:bg-[var(--color-glass)]"
            >
              <X className="w-5 h-5" />
            </button>
          </div>
        </div>
        {(metadataSummary || currentFile?.filename) && (
          <div className="px-5 pb-2 -mt-1 flex items-baseline justify-between gap-4 text-[11px] font-mono">
            {metadataSummary ? (
              <div className="flex items-baseline gap-2 min-w-0 text-[var(--color-fg-dim)]">
                <span
                  className={
                    resolved === "transcode"
                      ? "shrink-0 text-[var(--color-coral)]"
                      : "shrink-0 text-[var(--color-cyan)]"
                  }
                >▶</span>
                <span className="whitespace-nowrap">{metadataSummary}</span>
              </div>
            ) : <span />}
            {currentFile?.filename && (
              <span
                className="ml-auto min-w-0 truncate text-[var(--color-fg-dim)]"
                title={currentFile.filename}
              >
                {currentFile.filename}
              </span>
            )}
          </div>
        )}
        </div>

        <div className="flex-1 min-h-0 grid place-items-center bg-black overflow-hidden">
          {error ? (
            <div className="text-center px-6 py-12 text-[var(--color-fg-dim)]">
              <AlertTriangle className="w-8 h-8 mx-auto text-[var(--color-coral)] mb-2" />
              <div className="text-sm">{error}</div>
              <div className="text-xs mt-2">If the codec isn&apos;t playable in-browser, try the Reveal in Folder button.</div>
            </div>
          ) : parts === null || resolved === null ? (
            <div className="text-[var(--color-fg-muted)] flex flex-col items-center gap-2">
              <Loader2 className="w-6 h-6 animate-spin" />
              {probing && (
                <span className="text-[11px] font-mono">probing playback quality…</span>
              )}
            </div>
          ) : (
            <video
              ref={videoRef}
              key={streamUrl}
              // For transcoded HLS streams, the source is attached via the
              // hls.js useEffect above. For direct streams it's set here.
              src={transcoding ? undefined : streamUrl}
              controls
              autoPlay
              tabIndex={0}
              // Chrome treats <video> as HTML5-draggable by default. When
              // the user click-and-drags on the scrubber or volume slider,
              // Chrome interprets the drag past a small threshold as
              // "drag the video element out" and starts a native drag
              // operation, snatching whatever element is at the cursor
              // (often a card on the page beneath). Killing the drag
              // capability keeps slider drags inside the shadow-DOM
              // controls where they belong.
              draggable={false}
              onDragStart={(e) => e.preventDefault()}
              className="w-full h-full max-h-[calc(100vh-200px)] outline-none"
            >
              {trackUrl && (
                <track
                  key={selectedSubId}
                  kind="subtitles"
                  src={trackUrl}
                  srcLang={trackLang ?? undefined}
                  label={trackLabel}
                  default
                />
              )}
            </video>
          )}
        </div>

        {parts && (parts.length > 1 || (currentLocation && parts[currentLocation.partDisplay]!.variants.length > 1)) && (
          <div className="px-5 py-2 border-t border-[var(--color-glass-border)] flex items-center gap-2">
            <span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-dim)] shrink-0">Parts</span>
            <div className="flex items-center gap-1.5 overflow-x-auto min-w-0">
              {parts.map((p, i) => {
                const v = p.variants[variantPickByPart[i] ?? p.defaultIdx] ?? p.variants[p.defaultIdx]!;
                const active = currentLocation?.partDisplay === i;
                const summary = formatVideoSummary(v.metadata);
                const compact = formatVideoSummary(v.metadata, true);
                return (
                  <button
                    key={`${i}-${v.rel}`}
                    type="button"
                    onClick={() => selectPart(i)}
                    title={summary ? `${v.filename} · ${summary}` : v.filename}
                    className={
                      active
                        ? "text-xs font-mono px-3 py-1 rounded-md bg-[var(--color-cyan)] text-black font-semibold whitespace-nowrap"
                        : "text-xs font-mono px-3 py-1 rounded-md glass glass-hover whitespace-nowrap"
                    }
                  >
                    {p.partIndex}
                    {compact && <span className="ml-1 opacity-75">{compact}</span>}
                  </button>
                );
              })}
            </div>
            {currentLocation && parts[currentLocation.partDisplay]!.variants.length > 1 && (
              <div className="ml-auto shrink-0">
                <VariantPicker
                  options={parts[currentLocation.partDisplay]!.variants.map((v) => ({
                    id: v.partIdx,
                    label: v.label,
                    filename: v.filename,
                  }))}
                  selectedId={partIdx}
                  onChange={(id) => {
                    const idx = parts[currentLocation.partDisplay]!.variants.findIndex((v) => v.partIdx === id);
                    if (idx >= 0) selectVariantInCurrentPart(idx);
                  }}
                />
              </div>
            )}
          </div>
        )}
      </div>
    </div>,
    document.body,
  );
}
