// Direction F: Reel
// A production-company style showreel site: an edge-to-edge wall of looping
// video tiles with tiny credits, and the logo on a black top bar above the nav. Latest opens on a trailer of every film with
// a distressed SH / MOV logo that collapses into the bar as you scroll.
// Projects open full screen in a
// custom player. "Index" is a giant stacked list of titles; "About" flips to a
// stark white page with contact details, skills and the contact form.
//
// Routes live in the hash: #/ (latest), #/index, #/about, #/contact, #/project/<slug>.

// Full videos live on Cloudflare R2, served from videos.samharris.mov.
const F_VIDEO_BASE = 'https://videos.samharris.mov/';
// 6s muted loops for the tiles, kept in the repo.
const F_PREVIEW_BASE = 'Website/previews/';
// The showreel trailer for the top of Latest (4:3, silent web encodes of
// Website/videos/trailer.mp4), on R2 with the full videos. Phones get a lighter one.
const F_TRAILER_FILE = 'trailer.mp4';
const F_TRAILER_MOBILE_FILE = 'trailer-mobile.mp4';

// Contact form posts to the Cloudflare Pages Function in functions/contact.js.
const F_CONTACT_ENDPOINT = '/contact';

const F_SKILLS = [
  { name: 'videography', desc: 'capturing high-quality video content to communicate a clear message.' },
  { name: 'cinematography', desc: 'using composition, lighting, camera movement, and lens choice to create visually appealing and cinematic imagery.' },
  { name: 'video editing', desc: 'transforming raw footage into a polished final video through storytelling, pacing, music, and visual refinement.' },
  { name: 'directing', desc: 'overseeing the creative direction of a project, planning scenes, guiding talent, and ensuring the final vision comes together effectively.' },
  { name: 'sound design', desc: 'enhancing videos with music, sound effects, ambience, and audio mixing to create a more immersive viewing experience.' },
  { name: 'colour grading', desc: 'adjusting colour, contrast, and tone to create a unique visual style and mood, improving the overall look and feel of a video.' },
  { name: 'motion graphics', desc: 'creating simple animated text, graphics, and visual elements that help support storytelling and improve presentation.' },
];

// In the order they appear on Index (and left to right, top to bottom on Latest).
// desktopOnly: on the desktop Latest wall only, not on phones. Also left off
// Index unless inIndex is set. tallOnMobile: a taller (4:3) tile on phones.
const F_PROJECTS = [
  {
    slug: 'no-pictures', title: 'no pictures', kind: 'Music video', y: '2026', tallOnMobile: true,
    photo: 'Website/no pictures/no pictures.jpg', video: 'No Pictures.mp4',
  },
  {
    slug: 'man-drops-cigarette', title: 'man drops cigarette', kind: 'Short film', y: '2026', tallOnMobile: true,
    photo: 'Website/man drops cigarette/man drops cigarette.jpg', video: 'Man Drops Cigarette.mp4',
  },
  {
    slug: 'red-set', title: 'red set', kind: 'DJ set', y: '2026',
    photo: 'Website/red set/red set.jpg', video: 'Red Set.mp4',
  },
  {
    slug: 'in-my-dreams', title: 'in my dreams', kind: 'Music video', y: '2026', tallOnMobile: true,
    photo: 'Website/in my dreams/in my dreams.jpg', video: 'In My Dreams.mp4',
  },
  {
    slug: 'wedding', title: 'wedding', kind: 'Wedding film', y: '2026',
    photo: 'Website/canada lodge/canada lodge.jpg', video: 'Canada Lodge.mp4',
  },
  {
    slug: 'skate', title: 's.k.a.t.e', kind: 'Mini documentary', y: '2026',
    photo: 'Website/s.k.a.t.e/skate.jpg', video: 'S.K.A.T.E.mp4',
  },
  {
    slug: 'cardiff', title: 'cardiff', kind: 'Edit', y: '2025',
    photo: 'Website/cardiff/cardiff.jpg', video: 'cardiff.mp4',
  },
  {
    slug: 'fun-fare', title: 'fun fare', kind: 'Edit', y: '2026',
    photo: 'Website/fun fare/fun fare.jpg', video: 'Fun Fare.mp4',
  },
  {
    slug: 'cocktail-hour', title: 'cocktail hour', kind: 'Social media ad', y: '2026', portrait: true, desktopOnly: true, inIndex: true,
    photo: 'Website/cocktail hour/cocktail hour.jpg', video: 'Cocktail Hour.mp4',
  },
  {
    slug: 'volkswagen-up', title: 'volkswagen up', kind: 'Edit', y: '2025', desktopOnly: true, inIndex: true,
    photo: 'Website/cerys car/cerys car.jpg', video: 'Cerys Car.mp4',
  },
];

// The wall. Each row is a 12-column strip with a fixed aspect ratio; tiles
// span columns.
// Ordered so each tile's colours contrast with the tiles touching it
// (warm next to cool, bright next to dark).
// Row shapes vary (full width, 50/50, narrow portrait + wide) so the wall
// doesn't read as a stack of identical bricks.
const F_ROWS = [
  { ar: 32 / 9, tiles: [['no-pictures', 6], ['man-drops-cigarette', 6]] },
  { ar: 2.39, tiles: [['red-set', 12]] },
  { ar: 12 / (8 * 9 / 16), tiles: [['in-my-dreams', 8], ['wedding', 4]] },
  { ar: 32 / 9, tiles: [['skate', 6], ['cardiff', 6]] },
  { ar: 16 / 9, tiles: [['fun-fare', 12]] },
  // desktop only
  { ar: 12 / (3 * 16 / 9), tiles: [['cocktail-hour', 3], ['volkswagen-up', 9]] },
];

