// Sayso film library — a 3×3 grid of customer films with in-viewport muted
// video previews, and a full-screen vertical-swipe ("Reels") viewer.
// Grid sells volume; the viewer sells the proof. Performance-first:
//   • grid previews are lightweight <video> for direct files, poster-only for Vimeo
//   • a video element is mounted only while its tile is near the viewport
//   • the full film loads only when the viewer opens
//   • reduced-motion → static posters, no autoplay
(function () {
  const css = `
  /* ── 3×3 library grid (white, minimal, tight) ── */
  .lib-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:7px; max-width:720px; margin:22px auto 0; }
  .lib-tile { position:relative; aspect-ratio:3/4; border-radius:5px; overflow:hidden; border:0; padding:0; margin:0;
    cursor:pointer; background:#e9ebee; box-shadow:0 1px 2px rgba(0,0,0,0.07); }
  .lib-tile::after { content:""; position:absolute; inset:0; border-radius:5px; pointer-events:none;
    box-shadow:inset 0 0 0 0.5px rgba(0,0,0,0.06); z-index:4; }
  .lib-poster, .lib-vid { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center 26%; border:0; }
  /* Vimeo iframe can't use object-fit, so we size it to a 9:16 box centered on
     the 3:4 tile — the shorter (width) dimension covers 100% and the video
     overflows vertically, so the whole tile is filled with live footage */
  iframe.lib-vid { inset:auto; top:50%; left:50%; right:auto; bottom:auto; width:100%; height:calc(100% * 16 / 9); min-height:100%;
    transform:translate(-50%,-50%); pointer-events:none; }
  .lib-vid { z-index:1; }
  .lib-grad { position:absolute; left:0; right:0; bottom:0; height:32%; z-index:2; pointer-events:none;
    background:linear-gradient(to top, rgba(0,0,0,0.46), rgba(0,0,0,0)); }
  .lib-cap { position:absolute; left:0; right:0; bottom:0; z-index:3; padding:8px 9px; display:flex; align-items:flex-end; }
  .lib-caplogo { height:13px; width:auto; max-width:72%; object-fit:contain; object-position:left center; filter:brightness(0) invert(1); opacity:.95; }
  .lib-capname { font:600 11px/1.2 var(--font-sans); color:#fff; letter-spacing:-0.005em; }
  @media (max-width:767px) { .lib-grid { gap:6px; max-width:100%; margin-top:16px; } .lib-caplogo { height:11px; } .lib-capname { font-size:10px; } .lib-tile { border-radius:4px; } .lib-tile::after { border-radius:4px; } }
  .lib-gridhint { text-align:center; margin:14px 0 0; font:500 13px/1.3 var(--font-sans); letter-spacing:.01em; color:var(--text-tertiary); }
  @media (max-width:767px) { .lib-gridhint { display:none; } }

  /* ── mobile-only draggable matrix (rows slide L/R, columns slide U/D) ── */
  @media (max-width:767px) {
    .lib-grid { position:relative; touch-action:pan-y; -webkit-user-select:none; user-select:none;
      overscroll-behavior:contain; -webkit-tap-highlight-color:transparent; }
    .lib-grid.lib-dragging { overflow:hidden; }
    /* subtle immediate press feedback — the row feels grabbable, but never
       moves until the finger actually moves (drag sets an inline translate
       that overrides this scale) */
    .lib-tile:active { transform:scale(.985); transition:transform .12s cubic-bezier(.2,0,0,1); }
    @media (prefers-reduced-motion: reduce) { .lib-tile:active { transform:none; } }
    /* incoming preview cells drawn just outside the visible 3×3 */
    .lib-buf { position:absolute; z-index:0; background:#e9ebee; border-radius:4px; overflow:hidden;
      box-shadow:0 1px 2px rgba(0,0,0,0.07); pointer-events:none; }
    .lib-buf::after { content:""; position:absolute; inset:0; border-radius:4px; pointer-events:none;
      box-shadow:inset 0 0 0 0.5px rgba(0,0,0,0.06); z-index:4; }
    .lib-grid.lib-dragging .lib-tile { z-index:1; }
  }

  /* ── full-screen video browsing viewer ── */
  .lib-viewer { position:fixed; inset:0; z-index:10000; background:#000; overflow:hidden; touch-action:none; animation:libIn .16s ease;
    /* uniform control position for every film (no per-aspect shift) */
    --ctl-top: calc(max(env(safe-area-inset-top), 20px) + 72px); }
  @keyframes libIn { from { opacity:0; } to { opacity:1; } }
  .lib-stage { position:absolute; inset:0; transition:transform .13s cubic-bezier(.2,0,0,1); cursor:pointer; background:#000; }
  .lib-media { position:absolute; inset:0; width:100%; height:100%; border:0; background:#000; z-index:1; }
  video.lib-media, img.lib-media { object-fit:contain; }
  /* Preview poster and the live video share ONE frame: object-fit:contain +
     centred, matching how the Vimeo iframe centres the film — so there is no
     vertical jump or reposition when playback replaces the poster in place. */
  @media (max-width:767px) { video.lib-media, img.lib-media { object-position:center center; } }
  .lib-pause { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:3; width:66px; height:66px; border-radius:999px;
    background:rgba(0,0,0,0.4); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; pointer-events:none; opacity:0; transition:opacity .2s; }
  .lib-pause.show { opacity:1; }
  .lib-x { position:absolute; top:var(--ctl-top); right:calc(12px + env(safe-area-inset-right)); z-index:6;
    width:40px; height:40px; border-radius:999px; border:0; background:rgba(0,0,0,0.52); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; cursor:pointer;
    box-shadow:0 0 0 1px rgba(255,255,255,0.14), 0 2px 10px rgba(0,0,0,0.3); transition:background .2s; }
  .lib-x:hover { background:rgba(0,0,0,0.66); }
  .lib-brand { position:absolute; top:var(--ctl-top); left:calc(16px + env(safe-area-inset-left)); z-index:6;
    height:22px; width:auto; filter:brightness(0) invert(1); opacity:.95; pointer-events:none; }
  .lib-mute { position:absolute; top:calc(var(--ctl-top) + 52px); right:calc(12px + env(safe-area-inset-right)); bottom:auto;
    width:40px; height:40px; border-radius:999px; border:0; background:rgba(0,0,0,0.42); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; cursor:pointer; }
  .lib-book { position:absolute; top:calc(var(--ctl-top) + 104px); right:calc(12px + env(safe-area-inset-right)); z-index:6;
    width:40px; height:40px; border-radius:999px; border:0; background:rgba(0,0,0,0.42); backdrop-filter:blur(6px); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; }
  .lib-fs { position:absolute; top:calc(var(--ctl-top) + 156px); right:calc(12px + env(safe-area-inset-right)); z-index:6;
    width:40px; height:40px; border-radius:999px; border:0; background:rgba(0,0,0,0.42); backdrop-filter:blur(6px); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; }
  .lib-meta { position:absolute; left:calc(16px + env(safe-area-inset-left)); z-index:5;
    bottom:calc(96px + env(safe-area-inset-bottom));
    display:flex; flex-direction:column; gap:2px; max-width:66vw; text-shadow:0 1px 8px rgba(0,0,0,0.7); pointer-events:none; }
  .lib-metaname { font:600 15px/1.25 var(--font-sans); color:#fff; letter-spacing:-0.01em; }
  .lib-metaloc { font:400 12.5px/1.3 var(--font-sans); color:rgba(255,255,255,0.78); }
  /* right action rail — IG/FB vocabulary with a Sayso pink accent on Like */
  .lib-rail-actions { position:absolute; right:calc(10px + env(safe-area-inset-right));
    bottom:calc(96px + env(safe-area-inset-bottom)); z-index:6;
    display:flex; flex-direction:column; align-items:center; gap:22px; pointer-events:auto; }
  .lib-act { display:flex; flex-direction:column; align-items:center; gap:4px; background:transparent; border:0;
    padding:0; cursor:pointer; color:#fff; }
  .lib-act-btn { width:46px; height:46px; border-radius:999px; background:rgba(0,0,0,0.42); backdrop-filter:blur(6px);
    border:0; display:flex; align-items:center; justify-content:center; color:#fff; cursor:pointer; transition:transform .15s ease, background .2s; }
  .lib-act:hover .lib-act-btn { background:rgba(0,0,0,0.6); transform:scale(1.05); }
  .lib-act.liked .lib-act-btn { color:var(--brand-600); }
  .lib-act.liked .lib-act-btn svg { fill:var(--brand-600); stroke:var(--brand-600); }
  .lib-act-lbl { font:600 10.5px/1 var(--font-sans); color:#fff; letter-spacing:.01em; text-shadow:0 1px 4px rgba(0,0,0,0.6); }
  .lib-nav { position:absolute; z-index:4; width:48px; height:48px; border-radius:999px; border:0;
    background:rgba(0,0,0,0.56); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; cursor:pointer;
    box-shadow:0 0 0 1px rgba(255,255,255,0.16), 0 2px 10px rgba(0,0,0,0.3); transition:background .2s, transform .15s; }
  .lib-nav:hover { background:rgba(0,0,0,0.72); transform:scale(1.06); }
  .lib-nav.up { top:calc(50% - 54px); right:calc(11px + env(safe-area-inset-right)); bottom:auto; left:auto; }
  .lib-nav.down { top:calc(50% + 6px); right:calc(11px + env(safe-area-inset-right)); bottom:auto; left:auto; }
  .lib-rail { position:absolute; right:12px; top:50%; transform:translateY(-50%); z-index:4; display:flex; flex-direction:column; align-items:center; gap:6px; }
  .lib-dot { width:5px; height:5px; border-radius:999px; background:rgba(255,255,255,0.32); transition:height .25s, background .25s; }
  .lib-dot.on { background:#fff; height:15px; }
  .lib-bar { position:absolute; right:13px; top:50%; transform:translateY(-50%); z-index:4; width:3px; height:120px; border-radius:2px; background:rgba(255,255,255,0.2); }
  .lib-bar i { position:absolute; left:0; width:100%; border-radius:2px; background:#fff; transition:top .25s; }
  .lib-hint { position:absolute; left:50%; bottom:calc(92px + env(safe-area-inset-bottom)); transform:translateX(-50%); z-index:6; padding:9px 17px; border-radius:999px;
    background:rgba(0,0,0,0.55); backdrop-filter:blur(6px); color:#fff; font:500 13px/1 var(--font-sans); letter-spacing:.01em; animation:libHint .3s ease; white-space:nowrap; }
  /* bold "Tap for sound" CTA — films start muted, so this is the loud invite to unmute */
  .lib-unmute-cta { position:absolute; left:50%; top:38%; bottom:auto; transform:translateX(-50%); z-index:7;
    display:flex; align-items:center; gap:9px; height:48px; padding:0 22px; border:0; border-radius:999px; cursor:pointer;
    background:#fff; color:#000; font:600 15px/1 var(--font-sans); letter-spacing:-0.01em;
    box-shadow:0 8px 24px rgba(0,0,0,0.35); animation:libUnmute .28s cubic-bezier(.2,0,0,1); }
  .lib-unmute-cta svg { flex:0 0 auto; }
  .lib-unmute-cta:hover { background:#f0f0f0; }
  @keyframes libUnmute { from { opacity:0; transform:translate(-50%,10px); } to { opacity:1; transform:translate(-50%,0); } }
  @media (prefers-reduced-motion: reduce) { .lib-unmute-cta { animation:none; } }
  @keyframes libHint { from { opacity:0; transform:translate(-50%,6px); } to { opacity:1; transform:translate(-50%,0); } }
  @media (prefers-reduced-motion: reduce) { .lib-stage { transition:none; } .lib-viewer { animation:none; } }
  `;
  if (typeof document !== "undefined" && !document.getElementById("sayso-lib-css")) {
    const s = document.createElement("style"); s.id = "sayso-lib-css"; s.textContent = css; document.head.appendChild(s);
  }
})();

