// Body for the room media viewer — mounted inside both the mobile
// `MobileMediaViewerHorseshoe` panel-body and the desktop floating
// `RoomViewMediaWindow`. Owns:
//   • Resolving + decrypting the media URL for the current entry,
//     including revoke-on-unmount for blob URLs we create.
//   • Zoom for images (button row + `+`/`-`/`0` keys).
//   • Mouse-drag pan when zoomed, with bounds clamping so the
//     image can't be dragged off the stage.
//   • Multi-media navigation: prev / next via on-screen chevrons
//     (web only), keyboard arrows, and horizontal swipe. Images glide
//     on swipe; videos swipe too but only from above the native control
//     band (so the seekbar drag still scrubs) and switch instantly.
//   • Programmatic `<video>` play on each entry change.
//   • Download via `FileSaver`.

import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds';
import classNames from 'classnames';
import FileSaver from 'file-saver';
import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { MediaViewerEntry } from '../../state/mediaViewer';
import { useOpenMediaViewer } from '../../state/hooks/mediaViewer';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoom } from '../../hooks/useRoom';
import { useZoom } from '../../hooks/useZoom';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { isNativePlatform } from '../../utils/capacitor';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import {
  IImageContent,
  IImageInfo,
  IThumbnailContent,
  IVideoContent,
  IVideoInfo,
} from '../../../types/matrix/common';
import * as css from './MediaViewerBody.css';

// Distance past which a horizontal pointer drag commits to prev/next
// when zoom = 1 (matches the sheet's vertical close threshold).
const SWIPE_COMMIT_PX = 80;
// Horizontal must dominate vertical by at least this factor before
// the gesture is interpreted as a swipe — keeps accidental diagonal
// drags from committing.
const SWIPE_DOMINANCE = 1.5;
// Duration of the slide animation when a prev/next navigation commits
// (swipe release past threshold, chevron tap, arrow key).
const SWIPE_ANIM_MS = 280;
// Gutter between adjacent images in the swipe filmstrip, so consecutive
// pictures don't read as glued edge-to-edge while sliding. The panel
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
const SWIPE_GAP_PX = 24;
// Height of the bottom strip on a `<video controls>` reserved for the
// native control cluster. A horizontal page-swipe / drag-to-close must NOT
// start inside it or it would steal the seekbar's own horizontal drag. On
// Android Chrome the stacked cluster (timeline + button row) is ~96px and
// the seekbar itself is the bottom ~48px; 88px comfortably covers the
// seekbar plus most of the button row (a DRAG starting on a button would
// otherwise read as a swipe — a TAP still passes through, since nothing is
// claimed until the drag threshold). Clamped to 40% of the video's rendered
// height so a short clip stays swipeable above its controls. NB: the exact
// native band is browser/version-variable — worth an on-device sanity check.
const VIDEO_CONTROL_BAND_PX = 88;

// Walk the room's loaded timeline events and build the ordered set
// of viewable image + video events. Out-of-window media (older than
// the loaded scrollback) is intentionally not surfaced for phase 1;
// `step()` resistance-snaps at the boundary.
function mediaKindFor(ev: MatrixEvent): 'image' | 'video' | null {
  if (ev.getType() !== 'm.room.message') return null;
  if (ev.isRedacted()) return null;
  const content = ev.getContent() as { msgtype?: string; url?: string; file?: { url?: string } };
  if (!content) return null;
  const hasMedia = !!content.url || !!content.file?.url;
  if (!hasMedia) return null;
  if (content.msgtype === MsgType.Image) return 'image';
  if (content.msgtype === MsgType.Video) return 'video';
  return null;
}

function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null {
  const kind = mediaKindFor(ev);
  if (!kind) return null;
  const content = ev.getContent() as (IImageContent | IVideoContent) | undefined;
  if (!content) return null;
  const eventId = ev.getId();
  if (!eventId) return null;
  const { file } = content;
  const url = file?.url ?? content.url;
  if (!url) return null;
  const encInfo: EncryptedAttachmentInfo | undefined = file
    ? (() => {
        // Strip the `url` field — `EncryptedAttachmentInfo` doesn't
        // include it (the wire shape `IEncryptedFile` does).
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { url: _, ...rest } = file;
        return rest as EncryptedAttachmentInfo;
      })()
    : undefined;
  return {
    roomId,
    eventId,
    kind,
    url,
    body: content.filename ?? content.body ?? eventId,
    info: content.info as ((IImageInfo | IVideoInfo) & IThumbnailContent) | undefined,
    encInfo,
    mimeType: content.info?.mimetype,
  };
}

// One slide of the viewer's persistent filmstrip. The ±1 window around the
// current entry is always mounted and keyed by eventId, so paging is a track
// re-translate + index shift: the element that was the neighbour BECOMES the
// active slide without a remount — no decode-blank flash. Only the ACTIVE
// slide is interactive (zoom/pan via the parent's `activeTransform` + refs +
// handlers); neighbours are static, decoded-ahead images one pitch to either
// side that slide in on a swipe. A video renders only while active (video
// steps are instant — a video never glides into view as a neighbour).
function Slide({
  entry,
  resolveSrc,
  isActive,
  activeSrc,
  slideTransform,
  activeTransform,
  transition,
  panCursor,
  imgRefCb,
  videoRefCb,
  onMouseDown,
  onImageLoad,
}: {
  entry: MediaViewerEntry;
  resolveSrc: (e: MediaViewerEntry) => Promise<string>;
  isActive: boolean;
  activeSrc: string | undefined;
  slideTransform: string;
  activeTransform: string;
  transition: string;
  panCursor: 'grab' | 'grabbing' | 'initial';
  imgRefCb: (el: HTMLImageElement | null) => void;
  videoRefCb: (el: HTMLVideoElement | null) => void;
  onMouseDown: MouseEventHandler<HTMLImageElement>;
  onImageLoad: () => void;
}) {
  // Neighbours resolve their own decrypted src (shared cache) so they're
  // decoded before they slide in. The active slide instead uses the parent's
  // resolved `activeSrc` — the SAME cached blob, but routed through the parent
  // so its loading / error / retry + download chrome stays in sync.
  const [ownSrc, setOwnSrc] = useState<string | undefined>(undefined);
  useEffect(() => {
    let alive = true;
    resolveSrc(entry)
      .then((s) => {
        if (alive) setOwnSrc(s);
      })
      .catch(() => {
        /* best-effort — the parent's loader surfaces errors for the active slide */
      });
    return () => {
      alive = false;
    };
  }, [entry, resolveSrc]);

  const src = isActive ? activeSrc : ownSrc;

  if (entry.kind === 'video') {
    // Neighbour videos never glide into view, so they render nothing; only the
    // active video mounts (controls + muted autoplay). Switching to a video is
    // instant (no slide), so this mount-on-activation reads fine.
    if (!isActive || !src) return null;
    return (
      <div className={css.previewLayer} style={{ pointerEvents: 'auto' }}>
        <video
          ref={videoRefCb}
          src={src}
          title={entry.body}
          className={css.image}
          controls
          muted
          autoPlay
          playsInline
          style={{ transform: slideTransform, transition }}
        />
      </div>
    );
  }

  return (
    <div
      className={css.previewLayer}
      aria-hidden={!isActive}
      style={{ pointerEvents: isActive ? 'auto' : 'none' }}
    >
      {src && (
        // `onMouseDown` drives drag-pan when the active image is zoomed in.
        // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
        <img
          ref={isActive ? imgRefCb : undefined}
          src={src}
          alt={isActive ? entry.body : ''}
          className={css.image}
          draggable={false}
          // Lets the parent's reset effect confirm `imgRef` points at the
          // now-active image before deriving its zoom ceiling (see there).
          data-event-id={entry.eventId}
          style={{
            transform: isActive ? activeTransform : slideTransform,
            transition,
            cursor: isActive ? panCursor : undefined,
          }}
          onMouseDown={isActive ? onMouseDown : undefined}
          onLoad={isActive ? onImageLoad : undefined}
        />
      )}
    </div>
  );
}