const fBySlug = (slug) => F_PROJECTS.find((p) => p.slug === slug);
const fUrl = (path) => encodeURI(path);
// Smaller copies of the cover photos for phones, named by slug.
const F_THUMB_BASE = 'Website/thumbnails/';
const F_MOBILE_MQ = '(max-width: 768px)';
const fThumb = (p) => encodeURI(F_THUMB_BASE + p.slug + '.jpg');
const fPreview = (p) => encodeURI(F_PREVIEW_BASE + p.slug + '.mp4') + '?v=2';

// One 1080p mp4 per video, the same file on desktop and mobile.
const fVideoSrc = (p) => encodeURI(F_VIDEO_BASE + p.video);

// Local copy of the same encode, used when R2 doesn't have the file yet
// (e.g. testing a new video before it's uploaded). Not deployed.
const fLocalVideoSrc = (p) => encodeURI('Website/MP4/' + p.video);

function useFMedia(q) {
  const [m, setM] = React.useState(() => window.matchMedia(q).matches);
  React.useEffect(() => {
    const mq = window.matchMedia(q);
    const on = (e) => setM(e.matches);
    mq.addEventListener('change', on);
    return () => mq.removeEventListener('change', on);
  }, [q]);
  return m;
}

// Current route from the hash: { view: 'latest' | 'index' | 'about' | 'project', slug }.
function fRoute() {
  const h = window.location.hash.replace(/^#\/?/, '');
  if (h.startsWith('project/')) return { view: 'project', slug: h.slice(8) };
  if (h === 'index' || h === 'about' || h === 'contact') return { view: h };
  return { view: 'latest' };
}
const fGo = (path) => { window.location.hash = '/' + path; };

function useFRoute() {
  const [r, setR] = React.useState(fRoute);
  React.useEffect(() => {
    const on = () => setR(fRoute());
    window.addEventListener('hashchange', on);
    return () => window.removeEventListener('hashchange', on);
  }, []);
  return r;
}

// Cardiff local time, ticking.
function useFClock(seconds) {
  const fmt = React.useMemo(() => new Intl.DateTimeFormat('en-GB', {
    timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', second: seconds ? '2-digit' : undefined,
  }), [seconds]);
  const [now, setNow] = React.useState(() => fmt.format(new Date()));
  React.useEffect(() => {
    const id = setInterval(() => setNow(fmt.format(new Date())), seconds ? 1000 : 10000);
    return () => clearInterval(id);
  }, [fmt, seconds]);
  return now;
}

const fTime = (s) => {
  if (!isFinite(s)) s = 0;
  const m = Math.floor(s / 60), r = Math.floor(s % 60);
  return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
};

const F_CSS = `
.rl {
  --bg: #060309;
  --fg: #fff;
  --dim: #8b8790;
  --line: rgba(255,255,255,0.14);
  --pill: rgba(40,38,44,0.72);
  --pill-on: #d9d7dc;
  --red: #e8262b;
  --sans: "Archivo", "Helvetica Neue", Arial, sans-serif;
  min-height: 100vh; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 16px; line-height: 1.4;
  -webkit-font-smoothing: antialiased;
}
.rl *, .rl *::before, .rl *::after { box-sizing: border-box; }
.rl a { color: inherit; }
.rl button { font: inherit; color: inherit; }
.rl-caps { font-weight: 700; font-size: 12px; line-height: 1.25; letter-spacing: 0.01em; text-transform: uppercase; }

/* ---------- chrome ---------- */
.rl { --bar: 110px; --bar-min: 48px; --mark-top: 12px; padding-top: var(--bar); }
.rl--latest { padding-top: var(--bar-min); }
.rl--latest .rl-hero { max-height: calc(100svh - var(--bar-min)); }
.rl-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 60; height: var(--bar); display: flex; align-items: flex-end; justify-content: center; padding: 0 24px 8px; background-color: #000; transition: background-color 300ms ease; }
.rl-bar::before { content: ""; position: absolute; inset: 0 0 -24px; z-index: -1; background: linear-gradient(rgba(0,0,0,0.45), transparent); opacity: 0; transition: opacity 300ms ease; pointer-events: none; }
.rl-bar.is-clear { background-color: transparent; }
.rl-bar.is-clear::before { opacity: 1; }
.rl-nav { display: flex; align-items: center; gap: 4px; }
.rl-nav a { display: flex; align-items: center; height: 32px; padding: 0 12px; border-radius: 999px; text-decoration: none; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.01em; white-space: nowrap; transition: background 160ms ease, color 160ms ease; }
.rl-nav a:hover, .rl-nav a:focus-visible { background: rgba(255,255,255,0.14); outline: none; }
.rl-nav a[aria-current="page"] { background: var(--pill-on); color: var(--bg); }

/* SH / MOV logo: fixed, placed and scaled from JS on scroll */
.rl-mark { position: fixed; top: 0; left: 0; z-index: 61; display: block; transform-origin: 50% 50%; will-change: transform; }
.rl-mark img { display: block; height: clamp(180px, 30vw, 320px); width: auto; filter: drop-shadow(0 2px 18px rgba(0,0,0,0.35)); }
.rl-mark:focus-visible { outline: 2px solid #fff; outline-offset: 6px; }

/* trailer hero */
.rl-hero { position: relative; width: 100%; aspect-ratio: 16 / 9; max-height: calc(100svh - var(--bar)); overflow: hidden; background: #000; }
.rl-hero video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; transform-origin: 50% 50%; will-change: transform; }
.rl-hero::after { content: ""; position: absolute; inset: 0; background: radial-gradient(ellipse at center, rgba(0,0,0,0.05) 35%, rgba(0,0,0,0.55)); pointer-events: none; }
.rl-hero__label { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; display: flex; justify-content: space-between; gap: 16px; text-shadow: 0 1px 8px rgba(0,0,0,0.4); }
.rl-hero__label span:last-child { color: rgba(255,255,255,0.72); }

/* ---------- the wall ---------- */
.rl-wall { display: block; }
.rl-row { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); width: 100%; aspect-ratio: var(--ar); max-height: 100svh; }
.rl-tile { position: relative; display: block; overflow: hidden; background: #111; text-decoration: none; color: #fff; outline: none; }
.rl-tile video, .rl-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.rl-tile img { transition: opacity 600ms ease; }
.rl-tile.is-playing img { opacity: 0; }
.rl-tile::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.45), transparent 32%); pointer-events: none; }
.rl-tile__label { position: absolute; left: 16px; bottom: 14px; z-index: 3; text-shadow: 0 1px 8px rgba(0,0,0,0.35); }
.rl-tile__label b { display: block; }
.rl-tile__label span { display: block; color: rgba(255,255,255,0.72); }



/* ---------- footer ---------- */
.rl-foot { padding: 28px 24px calc(40px + env(safe-area-inset-bottom)); }
.rl-cta { display: inline-block; text-decoration: none; font-weight: 800; text-transform: uppercase; letter-spacing: -0.015em; line-height: 0.98; font-size: clamp(36px, 5.4vw, 76px); }
.rl-cta span { display: block; color: #3d3a42; transition: color 220ms ease; }
.rl-cta:hover span, .rl-cta:focus-visible span { color: var(--fg); }
.rl-cta:focus-visible { outline: none; }
.rl-foot__row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 28px; margin-top: 64px; font-size: 13px; }
.rl-foot__row:first-child { margin-top: 0; }
.rl-foot__row div { display: flex; flex-wrap: wrap; gap: 8px 24px; }
.rl-foot__row a { text-decoration: none; }
.rl-foot__row a:hover { text-decoration: underline; }

/* ---------- index ---------- */
.rl-index { position: relative; min-height: calc(100svh - var(--bar)); padding: 40px 24px 120px; }
.rl-index__bg { position: fixed; inset: 0; z-index: 0; opacity: 0; transition: opacity 400ms ease; pointer-events: none; }
.rl-index__bg.is-on { opacity: 0.5; }
.rl-index__bg video { width: 100%; height: 100%; object-fit: cover; }
.rl-index__list { position: relative; z-index: 1; list-style: none; margin: 0; padding: 0; }
.rl-index__list a { display: inline-flex; align-items: baseline; gap: 18px; text-decoration: none; font-weight: 800; text-transform: uppercase; letter-spacing: -0.015em; line-height: 1.02; font-size: clamp(34px, 4.2vw, 60px); transition: color 200ms ease; }
.rl-index__list.is-hovering a { color: #3a3740; }
.rl-index__list.is-hovering a:hover, .rl-index__list a:focus-visible { color: var(--fg); outline: none; }

/* ---------- about ---------- */
.rl-about { background: var(--bg); }

/* about: same layout as contact */
.rl-ab__body { display: grid; gap: 72px; align-content: start; }
.rl-ab__body p { max-width: 34ch; margin: 0; font-size: clamp(20px, 1.9vw, 26px); line-height: 1.45; letter-spacing: -0.01em; }
.rl-ab__body p span { color: var(--dim); }
.rl-ab__bio { display: grid; gap: 1em; }
.rl-ab__bio p { max-width: 46ch; font-size: clamp(18px, 1.5vw, 21px); }
.rl-skills { display: grid; gap: 12px; }
.rl-skills span { color: var(--dim); }
.rl-skills ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; list-style: none; margin: 0; padding: 0; }
.rl-skills li { padding: 14px 0; border-top: 1px solid var(--line); font-size: 17px; }

/* contact */
.rl-contact { padding: 56px 24px 96px; }
.rl-contact h1 { margin: 0 0 56px; font-weight: 800; text-transform: uppercase; letter-spacing: -0.015em; line-height: 1.02; font-size: clamp(34px, 4.2vw, 60px); }
.rl-contact__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 56px; }
.rl-direct { display: grid; gap: 22px; align-content: start; }
.rl-direct div { display: grid; gap: 4px; }
.rl-direct span { color: var(--dim); }
.rl-direct a, .rl-direct p { margin: 0; font-size: 18px; text-decoration: none; font-variant-numeric: tabular-nums; }
.rl-direct a:hover { text-decoration: underline; }
.rl-form { display: grid; gap: 28px; }
.rl-field { display: grid; gap: 8px; }
.rl-field span { color: var(--dim); }
.rl-field input, .rl-field textarea { width: 100%; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--fg); font: inherit; font-size: 18px; padding: 6px 0 10px; outline: none; transition: border-color 200ms ease; }
.rl-field textarea { resize: vertical; min-height: 110px; }
.rl-field input:focus, .rl-field textarea:focus { border-bottom-color: var(--fg); }
.rl-field input::placeholder, .rl-field textarea::placeholder { color: #4a4650; }
.rl-form__foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.rl .rl-send { cursor: pointer; border: 0; height: 38px; padding: 0 18px; border-radius: 999px; background: var(--pill-on); color: #000; font-weight: 800; font-size: 12px; text-transform: uppercase; letter-spacing: 0.01em; transition: background 160ms ease; }
.rl-send:hover:not(:disabled), .rl-send:focus-visible { background: #fff; outline: none; }
.rl-send:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px #fff; }
.rl-send:disabled { opacity: 0.6; cursor: wait; }
.rl-msg { margin: 0; font-size: 14px; color: var(--dim); }

/* ---------- project player ---------- */
.rl-proj { position: fixed; inset: 0; z-index: 80; background: #000; overflow-y: auto; -webkit-overflow-scrolling: touch; animation: rlIn 260ms ease; }
@keyframes rlIn { from { opacity: 0; } to { opacity: 1; } }
.rl-stage { position: relative; height: 100svh; background: #000; }
.rl-stage video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.rl-stage__load { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; background: rgba(0,0,0,0.35); pointer-events: none; }
.rl-stage__top { position: absolute; z-index: 3; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 12px; padding: 22px 24px; background: linear-gradient(to bottom, rgba(0,0,0,0.55), transparent); transition: opacity 300ms ease; }
.rl-back { display: flex; align-items: center; gap: 12px; text-decoration: none; font-weight: 700; font-size: 16px; text-transform: uppercase; letter-spacing: 0.005em; }
.rl-back svg { width: 22px; height: 22px; flex: none; }
.rl-back em { font-weight: 600; }
.rl-ctrls { position: absolute; z-index: 3; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 18px; padding: 24px 28px 30px; background: linear-gradient(to top, rgba(0,0,0,0.55), transparent); transition: opacity 300ms ease; }
.rl-stage.is-idle .rl-ctrls, .rl-stage.is-idle .rl-stage__top { opacity: 0; }
.rl-stage.is-idle { cursor: none; }
.rl-ic { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border: 0; padding: 0; background: none; cursor: pointer; border-radius: 6px; }
.rl-ic svg { width: 24px; height: 24px; }
.rl-ic:focus-visible { outline: 2px solid #fff; }
.rl-scrub { flex: 1; -webkit-appearance: none; appearance: none; height: 18px; background: transparent; cursor: pointer; }
.rl-scrub::-webkit-slider-runnable-track { height: 2px; background: linear-gradient(to right, #fff var(--p, 0%), rgba(255,255,255,0.3) var(--p, 0%)); }
.rl-scrub::-moz-range-track { height: 2px; background: rgba(255,255,255,0.3); }
.rl-scrub::-moz-range-progress { height: 2px; background: #fff; }
.rl-scrub::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -6px; border-radius: 50%; background: #fff; border: 0; }
.rl-scrub::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 0; }
.rl-scrub:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }
.rl-tc { flex: none; font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
.rl-hint { position: absolute; z-index: 3; left: 50%; bottom: 96px; transform: translateX(-50%); border: 1px solid #fff; background: rgba(0,0,0,0.4); border-radius: 999px; padding: 10px 18px; cursor: pointer; font-weight: 700; font-size: 12px; text-transform: uppercase; }
.rl-notes { padding: 56px 24px 40px; border-top: 1px solid var(--line); }
.rl-notes h2 { margin: 0 0 10px; font-weight: 800; text-transform: uppercase; font-size: clamp(32px, 3.6vw, 52px); line-height: 0.95; letter-spacing: -0.015em; overflow-wrap: anywhere; }
.rl-notes__meta { color: var(--dim); }
.rl-next { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.rl-next .rl-tile { aspect-ratio: 16 / 9; }

/* ---------- responsive ---------- */
@media (max-width: 900px) {
  .rl-contact__grid { grid-template-columns: 1fr; gap: 48px; }
}
@media (max-width: 768px) {
  .rl { --bar: 102px; --bar-min: 46px; --mark-top: 10px; }
  .rl--latest { padding-top: var(--bar); }
  .rl--latest .rl-hero { aspect-ratio: 4 / 3; max-height: calc(100svh - var(--bar)); }
  .rl-hero__label { left: 16px; right: 16px; }
  .rl-bar { padding: 0 8px 8px; }
  .rl-mark img { height: clamp(130px, 42vw, 190px); }
  .rl-row { display: block; aspect-ratio: auto; max-height: none; }
  .rl-row .rl-tile { aspect-ratio: 16 / 9; }
  .rl-row .rl-tile--portrait { aspect-ratio: 4 / 5; }
  .rl-row .rl-tile--tall { aspect-ratio: 4 / 3; }
  .rl-tile__label { left: 12px; bottom: 10px; }
  .rl-nav a { padding: 0 10px; height: 30px; font-size: 11px; }
  .rl-index { padding: 24px 16px 120px; }
  .rl-index__list { padding: 0; }
  .rl-index__list a { font-size: clamp(28px, 9vw, 44px); gap: 10px; }
  .rl-contact { padding: 32px 16px 64px; }
  .rl-contact h1 { margin-bottom: 36px; }
  .rl-ab__body { gap: 48px; }
  .rl-skills ul { grid-template-columns: 1fr; }
  .rl-stage__top { padding: 16px; }
  .rl-back { font-size: 13px; }
  .rl-ctrls { gap: 10px; padding: 16px 14px 22px; }
  .rl-next { grid-template-columns: 1fr; }
  .rl-foot { padding: 24px 16px calc(40px + env(safe-area-inset-bottom)); }
}
@media (prefers-reduced-motion: reduce) {
  .rl *, .rl *::before, .rl *::after { animation: none !important; transition-duration: 0ms !important; }
}
`;

// ---------- pieces ----------

const fReduce = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// A looping, muted preview that only loads and plays while it's on screen.
function Loop({ p, onPlaying }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const v = ref.current;
    if (!v || fReduce()) return;
    // iOS only autoplays inline video that is muted *as an attribute*, which
    // React doesn't write, so set it by hand.
    v.muted = true;
    v.setAttribute('muted', '');
    v.setAttribute('playsinline', '');
    let visible = false;
    // If the browser refuses (e.g. iPhone Low Power Mode), try again on the
    // first touch or scroll, which counts as the user asking.
    const retry = () => { if (visible) v.play().catch(() => {}); };
    const io = new IntersectionObserver(([e]) => {
      visible = e.isIntersecting;
      if (visible) {
        if (!v.src) v.src = fPreview(p);
        v.play().catch(() => {
          window.addEventListener('touchstart', retry, { once: true, passive: true });
          window.addEventListener('scroll', retry, { once: true, passive: true });
        });
      } else {
        v.pause();
      }
    }, { rootMargin: '200px 0px', threshold: 0.01 });
    io.observe(v);
    return () => {
      io.disconnect();
      window.removeEventListener('touchstart', retry);
      window.removeEventListener('scroll', retry);
    };
  }, [p]);
  return <video ref={ref} muted loop playsInline preload="none" aria-hidden="true" onPlaying={onPlaying} />;
}

