1// Sayso film library â a 3Ã3 grid of customer films with in-viewport muted 2// video previews, and a full-screen vertical-swipe ("Reels") viewer. 3// Grid sells volume; the viewer sells the proof. Performance-first: 4// ⢠grid previews are lightweight <video> for direct files, poster-only for Vimeo 5// ⢠a video element is mounted only while its tile is near the viewport 6// ⢠the full film loads only when the viewer opens 7// ⢠reduced-motion â static posters, no autoplay 8(function () { 9 const css = ` 10 /* ââ 3Ã3 library grid (white, minimal, tight) ââ */ 11 .lib-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:7px; max-width:720px; margin:22px auto 0; } 12 .lib-tile { position:relative; aspect-ratio:3/4; border-radius:5px; overflow:hidden; border:0; padding:0; margin:0; 13 cursor:pointer; background:#e9ebee; box-shadow:0 1px 2px rgba(0,0,0,0.07); } 14 .lib-tile::after { content:""; position:absolute; inset:0; border-radius:5px; pointer-events:none; 15 box-shadow:inset 0 0 0 0.5px rgba(0,0,0,0.06); z-index:4; } 16 .lib-poster, .lib-vid { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center 26%; border:0; } 17 /* Vimeo iframe can't use object-fit, so we size it to a 9:16 box centered on 18 the 3:4 tile â the shorter (width) dimension covers 100% and the video 19 overflows vertically, so the whole tile is filled with live footage */ 20 iframe.lib-vid { inset:auto; top:50%; left:50%; right:auto; bottom:auto; width:100%; height:calc(100% * 16 / 9); min-height:100%; 21 transform:translate(-50%,-50%); pointer-events:none; } 22 .lib-vid { z-index:1; } 23 .lib-grad { position:absolute; left:0; right:0; bottom:0; height:32%; z-index:2; pointer-events:none; 24 background:linear-gradient(to top, rgba(0,0,0,0.46), rgba(0,0,0,0)); } 25 .lib-cap { position:absolute; left:0; right:0; bottom:0; z-index:3; padding:8px 9px; display:flex; align-items:flex-end; } 26 .lib-caplogo { height:13px; width:auto; max-width:72%; object-fit:contain; object-position:left center; filter:brightness(0) invert(1); opacity:.95; } 27 .lib-capname { font:600 11px/1.2 var(--font-sans); color:#fff; letter-spacing:-0.005em; } 28 @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; } } 29 .lib-gridhint { text-align:center; margin:14px 0 0; font:500 13px/1.3 var(--font-sans); letter-spacing:.01em; color:var(--text-tertiary); } 30 @media (max-width:767px) { .lib-gridhint { display:none; } } 31 32 /* ââ mobile-only draggable matrix (rows slide L/R, columns slide U/D) ââ */ 33 @media (max-width:767px) { 34 .lib-grid { position:relative; touch-action:pan-y; -webkit-user-select:none; user-select:none; 35 overscroll-behavior:contain; -webkit-tap-highlight-color:transparent; } 36 .lib-grid.lib-dragging { overflow:hidden; } 37 /* subtle immediate press feedback â the row feels grabbable, but never 38 moves until the finger actually moves (drag sets an inline translate 39 that overrides this scale) */ 40 .lib-tile:active { transform:scale(.985); transition:transform .12s cubic-bezier(.2,0,0,1); } 41 @media (prefers-reduced-motion: reduce) { .lib-tile:active { transform:none; } } 42 /* incoming preview cells drawn just outside the visible 3Ã3 */ 43 .lib-buf { position:absolute; z-index:0; background:#e9ebee; border-radius:4px; overflow:hidden; 44 box-shadow:0 1px 2px rgba(0,0,0,0.07); pointer-events:none; } 45 .lib-buf::after { content:""; position:absolute; inset:0; border-radius:4px; pointer-events:none; 46 box-shadow:inset 0 0 0 0.5px rgba(0,0,0,0.06); z-index:4; } 47 .lib-grid.lib-dragging .lib-tile { z-index:1; } 48 } 49 50 /* ââ full-screen video browsing viewer ââ */ 51 .lib-viewer { position:fixed; inset:0; z-index:10000; background:#000; overflow:hidden; touch-action:none; animation:libIn .16s ease; 52 /* uniform control position for every film (no per-aspect shift) */ 53 --ctl-top: calc(max(env(safe-area-inset-top), 20px) + 72px); } 54 @keyframes libIn { from { opacity:0; } to { opacity:1; } } 55 .lib-stage { position:absolute; inset:0; transition:transform .13s cubic-bezier(.2,0,0,1); cursor:pointer; background:#000; } 56 .lib-media { position:absolute; inset:0; width:100%; height:100%; border:0; background:#000; z-index:1; } 57 video.lib-media, img.lib-media { object-fit:contain; } 58 /* Preview poster and the live video share ONE frame: object-fit:contain + 59 centred, matching how the Vimeo iframe centres the film â so there is no 60 vertical jump or reposition when playback replaces the poster in place. */ 61 @media (max-width:767px) { video.lib-media, img.lib-media { object-position:center center; } } 62 .lib-pause { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:3; width:66px; height:66px; border-radius:999px; 63 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; } 64 .lib-pause.show { opacity:1; } 65 .lib-x { position:absolute; top:var(--ctl-top); right:calc(12px + env(safe-area-inset-right)); z-index:6; 66 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; 67 box-shadow:0 0 0 1px rgba(255,255,255,0.14), 0 2px 10px rgba(0,0,0,0.3); transition:background .2s; } 68 .lib-x:hover { background:rgba(0,0,0,0.66); } 69 .lib-brand { position:absolute; top:var(--ctl-top); left:calc(16px + env(safe-area-inset-left));
69 z-index:6; 70 height:22px; width:auto; filter:brightness(0) invert(1); opacity:.95; pointer-events:none; } 71 .lib-mute { position:absolute; top:calc(var(--ctl-top) + 52px); right:calc(12px + env(safe-area-inset-right)); bottom:auto; 72 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; } 73 .lib-book { position:absolute; top:calc(var(--ctl-top) + 104px); right:calc(12px + env(safe-area-inset-right)); z-index:6; 74 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; } 75 .lib-fs { position:absolute; top:calc(var(--ctl-top) + 156px); right:calc(12px + env(safe-area-inset-right)); z-index:6; 76 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; } 77 .lib-meta { position:absolute; left:calc(16px + env(safe-area-inset-left)); z-index:5; 78 bottom:calc(96px + env(safe-area-inset-bottom)); 79 display:flex; flex-direction:column; gap:2px; max-width:66vw; text-shadow:0 1px 8px rgba(0,0,0,0.7); pointer-events:none; } 80 .lib-metaname { font:600 15px/1.25 var(--font-sans); color:#fff; letter-spacing:-0.01em; } 81 .lib-metaloc { font:400 12.5px/1.3 var(--font-sans); color:rgba(255,255,255,0.78); } 82 /* right action rail â IG/FB vocabulary with a Sayso pink accent on Like */ 83 .lib-rail-actions { position:absolute; right:calc(10px + env(safe-area-inset-right)); 84 bottom:calc(96px + env(safe-area-inset-bottom)); z-index:6; 85 display:flex; flex-direction:column; align-items:center; gap:22px; pointer-events:auto; } 86 .lib-act { display:flex; flex-direction:column; align-items:center; gap:4px; background:transparent; border:0; 87 padding:0; cursor:pointer; color:#fff; } 88 .lib-act-btn { width:46px; height:46px; border-radius:999px; background:rgba(0,0,0,0.42); backdrop-filter:blur(6px); 89 border:0; display:flex; align-items:center; justify-content:center; color:#fff; cursor:pointer; transition:transform .15s ease, background .2s; } 90 .lib-act:hover .lib-act-btn { background:rgba(0,0,0,0.6); transform:scale(1.05); } 91 .lib-act.liked .lib-act-btn { color:var(--brand-600); } 92 .lib-act.liked .lib-act-btn svg { fill:var(--brand-600); stroke:var(--brand-600); } 93 .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); } 94 .lib-nav { position:absolute; z-index:4; width:48px; height:48px; border-radius:999px; border:0; 95 background:rgba(0,0,0,0.56); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; cursor:pointer; 96 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; } 97 .lib-nav:hover { background:rgba(0,0,0,0.72); transform:scale(1.06); } 98 .lib-nav.up { top:calc(50% - 54px); right:calc(11px + env(safe-area-inset-right)); bottom:auto; left:auto; } 99 .lib-nav.down { top:calc(50% + 6px); right:calc(11px + env(safe-area-inset-right)); bottom:auto; left:auto; } 100 .lib-rail { position:absolute; right:12px; top:50%; transform:translateY(-50%); z-index:4; display:flex; flex-direction:column; align-items:center; gap:6px; } 101 .lib-dot { width:5px; height:5px; border-radius:999px; background:rgba(255,255,255,0.32); transition:height .25s, background .25s; } 102 .lib-dot.on { background:#fff; height:15px; } 103 .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); } 104 .lib-bar i { position:absolute; left:0; width:100%; border-radius:2px; background:#fff; transition:top .25s; } 105 .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; 106 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; } 107 /* bold "Tap for sound" CTA â films start muted, so this is the loud invite to unmute */ 108 .lib-unmute-cta { position:absolute; left:50%; top:38%; bottom:auto; transform:translateX(-50%); z-index:7; 109 display:flex; align-items:center; gap:9px; height:48px; padding:0 22px; border:0; border-radius:999px; cursor:pointer; 110 background:#fff; color:#000; font:600 15px/1 var(--font-sans); letter-spacing:-0.01em; 111 box-shadow:0 8px 24px rgba(0,0,0,0.35); animation:libUnmute .28s cubic-bezier(.2,0,0,1); } 112 .lib-unmute-cta svg { flex:0 0 auto; } 113 .lib-unmute-cta:hover { background:#f0f0f0; } 114 @keyframes libUnmute { from { opacity:0; transform:translate(-50%,10px); } to { opacity:1; transform:translate(-50%,0); } } 115 @media (prefers-reduced-motion: reduce) { .lib-unmute-cta { animation:none; } } 116 @keyframes libHint { from { opacity:0; transform:translate(-50%,6px); } to { opacity:1; transform:translate(-50%,0); } } 117 @media (prefers-reduced-motion: reduce) { .lib-stage { transition:none; } .lib-viewer { animation:none; } } 118 `; 119 if (typeof document !== "undefined" && !document.getElementById("sayso-lib-css")) { 120 const s = document.createElement("style"); s.id = "sayso-lib-css"; s.textContent = css; document.head.appendChild(s); 121 } 122})(); 123 124function libVimeo(url) { 125 if (!url || url.indexOf("vimeo") < 0) return null; 126 const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)(?:\/([a-zA-Z0-9]+))?/); 127 return m ? { id: m[1], hash: m[2] || null } : null; 128} 129function libIsFile(url) { return !!url && /\.(mp4|webm|ogg|mov)(\?|#|$)/i.test(url); } 130function libPh(v) { return !v || v === "Add a film" || v === "Add location" || v === "Customer" || /^Film \d+$/.test(v); } 131 132// static poster face (used by grid tiles and by the drag preview cells) â no video 133function LibFace({ f }) { 134 const client = libPh(f.client) ? "" : f.client; 135 return ( 136 <React.Fragment> 137 <img className="lib-poster" src={f.img} alt="" loading="lazy" draggable="false" style={f.focus ? { objectPosition: f.focus } : undefined} />
138 <span className="lib-grad" aria-hidden="true"></span> 139 {(f.logo || client) && ( 140 <span className="lib-cap"> 141 {f.logo ? <img className="lib-caplogo" src={f.logo} alt="" /> : <span className="lib-capname">{client}</span>} 142 </span> 143 )} 144 </React.Fragment> 145 ); 146} 147 148// ââ one grid tile: poster always; muted looping <video> or Vimeo background 149// iframe autoplays independently once the tile has entered view ââ 150function LibTile({ f, onOpen, reduce, pos, moving }) { 151 const wrapRef = React.useRef(null); 152 const [failed, setFailed] = React.useState(false); 153 const vidRef = React.useRef(null); 154 const [near, setNear] = React.useState(false); 155 const [loaded, setLoaded] = React.useState(false); 156 const file = libIsFile(f.video); 157 const vp = !file ? libVimeo(f.video) : null; 158 // only auto-play films that are natively portrait/full-frame; wide (16:9) films 159 // show a static poster in the grid to avoid cropped/composite look 160 const canPlay = !f.wide; 161 React.useEffect(() => { 162 const el = wrapRef.current; if (!el) return; 163 const io = new IntersectionObserver((es) => { 164 es.forEach((e) => { setNear(e.isIntersecting); if (e.isIntersecting) setLoaded(true); }); 165 }, { rootMargin: "300px 0px", threshold: 0.05 }); 166 io.observe(el); 167 return () => io.disconnect(); 168 }, []); 169 React.useEffect(() => { 170 const v = vidRef.current; if (!v || !file) return; 171 if (near && !reduce) { const p = v.play(); if (p) p.catch(() => {}); } 172 else { v.pause(); } 173 }, [near, reduce, loaded, file]); 174 // Vimeo background previews are owned by the shared VimeoFilm controller; 175 // it swaps to the poster (via onFail) if the embed can't play, so the tile 176 // never shows Vimeo's "Player error" UI. 177 const client = libPh(f.client) ? "" : f.client; 178 // per-film framing: `focus` (e.g. "50% 18%") keeps the face centred; falls 179 // back to the face-safe default in CSS when a film has no explicit framing 180 const fpos = f.focus ? { objectPosition: f.focus } : undefined; 181 return ( 182 <button className="lib-tile" type="button" ref={wrapRef} data-pos={pos} data-move={moving ? "1" : undefined} 183 onClick={onOpen} aria-label={client ? `Watch the ${client} film` : "Watch film"}> 184 <img className="lib-poster" src={f.img} alt="" loading="lazy" draggable="false" style={fpos} /> 185 {loaded && !reduce && file && canPlay && ( 186 <video ref={vidRef} className="lib-vid" src={f.video} poster={f.img} muted loop playsInline autoPlay preload="auto" style={fpos} /> 187 )} 188 {loaded && !reduce && vp && canPlay && !failed && ( 189 <window.VimeoFilm background video={f.video} poster={f.img} className="lib-vid" posterStyle={fpos} active={near} onFail={() => setFailed(true)} /> 190 )} 191 <span className="lib-grad" aria-hidden="true"></span> 192 {(f.logo || client) && ( 193 <span className="lib-cap"> 194 {f.logo ? <img className="lib-caplogo" src={f.logo} alt="" /> : <span className="lib-capname">{client}</span>} 195 </span> 196 )} 197 </button> 198 ); 199} 200 201// ââ the mobile "Customer films" 3Ã3 matrix. 202// A clean 3-col à 3-row grid at rest (visually identical to before). On mobile 203// (â¤767px) each ROW can be dragged left/right and each COLUMN up/down (the 204// latter after a ~200ms press-and-hold, so a plain vertical swipe still scrolls 205// the page). Released lines snap to a valid grid position; incoming cells are 206// drawn from films not currently visible so the 9 cells are always unique. 207// Desktop never mounts this component (it renders FilmReel instead), so no 208// desktop / video-player code is touched. 209 210function FilmLibraryGrid({ films, onOpen }) { 211 const reduce = window.PC_REDUCE; 212 const pool = films || []; 213 const n = pool.length; 214 const gridRef = React.useRef(null); 215 const suppressRef = React.useRef(0); 216 const [matrix, setMatrix] = React.useState(() => Array.from({ length: Math.min(9, n) }, (_, i) => i)); 217 const [active, setActive] = React.useState(null); // {line,before,after,cw,strideX} â horizontal only 218 const recentRef = React.useRef([]); 219 const st = React.useRef({}).current; 220 221 // reset the matrix if the underlying film list changes (e.g. Film Manager edits) 222 React.useEffect(() => { 223 setMatrix(Array.from({ length: Math.min(9, pool.length) }, (_, i) => i)); 224 recentRef.current = []; 225 }, [pool]); 226 227 const canDrag = n > 9; 228 const DIR = 7; // px before a horizontal gesture locks a row 229 const SNAP_EASE = "cubic-bezier(.22,1,.36,1)"; // ease-out, no overshoot 230 const isMobileNow = () => (typeof matchMedia !== "undefined" ? matchMedia("(max-width:767px)").matches : true); 231 // diminishing (rubber-band) resistance past the last available cell, so the 232 // edge feels soft instead of a hard wall fighting the finger 233 const rubber = (over, dim) => (1 - 1 / (over / dim * 0.55 + 1)) * dim; 234
235 // paint current horizontal offset directly onto the moving row (no re-render) 236 const paint = () => { 237 st.raf = 0; 238 const g = gridRef.current; if (!g) return; 239 if (!st.nodes || !st.nodes.length) st.nodes = Array.from(g.querySelectorAll('[data-move="1"]')); 240 const tf = "translate3d(" + st.off + "px,0,0)"; 241 st.nodes.forEach((el) => { el.style.transition = "none"; el.style.willChange = "transform"; el.style.transform = tf; }); 242 }; 243 const schedule = () => { if (st.raf) return; st.raf = requestAnimationFrame(paint); }; 244 245 React.useLayoutEffect(() => { if (active) { st.nodes = null; paint(); } }, [active]); 246 247 // pick preview films: not currently visible, preferring films not shown recently 248 const pickBuffers = (mtx) => { 249 const vis = new Set(mtx); 250 const recentSet = new Set(recentRef.current); 251 const fresh = [], stale = []; 252 for (let i = 0; i < n; i++) { if (!vis.has(i)) (recentSet.has(i) ? stale : fresh).push(i); } 253 const avail = fresh.concat(recentRef.current.filter((i) => !vis.has(i)), stale); 254 // de-dupe while preserving order 255 const seen = new Set(), ordered = []; 256 for (const i of avail) { if (!seen.has(i)) { seen.add(i); ordered.push(i); } } 257 return { before: ordered.slice(0, 3), after: ordered.slice(3, 6) }; 258 }; 259 260 const geom = () => { 261 const g = gridRef.current; const cs = getComputedStyle(g); 262 const gapX = parseFloat(cs.columnGap) || 6; 263 const cw = (g.clientWidth - 2 * gapX) / 3, ch = cw * 4 / 3; 264 return { cw, ch, strideX: cw + gapX }; 265 }; 266 267 const lockRow = () => { 268 const gm = geom(); 269 const { before, after } = pickBuffers(matrix); 270 Object.assign(st, { 271 locked: "row", line: st.row, cw: gm.cw, strideX: gm.strideX, before, after, nodes: null, 272 }); 273 if (gridRef.current) gridRef.current.classList.add("lib-dragging"); 274 if (st.pointerCapture && gridRef.current && st.id != null) { 275 try { gridRef.current.setPointerCapture(st.id); } catch (e) {} 276 } 277 setActive({ line: st.row, before, after, cw: gm.cw, strideX: gm.strideX }); 278 }; 279 280 const begin = (x, y, tileEl, id, isMouse) => { 281 if (!canDrag || !isMobileNow()) return; 282 // a new touch mid-momentum: finalize the previous gesture at its nearest 283 // boundary first, so the grid is in a clean committed state before this one 284 if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; if (st.nodes) settle(); } 285 const pos = tileEl ? parseInt(tileEl.getAttribute("data-pos"), 10) : NaN; 286 if (isNaN(pos)) return; 287 Object.assign(st, { 288 go: true, id, pointerCapture: !!isMouse, sx: x, sy: y, 289 row: Math.floor(pos / 3), pos, locked: null, off: 0, 290 samples: [{ t: performance.now(), x, y }], nodes: null, 291 }); 292 }; 293 294 const move = (x, y) => { 295 if (!st.go) return false; 296 const dx = x - st.sx, dy = y - st.sy; 297 st.samples.push({ t: performance.now(), x, y }); 298 if (st.samples.length > 6) st.samples.shift(); 299 if (st.locked === "scroll") return false; 300 if (!st.locked) { 301 const ax = Math.abs(dx), ay = Math.abs(dy); 302 if (Math.max(ax, ay) < DIR) return false; 303 // horizontal-only: a clearly horizontal gesture grabs the row; anything 304 // else (including any vertical movement) is left to the page as scroll. 305 if (ax > ay) { lockRow(); } 306 else { st.locked = "scroll"; return false; } 307 } 308 const maxBefore = st.before.length, maxAfter = st.after.length; 309 const lo = -maxAfter * st.strideX, hi = maxBefore * st.strideX; 310 st.off = dx > hi ? hi + rubber(dx - hi, st.cw) : dx < lo ? lo - rubber(lo - dx, st.cw) : dx; 311 schedule(); 312 return true; // row locked â caller should preventDefault 313 }; 314 315 const velocity = () => { 316 const s = st.samples; if (!s || s.length < 2) return 0; 317 const last = s[s.length - 1]; let ref = s[0]; 318 for (let i = s.length - 2; i >= 0; i--) { if (last.t - s[i].t >= 40) { ref = s[i]; break; } ref = s[i]; } 319 const dt = last.t - ref.t; if (dt <= 0) return 0; 320 return (last.x - ref.x) / dt; 321 }; 322 323 const commit = (s) => { 324 if (st.nodes) st.nodes.forEach((el) => { el.style.transition = ""; el.style.transform = ""; el.style.willChange = ""; }); 325 if (gridRef.current) gridRef.current.classList.remove("lib-dragging"); 326 if (s !== 0) { 327 const { line, before, after } = st; 328 const idxs = [line * 3, line * 3 + 1, line * 3 + 2]; 329 const cur = idxs.map((k) => matrix[k]); 330 const strip = before.slice().reverse().concat(cur).concat(after); 331 const win = strip.slice(3 - s, 6 - s); 332 const next = matrix.slice(); 333 idxs.forEach((k, j) => { next[k] = win[j]; }); 334 const exited = cur.filter((id) => win.indexOf(id) < 0); 335 recentRef.current = exited.concat(recentRef.current.filter((id) => exited.indexOf(id) < 0)).slice(0, 12); 336 setMatrix(next); 337 } 338 setActive(null); 339 st.nodes = null; 340 }; 341 342 const finish = () => { 343 if (!st.go) return; 344 st.go = false;
345 if (st.pointerCapture && gridRef.current && st.id != null) { try { gridRef.current.releasePointerCapture(st.id); } catch (e) {} } 346 if (st.locked !== "row") { st.locked = null; return; } // tap / scroll 347 st.nodes = Array.from(gridRef.current.querySelectorAll('[data-move="1"]')); 348 st.v = velocity(); // px/ms at release, signed 349 if (reduce) { settle(); return; } // reduced-motion: skip inertia, align now 350 st.lastT = performance.now(); 351 runMomentum(); 352 }; 353 354 // ââ inertial momentum: integrate position from release velocity with 355 // frame-rate-independent friction, painting transform each frame; when the 356 // motion nearly stops (or hits the buffer edge) hand off to settle(). ââ 357 const FRICTION = 0.90; // per 16.67ms frame 358 const MIN_V = 0.05; // px/ms â below this, settle 359 const runMomentum = () => { 360 st.raf2 = requestAnimationFrame(() => { 361 const now = performance.now(); 362 const dt = Math.min(32, now - st.lastT); st.lastT = now; 363 st.off += st.v * dt; 364 st.v *= Math.pow(FRICTION, dt / 16.67); 365 const lo = -st.after.length * st.strideX, hi = st.before.length * st.strideX; 366 let edge = false; 367 if (st.off <= lo) { st.off = lo; st.v = 0; edge = true; } 368 else if (st.off >= hi) { st.off = hi; st.v = 0; edge = true; } 369 const tf = "translate3d(" + st.off + "px,0,0)"; 370 st.nodes.forEach((el) => { el.style.transition = "none"; el.style.willChange = "transform"; el.style.transform = tf; }); 371 if (Math.abs(st.v) < MIN_V || edge) { settle(); return; } 372 runMomentum(); 373 }); 374 }; 375 376 // final short alignment to the nearest valid tile boundary, then commit 377 const settle = () => { 378 if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; } 379 const stride = st.strideX; 380 let s = Math.round(st.off / stride); 381 s = Math.max(-st.after.length, Math.min(st.before.length, s)); 382 const target = s * stride; 383 const dist = Math.abs(target - st.off); 384 const dur = reduce ? 0 : Math.min(180, Math.max(90, (dist / stride) * 150)); 385 st.nodes = st.nodes || Array.from(gridRef.current.querySelectorAll('[data-move="1"]')); 386 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)"; }); 387 suppressRef.current = performance.now() + dur + 140; 388 st.locked = null; 389 setTimeout(() => commit(s), dur + 10); 390 }; 391 392 // ââ mouse (pointer) path â for desktop testing; touch handled natively below ââ 393 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); }; 394 const onPM = (e) => { if (e.pointerType === "touch") return; move(e.clientX, e.clientY); }; 395 const onPU = (e) => { if (e.pointerType === "touch") return; finish(); }; 396 397 // ââ touch path â native non-passive so we can cancel page scroll on row lock. 398 // A vertical (or ambiguous) gesture never locks, so the page scrolls normally. 399 React.useEffect(() => { 400 const g = gridRef.current; if (!g) return; 401 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); }; 402 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(); } }; 403 const onTE = () => finish(); 404 g.addEventListener("touchstart", onTS, { passive: true }); 405 g.addEventListener("touchmove", onTM, { passive: false }); 406 g.addEventListener("touchend", onTE, { passive: true }); 407 g.addEventListener("touchcancel", onTE, { passive: true }); 408 return () => { 409 g.removeEventListener("touchstart", onTS); 410 g.removeEventListener("touchmove", onTM); 411 g.removeEventListener("touchend", onTE); 412 g.removeEventListener("touchcancel", onTE); 413 if (st.raf2) { cancelAnimationFrame(st.raf2); st.raf2 = 0; } 414 }; 415 }); 416 417 // ââ one-time HORIZONTAL discovery cue ââââââââââââââââââââââââââââââââââââ 418 // Removed: the matrix now relies on the gesture itself for discoverability. 419 // The initial state is always a perfectly aligned, static 3Ã3 grid â no
420 // peek, no automatic or scroll-triggered movement, no onboarding animation. 421 422 const openAt = (poolIdx) => { if (performance.now() < suppressRef.current) return; onOpen(poolIdx); }; 423 424 const buffers = []; 425 if (active) { 426 const { line, before, after, cw, strideX } = active; 427 const ch = cw * 4 / 3; 428 const gap = Math.max(0, strideX - cw); // rowGap == columnGap in this grid 429 const rowTop = line * (ch + gap); 430 const mk = (fi, left, key) => buffers.push( 431 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] })) 432 ); 433 before.forEach((fi, j) => mk(fi, -(j + 1) * strideX, "bl" + fi)); 434 after.forEach((fi, j) => mk(fi, (3 + j) * strideX, "br" + fi)); 435 } 436 437 return ( 438 <div className="lib-grid" ref={gridRef} onPointerDown={canDrag ? onPD : undefined} onPointerMove={canDrag ? onPM : undefined} onPointerUp={canDrag ? onPU : undefined} onPointerCancel={canDrag ? onPU : undefined}> 439 {matrix.map((fi, pos) => { 440 const row = Math.floor(pos / 3); 441 const moving = active && row === active.line; 442 return <LibTile key={fi} f={pool[fi]} reduce={reduce} pos={pos} moving={moving} onOpen={() => openAt(fi)} />; 443 })} 444 {buffers} 445 </div> 446 ); 447} 448 449// ââ full-screen video browsing viewer (Reels-style) ââ 450function SaysoFilmViewer({ films, index, onIndex, onClose }) { 451 const n = films.length; 452 const cur = films[index]; 453 const [muted, setMuted] = React.useState(() => { // sound choice persists for the session; default muted for reliable autoplay 454 try { return sessionStorage.getItem("sayso_sound_on") !== "1"; } catch (e) { return true; } 455 }); 456 const setSound = React.useCallback((next) => { 457 setMuted(next); 458 try { sessionStorage.setItem("sayso_sound_on", next ? "0" : "1"); } catch (e) {} 459 }, []); 460 // Unmute is run DIRECTLY inside the tap handler (never from an effect) so iOS 461 // treats it as part of the user gesture. We flip the UI to "sound on" only 462 // after the player confirms; on failure the UI stays muted and the tap is 463 // safely retryable. Vimeo goes through the SDK player promise; native <video> 464 // unmutes on the element. One owner per surface â no competing commands. 465 const unmuteFromTap = React.useCallback((e) => { 466 if (e) e.stopPropagation(); 467 const p = vimeoPlayerRef.current; 468 const v = vidRef.current; 469 if (v) { try { v.muted = false; v.volume = 1; } catch (_) {} setSound(false); return; } 470 if (p) { 471 Promise.resolve() 472 .then(() => p.setMuted(false)) 473 .then(() => p.setVolume(1)) 474 .then(() => (p.getMuted ? p.getMuted() : false)) 475 .then((m) => { if (!m) setSound(false); }) // UI flips only when sound is REALLY on 476 .catch(() => { /* stay muted; retry-safe */ }); 477 return; 478 } 479 setSound(false); // no player yet (poster) â safe default 480 }, [setSound]); 481 const muteFromTap = React.useCallback((e) => { 482 if (e) e.stopPropagation(); 483 const p = vimeoPlayerRef.current; 484 const v = vidRef.current; 485 if (v) { try { v.muted = true; } catch (_) {} } 486 if (p) { try { p.setMuted(true).catch(() => {}); } catch (_) {} } 487 setSound(true); 488 }, [setSound]); 489 const [paused, setPaused] = React.useState(false); 490 const [hint, setHint] = React.useState(false); 491 const stageRef = React.useRef(null); 492 const dragY = React.useRef(0); 493 const dragRaf = React.useRef(0); 494 const paintStage = () => { dragRaf.current = 0; const el = stageRef.current; if (el) el.style.transform = "translateY(" + (dragY.current * 0.2) + "px)"; }; 495 const setDrag = (y) => { dragY.current = y; if (!dragRaf.current) dragRaf.current = requestAnimationFrame(paintStage); }; 496 React.useEffect(() => () => { if (dragRaf.current) cancelAnimationFrame(dragRaf.current); }, []); 497 const [liked, setLiked] = React.useState(false); 498 const start = React.useRef(null); 499 const swiped = React.useRef(false); 500 const vidRef = React.useRef(null); 501 const iframeRef = React.useRef(null); 502 const vimeoPlayerRef = React.useRef(null); 503 const wheelLock = React.useRef(false); 504 const go = React.useCallback((d) => { setPaused(false); onIndex((index + d + n) % n); }, [index, n, onIndex]); 505 506 React.useEffect(() => { 507 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) {} 508 }, []); 509 React.useEffect(() => { 510 const onKey = (e) => { 511 if (e.key === "Escape") onClose(); 512 else if (e.key === "ArrowDown" || e.key === "ArrowRight") go(1); 513 else if (e.key === "ArrowUp" || e.key === "ArrowLeft") go(-1); 514 }; 515 window.addEventListener("keydown", onKey); 516 return () => window.removeEventListener("keydown", onKey); 517 }, [go, onClose]); 518 // lock scroll while open; restore exact scroll position on close 519 React.useEffect(() => { 520 const y = window.scrollY; const b = document.body; 521 const prev = { overflow: b.style.overflow, position: b.style.position, top: b.style.top, width: b.style.width }; 522 b.style.overflow = "hidden"; b.style.position = "fixed"; b.style.top = -y + "px"; b.style.width = "100%"; 523 return () => { 524 b.style.overflow = prev.overflow; b.style.position = prev.position; 525 b.style.top = prev.top; b.style.width = prev.width; 526 window.scrollTo(0, y); 527 }; 528 }, []); 529 // play the current film; keep the session sound choice â if unmuted autoplay is 530 // momentarily blocked on the new element, play muted then restore sound so the 531 // preference never silently resets between films 532 React.useEffect(() => { 533 const v = vidRef.current; if (!v) return; 534 v.currentTime = 0; v.muted = muted; 535 const p = v.play(); 536 if (p) p.catch(() => { 537 if (!muted) { v.muted = true; v.play().then(() => { try { v.muted = false; } catch (e) {} }).catch(() => {}); } 538 else { v.play().catch(() => {}); } 539 }); 540 }, [index]); 541 const vp = libVimeo(cur.video); 542 const file = libIsFile(cur.video); 543 React.useEffect(() => { vimeoPlayerRef.current = null; }, [index]); 544 // Phase-1 preload: once the CURRENT film has started, warm ONLY the next film 545 // (never the previous) so the Next transition feels faster. Best-effort; it
546 // never delays or competes with current playback. 547 const prefetchNext = React.useCallback(() => { 548 if (n < 2 || !window.saysoPrefetchFilm) return; 549 window.saysoPrefetchFilm(films[(index + 1) % n]); 550 }, [films, index, n]); 551 // Opening the viewer takes playback priority: the gate suspends/destroys every 552 // background grid preview while the viewer is open, and restores them on close. 553 React.useEffect(() => { 554 if (window.saysoPreviewGate) window.saysoPreviewGate.setForeground(true); 555 return () => { if (window.saysoPreviewGate) window.saysoPreviewGate.setForeground(false); }; 556 }, []); 557 // Native device fullscreen â Vimeo via the SDK, direct files via the element. 558 const goFullscreen = () => { 559 const v = vidRef.current; 560 if (v) { 561 const fn = v.requestFullscreen || v.webkitEnterFullscreen || v.webkitRequestFullscreen; 562 if (fn) { try { const r = fn.call(v); if (r && r.catch) r.catch(() => {}); } catch (e) {} } 563 return; 564 } 565 const p = vimeoPlayerRef.current; 566 if (p && p.requestFullscreen) { try { p.requestFullscreen().catch(() => {}); } catch (e) {} } 567 }; 568 // Vimeo playback (mute/unmute, play/pause, poster fallback) is owned by the 569 // shared SDK-only VimeoFilm controller â we just pass state down. `paused` 570 // resets on every film switch (see go()). 571 const togglePlay = () => { 572 const v = vidRef.current; 573 if (v) { if (v.paused) { v.play(); setPaused(false); } else { v.pause(); setPaused(true); } return; } 574 if (vp) setPaused((p) => !p); 575 }; 576 577 const onTStart = (e) => { start.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; setDrag(0); }; 578 const onTMove = (e) => { 579 if (!start.current) return; 580 const dx = e.touches[0].clientX - start.current.x, dy = e.touches[0].clientY - start.current.y; 581 setDrag(Math.abs(dx) > Math.abs(dy) ? 0 : dy); 582 }; 583 const onTEnd = (e) => { 584 const s = start.current; start.current = null; setDrag(0); 585 if (!s) return; 586 const t = e.changedTouches[0]; const dx = t.clientX - s.x, dy = t.clientY - s.y; 587 const ax = Math.abs(dx), ay = Math.abs(dy); 588 if (Math.max(ax, ay) < 45) { swiped.current = false; return; } // a tap â let onClick handle play/pause 589 swiped.current = true; setTimeout(() => { swiped.current = false; }, 220); 590 setHint(false); 591 if (ay >= ax) go(dy < 0 ? 1 : -1); else go(dx < 0 ? 1 : -1); 592 }; 593 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); }; 594 const onStageClick = () => { if (swiped.current) return; togglePlay(); }; 595 596 const client = libPh(cur.client) ? "" : cur.client; 597 const loc = libPh(cur.loc) ? "" : cur.loc; 598 const neighbours = [(index + 1) % n, (index - 1 + n) % n]; 599 600 return ( 601 <div className="lib-viewer" onWheel={onWheel} onTouchStart={onTStart} onTouchMove={onTMove} onTouchEnd={onTEnd}> 602 <div className="lib-stage" ref={stageRef} onClick={onStageClick}> 603 {vp 604 ? <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)} /> 605 : file 606 ? <video ref={vidRef} className="lib-media" src={cur.video} poster={cur.img} muted={muted} loop playsInline autoPlay preload="auto" onPlaying={prefetchNext} /> 607 : <img className="lib-media" src={cur.img} alt="" draggable="false" />} 608 <span className={"lib-pause" + (paused ? " show" : "")} aria-hidden="true"> 609 <svg width="26" height="26" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg> 610 </span> 611 </div> 612 613 {/* preload only next & prev (direct files) */} 614 {neighbours.map((k) => libIsFile(films[k].video) && !libVimeo(films[k].video) 615 ? <video key={k} src={films[k].video} preload="auto" muted style={{ display: "none" }} /> : null)} 616 617 {(client || loc) && ( 618 <div className="lib-meta"> 619 {client && <span className="lib-metaname">{client}</span>}
620 {loc && <span className="lib-metaloc">Filmed in {loc}</span>} 621 </div> 622 )} 623 624 <button className="lib-x" type="button" aria-label="Close" onClick={onClose}> 625 <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> 626 </button> 627 628 <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)))}> 629 <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> 630 </button> 631 632 <button className="lib-fs" type="button" aria-label="Play fullscreen" onClick={(e) => { e.stopPropagation(); goFullscreen(); }}> 633 <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> 634 </button> 635 636 <button className="lib-nav up" type="button" aria-label="Previous film" onClick={() => go(-1)}> 637 <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> 638 </button> 639 <button className="lib-nav down" type="button" aria-label="Next film" onClick={() => go(1)}> 640 <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> 641 </button> 642 643 {/* subtle "more films" indicator */} 644 645 <button className="lib-mute" type="button" aria-label={muted ? "Unmute" : "Mute"} onClick={muted ? unmuteFromTap : muteFromTap}> 646 {muted 647 ? <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> 648 : <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>} 649 </button> 650 651 {/* bold Unmute CTA â films start muted for reliable mobile autoplay; this 652 invites sound with an unmissable tap target that disappears once on */} 653 {muted && ( 654 <button className="lib-unmute-cta" type="button" onClick={unmuteFromTap}> 655 <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> 656 <span>Tap for sound</span> 657 </button> 658 )} 659 660 {hint && <div className="lib-hint">Swipe for more</div>} 661 </div> 662 ); 663} 664 665window.FilmLibraryGrid = FilmLibraryGrid; 666window.SaysoFilmViewer = SaysoFilmViewer;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.