import {
  Fragment,
  lazy,
  Suspense,
  useCallback,
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type WheelEvent as ReactWheelEvent,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
  ArrowLeft,
  Clock,
  Download,
  Info,
  Keyboard,
  Languages,
  Maximize,
  Minimize,
  Pause,
  Play,
  RotateCcw,
  Settings,
  SkipBack,
  SkipForward,
  Square,
  Volume2,
  VolumeX,
  X,
} from "lucide-react";
import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import {
  exitFullscreen,
  fullscreenElement,
  onFullscreenChange,
  requestFullscreen,
} from "../lib/fullscreen";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";

// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));

// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
// ordinal means different languages in different files, so we re-resolve the saved language to this
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
type AudioStream = { ord: number; language?: string | null; default: boolean };
type SubStream = { ord: number; language?: string | null; text: boolean };
function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
  if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
  const i = streams.findIndex((s) => (s.language ?? "") === lang);
  // absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
  // (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
  // the restored language silently fall back to track 0.)
  return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists
}
function subOrdForLang(subs: SubStream[], lang: string): number | null {
  if (!lang) return null; // "" = subtitles off
  const m = subs.find((s) => s.text && (s.language ?? "") === lang);
  return m ? m.ord : null; // preferred language not available here → off
}

// Per-account player preferences, persisted so they survive F5 and carry across items.
type PlexPlayerPrefs = {
  volume: number; // 0..1
  muted: boolean;
  audioLang: string; // "" = default first audio
  subLang: string; // "" = subtitles off
  subOffset: number; // seconds, +later / -earlier (client-side cue shift)
  audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
  barAutoHide: boolean; // auto-hide the control bar during playback
  seekStep: number; // plain ←/→ jump (s)
  fineSeekStep: number; // Ctrl+←/→ jump (s)
  subSize: number; // subtitle font size (%)
  subColor: string; // subtitle text color (hex)
  subBg: string; // subtitle background ("transparent" or hex)
  subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
  subShadow: number; // subtitle text-shadow blur px (0 = off)
  subShadowColor: string; // subtitle text-shadow color (hex)
  clockShow: boolean; // top-right wall clock
  clock24h: boolean; // 24h (European) vs 12h am/pm
  clockSize: number; // clock font size (%)
  clockColor: string; // clock color (hex)
  clockDate: boolean; // show the date alongside the time
  clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd  vs  YYYY-MON-DD Ddd
  autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
  autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
  autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
};
const DEFAULT_PREFS: PlexPlayerPrefs = {
  volume: 1,
  muted: false,
  audioLang: "",
  subLang: "",
  subOffset: 0,
  audioOffset: 0,
  barAutoHide: true,
  seekStep: 10,
  fineSeekStep: 1,
  subSize: 100,
  subColor: "#ffffff",
  subBg: "transparent",
  subPos: 88,
  subShadow: 0,
  subShadowColor: "#000000",
  clockShow: true,
  clock24h: true,
  clockSize: 100,
  clockColor: "#ffffff",
  clockDate: false,
  clockDateStyle: "hu",
  autoSkipIntro: false,
  autoSkipCredits: false,
  autoSkipDelay: 5,
};
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));

// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
// snow-white frame. Empty string when the slider is 0 (shadow off).
function subShadowCss(px: number, color: string): string {
  if (px <= 0) return "";
  const o = px;
  const b = Math.max(1, Math.round(px / 2));
  const dirs: [number, number][] = [
    [o, 0],
    [-o, 0],
    [0, o],
    [0, -o],
    [o, o],
    [o, -o],
    [-o, o],
    [-o, -o],
  ];
  const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
  return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
}

// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday).
function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } {
  const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", {
    hour: "2-digit",
    minute: "2-digit",
    hour12: !p.clock24h,
  });
  if (!p.clockDate) return { time, date: null };
  const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB";
  const dd = String(now.getDate()).padStart(2, "0");
  const yyyy = now.getFullYear();
  const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
  const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
  const date =
    p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
  return { time, date };
}
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
const HLS_SUB_LAG = 1.0;

// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
// restart the session at the target. Watch state / resume persist to plex_states.

// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };

// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
function fmt(t: number): string {
  return formatDuration(isFinite(t) && t >= 0 ? t : 0);
}