// Tile cover photo: the thumbnail on phones, the full photo otherwise.
function Cover({ p }) {
  return (
    <picture>
      <source media={F_MOBILE_MQ} srcSet={fThumb(p)} />
      <img src={fUrl(p.photo)} alt="" loading="lazy" decoding="async" />
    </picture>
  );
}

function Tile({ p, span }) {
  const [playing, setPlaying] = React.useState(false);
  return (
    <a
      className={`rl-tile ${p.portrait ? 'rl-tile--portrait' : ''} ${p.tallOnMobile ? 'rl-tile--tall' : ''} ${playing ? 'is-playing' : ''}`}
      style={{ gridColumn: `span ${span}` }}
      href={`#/project/${p.slug}`}
      aria-label={`${p.title}, ${p.kind}, ${p.y}`}
    >
      <Loop p={p} onPlaying={() => setPlaying(true)} />
      <Cover p={p} />
      <span className="rl-tile__label rl-caps"><b>{p.title}</b><span>{p.kind} · {p.y}</span></span>
    </a>
  );
}

function Footer({ cta = true }) {
  return (
    <footer className="rl-foot">
      {cta && <a className="rl-cta" href="#/contact">
        Got a project in mind?<span>Tell me the plot</span>
      </a>}
      <div className="rl-foot__row">
        <div>
          <span>© 2026 sam harris · filmed in cardiff</span>
          <a href="#/about">About</a>
          <a href="#/contact">Contact</a>
          <a href="#/" onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>Scroll to top</a>
        </div>
        <div>
          <a href="mailto:sam@samharris.mov">Email</a>
        </div>
      </div>
    </footer>
  );
}