type MediaViewerBodyProps = {
  entry: MediaViewerEntry;
  requestClose: () => void;
  // Drop the title/zoom/download/close toolbar entirely. The desktop
  // floating window owns its own minimal chrome (drag bar + close), so
  // it passes this; zoom is wheel-only and nav is keyboard/chevron
  // there. The mobile horseshoe + (legacy) side pane leave it false to
  // keep the full toolbar.
  hideHeader?: boolean;
  // Vertical drag-to-close sink. The mobile sheet passes this so a
  // downward drag from ANYWHERE on the stage (zoom=1 only — a zoomed
  // image pans instead) drives the sheet's close. Called live with the
  // raw downward finger distance (`dragging: true`) and once on release
  // (`dragging: false`); the sheet owns the rubber-band + commit
  // threshold. The desktop window leaves it undefined → no close drag.
  onCloseDrag?: (rawDeltaY: number, dragging: boolean) => void;
};

export function MediaViewerBody({
  entry,
  requestClose,
  hideHeader,
  onCloseDrag,
}: MediaViewerBodyProps) {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const room = useRoom();
  const openMediaViewer = useOpenMediaViewer();

  // `useZoom` gives us `zoom` + `setZoom`; `zoomIn`/`zoomOut` from
  // it use a flat max=5, but we want per-image dynamic ceiling.
  // Override with custom +/− callbacks that read `maxZoomRef`.
  const { zoom, setZoom } = useZoom(0.25);

  // Pan is local (not the shared `usePan` hook) because we need to
  // clamp against the image bounds so the user can't drag a zoomed
  // image past its edges — `usePan` accumulates raw `movementX/Y`
  // without bounds, which lets the picture escape off-screen.
  // Clamp formula: with `transform: translate3d(X, Y) scale(zoom)`,
  // the translate is applied AFTER scaling, so X/Y are in stage
  // pixels. Max pan in each axis = (rendered_size * zoom -
  // stage_size) / 2. Image fits within stage at zoom=1 (object-fit:
  // contain), so clamp is zero there and pan auto-resets via the
  // `useEffect` below that watches `zoom`.
  const stageRef = useRef<HTMLDivElement>(null);
  // `| null` (→ MutableRefObject) because the active slide assigns these via a
  // ref callback (`setActiveImgRef` / `setActiveVideoRef`) rather than React's
  // static `ref=` binding, so we write `.current` ourselves.
  const imgRef = useRef<HTMLImageElement | null>(null);
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
  const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');

  // Horizontal swipe / slide state (declared here, before the
  // navigation + keyboard handlers that read it). `swipeOffset` is the
  // live drag translate; `swipeAnimating` is true while a commit slide
  // is settling (so the transition is on for the ±stageWidth glide);
  // `stageWidth` feeds the neighbour-panel positions and the commit
  // target; `commitTimerRef` defers the entry swap to the end of the
  // slide. Gesture detail is in the big stage-listener effect below.
  const [swipeOffset, setSwipeOffset] = useState(0);
  const [swipeAnimating, setSwipeAnimating] = useState(false);
  // True for the single render after an INSTANT (non-glide) step so the
  // persisted incoming slide swaps WITHOUT a transition — otherwise it would
  // animate in from the neighbour slot (and, while zoomed, inherit the previous
  // slide's zoom/pan and wobble back to fit). Cleared in the entry-change reset
  // effect once the incoming slide is at centre.
  const [instantStep, setInstantStep] = useState(false);
  const [stageWidth, setStageWidth] = useState(0);
  const commitTimerRef = useRef<number | null>(null);
  const prefersReducedMotion = useMemo(
    () =>
      typeof window !== 'undefined' &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    []
  );

  // Mirror `zoom` and `pan` into refs so callbacks installed in
  // long-lived listeners can read the latest values without
  // re-binding, and so the inlined anchor-aware zoom math in
  // pinch / wheel can read current zoom/pan synchronously.
  const zoomRef = useRef(zoom);
  zoomRef.current = zoom;
  const panRef = useRef(pan);
  panRef.current = pan;

  // Per-image zoom bounds. `minZoom` is fixed at 1.0 — with
  // `object-fit: contain` the image already fills the stage to fit
  // at zoom=1, and going smaller just leaves dead space. `maxZoom`
  // is computed from the natural image size in `onImageLoad` so a
  // hi-res screenshot can zoom up to 1:1 pixels (~ natural /
  // displayed) and a small avatar gets a friendlier 2× ceiling.
  // Falls back to a flat 5× for the brief window before load.
  const MIN_ZOOM = 1;
  const ZOOM_CEILING_FALLBACK = 5;
  const ZOOM_CEILING_HARD_CAP = 8;
  const [maxZoom, setMaxZoom] = useState(ZOOM_CEILING_FALLBACK);
  // `maxZoom` mirrored into a ref for the long-lived gesture
  // listeners installed once with `deps=[]` below (they need the
  // latest ceiling, not the value at install time).
  const maxZoomRef = useRef(maxZoom);
  maxZoomRef.current = maxZoom;

  // Custom zoom +/− that respect the dynamic per-image ceiling
  // (`useZoom`'s built-in `zoomIn`/`zoomOut` would cap at the
  // flat default of 5). Step size 0.25 matches the original hook
  // config.
  const zoomIn = useCallback(() => {
    setZoom((z) => Math.min(maxZoomRef.current, z + 0.25));
  }, [setZoom]);
  const zoomOut = useCallback(() => {
    setZoom((z) => Math.max(MIN_ZOOM, z - 0.25));
  }, [setZoom]);

  const onImageLoad = useCallback(() => {
    const img = imgRef.current;
    if (!img) return;
    if (img.naturalWidth === 0 || img.offsetWidth === 0) return;
    // Ratio to reach 1:1 pixels — how far we'd need to scale the
    // displayed (object-fit-contained) image to match its native
    // resolution. For images naturally smaller than the display
    // (avatars), this is <1; floor to 2 so the user can still
    // inspect detail.
    const oneToOne = img.naturalWidth / img.offsetWidth;
    const computed = Math.max(2, oneToOne);
    setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
  }, []);

  // Point the parent's `imgRef` / `videoRef` at whichever slide is currently
  // active. A ref callback (not a static `ref`) lets the persistent filmstrip
  // hand the ref to the new element as the active slide changes, and ignoring
  // the `null` detach keeps the ref pointing at the live active element across
  // the swap (the attach of the new active element wins regardless of order).
  const setActiveImgRef = useCallback((el: HTMLImageElement | null) => {
    if (el) imgRef.current = el;
  }, []);
  const setActiveVideoRef = useCallback((el: HTMLVideoElement | null) => {
    if (el) videoRef.current = el;
  }, []);

  const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
    const stage = stageRef.current;
    const img = imgRef.current;
    if (!stage || !img) return raw;
    const stageRect = stage.getBoundingClientRect();
    const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2);
    const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2);
    return {
      x: Math.max(-maxX, Math.min(maxX, raw.x)),
      y: Math.max(-maxY, Math.min(maxY, raw.y)),
    };
  }, []);

  // Anchor-aware zoom math (image-local point under anchor stays
  // under the anchor after zoom change) is inlined in the pinch
  // and wheel handlers below — they each need to construct the
  // anchor from their own gesture state, so a shared helper would
  // just be plumbing.

  // Reset zoom, pan, and max-zoom-ceiling whenever the active
  // entry changes (swipe / arrow / programmatic). Without
  // resetting `maxZoom`, navigating from a low-res image (where
  // `onImageLoad` set a small ceiling) to a high-res one would
  // briefly use the stale ceiling until the new image's `onLoad`
  // fires. Gesture state is reset in a separate effect below.
  // Also cancel any in-flight commit slide (e.g. an external open
  // mid-animation) so its deferred entry swap can't fire stale.
  useEffect(() => {
    setZoom(1);
    setPan({ x: 0, y: 0 });
    // Re-derive the per-image zoom ceiling for the now-active image. In the
    // persistent filmstrip it was usually already loaded as a neighbour (so its
    // onLoad won't refire on activation) — read its natural size directly. The
    // `dataset.eventId` check ensures `imgRef` actually points at the NOW-active
    // image: the ref callback ignores the null detach, so when the new active
    // slide's src isn't resolved yet it can still point at the prior image —
    // then we fall back to the flat ceiling and its onLoad sets the real one.
    const activeImg = imgRef.current;
    if (
      activeImg &&
      activeImg.dataset.eventId === entry.eventId &&
      activeImg.naturalWidth > 0 &&
      activeImg.offsetWidth > 0
    ) {
      setMaxZoom(
        Math.min(ZOOM_CEILING_HARD_CAP, Math.max(2, activeImg.naturalWidth / activeImg.offsetWidth))
      );
    } else {
      setMaxZoom(ZOOM_CEILING_FALLBACK);
    }
    if (commitTimerRef.current !== null) {
      window.clearTimeout(commitTimerRef.current);
      commitTimerRef.current = null;
    }
    setSwipeAnimating(false);
    // Re-enable transitions after an instant step (the incoming slide is now at
    // centre, so flipping the transition back on doesn't retroactively animate).
    setInstantStep(false);
  }, [entry.eventId, setZoom]);

  // Track the stage width so the swipe filmstrip can position the
  // prev/next panels exactly one stage-width to either side and the
  // commit slide knows how far to glide.
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return undefined;
    setStageWidth(el.clientWidth);
    const ro = new ResizeObserver(() => setStageWidth(el.clientWidth));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Re-clamp the existing pan against the new bounds when zoom
  // changes (e.g. zooming out from 3× to 1× should snap a panned
  // image back to centre since there's nothing to pan into).
  useEffect(() => {
    setPan((prev) => {
      const next = clampPan(prev, zoom);
      if (next.x === prev.x && next.y === prev.y) return prev;
      return next;
    });
    setPanCursor(zoom === 1 ? 'initial' : 'grab');
  }, [zoom, clampPan]);

  // Mouse-drag pan. Touch is handled by the stage-listener block
  // below (which routes touch to swipe when zoom=1, ignores when
  // zoom > 1 — pinch + touch-pan is a follow-up).
  const onMouseDown: MouseEventHandler<HTMLImageElement> = useCallback(
    (evt) => {
      if (zoomRef.current === 1) return;
      evt.preventDefault();
      setPanCursor('grabbing');

      const onMove = (mEvt: MouseEvent) => {
        mEvt.preventDefault();
        mEvt.stopPropagation();
        const raw = {
          x: panRef.current.x + mEvt.movementX,
          y: panRef.current.y + mEvt.movementY,
        };
        const clamped = clampPan(raw, zoomRef.current);
        panRef.current = clamped;
        setPan(clamped);
      };
      const onUp = (mEvt: MouseEvent) => {
        mEvt.preventDefault();
        setPanCursor('grab');
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };

      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    },
    [clampPan]
  );

  // Resolve + decrypt the media URL into a displayable src, CACHED by
  // eventId so the current image and its swipe neighbours share one
  // decrypted blob — that's what lets a swipe slide between two
  // already-decrypted images with no reload gap. Blob URLs (encrypted
  // media) are tracked in `blobUrlsRef` so we revoke exactly those on
  // eviction / unmount; plain http(s) srcs need no revoke. The
  // concurrent-resolve re-check below means a rapidly-replaced load (or
  // a preview + main racing on the same event) never leaks a duplicate
  // blob.
  const srcCacheRef = useRef<Map<string, string>>(new Map());
  const blobUrlsRef = useRef<Set<string>>(new Set());

  const resolveMediaSrc = useCallback(
    async (e: MediaViewerEntry): Promise<string> => {
      const cached = srcCacheRef.current.get(e.eventId);
      if (cached) return cached;
      const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication);
      if (!mediaUrl) throw new Error('Invalid media URL');
      const enc = e.encInfo;
      if (!enc) {
        srcCacheRef.current.set(e.eventId, mediaUrl);
        return mediaUrl;
      }
      const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
        decryptFile(encBuf, e.mimeType ?? FALLBACK_MIMETYPE, enc)
      );
      // A concurrent resolve for the same event may have won the cache
      // while we decrypted — drop our duplicate blob and use the winner.
      const winner = srcCacheRef.current.get(e.eventId);
      if (winner) return winner;
      const blob = URL.createObjectURL(fileContent);
      blobUrlsRef.current.add(blob);
      srcCacheRef.current.set(e.eventId, blob);
      return blob;
    },
    [mx, useAuthentication]
  );

  const [srcState, loadSrc] = useAsyncCallback(
    useCallback(() => resolveMediaSrc(entry), [resolveMediaSrc, entry])
  );

  useEffect(() => {
    loadSrc();
  }, [loadSrc]);

  // Revoke every blob URL we created (and cancel a pending commit
  // slide) when the viewer body unmounts — sheet closed, room change.
  useEffect(
    () => () => {
      blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
      blobUrlsRef.current.clear();
      srcCacheRef.current.clear();
      if (commitTimerRef.current !== null) {
        window.clearTimeout(commitTimerRef.current);
        commitTimerRef.current = null;
      }
    },
    []
  );

  // Build the room's image set. Recomputed on each render — cheap
  // (timeline events are already in memory). Not subscribed to new
  // events for phase 1; the user navigates among the images that
  // were visible when they opened the viewer.
  // Bump a version counter on every live-timeline mutation in this
  // room so `mediaSet` rebuilds when a new image / video event
  // arrives mid-viewing — without this the user navigates a frozen
  // snapshot. Mirror of Element-Web's FilePanel pattern. Redaction
  // is also subscribed so a remotely-deleted image disappears from
  // the prev/next chain.
  //
  // Per-event `MatrixEventEvent.Decrypted` subscription is deliberately
  // skipped (cost of attaching N listeners). Late-arriving decrypts
  // of pre-existing events are an accepted phase-1 limitation —
  // documented in the comment on `mediaKindFor`.
  const [timelineVersion, setTimelineVersion] = useState(0);
  useEffect(() => {
    const bump = () => setTimelineVersion((v) => v + 1);
    // Filter Timeline events to media kinds so the mediaSet
    // recompute doesn't fire on every reaction / edit / receipt /
    // sync rollback (busy rooms produce dozens per second). Each
    // recompute does an O(n) walk of `getLiveTimeline().getEvents()`.
    const onTimeline = (ev: MatrixEvent) => {
      if (mediaKindFor(ev) !== null) bump();
    };
    // Encrypted rooms (Vojo's primary surface): events arrive as
    // `m.room.encrypted` and decrypt asynchronously after the
    // Timeline event has fired — `mediaKindFor` returns null at
    // that moment, so `onTimeline` above doesn't bump. When the
    // event decrypts, `MatrixEventEvent.Decrypted` fires on the
    // event itself. We attach via the MatrixClient so we catch
    // every decrypt without subscribing to N per-event listeners,
    // and filter to this room. Mirror of Element-Web's `FilePanel`
    // pattern.
    const onDecrypted = (ev: MatrixEvent) => {
      if (ev.getRoomId() !== room.roomId) return;
      if (mediaKindFor(ev) !== null) bump();
    };
    // Redactions: bump unconditionally — the redacted event may
    // have been a media we're currently navigating, and we can't
    // re-derive its kind once redacted.
    room.on(RoomEvent.Timeline, onTimeline);
    room.on(RoomEvent.Redaction, bump);
    mx.on(MatrixEventEvent.Decrypted, onDecrypted);
    return () => {
      room.removeListener(RoomEvent.Timeline, onTimeline);
      room.removeListener(RoomEvent.Redaction, bump);
      mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
    };
  }, [mx, room]);

  const mediaSet = useMemo(() => {
    const events = room.getLiveTimeline().getEvents();
    const entries: MediaViewerEntry[] = [];
    events.forEach((ev) => {
      const e = eventToEntry(room.roomId, ev);
      if (e) entries.push(e);
    });
    return entries;
    // `timelineVersion` is the actual refresh trigger; `entry.eventId`
    // is intentionally NOT a dep — stepping doesn't change the set.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [room, timelineVersion]);

  const currentIndex = useMemo(
    () => mediaSet.findIndex((e) => e.eventId === entry.eventId),
    [mediaSet, entry.eventId]
  );

  // Navigate prev/next with a slide. For image→image (and motion
  // allowed, stage measured) we glide the current image out and the
  // neighbour panel in over `SWIPE_ANIM_MS`, THEN swap the atom entry —
  // because the neighbour was prefetched into the shared src cache the
  // new current paints instantly at centre, so the swap is seamless.
  // Anything else (video involved, reduced motion, stage not measured)
  // falls back to an instant switch. Guarded against re-entry while a
  // commit slide is already in flight.
  const animatedStep = useCallback(
    (delta: -1 | 1) => {
      if (commitTimerRef.current !== null) return;
      if (currentIndex < 0) return;
      const targetIndex = currentIndex + delta;
      if (targetIndex < 0 || targetIndex >= mediaSet.length) return;
      const target = mediaSet[targetIndex];
      if (!target) return;
      const stageW = stageRef.current?.clientWidth ?? 0;
      // Slide only at rest zoom (a zoomed image has no neighbour panels
      // and shouldn't glide while scaled — chevron/arrow nav while zoomed
      // switches instantly). `zoomRef` keeps this out of the deps.
      const canSlide =
        !prefersReducedMotion &&
        stageW > 0 &&
        zoomRef.current === 1 &&
        entry.kind === 'image' &&
        target.kind === 'image';
      if (!canSlide) {
        // Instant switch (zoomed, reduced-motion, video, or unmeasured stage).
        // The incoming slide is a PERSISTED neighbour, so reset its transform
        // state synchronously and flag this commit as instant — without that it
        // would animate in from the neighbour slot, and while zoomed it would
        // inherit the previous slide's zoom/pan and wobble back to fit.
        setInstantStep(true);
        setZoom(1);
        setPan({ x: 0, y: 0 });
        setSwipeOffset(0);
        openMediaViewer(target);
        return;
      }
      setSwipeAnimating(true);
      // delta +1 (next) slides the whole filmstrip LEFT by one panel pitch
      // (stage + gutter): the current slide glides off-screen while the
      // already-mounted next slide glides to centre. At the commit below the
      // window shifts (currentIndex += delta) and swipeOffset resets to 0 —
      // each surviving slide's `slot*pitch + swipeOffset` is unchanged, so the
      // next slide simply BECOMES the active one with no remount and no jump.
      const pitch = stageW + SWIPE_GAP_PX;
      setSwipeOffset(delta === 1 ? -pitch : pitch);
      commitTimerRef.current = window.setTimeout(() => {
        commitTimerRef.current = null;
        setSwipeAnimating(false);
        setSwipeOffset(0);
        openMediaViewer(target);
      }, SWIPE_ANIM_MS);
    },
    [currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion, setZoom]
  );

  const canPrev = currentIndex > 0;
  const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;

  // Prefetch the immediate image neighbours so a swipe slides into an
  // already-decrypted picture, and prune cached srcs outside a small
  // window around the current index (revoking their blobs) to bound
  // memory. The current entry and its ±1 neighbours are always kept, so
  // we never revoke a src a live <img> is using.
  useEffect(() => {
    if (currentIndex < 0) return;
    [mediaSet[currentIndex - 1], mediaSet[currentIndex + 1]].forEach((e) => {
      if (e && e.kind === 'image') resolveMediaSrc(e).catch(() => undefined);
    });
    const keep = new Set<string>([entry.eventId]);
    for (let d = -2; d <= 2; d += 1) {
      const e = mediaSet[currentIndex + d];
      if (e) keep.add(e.eventId);
    }
    srcCacheRef.current.forEach((src, eid) => {
      if (keep.has(eid)) return;
      if (blobUrlsRef.current.has(src)) {
        URL.revokeObjectURL(src);
        blobUrlsRef.current.delete(src);
      }
      srcCacheRef.current.delete(eid);
    });
  }, [currentIndex, mediaSet, entry.eventId, resolveMediaSrc]);

  // Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
  // handled by the parent shell via an explicit window keydown listener
  // (both the mobile horseshoe and the desktop floating window).
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.defaultPrevented) return;
      const target = e.target as HTMLElement | null;
      if (
        target &&
        (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
      ) {
        return;
      }
      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        animatedStep(-1);
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        animatedStep(1);
      } else if (e.key === '+' || e.key === '=') {
        e.preventDefault();
        zoomIn();
      } else if (e.key === '-' || e.key === '_') {
        e.preventDefault();
        zoomOut();
      } else if (e.key === '0') {
        e.preventDefault();
        setZoom(1);
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [animatedStep, zoomIn, zoomOut, setZoom]);

  // Multi-touch gesture state on the image stage. Three modes:
  //   • zoom=1, 1 finger → horizontal-swipe to prev/next image.
  //   • zoom>1, 1 finger → touch pan (clamped to image bounds; same
  //     formula as the mouse pan path).
  //   • 2 fingers → pinch zoom (distance ratio drives zoom; pan
  //     resets to 0 so the image stays centered while pinching).
  // Listeners are installed ONCE and read the latest closure values
  // via refs — without that, a zoom-button press would re-bind every
  // listener mid-gesture. PointerEvents only (no TouchEvents) — on
  // Chromium both fire for touch, which double-fires every handler.
  // The MDN reference pattern that drove this implementation:
  // https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
  // (`swipeOffset` / `swipeAnimating` are declared up top with the rest
  // of the slide state.)

  const swipeStateRef = useRef({
    active: false,
    startX: 0,
    startY: 0,
    lastDx: 0,
    lastDy: 0,
    claimed: 'none' as 'none' | 'swipe' | 'reject' | 'close',
  });

  // Active touch pointers keyed by `pointerId`. Map (not array)
  // because PointerEvents can arrive interleaved across multiple
  // pointers and we need to update the per-id entry on each move.
  const pointerCacheRef = useRef<Map<number, { x: number; y: number }>>(new Map());

  // Pinch state. `null` when not pinching; set on the 2nd pointer
  // down. `initialDistance` is the finger spread at pinch start;
  // `initialZoom` and `initialPan` snapshot the state we'll scale
  // from. `anchorImageX/Y` is the image-local point under the
  // pinch midpoint at the start — held constant under the user's
  // fingers as zoom changes (anchor-aware pinch, mirrors what
  // Apple Photos / Google Photos / Element-Web's wheel-zoom do).
  const pinchStateRef = useRef<{
    initialDistance: number;
    initialZoom: number;
    initialPan: { x: number; y: number };
    anchorImageX: number;
    anchorImageY: number;
  } | null>(null);

  // Touch pan state. `null` when not panning. Stores the pan offset
  // at the start of the gesture so we can compute the delta off the
  // current pointer position (matches Element-Web's
  // absolute-delta-from-anchor approach, more reliable than
  // accumulating `movementX/Y`).
  const touchPanStateRef = useRef<{
    startX: number;
    startY: number;
    panAtStartX: number;
    panAtStartY: number;
  } | null>(null);

  // Refs mirror the latest values for the always-installed listeners
  // below. Without these, the listeners would close over the values
  // from the render that installed them.
  const canPrevRef = useRef(canPrev);
  canPrevRef.current = canPrev;
  const canNextRef = useRef(canNext);
  canNextRef.current = canNext;
  // Whether the prev/next neighbour is an IMAGE (i.e. slides). A video
  // neighbour (or a true edge) instead rubber-bands the drag — there's no
  // slide panel to follow the finger into, it switches instantly on
  // release. Mirrored for the once-installed gesture listeners.
  const prevIsImageRef = useRef(false);
  prevIsImageRef.current = currentIndex > 0 && mediaSet[currentIndex - 1]?.kind === 'image';
  const nextIsImageRef = useRef(false);
  nextIsImageRef.current =
    currentIndex >= 0 &&
    currentIndex < mediaSet.length - 1 &&
    mediaSet[currentIndex + 1]?.kind === 'image';
  const animatedStepRef = useRef(animatedStep);
  animatedStepRef.current = animatedStep;
  // Vertical-down drag-to-close sink (mobile sheet only; desktop window
  // leaves it undefined). Mirrored for the once-installed gesture
  // listeners; also gates whether the 'close' claim is even reachable.
  const onCloseDragRef = useRef(onCloseDrag);
  onCloseDragRef.current = onCloseDrag;
  const entryKindRef = useRef(entry.kind);
  entryKindRef.current = entry.kind;
  // `zoomRef` + `panRef` are already mirrored in the pan section
  // above; reusing them here keeps the source of truth singular.
  const clampPanRef = useRef(clampPan);
  clampPanRef.current = clampPan;
  const setZoomRef = useRef(setZoom);
  setZoomRef.current = setZoom;
  // `maxZoomRef` + `zoomIn` / `zoomOut` are declared higher up
  // (right after the `maxZoom` state) because the keyboard
  // useEffect above also depends on them.

  // Reset gesture state on entry change. A finger that lifted
  // off-stage (no `pointerup` delivered) leaves residue in
  // `pointerCacheRef`, so without this the next entry's first
  // `pointerdown` sees `count === 2` from nothing and starts a
  // fake pinch on a stale midpoint. Similar concern for swipe /
  // touch-pan state. `setPointerCapture` (added in the listeners
  // below) should normally guarantee a pointerup delivery; this
  // reset is the defence-in-depth.
  useEffect(() => {
    pointerCacheRef.current.clear();
    pinchStateRef.current = null;
    touchPanStateRef.current = null;
    // Release the sheet if an entry swap lands mid close-drag — otherwise it
    // would stay frozen at the dragged height with no release event coming.
    if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
    swipeStateRef.current.active = false;
    swipeStateRef.current.claimed = 'none';
    setSwipeOffset(0);
  }, [entry.eventId]);

  useEffect(() => {
    const stageEl = stageRef.current;
    if (!stageEl) return undefined;

    const distanceBetween = (a: { x: number; y: number }, b: { x: number; y: number }) =>
      Math.hypot(a.x - b.x, a.y - b.y);

    const onPointerDown = (e: PointerEvent) => {
      // Mouse drag at zoom>1 is handled by the image's `onMouseDown`
      // → pan; mouse drag at zoom=1 has no useful gesture. Skip
      // mouse here for everything.
      if (e.pointerType === 'mouse') return;

      // Video entries keep their native `<video controls>`. Only a single-
      // finger horizontal swipe pages between entries (no pinch / zoom / pan
      // — videos don't zoom), and only when the touch starts clear of the
      // bottom control band so dragging the seekbar still scrubs. Pointer
      // capture is deferred to swipe-claim (in `pointermove`) so a plain tap
      // still reaches the native controls (play/pause, show/hide overlay).
      if (entryKindRef.current !== 'image') {
        if (commitTimerRef.current !== null) return;
        if (pointerCacheRef.current.size > 0) return; // already tracking a finger
        const v = videoRef.current;
        // `v.isConnected` guards a stale ref: the ref callback ignores the null
        // detach, so while a freshly-active video is still loading (no <video>
        // mounted yet) `videoRef` can point at the previous, now-detached
        // element — whose getBoundingClientRect() is all-zero and would read as
        // «touch is in the control band», wrongly blocking the swipe/close.
        if (v && v.isConnected) {
          const r = v.getBoundingClientRect();
          const band = Math.min(VIDEO_CONTROL_BAND_PX, r.height * 0.4);
          if (e.clientY >= r.bottom - band) return; // leave the seekbar to scrub
        }
        pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
        const s = swipeStateRef.current;
        s.active = true;
        s.startX = e.clientX;
        s.startY = e.clientY;
        s.lastDx = 0;
        s.lastDy = 0;
        s.claimed = 'none';
        setSwipeOffset(0);
        return;
      }
      // Ignore new touches while a commit slide is gliding (the entry is
      // about to swap). Without this, a fresh pointerdown mid-glide would
      // run `setSwipeOffset(0)` while `swipeAnimating` is still on and
      // animate the outgoing image BACK toward centre — a reverse/stutter
      // on fast double-swipes. Mirrors the same guard in `animatedStep`.
      if (commitTimerRef.current !== null) return;

      // Capture the pointer so subsequent `pointermove`/`pointerup`
      // events are dispatched to the stage even when the finger
      // leaves the stage rect (e.g. user pans onto the header).
      // Without this, the gesture can be stranded mid-flight with
      // a finger off-stage, leaving `pointerCacheRef` populated and
      // breaking the next gesture. `try/catch` because some
      // browsers reject capture on non-trusted events under
      // automation harnesses.
      try {
        stageEl.setPointerCapture(e.pointerId);
      } catch {
        // ignore — capture is best-effort
      }

      pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
      const count = pointerCacheRef.current.size;

      if (count === 2) {
        // 2nd finger arrived — switch to pinch mode. Cancel any
        // in-flight single-finger gesture (swipe / touch pan) so
        // it doesn't leak state into the pinch.
        const pts = Array.from(pointerCacheRef.current.values());
        // Pinch midpoint → stage-center-relative anchor → derive
        // the image-local point that's under the midpoint right
        // now. Holding this point under the moving midpoint is
        // what makes the zoom feel anchored.
        const midClientX = (pts[0].x + pts[1].x) / 2;
        const midClientY = (pts[0].y + pts[1].y) / 2;
        const stageRect = stageEl.getBoundingClientRect();
        const anchorX = midClientX - (stageRect.left + stageRect.width / 2);
        const anchorY = midClientY - (stageRect.top + stageRect.height / 2);
        const z0 = zoomRef.current;
        const p0 = panRef.current;
        pinchStateRef.current = {
          initialDistance: distanceBetween(pts[0], pts[1]),
          initialZoom: z0,
          initialPan: { x: p0.x, y: p0.y },
          anchorImageX: (anchorX - p0.x) / z0,
          anchorImageY: (anchorY - p0.y) / z0,
        };
        if (swipeStateRef.current.active) {
          // A second finger cancels an in-flight swipe / close: spring the
          // sheet back if a close drag was running, then drop into pinch.
          if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
          swipeStateRef.current.active = false;
          swipeStateRef.current.claimed = 'none';
          setSwipeOffset(0);
        }
        touchPanStateRef.current = null;
        return;
      }

      if (count === 1) {
        if (zoomRef.current === 1) {
          // 1 finger + zoom=1 → swipe
          const s = swipeStateRef.current;
          s.active = true;
          s.startX = e.clientX;
          s.startY = e.clientY;
          s.lastDx = 0;
          s.lastDy = 0;
          s.claimed = 'none';
          setSwipeOffset(0);
        } else {
          // 1 finger + zoom>1 → touch pan
          touchPanStateRef.current = {
            startX: e.clientX,
            startY: e.clientY,
            panAtStartX: panRef.current.x,
            panAtStartY: panRef.current.y,
          };
        }
      }
    };

    const onPointerMove = (e: PointerEvent) => {
      if (e.pointerType === 'mouse') return;
      const cached = pointerCacheRef.current.get(e.pointerId);
      if (!cached) return;
      pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });

      // Pinch always wins when 2 fingers are down.
      if (pinchStateRef.current && pointerCacheRef.current.size === 2) {
        if (e.cancelable) e.preventDefault();
        const ps = pinchStateRef.current;
        const pts = Array.from(pointerCacheRef.current.values());
        const dist = distanceBetween(pts[0], pts[1]);
        const ratio = dist / ps.initialDistance;
        const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, ps.initialZoom * ratio));
        // Anchor-aware: hold `anchorImage*` (image-local point
        // captured at pinch start) under the moving midpoint.
        const midClientX = (pts[0].x + pts[1].x) / 2;
        const midClientY = (pts[0].y + pts[1].y) / 2;
        const stageRect = stageEl.getBoundingClientRect();
        const anchorStageX = midClientX - (stageRect.left + stageRect.width / 2);
        const anchorStageY = midClientY - (stageRect.top + stageRect.height / 2);
        const rawPan = {
          x: anchorStageX - ps.anchorImageX * nextZoom,
          y: anchorStageY - ps.anchorImageY * nextZoom,
        };
        const clamped = clampPanRef.current(rawPan, nextZoom);
        panRef.current = clamped;
        setPan(clamped);
        setZoomRef.current(nextZoom);
        return;
      }

      // Single-finger paths run only if pinch isn't active.
      if (pinchStateRef.current) return;

      // Swipe path (zoom=1).
      const s = swipeStateRef.current;
      if (s.active) {
        const dx = e.clientX - s.startX;
        const dy = e.clientY - s.startY;
        s.lastDx = dx;
        s.lastDy = dy;
        if (s.claimed === 'none') {
          if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
            if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) {
              s.claimed = 'swipe';
            } else if (onCloseDragRef.current && dy > 0 && Math.abs(dy) > Math.abs(dx)) {
              // Vertical-DOWN drag → drive the sheet's drag-to-close. Only
              // when a close sink is wired (mobile sheet) and the motion
              // clearly dominates vertical; an ambiguous diagonal stays
              // 'reject' so it neither pages nor closes.
              s.claimed = 'close';
            } else {
              s.claimed = 'reject';
            }
            // Once the gesture is a real swipe/close, capture the pointer so
            // it tracks even if the finger leaves the stage. Images already
            // captured on `pointerdown` (re-capture is a no-op); video / a
            // tap that never resolves stays with the native controls.
            if (s.claimed === 'swipe' || s.claimed === 'close') {
              try {
                stageEl.setPointerCapture(e.pointerId);
              } catch {
                // ignore — capture is best-effort
              }
            }
          }
        }
        if (s.claimed === 'close') {
          if (e.cancelable) e.preventDefault();
          onCloseDragRef.current?.(Math.max(0, dy), true);
          return;
        }
        if (s.claimed !== 'swipe') return;
        if (e.cancelable) e.preventDefault();
        // Rubber-band (1/3 travel) when the swipe direction has nothing to
        // slide into — a true list edge OR a video neighbour (videos switch
        // instantly on release rather than gliding). A real image neighbour
        // follows the finger 1:1.
        const resisted = (dx > 0 && !prevIsImageRef.current) || (dx < 0 && !nextIsImageRef.current);
        setSwipeOffset(resisted ? dx / 3 : dx);
        return;
      }

      // Touch-pan path (zoom>1).
      const tp = touchPanStateRef.current;
      if (tp) {
        if (e.cancelable) e.preventDefault();
        const raw = {
          x: tp.panAtStartX + (e.clientX - tp.startX),
          y: tp.panAtStartY + (e.clientY - tp.startY),
        };
        const clamped = clampPanRef.current(raw, zoomRef.current);
        panRef.current = clamped;
        setPan(clamped);
      }
    };

    const onPointerEnd = (e: PointerEvent) => {
      if (e.pointerType === 'mouse') return;
      // Release the pointer capture taken on `pointerdown`. Browsers
      // auto-release on `pointerup` / `pointercancel`, but
      // explicitly calling it on a non-captured pointer is a no-op
      // and the explicit release pairs the API call for clarity.
      try {
        if (stageEl.hasPointerCapture(e.pointerId)) {
          stageEl.releasePointerCapture(e.pointerId);
        }
      } catch {
        // ignore
      }
      pointerCacheRef.current.delete(e.pointerId);
      const count = pointerCacheRef.current.size;

      if (pinchStateRef.current && count < 2) {
        // Pinch ended. Reset pinch state but DON'T immediately
        // switch back to swipe / pan — the remaining finger (if
        // any) needs to lift first and the user starts a fresh
        // gesture cleanly.
        pinchStateRef.current = null;
        return;
      }

      if (count === 0) {
        const s = swipeStateRef.current;
        if (s.active) {
          s.active = false;
          if (s.claimed === 'close') {
            // Hand the release to the sheet — it applies the rubber-band,
            // checks the commit threshold, and either closes or springs back.
            onCloseDragRef.current?.(Math.max(0, s.lastDy), false);
          } else {
            let committed = false;
            if (s.claimed === 'swipe') {
              if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
                animatedStepRef.current(-1);
                committed = true;
              } else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) {
                animatedStepRef.current(1);
                committed = true;
              }
            }
            // A commit lets `animatedStep` own the offset (glide to
            // ±stageWidth); a non-committing release snaps back to centre.
            if (!committed) setSwipeOffset(0);
          }
        }
        touchPanStateRef.current = null;
      }
    };

    // Desktop wheel zoom — anchored to the cursor (the same point
    // under the cursor stays under the cursor as zoom changes).
    // Element-Web's idiom: step factor proportional to `deltaY`,
    // sign inverted so wheel-up zooms IN. `passive: false` so we
    // can preventDefault the browser's native page scroll.
    //
    // Browsers send wheel events with very different `deltaMode`
    // values (pixel vs line vs page). We normalize to pixels.
    const onWheel = (e: WheelEvent) => {
      if (entryKindRef.current !== 'image') return;
      e.preventDefault();
      let dyPx: number;
      if (e.deltaMode === 1) {
        dyPx = e.deltaY * 16; // line mode → ~16px / line
      } else if (e.deltaMode === 2) {
        dyPx = e.deltaY * stageEl.clientHeight; // page mode
      } else {
        dyPx = e.deltaY;
      }
      const factor = Math.exp(-dyPx * 0.0025);
      const oldZoom = zoomRef.current;
      const nextZoomRaw = oldZoom * factor;
      const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, nextZoomRaw));
      if (nextZoom === oldZoom) return;
      // Anchor-aware: hold the image-local point under the cursor
      // fixed across the zoom change.
      const stageRect = stageEl.getBoundingClientRect();
      const anchorX = e.clientX - (stageRect.left + stageRect.width / 2);
      const anchorY = e.clientY - (stageRect.top + stageRect.height / 2);
      const p0 = panRef.current;
      const imageX = (anchorX - p0.x) / oldZoom;
      const imageY = (anchorY - p0.y) / oldZoom;
      const rawPan = {
        x: anchorX - imageX * nextZoom,
        y: anchorY - imageY * nextZoom,
      };
      const clamped = clampPanRef.current(rawPan, nextZoom);
      panRef.current = clamped;
      setPan(clamped);
      setZoomRef.current(nextZoom);
    };

    stageEl.addEventListener('pointerdown', onPointerDown);
    stageEl.addEventListener('pointermove', onPointerMove);
    stageEl.addEventListener('pointerup', onPointerEnd);
    stageEl.addEventListener('pointercancel', onPointerEnd);
    stageEl.addEventListener('wheel', onWheel, { passive: false });

    return () => {
      stageEl.removeEventListener('pointerdown', onPointerDown);
      stageEl.removeEventListener('pointermove', onPointerMove);
      stageEl.removeEventListener('pointerup', onPointerEnd);
      stageEl.removeEventListener('pointercancel', onPointerEnd);
      stageEl.removeEventListener('wheel', onWheel);
    };
  }, []);

  // Prefer the shared cache so a just-committed (prefetched) entry paints
  // instantly with no `useAsyncCallback` Loading flash; fall back to the
  // loader's result for the first, uncached open.
  const cachedSrc = srcCacheRef.current.get(entry.eventId);
  const effectiveSrc =
    cachedSrc ?? (srcState.status === AsyncStatus.Success ? srcState.data : undefined);
  const isLoading =
    !effectiveSrc &&
    (srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
  const isError = !effectiveSrc && srcState.status === AsyncStatus.Error;
  const isReady = !!effectiveSrc;

  const handleDownload = useCallback(async () => {
    if (!effectiveSrc) return;
    FileSaver.saveAs(effectiveSrc, entry.body);
  }, [effectiveSrc, entry.body]);

  // Video autoplay: the active `<video>` ships `muted` + `autoPlay`. Muted
  // autoplay is universally allowed without a gesture (Chrome / Firefox /
  // WebKit / Android Capacitor / iOS Safari); unmuted would need a fresh
  // gesture per video and fails on iOS Safari after the first step. The user
  // unmutes via native controls; that doesn't persist across steps because a
  // neighbour video isn't mounted until it becomes the active slide.
  // Element-Web takes the same muted stance.

  // The active image's transform composes the paging offset with zoom + pan;
  // neighbour slides use the plain slot offset (computed per-slide below).
  const activeTransform = `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`;
  // Transition ON while a commit slide settles (swipeAnimating) and at rest
  // (smooth zoom / snap-back); OFF during an active finger drag (1:1 tracking).
  let imageTransition = 'none';
  if (instantStep) {
    // Instant step: no transition this commit so the persisted incoming slide
    // appears at centre at once — no slide-in from the neighbour slot, and no
    // wobble from a stale zoom/pan (also honours prefers-reduced-motion steps).
    imageTransition = 'none';
  } else if (swipeAnimating) {
    imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
  } else if (swipeOffset === 0) {
    imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
  }

  // Persistent filmstrip — the ±1 window around the current entry, always
  // mounted and keyed by eventId. Paging shifts `currentIndex` (via the atom)
  // and resets `swipeOffset`; each surviving slide's `slot*pitch + swipeOffset`
  // is unchanged across that shift, so nothing remounts or jumps — the old
  // neighbour just becomes the active slide. Neighbours sit one pitch to either
  // side (off-screen) and slide in on a swipe.
  const pitch = stageWidth + SWIPE_GAP_PX;
  const slides: Array<{ entry: MediaViewerEntry; slot: number }> = [];
  if (currentIndex >= 0) {
    // Neighbours need the measured `pitch` to sit off-screen; until the stage
    // is measured (first frame) render only the active slide so they don't
    // briefly flash near centre with a stage-less `pitch`.
    const neighboursReady = stageWidth > 0;
    for (let slot = -1; slot <= 1; slot += 1) {
      if (slot === 0 || neighboursReady) {
        const slideEntry = mediaSet[currentIndex + slot];
        if (slideEntry) slides.push({ entry: slideEntry, slot });
      }
    }
  } else {
    // Entry isn't in the loaded timeline window (out-of-scrollback media): show
    // it alone as the active slide — there are no neighbours to page to, and
    // `canPrev`/`canNext` already gate stepping off `currentIndex >= 0`.
    slides.push({ entry, slot: 0 });
  }
  // On native the prev/next chevrons are redundant — a horizontal swipe pages
  // between entries (photos glide; videos switch instantly above their control
  // band). Web keeps the chevrons on every entry: swipe is touch-only, so a
  // desktop mouse still needs the buttons (web-mobile touch gets both).
  const showChevrons = mediaSet.length > 1 && !isNativePlatform();

  return (
    <div className={css.root}>
      {!hideHeader && (
        <PageHeader
          // `PageHeader` (= folds `Header size="600"`) for a consistent
          // strip height. `outlined={false}` drops the default bottom rule:
          // the only consumer that renders this header is the mobile sheet
          // (the desktop window passes `hideHeader`), and there it's a
          // free-floating viewer over the image — a divider under the title
          // just reads as a stray line, not a cross-pane seam.
          outlined={false}
          className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
        >
          <Box grow="Yes" alignItems="Center" gap="200">
            <Text className={css.title} size="H4" truncate>
              {entry.body}
            </Text>
            {mediaSet.length > 1 && (
              <span className={css.indexPill} aria-hidden="true">
                {currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
              </span>
            )}
          </Box>
          <Box shrink="No" alignItems="Center" gap="100">
            {entry.kind === 'image' && (
              <>
                <IconButton
                  variant="Background"
                  fill="None"
                  radii="300"
                  size="300"
                  onClick={zoomOut}
                  aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
                >
                  <Icon size="100" src={Icons.Minus} />
                </IconButton>
                <Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
                  {Math.round(zoom * 100)}%
                </Text>
                <IconButton
                  variant="Background"
                  fill="None"
                  radii="300"
                  size="300"
                  onClick={zoomIn}
                  aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
                >
                  <Icon size="100" src={Icons.Plus} />
                </IconButton>
              </>
            )}
            <IconButton
              variant="Background"
              fill="None"
              radii="300"
              size="300"
              onClick={handleDownload}
              disabled={!isReady}
              aria-label={t('MediaViewer.download', 'Download')}
            >
              <Icon size="100" src={Icons.Download} />
            </IconButton>
            {entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
            <IconButton
              variant="Background"
              fill="None"
              radii="300"
              size="300"
              onClick={requestClose}
              aria-label={t('Common.close', 'Close')}
            >
              <Icon size="100" src={Icons.Cross} />
            </IconButton>
          </Box>
        </PageHeader>
      )}

      <div className={css.stage} ref={stageRef}>
        {slides.map(({ entry: slideEntry, slot }) => (
          <Slide
            key={slideEntry.eventId}
            entry={slideEntry}
            resolveSrc={resolveMediaSrc}
            isActive={slot === 0}
            activeSrc={effectiveSrc}
            slideTransform={`translate3d(${slot * pitch + swipeOffset}px, 0, 0)`}
            activeTransform={activeTransform}
            transition={imageTransition}
            panCursor={panCursor}
            imgRefCb={setActiveImgRef}
            videoRefCb={setActiveVideoRef}
            onMouseDown={onMouseDown}
            onImageLoad={onImageLoad}
          />
        ))}

        {isLoading && (
          <div className={css.stateOverlay}>
            <Spinner variant="Secondary" size="600" />
          </div>
        )}
        {isError && (
          <div className={css.stateOverlay} style={{ pointerEvents: 'auto' }}>
            <IconButton
              variant="Critical"
              fill="Soft"
              radii="300"
              size="400"
              onClick={() => loadSrc()}
              aria-label={t('Common.retry', 'Retry')}
            >
              <Icon size="200" src={Icons.Warning} />
            </IconButton>
          </div>
        )}

        {showChevrons && (
          <>
            <button
              type="button"
              className={classNames(css.chevron, css.chevronLeft)}
              aria-label={t('MediaViewer.previous', 'Previous')}
              // `aria-disabled` (not `disabled`) so the CSS dimmed-
              // state selector still matches; the onClick already
              // bails internally via `animatedStep`'s bounds check.
              aria-disabled={!canPrev}
              onClick={() => animatedStep(-1)}
            >
              <Icon size="200" src={Icons.ChevronLeft} />
            </button>
            <button
              type="button"
              className={classNames(css.chevron, css.chevronRight)}
              aria-label={t('MediaViewer.next', 'Next')}
              aria-disabled={!canNext}
              onClick={() => animatedStep(1)}
            >
              <Icon size="200" src={Icons.ChevronRight} />
            </button>
          </>
        )}
      </div>
    </div>
  );
}
