patx/demo

fix iPhone hero video fallback

Commit 87d8729 · patx · 2026-08-16T02:13:42-04:00

Changeset
87d872973604a04bc59d7f7f780b094740ff6729
Parents
cc7674aecf6c8bb2f1ce6cb4ab0f609d6ed88caa

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/docs/voldservices/index.html b/docs/voldservices/index.html
index e09e6c9..a579466 100644
--- a/docs/voldservices/index.html
+++ b/docs/voldservices/index.html
@@ -407,6 +407,9 @@
     }
     .hero-media video,
     .hero-media img.hero-fallback {
+      position: absolute;
+      inset: 0;
+      display: block;
       width: 100%;
       height: 100%;
       object-fit: cover;
@@ -416,11 +419,18 @@
       transform-origin: center 52%;
       will-change: transform;
     }
+    .hero-media img.hero-fallback { z-index: 0; }
+    .hero-media video {
+      z-index: 1;
+      opacity: 0;
+      transition: opacity .18s ease-out;
+    }
+    .hero-media.is-video-ready video { opacity: 1; }
     .hero-media::after {
       content: "";
       position: absolute;
       inset: 0;
-      z-index: 1;
+      z-index: 2;
       pointer-events: none;
       background:
         linear-gradient(90deg, rgba(8, 8, 8, .94) 0%, rgba(8, 8, 8, .62) 43%, rgba(8, 8, 8, .06) 74%),
@@ -1256,13 +1266,13 @@
     <section class="hero" id="top">
       <div class="hero-sticky" id="hero-sticky">
         <div class="hero-media">
+          <img class="hero-fallback" src="https://gitman.io/patx/demo/raw/docs/voldservices/img/hero-scrub-poster.jpg" alt="" aria-hidden="true" />
           <video
             id="hero-video"
             poster="https://gitman.io/patx/demo/raw/docs/voldservices/img/hero-scrub-poster.jpg"
             muted playsinline preload="auto"
             aria-hidden="true" tabindex="-1">
             <source src="https://gitman.io/patx/demo/raw/docs/voldservices/hero-scrub.mp4" type="video/mp4" />
-            <img class="hero-fallback" src="https://gitman.io/patx/demo/raw/docs/voldservices/img/hero-scrub-poster.jpg" alt="" />
           </video>
         </div>
         <div class="hero-grain" aria-hidden="true"></div>
@@ -1913,8 +1923,11 @@
       var heroEyebrow = heroSticky.querySelector('.hero-eyebrow');
       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 heroVideoVisible = false;
+      var heroVideoRevealPending = false;
       var heroVideoProgress = 0;
 
       function clamp01(value) {
@@ -1939,11 +1952,38 @@
         if (reduceMotion || !heroVideoReady || !Number.isFinite(heroVideo.duration)) return;
 
         var usableDuration = Math.max(heroVideo.duration - (1 / 24), 0);
-        var targetTime = usableDuration * heroVideoProgress;
+        // 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);
+          });
+        }
+      }
+
       heroVideo.addEventListener('loadedmetadata', function () {
         heroVideo.pause();
         heroVideoReady = true;
@@ -1953,9 +1993,17 @@
       // 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;
+        heroVideoVisible = false;
+        heroVideoRevealPending = false;
+        heroMedia.classList.remove('is-video-ready');
+      });
+
       function paintHeroScroll() {
         if (reduceMotion) {
           heroActions.inert = false;