// The trailer starts letterboxed (black at the sides) and zooms in to fill
// the frame as you scroll, so it meets the edge-to-edge wall below it.
function Trailer() {
  const isMobile = useFMedia(F_MOBILE_MQ);
  const box = React.useRef(null);
  const ref = React.useRef(null);
  React.useEffect(() => {
    const v = ref.current;
    if (v && !fReduce()) v.play().catch(() => {});
  }, [isMobile]);
  React.useLayoutEffect(() => {
    const el = box.current, v = ref.current;
    const zoom = () => {
      const W = el.offsetWidth, H = el.offsetHeight;
      const vw = v.videoWidth || 4, vh = v.videoHeight || 3;
      const fill = Math.max(W / vw, H / vh) / Math.min(W / vw, H / vh);
      const p = Math.min(1, Math.max(0, window.scrollY / (H * 0.6)));
      const e = 1 - (1 - p) * (1 - p);
      v.style.transform = `scale(${1 + (fill - 1) * e})`;
    };
    zoom();
    v.addEventListener('loadedmetadata', zoom);
    window.addEventListener('scroll', zoom, { passive: true });
    window.addEventListener('resize', zoom);
    return () => {
      v.removeEventListener('loadedmetadata', zoom);
      window.removeEventListener('scroll', zoom);
      window.removeEventListener('resize', zoom);
    };
  }, []);
  return (
    <section className="rl-hero" ref={box} aria-label="Showreel trailer">
      <video ref={ref} src={F_VIDEO_BASE + (isMobile ? F_TRAILER_MOBILE_FILE : F_TRAILER_FILE)} muted loop playsInline preload="auto" aria-hidden="true" />
      <div className="rl-hero__label rl-caps"><span>Showreel · 2026</span><span>Trailer</span></div>
    </section>
  );
}