export default function PlexPlayer({ itemId, onClose, queue }: Props) {
  const { t } = useTranslation();
  // `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
  // through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
  // rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
  // position (losing live progress). See the detail effect's deps.
  const tRef = useRef(t);
  tRef.current = t;
  const qc = useQueryClient();
  const [id, setId] = useState(itemId);
  const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
  const detail = detailQ.data;
  const duration = detail?.duration_seconds ?? 0;

  const videoRef = useRef<HTMLVideoElement>(null);
  const hlsRef = useRef<Hls | null>(null);
  // False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
  // can't start a detached <video> playing audio in the background.
  const aliveRef = useRef(true);
  const wrapRef = useRef<HTMLDivElement>(null);
  const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
  const durationRef = useRef(0);
  // ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
  // client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
  const multiAudioRef = useRef(false);
  multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
  // Auto-skip countdown state, via refs so the keyboard handler (defined earlier than the skip
  // machinery) can read/cancel it without a declaration-order dependency.
  const skipProgressRef = useRef<number | null>(null);
  const cancelAutoSkipRef = useRef<() => void>(() => {});

  const [playing, setPlaying] = useState(false);
  const [abs, setAbs] = useState(0); // absolute current time
  const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
  const [fs, setFs] = useState(false);
  const [ready, setReady] = useState(false);
  const [loadError, setLoadError] = useState<string | null>(null);
  const [uiVisible, setUiVisible] = useState(true);
  const hideTimer = useRef<number | null>(null);
  // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
  const [audioOrd, setAudioOrd] = useState<number | null>(null);
  const [subOrd, setSubOrd] = useState<number | null>(null);
  // The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
  // absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
  // this and re-fetches when a seek-restart changes it.
  const [sessionK, setSessionK] = useState(0);

  // Per-account persisted player prefs — survive F5, carry across items.
  const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
    LS.plexPlayerPrefs,
    DEFAULT_PREFS,
  );
  const { volume, muted } = prefs;
  // Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
  const barAutoHideRef = useRef(prefs.barAutoHide);
  barAutoHideRef.current = prefs.barAutoHide;
  const seekStepRef = useRef(prefs.seekStep);
  seekStepRef.current = prefs.seekStep;
  const fineSeekStepRef = useRef(prefs.fineSeekStep);
  fineSeekStepRef.current = prefs.fineSeekStep;
  const aoffRef = useRef(prefs.audioOffset);
  aoffRef.current = prefs.audioOffset;
  // Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
  const [subOffApplied, setSubOffApplied] = useState(prefs.subOffset);
  useEffect(() => {
    const tmr = window.setTimeout(() => setSubOffApplied(prefs.subOffset), 250);
    return () => window.clearTimeout(tmr);
  }, [prefs.subOffset]);
  const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
  const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
  const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
    "sync",
  );
  const audioRef = useRef<number | null>(null);
  const menuOpenRef = useRef(false);
  const menuRef = useRef<HTMLDivElement>(null);
  const menuTriggerRef = useRef<HTMLButtonElement>(null);
  const tracksRef = useRef<HTMLDivElement | null>(null);
  const tracksTriggerRef = useRef<HTMLButtonElement>(null);
  audioRef.current = audioOrd;
  menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
  // Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
  useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
  useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
  // The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
  // Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
  // and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
  const tracksFade = useScrollFade();
  const setTracksNode = useCallback(
    (node: HTMLElement | null) => {
      tracksRef.current = node as HTMLDivElement | null;
      tracksFade.ref(node);
    },
    [tracksFade.ref],
  );
  // Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
  const [helpOpen, setHelpOpen] = useState(false);
  const helpOpenRef = useRef(false);
  helpOpenRef.current = helpOpen;
  // "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
  const [infoOpen, setInfoOpen] = useState(false);
  const infoOpenRef = useRef(false);
  infoOpenRef.current = infoOpen;
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const iv = window.setInterval(() => setNow(new Date()), 15000);
    return () => window.clearInterval(iv);
  }, []);

  durationRef.current = duration;

  // Track selection is (re)derived from the saved language prefs when each item's detail arrives
  // (see the detail effect below) — nothing to reset on id change.

  // --- media setup: (re)load a session for `id` at a start offset -------------------------------
  const loadSession = useCallback(
    async (startAt: number, resumePlay = true) => {
      const video = videoRef.current;
      if (!video) return;
      setReady(false);
      setLoadError(null);
      let sess;
      try {
        // Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
        // selected client-side after the manifest loads.
        const m = multiAudioRef.current;
        sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
      } catch (e) {
        // Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
        // isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
        // needs full transcoding (a later phase).
        const status = e instanceof HttpError ? e.status : undefined;
        setLoadError(
          status === 501
            ? tRef.current("plex.player.errTranscode")
            : status === 404
              ? tRef.current("plex.player.errNotFound")
              : tRef.current("plex.player.errGeneric"),
        );
        return;
      }
      // hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
      // fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
      // content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
      const trueStart =
        sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
      sessionStartRef.current = trueStart;
      setSessionK(trueStart);
      // Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
      // rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
      // the video paused after a seek even though the user was watching. Retry once the element can
      // actually play. Idempotent — only acts if still paused.
      if (resumePlay) {
        const onCanPlay = () => {
          if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {});
        };
        video.addEventListener("canplay", onCanPlay, { once: true });
      }
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
      if (sess.mode === "hls" && Hls.isSupported()) {
        const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
        hlsRef.current = hls;
        hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
        hls.attachMedia(video);
        // Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          setReady(true);
          if (aliveRef.current && resumePlay) video.play().catch(() => {});
        });
        // Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on
        // MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and
        // playback stayed on the default track while the UI showed the restored selection).
        hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
          if (multiAudioRef.current) {
            const want = audioRef.current ?? 0;
            if (hls.audioTrack !== want) {
              try {
                hls.audioTrack = want;
              } catch {
                /* out of range — ignore */
              }
            }
          }
        });
        // A fatal HLS error (e.g. the remux session died / the file became unreadable) would
        // otherwise leave the spinner up forever — surface it instead.
        hls.on(Hls.Events.ERROR, (_e, data) => {
          if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
        });
      } else {
        // direct file (or Safari native HLS)
        video.src = sess.url;
        video.load();
        const onMeta = () => {
          if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
          setReady(true);
          if (aliveRef.current && resumePlay) video.play().catch(() => {});
          video.removeEventListener("loadedmetadata", onMeta);
        };
        video.addEventListener("loadedmetadata", onMeta);
      }
    },
    [id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
  );

  // Start playback when the detail arrives (resume from the saved position unless already watched).
  useEffect(() => {
    if (!detail) return;
    // Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
    // audio ref synchronously so the loadSession below picks the right track on the first request.
    const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
    audioRef.current = aOrd;
    setAudioOrd(aOrd);
    setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
    const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
    loadSession(resume);
    return () => {
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
    };
    // Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
    // double-load the session — the picker handlers already reload/toggle).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [detail, loadSession]);

  // Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
  // play() can't leave audio playing on a detached element after leaving the player.
  useEffect(() => {
    return () => {
      aliveRef.current = false;
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
      const v = videoRef.current;
      if (v) {
        try {
          v.pause();
          v.removeAttribute("src");
          v.load();
        } catch {
          /* ignore */
        }
      }
    };
  }, []);

  // --- time tracking + progress checkpoints ----------------------------------------------------
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const onTime = () => {
      const a = sessionStartRef.current + video.currentTime;
      setAbs(a);
      try {
        const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
        setSeekableEnd(end);
      } catch {
        /* ignore */
      }
    };
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    // A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
    // which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
    // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
    // so the icon can never contradict the video.
    const syncPlaying = () => setPlaying(!video.paused);
    video.addEventListener("timeupdate", onTime);
    video.addEventListener("play", onPlay);
    video.addEventListener("pause", onPause);
    video.addEventListener("emptied", syncPlaying);
    video.addEventListener("canplay", syncPlaying);
    video.addEventListener("seeked", syncPlaying);
    return () => {
      video.removeEventListener("timeupdate", onTime);
      video.removeEventListener("play", onPlay);
      video.removeEventListener("pause", onPause);
      video.removeEventListener("emptied", syncPlaying);
      video.removeEventListener("canplay", syncPlaying);
      video.removeEventListener("seeked", syncPlaying);
    };
  }, [id, patchPrefs]);

  // Periodic + on-unmount progress checkpoint.
  const absRef = useRef(0);
  absRef.current = abs;
  useEffect(() => {
    // `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
    // Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
    // sees the resume position when the user actually stops — not a timeline call every 10 seconds.
    const flush = (final = false) => {
      if (absRef.current > 0 && durationRef.current > 0)
        api
          .plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
          .catch(() => {});
    };
    const iv = window.setInterval(flush, 10000);
    // Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
    const onPauseFlush = () => flush(true);
    const video = videoRef.current;
    video?.addEventListener("pause", onPauseFlush);
    // Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
    // so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
    // keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
    const onHide = () => {
      if (absRef.current > 0 && durationRef.current > 0)
        api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
    };
    window.addEventListener("pagehide", onHide);
    return () => {
      window.clearInterval(iv);
      window.removeEventListener("pagehide", onHide);
      video?.removeEventListener("pause", onPauseFlush);
      flush(true);
      // Back (unmount) persists the position server-side via flush(), but reopening the same item in
      // the SAME session read react-query's CACHED detail — whose position_seconds predated this
      // watch — so the resume landed at the old spot (the progress GET could also race the POST).
      // Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
      // a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
      // unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
      if (absRef.current > 0 && durationRef.current > 0) {
        const pos = Math.floor(absRef.current);
        qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
      }
      qc.invalidateQueries({ queryKey: qk.plexLibrary() });
      qc.invalidateQueries({ queryKey: qk.plexShow() });
    };
  }, [id, qc]);

  // --- seeking (native within the loaded region, else restart the session) ---------------------
  const seekTo = useCallback(
    (target: number) => {
      const video = videoRef.current;
      if (!video) return;
      const wasPlaying = !video.paused; // preserve play/pause intent across the seek
      const dur = durationRef.current || target;
      const T = Math.max(0, Math.min(target, dur - 1));
      absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
      const rel = T - sessionStartRef.current;
      if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
        video.currentTime = rel; // native seek within the loaded region keeps playing on its own
      } else {
        loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
      }
    },
    [loadSession],
  );

  // --- controls --------------------------------------------------------------------------------
  const togglePlay = useCallback(() => {
    const v = videoRef.current;
    if (!v) return;
    if (v.paused) v.play().catch(() => {});
    else v.pause();
  }, []);

  const applyVolume = useCallback((vol: number, mute: boolean) => {
    const v = videoRef.current;
    if (!v) return;
    v.volume = clamp01(vol);
    v.muted = mute;
  }, []);
  // Set volume/mute on the <video> AND persist (survives F5).
  const setVol = useCallback(
    (vol: number, mute: boolean) => {
      applyVolume(vol, mute);
      patchPrefs({ volume: clamp01(vol), muted: mute });
    },
    [applyVolume, patchPrefs],
  );
  const nudgeVolume = useCallback(
    (d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0),
    [prefs.volume, setVol],
  );
  const toggleMute = useCallback(
    () => setVol(prefs.volume, !prefs.muted),
    [prefs.volume, prefs.muted, setVol],
  );
  // Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
  // is fresh after a load/seek-restart, and starts at its default volume otherwise).
  useEffect(() => {
    if (ready) applyVolume(prefs.volume, prefs.muted);
  }, [ready, applyVolume, prefs.volume, prefs.muted]);
  // Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
  const onWheel = useCallback(
    (e: ReactWheelEvent) => {
      if (infoOpenRef.current || helpOpenRef.current) return;
      nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
    },
    [nudgeVolume],
  );

  // Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling,
  // so reading `document.fullscreenElement` alone made this player believe it is never fullscreen
  // — the button did nothing, `fs` never flipped, and Escape (below) read "not fullscreen" as
  // permission to close the whole player instead of just leaving fullscreen.
  const toggleFs = useCallback(() => {
    const el = wrapRef.current;
    if (!el) return;
    if (fullscreenElement()) exitFullscreen();
    else requestFullscreen(el);
  }, []);
  useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []);

  const go = useCallback((otherId: string | null | undefined) => {
    if (otherId) {
      // Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
      // file), loadSession returns without replacing the <video> source, leaving the old episode
      // still playing — it would then fire 'ended' against the NEW id and mark the wrong item
      // watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
      try {
        videoRef.current?.pause();
      } catch {
        /* ignore */
      }
      setAbs(0);
      setId(otherId);
    }
  }, []);

  // Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
  const qIdx = queue ? queue.indexOf(id) : -1;
  const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
  const nextId = queue
    ? qIdx >= 0 && qIdx < queue.length - 1
      ? queue[qIdx + 1]
      : null
    : detail?.next_id;

  // Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
  const download = useCallback(() => {
    const a = document.createElement("a");
    a.href = api.plexDownloadUrl(id);
    a.rel = "noopener";
    document.body.appendChild(a);
    a.click();
    a.remove();
  }, [id]);

  // Switching AUDIO re-maps the track server-side, so it restarts the session at the current
  // position (like a seek). The ref is updated synchronously so loadSession sees it.
  const changeAudio = useCallback(
    (ord: number | null) => {
      audioRef.current = ord;
      setAudioOrd(ord);
      // Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
      patchPrefs({
        audioLang:
          ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
      });
      if (multiAudioRef.current && hlsRef.current) {
        // Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
        try {
          hlsRef.current.audioTrack = ord ?? 0;
        } catch {
          /* ignore */
        }
      } else {
        loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
      }
    },
    [loadSession, detail, patchPrefs],
  );
  // Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
  // (applied by the effect below), NO session restart. Off = null.
  const changeSubtitle = useCallback(
    (ord: number | null) => {
      setSubOrd(ord);
      patchPrefs({
        subLang:
          ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
      });
    },
    [detail, patchPrefs],
  );
  // 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
  const cycleAudio = useCallback(() => {
    const streams = detail?.audio_streams ?? [];
    if (streams.length < 2) return;
    const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
    const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
    changeAudio(next.ord === 0 ? null : next.ord);
  }, [detail, changeAudio]);
  const cycleSubtitle = useCallback(() => {
    const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
    const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
    const i = order.findIndex((o) => o === subOrd);
    changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
  }, [detail, subOrd, changeSubtitle]);
  // Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
  // dragging doesn't respawn ffmpeg on every step.
  const aoffTimer = useRef<number | null>(null);
  const changeAudioOffset = useCallback(
    (v: number) => {
      patchPrefs({ audioOffset: v });
      aoffRef.current = v;
      if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
      aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
    },
    [patchPrefs, loadSession],
  );

  // Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
  const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
  // Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
  // modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
  const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
  // The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
  // HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
  // the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
  // sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
  // must be shifted by; the user offset nudges on top.
  const subTrackOffset = sessionK - subOffApplied;
  const settingsTabs = [
    { key: "sync" as const, label: t("plex.player.sync") },
    { key: "playback" as const, label: t("plex.player.playback") },
    ...(textSubs.length > 0
      ? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }]
      : []),
    { key: "clock" as const, label: t("plex.player.clock") },
  ];
  const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
  // Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
  // after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
  }, [subOrd, id, subTrackOffset]);

  // Position subtitle cues per the user's vertical setting (prefs.subPos). The video fills the viewport
  // height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
  // Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const placeAll = () => {
      for (const tr of Array.from(video.textTracks)) {
        for (const c of Array.from(tr.cues || [])) {
          try {
            (c as VTTCue).snapToLines = false;
            (c as VTTCue).line = prefs.subPos;
          } catch {
            /* not a positionable cue */
          }
        }
      }
    };
    const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
    for (const tr of Array.from(video.textTracks)) bind(tr);
    const onAddTrack = (e: TrackEvent) => {
      if (e.track) {
        bind(e.track as TextTrack);
        placeAll();
      }
    };
    video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
    placeAll();
    return () => {
      video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
      for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
    };
  }, [id, prefs.subPos]);

  // Auto-advance to the next episode when one finishes.
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const onEnded = () => {
      // Only a genuine play-to-the-end counts. A next episode that failed to load never played
      // (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
      if (absRef.current <= 0) return;
      api.plexSetState(id, "watched").catch(() => {});
      if (nextId) go(nextId);
    };
    video.addEventListener("ended", onEnded);
    return () => video.removeEventListener("ended", onEnded);
  }, [id, nextId, go]);

  // Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
  const wake = useCallback(() => {
    setUiVisible(true);
    if (hideTimer.current) window.clearTimeout(hideTimer.current);
    if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
    hideTimer.current = window.setTimeout(() => {
      // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
      if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
    }, 3000);
  }, []);

  // "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
  // off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
  // overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
  // with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
  const handleBack = useCallback(() => {
    if (menuOpenRef.current) {
      setMenuOpen(false);
      setTracksOpen(false);
    } else if (helpOpenRef.current) setHelpOpen(false);
    else if (infoOpenRef.current) setInfoOpen(false);
    else onClose();
  }, [onClose]);

  // The mouse "back" button (and the browser Back) fire history popstate — which would pop the whole
  // player subview and drop straight to the grid, even with a panel or an auto-skip countdown up.
  // While any of those is showing we register a history entry (BackClose below) whose Back handler
  // runs this: cancel the countdown, else close the topmost panel — matching ⌫ / Esc exactly.
  const backIntent = useCallback(() => {
    if (skipProgressRef.current != null) {
      cancelAutoSkipRef.current();
      return;
    }
    handleBack();
  }, [handleBack]);

  // This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
  // settings/tracks menus included) takes Escape first and this player defers (R8 S3).
  const layerIdRef = useRef<number | null>(null);
  useEffect(() => {
    const id = pushLayer();
    layerIdRef.current = id;
    return () => {
      removeLayer(id);
      layerIdRef.current = null;
    };
  }, []);

  // --- keyboard --------------------------------------------------------------------------------
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const tag = (e.target as HTMLElement)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA") return;
      wake(); // keyboard use also reveals the controls
      switch (e.key) {
        case " ":
          // Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
          // playback when the focus isn't on a control — matching PlayerModal's guard.
          if (tag === "BUTTON" || tag === "A") return;
          e.preventDefault();
          togglePlay();
          break;
        case "k":
          e.preventDefault();
          togglePlay();
          break;
        case "f":
          toggleFs();
          break;
        case "ArrowLeft":
          e.preventDefault();
          if (e.shiftKey) go(prevId);
          else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
          break;
        case "ArrowRight":
          e.preventDefault();
          if (e.shiftKey) go(nextId);
          else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
          break;
        case "ArrowUp":
          e.preventDefault();
          nudgeVolume(0.05);
          break;
        case "ArrowDown":
          e.preventDefault();
          nudgeVolume(-0.05);
          break;
        case "m":
          toggleMute();
          break;
        case "a":
        case "A":
          cycleAudio();
          break;
        case "s":
        case "S":
          cycleSubtitle();
          break;
        case "h":
        case "H":
          setHelpOpen((v) => !v);
          break;
        case "i":
        case "I":
          setInfoOpen((v) => !v);
          break;
        case "Backspace":
          // HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
          e.preventDefault();
          if (skipProgressRef.current != null)
            cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
          else handleBack();
          break;
        case "Escape":
          // Defer to any layer opened above the player (Modal or anchored popover) — let it take the
          // Escape, not us. A player MENU registers as a layer too, so it closes first and the next
          // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
          if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
          if (skipProgressRef.current != null) cancelAutoSkipRef.current();
          else if (menuOpenRef.current) {
            setMenuOpen(false);
            setTracksOpen(false);
          } else if (helpOpenRef.current) setHelpOpen(false);
          else if (infoOpenRef.current) setInfoOpen(false);
          else if (!fullscreenElement()) onClose();
          break;
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [
    togglePlay,
    toggleFs,
    seekTo,
    go,
    nudgeVolume,
    toggleMute,
    cycleAudio,
    cycleSubtitle,
    detail,
    prevId,
    nextId,
    onClose,
    wake,
    handleBack,
  ]);

  const activeMarker: PlexMarker | undefined = detail?.markers.find(
    (m) => abs >= m.start_s && abs < m.end_s - 1,
  );
  const activeMarkerRef = useRef(activeMarker);
  activeMarkerRef.current = activeMarker;

  // Auto-skip intro/credits: entering a marker whose auto-skip is on runs a countdown (skipProgress
  // 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
  // marker end, credits → next item (binge). Esc/Backspace during the countdown cancels it (and must
  // NOT navigate back). 0s delay = skip immediately.
  const [skipProgress, setSkipProgress] = useState<number | null>(null);
  skipProgressRef.current = skipProgress;
  const cancelledMarkerRef = useRef<string | null>(null);
  // Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
  // marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
  // the previous one (auto-skip silently dead for the rest of a binge).
  useEffect(() => {
    cancelledMarkerRef.current = null;
  }, [id]);
  // The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
  // alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
  const skipTimerRef = useRef<number | null>(null);
  const doSkip = useCallback(
    (m: PlexMarker) => {
      setSkipProgress(null);
      if (m.type === "credits" && nextId) {
        // Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
        // (the current id, captured here — not the reactive id, which go() is about to change).
        api.plexSetState(id, "watched").catch(() => {});
        go(nextId);
      } else seekTo(m.end_s);
    },
    [id, go, nextId, seekTo],
  );
  const cancelAutoSkip = useCallback(() => {
    const m = activeMarkerRef.current;
    if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
    if (skipTimerRef.current) {
      window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it
      skipTimerRef.current = null;
    }
    setSkipProgress(null);
  }, []);
  cancelAutoSkipRef.current = cancelAutoSkip;
  useEffect(() => {
    const m = activeMarker;
    if (!m) return setSkipProgress(null);
    const key = `${m.type}-${m.start_s}`;
    const enabled = m.type === "intro" ? prefs.autoSkipIntro : prefs.autoSkipCredits;
    if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
    if (prefs.autoSkipDelay <= 0) return doSkip(m); // immediate
    let elapsed = 0;
    setSkipProgress(0);
    const iv = window.setInterval(() => {
      // Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
      // deliberately-paused video. It resumes from where it stopped when playback resumes.
      if (videoRef.current?.paused) return;
      elapsed += 0.1;
      const p = Math.min(1, elapsed / prefs.autoSkipDelay);
      setSkipProgress(p);
      if (p >= 1) {
        window.clearInterval(iv);
        skipTimerRef.current = null;
        doSkip(m);
      }
    }, 100);
    skipTimerRef.current = iv;
    return () => {
      window.clearInterval(iv);
      skipTimerRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    activeMarker?.type,
    activeMarker?.start_s,
    prefs.autoSkipIntro,
    prefs.autoSkipCredits,
    prefs.autoSkipDelay,
    doSkip,
  ]);

  // Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
  // (0..1), not pixels: the bar sits inside the transform:scale(1.25) subtree, so a px `left` would be
  // re-scaled and drift right of the cursor (only 0 stayed correct). A % `left` is relative to the
  // bar's own width and scales cleanly.
  const [hover, setHover] = useState<{ frac: number; t: number } | null>(null);
  // Seekbar scrubbing: the absolute time under the dragging finger. While non-null the head/thumb
  // preview this position; the real seek fires once on release (avoids restarting the session on
  // every out-of-buffer step of the drag). A plain click is a down+up at one spot → seeks there.
  const [scrub, setScrub] = useState<number | null>(null);
  // Volume bar: a 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set.
  const [volHover, setVolHover] = useState<number | null>(null); // 0..1 under the cursor
  const volBarRef = useRef<HTMLDivElement>(null);
  const volAt = useCallback((clientX: number) => {
    const el = volBarRef.current;
    if (!el) return 0;
    const r = el.getBoundingClientRect();
    return clamp01((clientX - r.left) / r.width);
  }, []);

  const pct = duration > 0 ? (abs / duration) * 100 : 0;
  const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
  // While scrubbing the head follows the finger (preview); otherwise it tracks playback.
  const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;

  // Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
  // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
  // moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
  // this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
  // over the player. That is exactly what the scroller's edge-fade did before this moved out.
  return createPortal(
    <div
      ref={wrapRef}
      className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
      onMouseMove={wake}
      onClick={wake}
      onWheel={onWheel}
      // Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
      // out at 80vw×80vh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
      // overflow, controls stay on-screen). We use `transform: scale` rather than `zoom` deliberately:
      // under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
      // pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
      // both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
      //
      // In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80vh, the
      // scaled layer is composited letterboxed and the video never grows past its windowed size (the UA
      // does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to
      // the full screen (100vw×100vh) with NO transform — the flex box then centres the video and its
      // `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the
      // clientX/rect math stay correct under the identity transform.
      style={{
        cursor: uiVisible ? "default" : "none",
        ...(fs
          ? { width: "100vw", height: "100vh" }
          : {
              width: "80vw",
              height: "80vh",
              transform: "scale(1.25)",
              transformOrigin: "top left",
            }),
      }}
    >
      {/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
          player owns the only <video> on screen. */}
      <style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
        prefs.subShadow,
        prefs.subShadowColor,
      )}}`}</style>
      <video
        ref={videoRef}
        // Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
        // object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
        // (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
        className="w-full h-full object-contain"
        onClick={togglePlay}
        playsInline
      >
        {/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
            above flips modes; the browser fetches a track's VTT the first time it's shown. */}
        {selectedSub && (
          <track
            // One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
            // a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
            key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
            kind="subtitles"
            src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
            srcLang={selectedSub.language ?? undefined}
            label={selectedSub.label}
            default
          />
        )}
      </video>

      {!ready && (
        <div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
          {loadError ? (
            <span className="max-w-md text-red-300">{loadError}</span>
          ) : (
            <span className="text-white/70">{t("plex.player.loading")}</span>
          )}
        </div>
      )}

      {/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
          Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
      {(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
        <BackClose onBack={backIntent} />
      )}

      {/* Top bar */}
      <div
        className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
          uiVisible ? "opacity-100" : "opacity-0"
        }`}
      >
        <div className="flex items-center gap-3 text-white">
          <button
            onClick={handleBack}
            className="p-1.5 rounded-lg hover:bg-white/15"
            title={t("plex.player.back")}
          >
            <ArrowLeft className="w-5 h-5" />
          </button>
          <div className="min-w-0">
            <div className="font-semibold truncate">
              {detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
            </div>
            {detail?.kind === "episode" && (
              <div className="text-xs text-white/70 truncate">
                S{detail.season_number}·E{detail.episode_number} — {detail.title}
              </div>
            )}
          </div>
          {/* Live wall clock — handy on a lean-back / HTPC screen (toggle + style in the Clock tab). */}
          {prefs.clockShow &&
            (() => {
              const c = clockParts(now, prefs);
              return (
                <div
                  className="ml-auto flex items-center gap-1.5 tabular-nums shrink-0"
                  style={{ color: prefs.clockColor, fontSize: `${prefs.clockSize}%` }}
                >
                  <Clock className="h-4 w-4 opacity-70" />
                  <span>
                    {c.time}
                    {c.date ? ` · ${c.date}` : ""}
                  </span>
                </div>
              );
            })()}
        </div>
      </div>

      {/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
      {activeMarker && (
        <button
          onClick={() => doSkip(activeMarker)}
          className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
            uiVisible ? "opacity-100" : "opacity-80"
          }`}
        >
          {skipProgress != null && (
            <>
              {/* draining fill across the whole button… */}
              <span
                className="absolute inset-y-0 left-0 bg-accent/50"
                style={{ width: `${skipProgress * 100}%` }}
              />
              {/* …plus a crisp bottom bar so the countdown is unmistakable */}
              <span
                className="absolute bottom-0 left-0 h-1 bg-accent"
                style={{ width: `${skipProgress * 100}%` }}
              />
            </>
          )}
          <span className="relative">
            {activeMarker.type === "intro"
              ? t("plex.player.skipIntro")
              : t("plex.player.skipCredits")}
          </span>
        </button>
      )}

      {/* Bottom controls */}
      <div
        className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
          uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
        }`}
      >
        {/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
            release so a long drag doesn't restart the stream on every step. */}
        <div
          className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
          onPointerDown={(e) => {
            const el = e.currentTarget as HTMLElement;
            try {
              el.setPointerCapture(e.pointerId);
            } catch {
              /* capture unavailable — dragging still works via buttons state */
            }
            const r = el.getBoundingClientRect();
            const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
            setScrub((x / r.width) * duration);
          }}
          onPointerMove={(e) => {
            const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
            const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
            const tt = frac * duration;
            setHover({ frac, t: tt });
            if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
          }}
          onPointerUp={() => {
            if (scrub != null) {
              seekTo(scrub);
              setScrub(null);
            }
          }}
          onMouseLeave={() => setHover(null)}
        >
          {/* Hover timestamp tooltip — follows the cursor along the bar. */}
          {hover && duration > 0 && (
            <div
              className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
              style={{ left: `${hover.frac * 100}%` }}
            >
              {fmt(hover.t)}
            </div>
          )}
          <div
            className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
            style={{ width: `${bufPct}%` }}
          />
          {/* intro/credit marker regions */}
          {detail?.markers.map((m, i) => (
            <div
              key={i}
              className="absolute inset-y-0 bg-amber-400/50"
              style={{
                left: `${(m.start_s / duration) * 100}%`,
                width: `${((m.end_s - m.start_s) / duration) * 100}%`,
              }}
            />
          ))}
          <div
            className="absolute inset-y-0 left-0 bg-accent rounded-full"
            style={{ width: `${headPct}%` }}
          />
          <div
            className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
              scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
            }`}
            style={{ left: `${headPct}%` }}
          />
        </div>

        <div className="flex items-center gap-3 text-white">
          <Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
            {playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
          </Ctrl>
          <Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
            <RotateCcw className="w-5 h-5" />
          </Ctrl>
          <Ctrl label={t("plex.player.stop")} onClick={onClose}>
            <Square className="w-5 h-5" />
          </Ctrl>
          {(detail?.kind === "episode" || queue) && (
            <>
              <Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
                <SkipBack className="w-5 h-5" />
              </Ctrl>
              <Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
                <SkipForward className="w-5 h-5" />
              </Ctrl>
            </>
          )}

          <div className="flex items-center gap-2">
            <Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
              {muted || volume === 0 ? (
                <VolumeX className="w-5 h-5" />
              ) : (
                <Volume2 className="w-5 h-5" />
              )}
            </Ctrl>
            {/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0–100
                value under the cursor; click/drag sets it. */}
            <div
              ref={volBarRef}
              className="relative h-1.5 w-24 cursor-pointer rounded-full bg-white/25"
              onPointerDown={(e) => {
                try {
                  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
                } catch {
                  /* capture unavailable — dragging still works via buttons state */
                }
                const v = volAt(e.clientX);
                setVol(v, v === 0);
              }}
              onPointerMove={(e) => {
                const v = volAt(e.clientX);
                setVolHover(v);
                if (e.buttons & 1) setVol(v, v === 0); // dragging with the primary button held
              }}
              onMouseLeave={() => setVolHover(null)}
            >
              {/* filled level */}
              <div
                className="absolute inset-y-0 left-0 rounded-full bg-accent"
                style={{ width: `${(muted ? 0 : volume) * 100}%` }}
              />
              {/* thumb */}
              <div
                className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow"
                style={{ left: `${(muted ? 0 : volume) * 100}%` }}
              />
              {/* hover value tooltip (0–100), like the seek bar */}
              {volHover != null && (
                <div
                  className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
                  style={{ left: `${volHover * 100}%` }}
                >
                  {Math.round(volHover * 100)}
                </div>
              )}
            </div>
          </div>

          <div className="text-xs tabular-nums text-white/90">
            {fmt(abs)} / {fmt(duration)}
            <span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
            {/* Projected finish time (wall clock now + time remaining), in the clock's own format —
                pushes later while paused, since the remaining time then rides the advancing clock. */}
            {duration > 0 && (
              <span className="ml-1.5 text-white/45">
                (
                {
                  clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
                    .time
                }
                )
              </span>
            )}
          </div>

          <div className="ml-auto flex items-center gap-2">
            {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
            {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
              <div className="relative">
                <button
                  ref={tracksTriggerRef}
                  onClick={() => setTracksOpen((o) => !o)}
                  aria-label={t("plex.player.tracks")}
                  className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
                >
                  <Languages className="w-5 h-5" />
                </button>
                {tracksOpen && (
                  <div
                    ref={setTracksNode}
                    style={tracksFade.style}
                    onClick={(e) => e.stopPropagation()}
                    onWheel={(e) => e.stopPropagation()}
                    className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
                  >
                    {detail.audio_streams.length > 1 && (
                      <>
                        <PanelHead>{t("plex.player.audio")}</PanelHead>
                        {detail.audio_streams.map((a) => (
                          <button
                            key={a.ord}
                            onClick={() => changeAudio(a.ord)}
                            className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
                              (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
                            }`}
                          >
                            {a.label}
                          </button>
                        ))}
                      </>
                    )}
                    {textSubs.length > 0 && (
                      <>
                        <PanelHead>{t("plex.player.subtitles")}</PanelHead>
                        <button
                          onClick={() => changeSubtitle(null)}
                          className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
                            subOrd == null ? "text-accent" : "text-white"
                          }`}
                        >
                          {t("plex.player.subOff")}
                        </button>
                        {textSubs.map((s) => (
                          <button
                            key={s.ord}
                            onClick={() => changeSubtitle(s.ord)}
                            className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
                              subOrd === s.ord ? "text-accent" : "text-white"
                            }`}
                          >
                            {s.label}
                          </button>
                        ))}
                      </>
                    )}
                  </div>
                )}
              </div>
            )}

            {/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
            {detail && (
              <div className="relative">
                <button
                  ref={menuTriggerRef}
                  onClick={() => setMenuOpen((o) => !o)}
                  aria-label={t("plex.player.settings")}
                  className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
                >
                  <Settings className="w-5 h-5" />
                </button>
                {menuOpen && (
                  <div
                    ref={menuRef}
                    onWheel={(e) => e.stopPropagation()}
                    onClick={(e) => e.stopPropagation()}
                    className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
                  >
                    <div className="mb-1.5 flex gap-1">
                      {settingsTabs.map((tab) => (
                        <button
                          key={tab.key}
                          onClick={() => setSettingsTab(tab.key)}
                          className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-[11px] ${
                            activeTab === tab.key
                              ? "bg-white/15 text-accent"
                              : "text-white/70 hover:bg-white/10"
                          }`}
                        >
                          {tab.label}
                        </button>
                      ))}
                    </div>

                    {activeTab === "sync" && (
                      <>
                        <Slider
                          label={t("plex.player.subOffset")}
                          value={prefs.subOffset}
                          min={-10}
                          max={10}
                          step={0.1}
                          suffix="s"
                          onChange={(v) => patchPrefs({ subOffset: v })}
                          onReset={() => patchPrefs({ subOffset: 0 })}
                        />
                        <Slider
                          label={t("plex.player.audioOffset")}
                          value={prefs.audioOffset}
                          min={-5}
                          max={5}
                          step={0.1}
                          suffix="s"
                          onChange={changeAudioOffset}
                          onReset={() => changeAudioOffset(0)}
                        />
                      </>
                    )}

                    {activeTab === "playback" && (
                      <>
                        <Slider
                          label={t("plex.player.seekStep")}
                          value={prefs.seekStep}
                          min={5}
                          max={30}
                          step={1}
                          suffix="s"
                          onChange={(v) => patchPrefs({ seekStep: v })}
                        />
                        <Slider
                          label={t("plex.player.fineSeek")}
                          value={prefs.fineSeekStep}
                          min={0.1}
                          max={5}
                          step={0.1}
                          suffix="s"
                          onChange={(v) => patchPrefs({ fineSeekStep: v })}
                        />
                        <Toggle
                          label={t("plex.player.autoHide")}
                          checked={prefs.barAutoHide}
                          onChange={(v) => patchPrefs({ barAutoHide: v })}
                        />
                        <Toggle
                          label={t("plex.player.autoSkipIntro")}
                          checked={prefs.autoSkipIntro}
                          onChange={(v) => patchPrefs({ autoSkipIntro: v })}
                        />
                        <Toggle
                          label={t("plex.player.autoSkipCredits")}
                          checked={prefs.autoSkipCredits}
                          onChange={(v) => patchPrefs({ autoSkipCredits: v })}
                        />
                        {(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
                          <Slider
                            label={t("plex.player.autoSkipDelay")}
                            value={prefs.autoSkipDelay}
                            min={0}
                            max={10}
                            step={1}
                            suffix="s"
                            onChange={(v) => patchPrefs({ autoSkipDelay: v })}
                          />
                        )}
                      </>
                    )}

                    {activeTab === "subtitle" && (
                      <>
                        <Slider
                          label={t("plex.player.subSize")}
                          value={prefs.subSize}
                          min={50}
                          max={250}
                          step={5}
                          suffix="%"
                          onChange={(v) => patchPrefs({ subSize: v })}
                        />
                        <Slider
                          label={t("plex.player.subPos")}
                          value={prefs.subPos}
                          min={50}
                          max={95}
                          step={1}
                          suffix="%"
                          onChange={(v) => patchPrefs({ subPos: v })}
                        />
                        <div className="flex items-center justify-between px-1 py-1.5">
                          <span className="text-[12px] text-white/80">
                            {t("plex.player.subColor")}
                          </span>
                          <input
                            type="color"
                            value={prefs.subColor}
                            onChange={(e) => patchPrefs({ subColor: e.target.value })}
                            className="h-6 w-10 cursor-pointer rounded bg-transparent"
                          />
                        </div>
                        <div className="flex items-center justify-between px-1 py-1.5">
                          <span className="text-[12px] text-white/80">
                            {t("plex.player.subBg")}
                          </span>
                          <div className="flex items-center gap-2">
                            <label className="flex items-center gap-1 text-[11px] text-white/70">
                              <input
                                type="checkbox"
                                checked={prefs.subBg === "transparent"}
                                onChange={(e) =>
                                  patchPrefs({
                                    subBg: e.target.checked ? "transparent" : "#000000",
                                  })
                                }
                                className="accent-accent"
                              />
                              {t("plex.player.subBgNone")}
                            </label>
                            {prefs.subBg !== "transparent" && (
                              <input
                                type="color"
                                value={prefs.subBg}
                                onChange={(e) => patchPrefs({ subBg: e.target.value })}
                                className="h-6 w-10 cursor-pointer rounded bg-transparent"
                              />
                            )}
                          </div>
                        </div>
                        <Slider
                          label={t("plex.player.subShadow")}
                          value={prefs.subShadow}
                          min={0}
                          max={10}
                          step={1}
                          suffix="px"
                          onChange={(v) => patchPrefs({ subShadow: v })}
                        />
                        {prefs.subShadow > 0 && (
                          <div className="flex items-center justify-between px-1 py-1.5">
                            <span className="text-[12px] text-white/80">
                              {t("plex.player.subShadowColor")}
                            </span>
                            <input
                              type="color"
                              value={prefs.subShadowColor}
                              onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
                              className="h-6 w-10 cursor-pointer rounded bg-transparent"
                            />
                          </div>
                        )}
                      </>
                    )}

                    {activeTab === "clock" && (
                      <>
                        <Toggle
                          label={t("plex.player.clockShow")}
                          checked={prefs.clockShow}
                          onChange={(v) => patchPrefs({ clockShow: v })}
                        />
                        <Toggle
                          label={t("plex.player.clock24h")}
                          checked={prefs.clock24h}
                          onChange={(v) => patchPrefs({ clock24h: v })}
                        />
                        <Slider
                          label={t("plex.player.clockSize")}
                          value={prefs.clockSize}
                          min={70}
                          max={200}
                          step={5}
                          suffix="%"
                          onChange={(v) => patchPrefs({ clockSize: v })}
                        />
                        <div className="flex items-center justify-between px-1 py-1.5">
                          <span className="text-[12px] text-white/80">
                            {t("plex.player.clockColor")}
                          </span>
                          <input
                            type="color"
                            value={prefs.clockColor}
                            onChange={(e) => patchPrefs({ clockColor: e.target.value })}
                            className="h-6 w-10 cursor-pointer rounded bg-transparent"
                          />
                        </div>
                        <Toggle
                          label={t("plex.player.clockDate")}
                          checked={prefs.clockDate}
                          onChange={(v) => patchPrefs({ clockDate: v })}
                        />
                        {prefs.clockDate && (
                          <div className="flex gap-1 px-1 py-1">
                            {(["hu", "en"] as const).map((st) => (
                              <button
                                key={st}
                                onClick={() => patchPrefs({ clockDateStyle: st })}
                                className={`flex-1 rounded px-2 py-1 text-[11px] ${
                                  prefs.clockDateStyle === st
                                    ? "bg-white/15 text-accent"
                                    : "text-white/70 hover:bg-white/10"
                                }`}
                              >
                                {
                                  clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st })
                                    .date
                                }
                              </button>
                            ))}
                          </div>
                        )}
                      </>
                    )}
                  </div>
                )}
              </div>
            )}
            <Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
              <Info className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
              <Keyboard className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.download")} onClick={download}>
              <Download className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
              {fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
            </Ctrl>
          </div>
        </div>
      </div>

      {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
      {helpOpen && (
        <div
          className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
          onClick={(e) => {
            e.stopPropagation();
            setHelpOpen(false);
          }}
        >
          <div
            className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="mb-4 flex items-center justify-between">
              <div className="flex items-center gap-2 font-semibold">
                <Keyboard className="w-5 h-5" /> {t("plex.player.help")}
              </div>
              <button
                onClick={() => setHelpOpen(false)}
                className="p-1 rounded-lg hover:bg-white/10"
                aria-label={t("plex.player.back")}
              >
                <X className="w-5 h-5" />
              </button>
            </div>
            <dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
              {(
                [
                  [["Space", "K"], "plex.player.keys.playPause"],
                  [["←", "→"], "plex.player.keys.seek"],
                  [["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
                  ...(detail?.kind === "episode"
                    ? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
                    : []),
                  [["↑", "↓"], "plex.player.keys.volume"],
                  [["M"], "plex.player.keys.mute"],
                  [["A"], "plex.player.keys.audio"],
                  [["S"], "plex.player.keys.subtitle"],
                  [["F"], "plex.player.keys.fullscreen"],
                  [["I"], "plex.player.keys.info"],
                  [["⌫", "Esc"], "plex.player.keys.back"],
                  [["H"], "plex.player.keys.help"],
                ] as [string[], string][]
              ).map(([keys, labelKey]) => (
                <Fragment key={labelKey}>
                  <dt className="flex flex-wrap gap-1">
                    {keys.map((k) => (
                      <kbd
                        key={k}
                        className="rounded bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
                      >
                        {k}
                      </kbd>
                    ))}
                  </dt>
                  <dd className="text-white/80">{t(labelKey)}</dd>
                </Fragment>
              ))}
            </dl>
          </div>
        </div>
      )}

      {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
      {infoOpen && detail && (
        <div
          className="absolute inset-0 z-menu grid place-items-center overflow-y-auto bg-black/70 p-4"
          onClick={(e) => {
            e.stopPropagation();
            setInfoOpen(false);
          }}
        >
          <div
            className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
            onClick={(e) => e.stopPropagation()}
          >
            <Suspense fallback={null}>
              <PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
            </Suspense>
          </div>
        </div>
      )}
    </div>,
    document.body,
  );
}

// Renders nothing; while mounted it claims one history entry so the browser/mouse Back button runs
// `onBack` (close the panel / cancel the skip) instead of navigating out of the player.
function BackClose({ onBack }: { onBack: () => void }) {
  useBackToClose(onBack);
  return null;
}

// --- settings-panel building blocks ------------------------------------------------------------
function PanelHead({ children }: { children: ReactNode }) {
  return (
    <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">
      {children}
    </div>
  );
}
function Slider({
  label,
  value,
  min,
  max,
  step,
  suffix,
  onChange,
  onReset,
}: {
  label: string;
  value: number;
  min: number;
  max: number;
  step: number;
  suffix?: string;
  onChange: (v: number) => void;
  onReset?: () => void;
}) {
  return (
    <div className="px-1 py-1">
      <div className="mb-1 flex items-center justify-between text-[12px] text-white/80">
        <span>{label}</span>
        <span className="flex items-center gap-1.5">
          <span className="tabular-nums text-white/60">
            {Number.isInteger(value) ? value : value.toFixed(1)}
            {suffix ?? ""}
          </span>
          {onReset && (
            <button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
              <RotateCcw className="h-3 w-3" />
            </button>
          )}
        </span>
      </div>
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="w-full accent-accent"
      />
    </div>
  );
}
function Toggle({
  label,
  checked,
  onChange,
}: {
  label: string;
  checked: boolean;
  onChange: (v: boolean) => void;
}) {
  return (
    <label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
      <span className="text-[12px] text-white/80">{label}</span>
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="h-4 w-4 accent-accent"
      />
    </label>
  );
}

// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl({
  label,
  onClick,
  disabled,
  align = "center",
  children,
}: {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  // Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
  align?: "start" | "center" | "end";
  children: ReactNode;
}) {
  const pos =
    align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
  return (
    <div className="relative flex group/ctrl">
      <button
        onClick={onClick}
        disabled={disabled}
        aria-label={label}
        className="p-1 hover:text-accent disabled:opacity-30"
      >
        {children}
      </button>
      <span
        className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
      >
        {label}
      </span>
    </div>
  );
}
