patx/voldservices

(function () {
'use strict';
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.getElementById('hero-sticky')) return;
      /* ── cinematic hero scroll ────────────────────────────────
         The section supplies the scroll distance; its sticky child remains
         pinned while these values interpolate from the user's real scroll.
         No scroll is captured or eased, so trackpads and touch keep their
         native feel. */
      var heroCinematic = document.getElementById('top');
      var heroSticky = document.getElementById('hero-sticky');
      var heroCue = heroSticky.querySelector('.hero-cue');
      var heroLocation = heroSticky.querySelector('.hero-location');
      var heroLead = heroSticky.querySelector('.hero-lead');
      var heroActions = document.getElementById('hero-actions');
      var heroMedia = heroSticky.querySelector('.hero-media');
      var heroVideo = document.getElementById('hero-video');
      var heroVideoReady = false;
      var heroVideoPrimed = false;
      var heroVideoPrimePending = false;
      var heroTouchStarted = false;
      var heroUsesTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
      var heroVideoVisible = false;
      var heroVideoRevealPending = false;
      var heroVideoProgress = 0;
      var heroVideoObjectUrl = '';

      function clamp01(value) {
        return Math.max(0, Math.min(1, value));
      }

      function smoothstep(value) {
        value = clamp01(value);
        return value * value * (3 - (2 * value));
      }

      function paintReveal(element, progress, travel) {
        element.style.opacity = progress.toFixed(4);
        element.style.transform = 'translate3d(0,' + ((1 - progress) * travel).toFixed(2) + 'px,0)';
      }

      /* The optimized file has a seek point every three frames. Keep the
         video paused and seek its playhead to the user's actual scroll
         position; there is no autonomous playback or scroll hijacking. */
      function syncHeroVideo(progress, force) {
        heroVideoProgress = progress;
        if (reduceMotion || !heroVideoReady || !Number.isFinite(heroVideo.duration)) return;

        var usableDuration = Math.max(heroVideo.duration - (1 / 24), 0);
        // Safari can clear the poster before it has decoded a seek at exactly
        // zero. Start a fraction into the first frame and keep a real image
        // beneath the video until a decoded frame has actually been painted.
        var targetTime = Math.max(1 / 48, usableDuration * heroVideoProgress);
        if (!force && (heroVideo.seeking || Math.abs(heroVideo.currentTime - targetTime) < (1 / 48))) return;
        heroVideo.currentTime = targetTime;
      }

      function revealHeroVideo() {
        if (reduceMotion || heroVideoVisible || heroVideoRevealPending || heroVideo.readyState < 2) return;
        heroVideoRevealPending = true;

        var reveal = function () {
          heroVideoRevealPending = false;
          if (reduceMotion || heroVideoVisible || heroVideo.readyState < 2) return;
          heroVideoVisible = true;
          heroMedia.classList.add('is-video-ready');
        };

        if (typeof heroVideo.requestVideoFrameCallback === 'function') {
          heroVideo.requestVideoFrameCallback(reveal);
          // A paused iOS video can finish seeking before a queued frame
          // callback is delivered. By this point seeked + HAVE_CURRENT_DATA
          // make a delayed reveal safe, while the poster covers the handoff.
          window.setTimeout(reveal, 160);
        } else {
          window.requestAnimationFrame(function () {
            window.requestAnimationFrame(reveal);
          });
        }
      }

      function finishHeroVideoPrime() {
        heroVideo.pause();
        heroVideoPrimePending = false;
        heroVideoPrimed = true;
        heroVideoReady = true;
        window.removeEventListener('touchstart', primeHeroVideoFromTouch);
        syncHeroVideo(heroVideoProgress, true);
      }

      function usePausedSeekFallback() {
        heroVideo.pause();
        heroVideoPrimePending = false;
        heroVideoReady = true;
        syncHeroVideo(heroVideoProgress, true);
      }

      // iOS may load metadata for a muted video without activating its decoder.
      // On touch devices, a brief hidden play/pause on the first scroll touch
      // primes frame presentation without autoplay or visible player chrome.
      function primeHeroVideo() {
        if (reduceMotion || heroVideoPrimed || heroVideoPrimePending || heroVideo.readyState < 1) return;

        heroVideoPrimePending = true;
        heroVideo.muted = true;
        heroVideo.defaultMuted = true;

        var playAttempt = heroVideo.play();
        if (playAttempt && typeof playAttempt.then === 'function') {
          playAttempt.then(finishHeroVideoPrime).catch(usePausedSeekFallback);
        } else {
          finishHeroVideoPrime();
        }
      }

      function primeHeroVideoFromTouch() {
        heroTouchStarted = true;
        primeHeroVideo();
      }

      function prepareHeroVideo() {
        if (!heroUsesTouch || heroTouchStarted) primeHeroVideo();
      }

      function attachHeroVideoSource(source) {
        heroVideo.src = source;
        heroVideo.load();
      }

      /* The origin currently serves MP4 files without byte-range responses.
         Mobile's smaller encode can finish downloading before that becomes
         noticeable, but desktop seeks otherwise snap back to frame zero.
         Buffer the desktop file into a blob so its full timeline is locally
         seekable, keeping the poster visible until the first frame is ready. */
      function loadHeroVideo() {
        var mobileSource = window.innerWidth <= 720;
        var source = mobileSource
          ? heroVideo.getAttribute('data-mobile-src')
          : heroVideo.getAttribute('data-desktop-src');

        if (mobileSource || typeof window.fetch !== 'function' || typeof window.URL.createObjectURL !== 'function') {
          attachHeroVideoSource(source);
          return;
        }

        window.fetch(source, { cache: 'force-cache' })
          .then(function (response) {
            if (!response.ok) throw new Error('Hero video request failed');
            return response.blob();
          })
          .then(function (blob) {
            heroVideoObjectUrl = window.URL.createObjectURL(blob);
            attachHeroVideoSource(heroVideoObjectUrl);
          })
          .catch(function () {
            attachHeroVideoSource(source);
          });
      }

      if (reduceMotion) {
        heroVideo.pause();
      } else {
        heroVideo.addEventListener('loadedmetadata', prepareHeroVideo);
        if (heroUsesTouch) {
          window.addEventListener('touchstart', primeHeroVideoFromTouch, { passive: true });
        }

        // Cached media can reach metadata before this bottom-of-page script runs.
        if (heroVideo.readyState >= 1) prepareHeroVideo();

        loadHeroVideo();
      }

      window.addEventListener('beforeunload', function () {
        if (heroVideoObjectUrl) window.URL.revokeObjectURL(heroVideoObjectUrl);
      });

      // If the user scrolls again while a seek is being decoded, catch the
      // playhead up to the most recent position as soon as that frame lands.
      heroVideo.addEventListener('seeked', function () {
        revealHeroVideo();
        syncHeroVideo(heroVideoProgress, false);
      });

      heroVideo.addEventListener('error', function () {
        heroVideoReady = false;
        heroVideoPrimed = false;
        heroVideoPrimePending = false;
        heroVideoVisible = false;
        heroVideoRevealPending = false;
        heroMedia.classList.remove('is-video-ready');
      });

      function paintHeroScroll() {
        if (reduceMotion) {
          heroActions.inert = false;
          heroActions.setAttribute('aria-hidden', 'false');
          return;
        }

        var runway = Math.max(heroCinematic.offsetHeight - window.innerHeight, 1);
        var progress = clamp01(-heroCinematic.getBoundingClientRect().top / runway);
        syncHeroVideo(progress, false);
        var locationIn = smoothstep((progress - 0.1) / 0.15);
        var leadIn = smoothstep((progress - 0.28) / 0.18);
        var actionsIn = smoothstep((progress - 0.42) / 0.18);
        var cueOut = smoothstep(progress / 0.12);
        var scrimIn = smoothstep((progress - 0.08) / 0.42);

        heroSticky.style.setProperty('--hero-media-scale', (1.03 + (0.08 * smoothstep(progress))).toFixed(4));
        heroSticky.style.setProperty('--hero-scrim-opacity', (0.3 + (0.7 * scrimIn)).toFixed(4));
        heroSticky.style.setProperty('--hero-cue-opacity', (1 - cueOut).toFixed(4));

        paintReveal(heroLocation, locationIn, 20);
        paintReveal(heroLead, leadIn, 30);
        paintReveal(heroActions, actionsIn, 24);

        var cueActive = cueOut < 0.92;
        var actionsActive = actionsIn > 0.8;

        heroCue.style.pointerEvents = cueActive ? '' : 'none';
        heroCue.inert = !cueActive;
        heroCue.setAttribute('aria-hidden', String(!cueActive));
        heroActions.style.pointerEvents = actionsActive ? '' : 'none';
        heroActions.inert = !actionsActive;
        heroActions.setAttribute('aria-hidden', String(!actionsActive));
      }


window.voldPaintHero = paintHeroScroll;
})();