function Latest() {
  const isMobile = useFMedia(F_MOBILE_MQ);
  const rows = isMobile
    ? F_ROWS.filter((row) => row.tiles.some(([slug]) => !fBySlug(slug).desktopOnly))
    : F_ROWS;
  return (
    <main>
      <Trailer />
      <div className="rl-wall">
        {rows.map((row, i) => (
          <div key={i} className="rl-row" style={{ '--ar': row.ar }}>
            {row.tiles.map(([slug, span]) => <Tile key={slug} p={fBySlug(slug)} span={span} />)}
          </div>
        ))}
      </div>
      <Footer />
    </main>
  );
}

function IndexView() {
  const [hover, setHover] = React.useState(null);
  const bg = React.useRef(null);

  React.useEffect(() => {
    const v = bg.current;
    if (!v || !hover || fReduce()) return;
    v.src = fPreview(hover);
    v.play().catch(() => {});
  }, [hover]);

  return (
    <main className="rl-index">
      <div className={`rl-index__bg ${hover ? 'is-on' : ''}`} aria-hidden="true">
        <video ref={bg} muted loop playsInline />
      </div>
      <ul className={`rl-index__list ${hover ? 'is-hovering' : ''}`} onMouseLeave={() => setHover(null)}>
        {F_PROJECTS.filter((p) => !p.desktopOnly || p.inIndex).map((p) => (
          <li key={p.slug}>
            <a href={`#/project/${p.slug}`} onMouseEnter={() => setHover(p)} onFocus={() => setHover(p)}>
              {p.title}
            </a>
          </li>
        ))}
      </ul>
    </main>
  );
}