function libVimeo(url) {
  if (!url || url.indexOf("vimeo") < 0) return null;
  const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)(?:\/([a-zA-Z0-9]+))?/);
  return m ? { id: m[1], hash: m[2] || null } : null;
}
function libIsFile(url) { return !!url && /\.(mp4|webm|ogg|mov)(\?|#|$)/i.test(url); }
function libPh(v) { return !v || v === "Add a film" || v === "Add location" || v === "Customer" || /^Film \d+$/.test(v); }

// static poster face (used by grid tiles and by the drag preview cells) — no video
function LibFace({ f }) {
  const client = libPh(f.client) ? "" : f.client;
  return (
    <React.Fragment>
      <img className="lib-poster" src={f.img} alt="" loading="lazy" draggable="false" style={f.focus ? { objectPosition: f.focus } : undefined} />
      <span className="lib-grad" aria-hidden="true"></span>
      {(f.logo || client) && (
        <span className="lib-cap">
          {f.logo ? <img className="lib-caplogo" src={f.logo} alt="" /> : <span className="lib-capname">{client}</span>}
        </span>
      )}
    </React.Fragment>
  );
}

// ── one grid tile: poster always; muted looping <video> or Vimeo background
// iframe autoplays independently once the tile has entered view ──
function LibTile({ f, onOpen, reduce, pos, moving }) {
  const wrapRef = React.useRef(null);
  const [failed, setFailed] = React.useState(false);
  const vidRef = React.useRef(null);
  const [near, setNear] = React.useState(false);
  const [loaded, setLoaded] = React.useState(false);
  const file = libIsFile(f.video);
  const vp = !file ? libVimeo(f.video) : null;
  // only auto-play films that are natively portrait/full-frame; wide (16:9) films
  // show a static poster in the grid to avoid cropped/composite look
  const canPlay = !f.wide;
  React.useEffect(() => {
    const el = wrapRef.current; if (!el) return;
    const io = new IntersectionObserver((es) => {
      es.forEach((e) => { setNear(e.isIntersecting); if (e.isIntersecting) setLoaded(true); });
    }, { rootMargin: "300px 0px", threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  React.useEffect(() => {
    const v = vidRef.current; if (!v || !file) return;
    if (near && !reduce) { const p = v.play(); if (p) p.catch(() => {}); }
    else { v.pause(); }
  }, [near, reduce, loaded, file]);
  // Vimeo background previews are owned by the shared VimeoFilm controller;
  // it swaps to the poster (via onFail) if the embed can't play, so the tile
  // never shows Vimeo's "Player error" UI.
  const client = libPh(f.client) ? "" : f.client;
  // per-film framing: `focus` (e.g. "50% 18%") keeps the face centred; falls
  // back to the face-safe default in CSS when a film has no explicit framing
  const fpos = f.focus ? { objectPosition: f.focus } : undefined;
  return (
    <button className="lib-tile" type="button" ref={wrapRef} data-pos={pos} data-move={moving ? "1" : undefined}
      onClick={onOpen} aria-label={client ? `Watch the ${client} film` : "Watch film"}>
      <img className="lib-poster" src={f.img} alt="" loading="lazy" draggable="false" style={fpos} />
      {loaded && !reduce && file && canPlay && (
        <video ref={vidRef} className="lib-vid" src={f.video} poster={f.img} muted loop playsInline autoPlay preload="auto" style={fpos} />
      )}
      {loaded && !reduce && vp && canPlay && !failed && (
        <window.VimeoFilm background video={f.video} poster={f.img} className="lib-vid" posterStyle={fpos} active={near} onFail={() => setFailed(true)} />
      )}
      <span className="lib-grad" aria-hidden="true"></span>
      {(f.logo || client) && (
        <span className="lib-cap">
          {f.logo ? <img className="lib-caplogo" src={f.logo} alt="" /> : <span className="lib-capname">{client}</span>}
        </span>
      )}
    </button>
  );
}

// ── the mobile "Customer films" 3×3 matrix.
// A clean 3-col × 3-row grid at rest (visually identical to before). On mobile
// (≤767px) each ROW can be dragged left/right and each COLUMN up/down (the
// latter after a ~200ms press-and-hold, so a plain vertical swipe still scrolls
// the page). Released lines snap to a valid grid position; incoming cells are
// drawn from films not currently visible so the 9 cells are always unique.
// Desktop never mounts this component (it renders FilmReel instead), so no
// desktop / video-player code is touched.

function FilmLibraryGrid({ films, onOpen }) {
  const reduce = window.PC_REDUCE;
  const pool = films || [];
  const n = pool.length;
  const gridRef = React.useRef(null);
  const suppressRef = React.useRef(0);
  const [matrix, setMatrix] = React.useState(() => Array.from({ length: Math.min(9, n) }, (_, i) => i));
  const [active, setActive] = React.useState(null); // {line,before,after,cw,strideX} — horizontal only
  const recentRef = React.useRef([]);
  const st = React.useRef({}).current;

  // reset the matrix if the underlying film list changes (e.g. Film Manager edits)
  React.useEffect(() => {
    setMatrix(Array.from({ length: Math.min(9, pool.length) }, (_, i) => i));
    recentRef.current = [];
  }, [pool]);

  const canDrag = n > 9;
  const DIR = 7;                               // px before a horizontal gesture locks a row
  const SNAP_EASE = "cubic-bezier(.22,1,.36,1)"; // ease-out, no overshoot
  const isMobileNow = () => (typeof matchMedia !== "undefined" ? matchMedia("(max-width:767px)").matches : true);
  // diminishing (rubber-band) resistance past the last available cell, so the
  // edge feels soft instead of a hard wall fighting the finger
  const rubber = (over, dim) => (1 - 1 / (over / dim * 0.55 + 1)) * dim;

  // paint current horizontal offset directly onto the moving row (no re-render)
  const paint = () => {
    st.raf = 0;
    const g = gridRef.current; if (!g) return;
    if (!st.nodes || !st.nodes.length) st.nodes = Array.from(g.querySelectorAll('[data-move="1"]'));
    const tf = "translate3d(" + st.off + "px,0,0)";
    st.nodes.forEach((el) => { el.style.transition = "none"; el.style.willChange = "transform"; el.style.transform = tf; });
  };
  const schedule = () => { if (st.raf) return; st.raf = requestAnimationFrame(paint); };

  React.useLayoutEffect(() => { if (active) { st.nodes = null; paint(); } }, [active]);

  // pick preview films: not currently visible, preferring films not shown recently
  const pickBuffers = (mtx) => {
    const vis = new Set(mtx);
    const recentSet = new Set(recentRef.current);
    const fresh = [], stale = [];
    for (let i = 0; i < n; i++) { if (!vis.has(i)) (recentSet.has(i) ? stale : fresh).push(i); }
    const avail = fresh.concat(recentRef.current.filter((i) => !vis.has(i)), stale);
    // de-dupe while preserving order
    const seen = new Set(), ordered = [];
    for (const i of avail) { if (!seen.has(i)) { seen.add(i); ordered.push(i); } }
    return { before: ordered.slice(0, 3), after: ordered.slice(3, 6) };
  };

  const geom = () => {
    const g = gridRef.current; const cs = getComputedStyle(g);
    const gapX = parseFloat(cs.columnGap) || 6;
    const cw = (g.clientWidth - 2 * gapX) / 3, ch = cw * 4 / 3;
    return { cw, ch, strideX: cw + gapX };
  };

  const lockRow = () => {
    const gm = geom();
    const { before, after } = pickBuffers(matrix);
    Object.assign(st, {
      locked: "row", line: st.row, cw: gm.cw, strideX: gm.strideX, before, after, nodes: null,
    });
    if (gridRef.current) gridRef.current.classList.add("lib-dragging");
    if (st.pointerCapture && gridRef.current && st.id != null) {
      try { gridRef.current.setPointerCapture(st.id); } catch (e) {}
    }
    setActive({ line: st.row, before, after, cw: gm.cw, strideX: gm.strideX });
  };

  const begin = (x, y, tileEl, id, isMouse) => {
    if (!canDrag || !isMobileNow()) return;
    // a new touch mid-momentum: finalize the previous gesture at its nearest
    // boundary first, so the grid is in a clean committed state before this one
    if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; if (st.nodes) settle(); }
    const pos = tileEl ? parseInt(tileEl.getAttribute("data-pos"), 10) : NaN;
    if (isNaN(pos)) return;
    Object.assign(st, {
      go: true, id, pointerCapture: !!isMouse, sx: x, sy: y,
      row: Math.floor(pos / 3), pos, locked: null, off: 0,
      samples: [{ t: performance.now(), x, y }], nodes: null,
    });
  };

  const move = (x, y) => {
    if (!st.go) return false;
    const dx = x - st.sx, dy = y - st.sy;
    st.samples.push({ t: performance.now(), x, y });
    if (st.samples.length > 6) st.samples.shift();
    if (st.locked === "scroll") return false;
    if (!st.locked) {
      const ax = Math.abs(dx), ay = Math.abs(dy);
      if (Math.max(ax, ay) < DIR) return false;
      // horizontal-only: a clearly horizontal gesture grabs the row; anything
      // else (including any vertical movement) is left to the page as scroll.
      if (ax > ay) { lockRow(); }
      else { st.locked = "scroll"; return false; }
    }
    const maxBefore = st.before.length, maxAfter = st.after.length;
    const lo = -maxAfter * st.strideX, hi = maxBefore * st.strideX;
    st.off = dx > hi ? hi + rubber(dx - hi, st.cw) : dx < lo ? lo - rubber(lo - dx, st.cw) : dx;
    schedule();
    return true; // row locked → caller should preventDefault
  };

  const velocity = () => {
    const s = st.samples; if (!s || s.length < 2) return 0;
    const last = s[s.length - 1]; let ref = s[0];
    for (let i = s.length - 2; i >= 0; i--) { if (last.t - s[i].t >= 40) { ref = s[i]; break; } ref = s[i]; }
    const dt = last.t - ref.t; if (dt <= 0) return 0;
    return (last.x - ref.x) / dt;
  };

  const commit = (s) => {
    if (st.nodes) st.nodes.forEach((el) => { el.style.transition = ""; el.style.transform = ""; el.style.willChange = ""; });
    if (gridRef.current) gridRef.current.classList.remove("lib-dragging");
    if (s !== 0) {
      const { line, before, after } = st;
      const idxs = [line * 3, line * 3 + 1, line * 3 + 2];
      const cur = idxs.map((k) => matrix[k]);
      const strip = before.slice().reverse().concat(cur).concat(after);
      const win = strip.slice(3 - s, 6 - s);
      const next = matrix.slice();
      idxs.forEach((k, j) => { next[k] = win[j]; });
      const exited = cur.filter((id) => win.indexOf(id) < 0);
      recentRef.current = exited.concat(recentRef.current.filter((id) => exited.indexOf(id) < 0)).slice(0, 12);
      setMatrix(next);
    }
    setActive(null);
    st.nodes = null;
  };

  const finish = () => {
    if (!st.go) return;
    st.go = false;
    if (st.pointerCapture && gridRef.current && st.id != null) { try { gridRef.current.releasePointerCapture(st.id); } catch (e) {} }
    if (st.locked !== "row") { st.locked = null; return; } // tap / scroll
    st.nodes = Array.from(gridRef.current.querySelectorAll('[data-move="1"]'));
    st.v = velocity();                         // px/ms at release, signed
    if (reduce) { settle(); return; }          // reduced-motion: skip inertia, align now
    st.lastT = performance.now();
    runMomentum();
  };

  // ── inertial momentum: integrate position from release velocity with
  // frame-rate-independent friction, painting transform each frame; when the
  // motion nearly stops (or hits the buffer edge) hand off to settle(). ──
  const FRICTION = 0.90;   // per 16.67ms frame
  const MIN_V = 0.05;      // px/ms — below this, settle
  const runMomentum = () => {
    st.raf2 = requestAnimationFrame(() => {
      const now = performance.now();
      const dt = Math.min(32, now - st.lastT); st.lastT = now;
      st.off += st.v * dt;
      st.v *= Math.pow(FRICTION, dt / 16.67);
      const lo = -st.after.length * st.strideX, hi = st.before.length * st.strideX;
      let edge = false;
      if (st.off <= lo) { st.off = lo; st.v = 0; edge = true; }
      else if (st.off >= hi) { st.off = hi; st.v = 0; edge = true; }
      const tf = "translate3d(" + st.off + "px,0,0)";
      st.nodes.forEach((el) => { el.style.transition = "none"; el.style.willChange = "transform"; el.style.transform = tf; });
      if (Math.abs(st.v) < MIN_V || edge) { settle(); return; }
      runMomentum();
    });
  };

  // final short alignment to the nearest valid tile boundary, then commit
  const settle = () => {
    if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; }
    const stride = st.strideX;
    let s = Math.round(st.off / stride);
    s = Math.max(-st.after.length, Math.min(st.before.length, s));
    const target = s * stride;
    const dist = Math.abs(target - st.off);
    const dur = reduce ? 0 : Math.min(180, Math.max(90, (dist / stride) * 150));
    st.nodes = st.nodes || Array.from(gridRef.current.querySelectorAll('[data-move="1"]'));
    st.nodes.forEach((el) => { el.style.transition = dur ? "transform " + dur + "ms cubic-bezier(.22,1,.36,1)" : "none"; el.style.transform = "translate3d(" + target + "px,0,0)"; });
    suppressRef.current = performance.now() + dur + 140;
    st.locked = null;
    setTimeout(() => commit(s), dur + 10);
  };

  // ── mouse (pointer) path — for desktop testing; touch handled natively below ──
  const onPD = (e) => { if (e.pointerType === "touch") return; const t = e.target.closest(".lib-tile"); if (!t) return; begin(e.clientX, e.clientY, t, e.pointerId, true); };
  const onPM = (e) => { if (e.pointerType === "touch") return; move(e.clientX, e.clientY); };
  const onPU = (e) => { if (e.pointerType === "touch") return; finish(); };

  // ── touch path — native non-passive so we can cancel page scroll on row lock.
  // A vertical (or ambiguous) gesture never locks, so the page scrolls normally.
  React.useEffect(() => {
    const g = gridRef.current; if (!g) return;
    const onTS = (e) => { const t = e.target.closest(".lib-tile"); if (!t) return; begin(e.touches[0].clientX, e.touches[0].clientY, t, null, false); };
    const onTM = (e) => { if (!st.go) return; const locked = move(e.touches[0].clientX, e.touches[0].clientY); if (locked || st.locked === "row") { e.preventDefault(); } };
    const onTE = () => finish();
    g.addEventListener("touchstart", onTS, { passive: true });
    g.addEventListener("touchmove", onTM, { passive: false });
    g.addEventListener("touchend", onTE, { passive: true });
    g.addEventListener("touchcancel", onTE, { passive: true });
    return () => {
      g.removeEventListener("touchstart", onTS);
      g.removeEventListener("touchmove", onTM);
      g.removeEventListener("touchend", onTE);
      g.removeEventListener("touchcancel", onTE);
      if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; }
    };
  });

  // ── one-time HORIZONTAL discovery cue ────────────────────────────────────
  // Removed: the matrix now relies on the gesture itself for discoverability.
  // The initial state is always a perfectly aligned, static 3×3 grid — no
  // peek, no automatic or scroll-triggered movement, no onboarding animation.

  const openAt = (poolIdx) => { if (performance.now() < suppressRef.current) return; onOpen(poolIdx); };

  const buffers = [];
  if (active) {
    const { line, before, after, cw, strideX } = active;
    const ch = cw * 4 / 3;
    const gap = Math.max(0, strideX - cw);       // rowGap == columnGap in this grid
    const rowTop = line * (ch + gap);
    const mk = (fi, left, key) => buffers.push(
      React.createElement("div", { key, className: "lib-buf", "data-move": "1", "aria-hidden": "true", style: { left: left, top: rowTop, width: cw, height: ch } }, React.createElement(LibFace, { f: pool[fi] }))
    );
    before.forEach((fi, j) => mk(fi, -(j + 1) * strideX, "bl" + fi));
    after.forEach((fi, j) => mk(fi, (3 + j) * strideX, "br" + fi));
  }

  return (
    <div className="lib-grid" ref={gridRef} onPointerDown={canDrag ? onPD : undefined} onPointerMove={canDrag ? onPM : undefined} onPointerUp={canDrag ? onPU : undefined} onPointerCancel={canDrag ? onPU : undefined}>
      {matrix.map((fi, pos) => {
        const row = Math.floor(pos / 3);
        const moving = active && row === active.line;
        return <LibTile key={fi} f={pool[fi]} reduce={reduce} pos={pos} moving={moving} onOpen={() => openAt(fi)} />;
      })}
      {buffers}
    </div>
  );
}

// ── full-screen video browsing viewer (Reels-style) ──
function SaysoFilmViewer({ films, index, onIndex, onClose }) {
  const n = films.length;
  const cur = films[index];
  const [muted, setMuted] = React.useState(() => {   // sound choice persists for the session; default muted for reliable autoplay
    try { return sessionStorage.getItem("sayso_sound_on") !== "1"; } catch (e) { return true; }
  });
  const setSound = React.useCallback((next) => {
    setMuted(next);
    try { sessionStorage.setItem("sayso_sound_on", next ? "0" : "1"); } catch (e) {}
  }, []);
  // Unmute is run DIRECTLY inside the tap handler (never from an effect) so iOS
  // treats it as part of the user gesture. We flip the UI to "sound on" only
  // after the player confirms; on failure the UI stays muted and the tap is
  // safely retryable. Vimeo goes through the SDK player promise; native <video>
  // unmutes on the element. One owner per surface — no competing commands.
  const unmuteFromTap = React.useCallback((e) => {
    if (e) e.stopPropagation();
    const p = vimeoPlayerRef.current;
    const v = vidRef.current;
    if (v) { try { v.muted = false; v.volume = 1; } catch (_) {} setSound(false); return; }
    if (p) {
      Promise.resolve()
        .then(() => p.setMuted(false))
        .then(() => p.setVolume(1))
        .then(() => (p.getMuted ? p.getMuted() : false))
        .then((m) => { if (!m) setSound(false); })   // UI flips only when sound is REALLY on
        .catch(() => { /* stay muted; retry-safe */ });
      return;
    }
    setSound(false);                             // no player yet (poster) — safe default
  }, [setSound]);
  const muteFromTap = React.useCallback((e) => {
    if (e) e.stopPropagation();
    const p = vimeoPlayerRef.current;
    const v = vidRef.current;
    if (v) { try { v.muted = true; } catch (_) {} }
    if (p) { try { p.setMuted(true).catch(() => {}); } catch (_) {} }
    setSound(true);
  }, [setSound]);
  const [paused, setPaused] = React.useState(false);
  const [hint, setHint] = React.useState(false);
  const stageRef = React.useRef(null);
  const dragY = React.useRef(0);
  const dragRaf = React.useRef(0);
  const paintStage = () => { dragRaf.current = 0; const el = stageRef.current; if (el) el.style.transform = "translateY(" + (dragY.current * 0.2) + "px)"; };
  const setDrag = (y) => { dragY.current = y; if (!dragRaf.current) dragRaf.current = requestAnimationFrame(paintStage); };
  React.useEffect(() => () => { if (dragRaf.current) cancelAnimationFrame(dragRaf.current); }, []);
  const [liked, setLiked] = React.useState(false);
  const start = React.useRef(null);
  const swiped = React.useRef(false);
  const vidRef = React.useRef(null);
  const iframeRef = React.useRef(null);
  const vimeoPlayerRef = React.useRef(null);
  const wheelLock = React.useRef(false);
  const go = React.useCallback((d) => { setPaused(false); onIndex((index + d + n) % n); }, [index, n, onIndex]);

  React.useEffect(() => {
    try { if (!localStorage.getItem("sayso_swipe_hint")) { setHint(true); localStorage.setItem("sayso_swipe_hint", "1"); const t = setTimeout(() => setHint(false), 2000); return () => clearTimeout(t); } } catch (e) {}
  }, []);
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowDown" || e.key === "ArrowRight") go(1);
      else if (e.key === "ArrowUp" || e.key === "ArrowLeft") go(-1);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [go, onClose]);
  // lock scroll while open; restore exact scroll position on close
  React.useEffect(() => {
    const y = window.scrollY; const b = document.body;
    const prev = { overflow: b.style.overflow, position: b.style.position, top: b.style.top, width: b.style.width };
    b.style.overflow = "hidden"; b.style.position = "fixed"; b.style.top = -y + "px"; b.style.width = "100%";
    return () => {
      b.style.overflow = prev.overflow; b.style.position = prev.position;
      b.style.top = prev.top; b.style.width = prev.width;
      window.scrollTo(0, y);
    };
  }, []);
  // play the current film; keep the session sound choice — if unmuted autoplay is
  // momentarily blocked on the new element, play muted then restore sound so the
  // preference never silently resets between films
  React.useEffect(() => {
    const v = vidRef.current; if (!v) return;
    v.currentTime = 0; v.muted = muted;
    const p = v.play();
    if (p) p.catch(() => {
      if (!muted) { v.muted = true; v.play().then(() => { try { v.muted = false; } catch (e) {} }).catch(() => {}); }
      else { v.play().catch(() => {}); }
    });
  }, [index]);
  const vp = libVimeo(cur.video);
  const file = libIsFile(cur.video);
  React.useEffect(() => { vimeoPlayerRef.current = null; }, [index]);
  // Phase-1 preload: once the CURRENT film has started, warm ONLY the next film
  // (never the previous) so the Next transition feels faster. Best-effort; it
  // never delays or competes with current playback.
  const prefetchNext = React.useCallback(() => {
    if (n < 2 || !window.saysoPrefetchFilm) return;
    window.saysoPrefetchFilm(films[(index + 1) % n]);
  }, [films, index, n]);
  // Opening the viewer takes playback priority: the gate suspends/destroys every
  // background grid preview while the viewer is open, and restores them on close.
  React.useEffect(() => {
    if (window.saysoPreviewGate) window.saysoPreviewGate.setForeground(true);
    return () => { if (window.saysoPreviewGate) window.saysoPreviewGate.setForeground(false); };
  }, []);
  // Native device fullscreen — Vimeo via the SDK, direct files via the element.
  const goFullscreen = () => {
    const v = vidRef.current;
    if (v) {
      const fn = v.requestFullscreen || v.webkitEnterFullscreen || v.webkitRequestFullscreen;
      if (fn) { try { const r = fn.call(v); if (r && r.catch) r.catch(() => {}); } catch (e) {} }
      return;
    }
    const p = vimeoPlayerRef.current;
    if (p && p.requestFullscreen) { try { p.requestFullscreen().catch(() => {}); } catch (e) {} }
  };
  // Vimeo playback (mute/unmute, play/pause, poster fallback) is owned by the
  // shared SDK-only VimeoFilm controller — we just pass state down. `paused`
  // resets on every film switch (see go()).
  const togglePlay = () => {
    const v = vidRef.current;
    if (v) { if (v.paused) { v.play(); setPaused(false); } else { v.pause(); setPaused(true); } return; }
    if (vp) setPaused((p) => !p);
  };

  const onTStart = (e) => { start.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; setDrag(0); };
  const onTMove = (e) => {
    if (!start.current) return;
    const dx = e.touches[0].clientX - start.current.x, dy = e.touches[0].clientY - start.current.y;
    setDrag(Math.abs(dx) > Math.abs(dy) ? 0 : dy);
  };
  const onTEnd = (e) => {
    const s = start.current; start.current = null; setDrag(0);
    if (!s) return;
    const t = e.changedTouches[0]; const dx = t.clientX - s.x, dy = t.clientY - s.y;
    const ax = Math.abs(dx), ay = Math.abs(dy);
    if (Math.max(ax, ay) < 45) { swiped.current = false; return; }   // a tap → let onClick handle play/pause
    swiped.current = true; setTimeout(() => { swiped.current = false; }, 220);
    setHint(false);
    if (ay >= ax) go(dy < 0 ? 1 : -1); else go(dx < 0 ? 1 : -1);
  };
  const onWheel = (e) => { if (wheelLock.current || Math.abs(e.deltaY) < 18) return; wheelLock.current = true; setHint(false); go(e.deltaY > 0 ? 1 : -1); setTimeout(() => { wheelLock.current = false; }, 360); };
  const onStageClick = () => { if (swiped.current) return; togglePlay(); };

  const client = libPh(cur.client) ? "" : cur.client;
  const loc = libPh(cur.loc) ? "" : cur.loc;
  const neighbours = [(index + 1) % n, (index - 1 + n) % n];

  return (
    <div className="lib-viewer" onWheel={onWheel} onTouchStart={onTStart} onTouchMove={onTMove} onTouchEnd={onTEnd}>
      <div className="lib-stage" ref={stageRef} onClick={onStageClick}>
        {vp
          ? <window.VimeoFilm key={vp.id} video={cur.video} poster={cur.img} alt={client || ""} loader="logo" className="lib-media" muted={muted} paused={paused} onReady={(p) => { vimeoPlayerRef.current = p; prefetchNext(); }} onSoundBlocked={() => setSound(true)} />
          : file
            ? <video ref={vidRef} className="lib-media" src={cur.video} poster={cur.img} muted={muted} loop playsInline autoPlay preload="auto" onPlaying={prefetchNext} />
            : <img className="lib-media" src={cur.img} alt="" draggable="false" />}
        <span className={"lib-pause" + (paused ? " show" : "")} aria-hidden="true">
          <svg width="26" height="26" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg>
        </span>
      </div>

      {/* preload only next & prev (direct files) */}
      {neighbours.map((k) => libIsFile(films[k].video) && !libVimeo(films[k].video)
        ? <video key={k} src={films[k].video} preload="auto" muted style={{ display: "none" }} /> : null)}

      {(client || loc) && (
        <div className="lib-meta">
          {client && <span className="lib-metaname">{client}</span>}
          {loc && <span className="lib-metaloc">Filmed in {loc}</span>}
        </div>
      )}

      <button className="lib-x" type="button" aria-label="Close" onClick={onClose}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
      </button>

      <button className="lib-book" type="button" aria-label="Book a meeting" onClick={() => window.pcGo && window.pcGo((window.PC_LINKS && (window.PC_LINKS.bookMeeting || window.PC_LINKS.navBook)))}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></svg>
      </button>

      <button className="lib-fs" type="button" aria-label="Play fullscreen" onClick={(e) => { e.stopPropagation(); goFullscreen(); }}>
        <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3" /></svg>
      </button>

      <button className="lib-nav up" type="button" aria-label="Previous film" onClick={() => go(-1)}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M18 15l-6-6-6 6" /></svg>
      </button>
      <button className="lib-nav down" type="button" aria-label="Next film" onClick={() => go(1)}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg>
      </button>

      {/* subtle "more films" indicator */}

      <button className="lib-mute" type="button" aria-label={muted ? "Unmute" : "Mute"} onClick={muted ? unmuteFromTap : muteFromTap}>
        {muted
          ? <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z" /><path d="m23 9-6 6M17 9l6 6" /></svg>
          : <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z" /><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14" /></svg>}
      </button>

      {/* bold Unmute CTA — films start muted for reliable mobile autoplay; this
          invites sound with an unmissable tap target that disappears once on */}
      {muted && (
        <button className="lib-unmute-cta" type="button" onClick={unmuteFromTap}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z" /><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14" /></svg>
          <span>Tap for sound</span>
        </button>
      )}

      {hint && <div className="lib-hint">Swipe for more</div>}
    </div>
  );
}

window.FilmLibraryGrid = FilmLibraryGrid;
window.SaysoFilmViewer = SaysoFilmViewer;