function ContactForm() {
  const [form, setForm] = React.useState({ name: '', email: '', message: '' });
  const [status, setStatus] = React.useState('idle');
  const time = useFClock(false);

  async function handleSubmit(e) {
    e.preventDefault();
    if (status === 'submitting') return;
    setStatus('submitting');
    try {
      const res = await fetch(F_CONTACT_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });
      if (res.ok) {
        setStatus('success');
        setForm({ name: '', email: '', message: '' });
      } else {
        setStatus('error');
      }
    } catch {
      setStatus('error');
    }
  }

  const field = (key) => ({ value: form[key], onChange: (e) => setForm({ ...form, [key]: e.target.value }) });

  return (
    <section className="rl-contact" aria-label="Contact">
      <h1>Got a project?<br />Get in touch.</h1>
      <div className="rl-contact__grid">
        <div className="rl-direct">
          <div><span className="rl-caps">Email</span><a href="mailto:sam@samharris.mov">sam@samharris.mov</a></div>
          <div><span className="rl-caps">Based in</span><p>cardiff, wales · {time}</p></div>
        </div>
        <form className="rl-form" onSubmit={handleSubmit}>
          <label className="rl-field"><span className="rl-caps">Name</span><input type="text" required autoComplete="name" spellCheck={false} {...field('name')} /></label>
          <label className="rl-field"><span className="rl-caps">Email</span><input type="email" required inputMode="email" autoComplete="email" autoCapitalize="off" autoCorrect="off" spellCheck={false} {...field('email')} /></label>
          <label className="rl-field"><span className="rl-caps">Message</span><textarea required rows={4} {...field('message')} /></label>
          <div className="rl-form__foot">
            <p className="rl-msg" role="status">
              {status === 'success' ? 'Thanks, i’ll reply soon.' : status === 'error' ? 'Something went wrong. Please email me directly instead.' : ''}
            </p>
            <button className="rl-send" type="submit" disabled={status === 'submitting'}>{status === 'submitting' ? 'Sending…' : 'Send'}</button>
          </div>
        </form>
      </div>
    </section>
  );
}

function About() {
  const time = useFClock(false);
  return (
    <main className="rl-about">
      <section className="rl-contact" aria-label="About">
        <h1>Director, editor<br />and colourist.</h1>
        <div className="rl-contact__grid">
          <div className="rl-direct">
            <div><span className="rl-caps">Based in</span><p>cardiff, wales · {time}</p></div>
            <div><span className="rl-caps">Since</span><p>2013</p></div>
            <div><span className="rl-caps">Training</span><p>self taught</p></div>
            <div><span className="rl-caps">Into</span><p>music, food, fashion and film</p></div>
          </div>
          <div className="rl-ab__body">
            <div className="rl-ab__bio">
              <p>I’m Sam. I focus my time on creating short films and music videos - but open to most things. I’m an entirely self taught videographer / cinematographer, colourist, director, editor, and all the other bits and bobs. I’m basically a one man army, and I love it.</p>
              <p>I’ve been editing videos for over 10 years, and shooting them for about 2, and after spending countless hours freelancing for other business promoting apps, energy efficiency schemes, restaurants, events and more I decided I wasn’t in it for the money - I was in it for the love of the game. That’s why I’ve recently decided to prioritise working on projects that I find exciting!</p>
              <p>If you’re looking to shoot, edit or need help directing a creative project, shoot me a message.</p>
            </div>
            <div className="rl-skills">
              <span className="rl-caps">What I do</span>
              <ul>{F_SKILLS.map((s) => <li key={s.name}>{s.name}</li>)}</ul>
            </div>
          </div>
        </div>
      </section>
      <Footer cta={false} />
    </main>
  );
}

function Contact() {
  return (
    <main>
      <ContactForm />
      <Footer cta={false} />
    </main>
  );
}

const FPlayIcon = {
  play: <svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round"><path d="M7 4.5v15l12-7.5z" /></svg>,
  pause: <svg viewBox="0 0 24 24" fill="#fff"><rect x="6.5" y="5" width="3.5" height="14" /><rect x="14" y="5" width="3.5" height="14" /></svg>,
  sound: <svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z" /><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10" /></svg>,
  muted: <svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z" /><path d="M16 10l4 4M20 10l-4 4" /></svg>,
  full: <svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinecap="round"><path d="M14 4h6v6M10 20H4v-6M20 4l-6.5 6.5M4 20l6.5-6.5" /></svg>,
  back: <svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4l-8 8 8 8" /></svg>,
};

function Project({ slug, from }) {
  const p = fBySlug(slug);
  const isMobile = useFMedia(F_MOBILE_MQ);
  const box = React.useRef(null);
  const stage = React.useRef(null);
  const v = React.useRef(null);
  const [playing, setPlaying] = React.useState(false);
  const [muted, setMuted] = React.useState(false);
  const [blocked, setBlocked] = React.useState(false);
  const [buffering, setBuffering] = React.useState(true);
  const [t, setT] = React.useState(0);
  const [d, setD] = React.useState(0);
  const [idle, setIdle] = React.useState(false);
  const idleT = React.useRef(null);

  // Step through the projects this device shows.
  const list = isMobile ? F_PROJECTS.filter((q) => !q.desktopOnly || q === p) : F_PROJECTS;
  const i = list.indexOf(p);
  const prev = list[(i - 1 + list.length) % list.length];
  const next = list[(i + 1) % list.length];
  const close = React.useCallback(() => fGo(from === 'latest' ? '' : from), [from]);

  // Lock the page behind, Escape closes, arrows step through projects, space toggles.
  React.useEffect(() => {
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    const k = (e) => {
      if (/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
      if (e.key === 'Escape') close();
      else if (e.key === 'ArrowRight' && e.shiftKey) fGo('project/' + next.slug);
      else if (e.key === 'ArrowLeft' && e.shiftKey) fGo('project/' + prev.slug);
      else if (e.key === ' ' && e.target === document.body) { e.preventDefault(); toggle(); }
    };
    document.addEventListener('keydown', k);
    return () => { document.body.style.overflow = prevOverflow; document.removeEventListener('keydown', k); };
  }, [close, next, prev]);

  // Try to start with sound; browsers that refuse get a muted start and a hint.
  React.useEffect(() => {
    const el = v.current;
    if (!el) return;
    box.current && box.current.scrollTo(0, 0);
    setBlocked(false); setBuffering(true); setT(0); setD(0);
    el.muted = false; setMuted(false);
    el.play().catch(() => {
      el.muted = true; setMuted(true); setBlocked(true);
      el.play().catch(() => {});
    });
  }, [slug]);

  const poke = () => {
    setIdle(false);
    clearTimeout(idleT.current);
    idleT.current = setTimeout(() => setIdle(true), 2600);
  };
  React.useEffect(() => () => clearTimeout(idleT.current), []);

  function toggle() {
    const el = v.current;
    if (!el) return;
    if (el.paused) el.play().catch(() => {}); else el.pause();
  }
  const toggleMute = () => { const el = v.current; el.muted = !el.muted; setMuted(el.muted); setBlocked(false); };
  const full = () => {
    const el = stage.current;
    if (document.fullscreenElement) document.exitFullscreen();
    else if (el.requestFullscreen) el.requestFullscreen();
    else if (v.current.webkitEnterFullscreen) v.current.webkitEnterFullscreen();
  };
  const pct = d ? (t / d) * 100 : 0;

  return (
    <div className="rl-proj" ref={box} role="dialog" aria-modal="true" aria-label={p.title}>
      <div ref={stage} className={`rl-stage ${idle && playing ? 'is-idle' : ''}`} onMouseMove={poke} onTouchStart={poke}>
        <video
          ref={v}
          src={fVideoSrc(p)}
          onError={(e) => {
            const local = fLocalVideoSrc(p);
            if (!e.target.src.endsWith(local)) { e.target.src = local; e.target.play().catch(() => {}); }
          }}
          poster={isMobile ? fThumb(p) : fUrl(p.photo)}
          playsInline
          preload="metadata"
          controlsList="nodownload"
          onClick={toggle}
          onPlay={() => { setPlaying(true); poke(); }}
          onWaiting={() => setBuffering(true)}
          onCanPlay={() => setBuffering(false)}
          onPlaying={() => setBuffering(false)}
          onPause={() => { setPlaying(false); setIdle(false); }}
          onTimeUpdate={(e) => setT(e.target.currentTime)}
          onLoadedMetadata={(e) => setD(e.target.duration)}
          onEnded={() => fGo('project/' + next.slug)}
        />
        <div className="rl-stage__top">
          <a className="rl-back" href={from === 'latest' ? '#/' : '#/' + from} aria-label="Back">
            {FPlayIcon.back}
            <span>{p.title} · {p.kind} <em>{p.y}</em></span>
          </a>
        </div>
        {buffering && <div className="rl-stage__load" role="progressbar" aria-label="Loading video"><span className="rl-logo-fill rl-logo-fill--loop"><i /></span></div>}
        {blocked && <button className="rl-hint" onClick={toggleMute}>Tap for sound</button>}
        <div className="rl-ctrls">
          <button className="rl-ic" onClick={toggle} aria-label={playing ? 'Pause' : 'Play'}>{playing ? FPlayIcon.pause : FPlayIcon.play}</button>
          <button className="rl-ic" onClick={toggleMute} aria-label={muted ? 'Unmute' : 'Mute'}>{muted ? FPlayIcon.muted : FPlayIcon.sound}</button>
          <input
            className="rl-scrub" type="range" min="0" max={d || 0} step="0.01" value={t} aria-label="Seek"
            style={{ '--p': pct + '%' }}
            onChange={(e) => { const s = +e.target.value; v.current.currentTime = s; setT(s); }}
          />
          <span className="rl-tc">{fTime(t)} / {fTime(d)}</span>
          <button className="rl-ic" onClick={full} aria-label="Full screen">{FPlayIcon.full}</button>
        </div>
      </div>

      <section className="rl-notes">
        <div>
          <h2>{p.title}</h2>
          <div className="rl-notes__meta rl-caps">{p.kind}</div>
        </div>
      </section>
      <div className="rl-next">
        <Tile p={prev} span={1} />
        <Tile p={next} span={1} />
      </div>
    </div>
  );
}

// On Latest the logo sits big in the middle of the trailer, then shrinks up
// into the top of the bar over the first ~two wheel notches of scrolling. The
// bar starts as just the nav row and grows to make room for it, then turns
// see-through once the trailer has scrolled away. Everywhere else, and on
// on phones, the logo is already parked in a full, solid bar.
const F_MARK_END = 44;
const F_MARK_SCROLL = 220;

function Mark({ view }) {
  const ref = React.useRef(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    const place = () => {
      const css = getComputedStyle(el.parentNode);
      const top = parseFloat(css.getPropertyValue('--mark-top')) || 10;
      const barMax = parseFloat(css.getPropertyValue('--bar')) || 96;
      const barMin = parseFloat(css.getPropertyValue('--bar-min')) || 48;
      const bar = document.querySelector('.rl-bar');
      const hero = view === 'latest' && !window.matchMedia(F_MOBILE_MQ).matches && document.querySelector('.rl-hero');
      const x = document.documentElement.clientWidth / 2;
      const end = F_MARK_END / el.offsetHeight;
      const endY = top + F_MARK_END / 2;
      let p = 1, startY = endY;
      if (hero) {
        startY = hero.offsetTop + hero.offsetHeight / 2;
        p = Math.min(1, Math.max(0, window.scrollY / F_MARK_SCROLL));
      }
      const e = 1 - (1 - p) * (1 - p);
      const y = startY - window.scrollY * (1 - e) + (endY - startY) * e;
      const sc = 1 + (end - 1) * e;
      el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%) scale(${sc})`;
      const h = hero ? barMin + (barMax - barMin) * e : barMax;
      bar.style.height = h + 'px';
      bar.classList.toggle('is-clear', !!hero && hero.offsetTop + hero.offsetHeight - window.scrollY <= h);
    };
    place();
    window.addEventListener('scroll', place, { passive: true });
    window.addEventListener('resize', place);
    if (document.fonts) document.fonts.ready.then(place);
    // Re-place when the layout settles (trailer or page size changes while loading).
    const ro = new ResizeObserver(() => place());
    ro.observe(document.body);
    const heroEl = document.querySelector('.rl-hero');
    if (heroEl) ro.observe(heroEl);
    return () => { window.removeEventListener('scroll', place); window.removeEventListener('resize', place); ro.disconnect(); };
  }, [view]);
  return (
    <a className="rl-mark" ref={ref} href="#/" aria-label="sam harris, home">
      <img src="Website/white.png" alt="" onLoad={() => window.dispatchEvent(new Event('resize'))} />
    </a>
  );
}

function Bar({ view }) {
  const cur = (v) => (view === v ? 'page' : undefined);
  return (
    <header className="rl-bar">
      <nav className="rl-nav" aria-label="Main">
        <a href="#/" aria-current={cur('latest')}>Latest</a>
        <a href="#/index" aria-current={cur('index')}>Index</a>
        <a href="#/about" aria-current={cur('about')}>About</a>
        <a href="#/contact" aria-current={cur('contact')}>Contact</a>
      </nav>
    </header>
  );
}

// Always open at the top so Latest starts on the letterboxed trailer, not a
// restored scroll position with it already zoomed in.
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';

// Drives the #boot logo loader from index.html: fills the logo as the images
// and the trailer on the first screen load, then fades it out. Gives up after
// F_BOOT_MAX so a slow file never holds the page.
const F_BOOT_MIN = 700;
const F_BOOT_MAX = 6000;

function useBootLoader() {
  React.useEffect(() => {
    const boot = document.getElementById('boot');
    if (!boot) return;
    const fill = boot.querySelector('i');
    const start = performance.now();
    let finished = false;
    let gone = false;

    const setP = (p) => { fill.style.animation = 'none'; fill.style.height = Math.round(p * 100) + '%'; };
    const finish = () => {
      if (finished) return;
      finished = true;
      setP(1);
      setTimeout(() => {
        boot.classList.add('is-done');
        setTimeout(() => { gone = true; boot.remove(); }, 700);
      }, Math.max(450, F_BOOT_MIN - (performance.now() - start)));
    };

    // On desktop Latest, sit the loader logo exactly over the big logo it hands
    // over to (the trailer's centre isn't the screen's centre). The page keeps
    // settling while it loads, so follow it every frame until the loader is gone.
    const logo = boot.querySelector('.rl-logo-fill');
    let follow = 0;
    const align = () => {
      if (gone) return;
      follow = requestAnimationFrame(align);
      // Target where Mark rests at the top of the page: centred on the trailer,
      // at the img's own height.
      const hero = document.querySelector('.rl-hero');
      const m = document.querySelector('.rl-mark img');
      if (!hero || !m || !m.offsetHeight || window.matchMedia(F_MOBILE_MQ).matches) { logo.style.cssText = ''; return; }
      // The loader logo stays centred in #boot; nudge it onto the trailer's centre.
      const r = hero.getBoundingClientRect(), o = boot.getBoundingClientRect();
      const h = m.offsetHeight, w = h * 1518 / 1728;
      const dx = r.left + r.width / 2 - (o.left + o.width / 2);
      const dy = r.top + r.height / 2 - (o.top + o.height / 2);
      Object.assign(logo.style, { width: w + 'px', height: h + 'px', transform: `translate(${dx}px, ${dy}px)` });
    };

    // Wait a frame so the first page has rendered, then collect what's on screen.
    const raf = requestAnimationFrame(() => {
      align();
      const onScreen = (el) => el.getBoundingClientRect().top < window.innerHeight;
      const imgs = [...document.querySelectorAll('.rl img')].filter(onScreen);
      const vids = [...document.querySelectorAll('.rl-hero video')];
      const total = imgs.length + vids.length;
      if (!total) return finish();
      let done = 0;
      const tick = () => { done++; setP(0.3 + 0.7 * (done / total)); if (done >= total) finish(); };
      imgs.forEach((img) => {
        if (img.complete) tick();
        else { img.addEventListener('load', tick, { once: true }); img.addEventListener('error', tick, { once: true }); }
      });
      vids.forEach((v) => {
        if (v.readyState >= 3) tick();
        else { v.addEventListener('canplay', tick, { once: true }); v.addEventListener('error', tick, { once: true }); }
      });
    });
    const cap = setTimeout(finish, F_BOOT_MAX);
    return () => { cancelAnimationFrame(raf); cancelAnimationFrame(follow); clearTimeout(cap); };
  }, []);
}

function DirectionF() {
  useBootLoader();
  const route = useFRoute();
  // The page under an open project stays whatever was showing before it.
  const [base, setBase] = React.useState(route.view === 'project' ? 'latest' : route.view);
  React.useEffect(() => {
    if (route.view !== 'project') {
      setBase(route.view);
      window.scrollTo({ top: 0, behavior: 'instant' });
    }
  }, [route.view]);

  const project = route.view === 'project' && fBySlug(route.slug) ? route.slug : null;

  return (
    <div className={`rl ${base === 'latest' ? 'rl--latest' : ''}`}>
      <style>{F_CSS}</style>
      <Bar view={project ? base : route.view} />
      {base === 'index' ? <IndexView /> : base === 'about' ? <About /> : base === 'contact' ? <Contact /> : <Latest />}
      <Mark view={base} />
      {project && <Project key="proj" slug={project} from={base} />}
    </div>
  );
}

window.DirectionF = DirectionF;
