diff --git a/frontend/public/hero/egg-sm.mp4 b/frontend/public/hero/egg-sm.mp4 new file mode 100644 index 0000000..382ee82 Binary files /dev/null and b/frontend/public/hero/egg-sm.mp4 differ diff --git a/frontend/public/hero/egg.mp4 b/frontend/public/hero/egg.mp4 new file mode 100644 index 0000000..c96cc3b Binary files /dev/null and b/frontend/public/hero/egg.mp4 differ diff --git a/frontend/public/hero/fry-sm.mp4 b/frontend/public/hero/fry-sm.mp4 new file mode 100644 index 0000000..79727ef Binary files /dev/null and b/frontend/public/hero/fry-sm.mp4 differ diff --git a/frontend/public/hero/fry.mp4 b/frontend/public/hero/fry.mp4 new file mode 100644 index 0000000..fb28248 Binary files /dev/null and b/frontend/public/hero/fry.mp4 differ diff --git a/frontend/public/hero/oil-sm.mp4 b/frontend/public/hero/oil-sm.mp4 new file mode 100644 index 0000000..231b3f0 Binary files /dev/null and b/frontend/public/hero/oil-sm.mp4 differ diff --git a/frontend/public/hero/oil.mp4 b/frontend/public/hero/oil.mp4 new file mode 100644 index 0000000..d3d172e Binary files /dev/null and b/frontend/public/hero/oil.mp4 differ diff --git a/frontend/public/hero/steam-sm.mp4 b/frontend/public/hero/steam-sm.mp4 new file mode 100644 index 0000000..943f890 Binary files /dev/null and b/frontend/public/hero/steam-sm.mp4 differ diff --git a/frontend/public/hero/steam.mp4 b/frontend/public/hero/steam.mp4 new file mode 100644 index 0000000..a89c480 Binary files /dev/null and b/frontend/public/hero/steam.mp4 differ diff --git a/frontend/src/components/HeroVideo.test.tsx b/frontend/src/components/HeroVideo.test.tsx index 6a3d21f..ed84fb6 100644 --- a/frontend/src/components/HeroVideo.test.tsx +++ b/frontend/src/components/HeroVideo.test.tsx @@ -46,7 +46,12 @@ test('always renders a sized poster, then attaches a muted decorative video once }) expect(video.muted).toBe(true) expect(video).toHaveAttribute('preload', 'metadata') - expect(video.getAttribute('src')).toContain('hero/steam') + const sources = [...video.querySelectorAll('source')] + expect(sources.map((s) => s.getAttribute('type'))).toEqual([ + 'video/webm; codecs="vp9"', + 'video/mp4', + ]) + expect(sources[1].getAttribute('src')).toMatch(/hero\/steam\.mp4$/) expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true') expect( screen.getByRole('button', { name: 'Pause background video' }), @@ -60,7 +65,9 @@ test('advances to the next clip when one ends, wrapping around the playlist', as const order = ['steam', 'fry', 'egg', 'oil', 'steam'] for (const [i, name] of order.entries()) { - expect(getVideo(container).getAttribute('src')).toContain(`hero/${name}`) + expect( + getVideo(container).querySelector('source')!.getAttribute('src'), + ).toContain(`hero/${name}`) if (i < order.length - 1) fireEvent.ended(getVideo(container)) } }) diff --git a/frontend/src/components/HeroVideo.tsx b/frontend/src/components/HeroVideo.tsx index c02e1ba..fc61b40 100644 --- a/frontend/src/components/HeroVideo.tsx +++ b/frontend/src/components/HeroVideo.tsx @@ -35,9 +35,26 @@ function shouldSkipVideo() { ) } -function videoUrl(base: string, clip: string) { +// WebM (VP9) is the smaller file, but iOS Safari only plays it from 17.4; +// older iPhones need the H.264 MP4. The browser picks the first it +// can decode, so a type is always given. +const FORMATS = [ + { ext: 'webm', type: 'video/webm; codecs="vp9"' }, + { ext: 'mp4', type: 'video/mp4' }, +] + +function videoUrl(base: string, clip: string, ext: string) { const suffix = matches(SMALL_SCREEN) ? '-sm' : '' - return `${base}hero/${clip}${suffix}.webm` + return `${base}hero/${clip}${suffix}.${ext}` +} + +// For the manual preload of the next clip, where no list is used. +function playableUrl(base: string, clip: string) { + const probe = document.createElement('video') + const format = + FORMATS.find((f) => probe.canPlayType?.(f.type)) ?? + FORMATS[FORMATS.length - 1] + return videoUrl(base, clip, format.ext) } // Runs `callback` once the page has finished loading and the browser is idle, @@ -105,6 +122,9 @@ export default function HeroVideo() { useEffect(() => { const video = videoRef.current if (!video) return + // React sets `muted` only as a property, never as the HTML attribute; + // setting it again right before play() keeps iOS from refusing autoplay. + video.muted = true if (shouldPlay) void video.play?.()?.catch?.(() => setUserPaused(true)) else video.pause?.() }, [shouldPlay, index, ready]) @@ -120,7 +140,7 @@ export default function HeroVideo() { preloadedFor.current = index const next = document.createElement('video') next.preload = 'auto' - next.src = videoUrl(base, CLIPS[(index + 1) % CLIPS.length]) + next.src = playableUrl(base, CLIPS[(index + 1) % CLIPS.length]) } return ( @@ -138,9 +158,11 @@ export default function HeroVideo() { /> {!skip && ready && ( )} {/* Outside the aria-hidden wrapper so assistive tech can reach it. */} diff --git a/frontend/src/styles/HeroVideo.css b/frontend/src/styles/HeroVideo.css index 14d1167..1bf41c7 100644 --- a/frontend/src/styles/HeroVideo.css +++ b/frontend/src/styles/HeroVideo.css @@ -34,9 +34,12 @@ .hero-video-toggle { position: absolute; - right: 1rem; - bottom: 1rem; + /* env(): keep clear of the iPhone home indicator / rounded corners. */ + right: max(1rem, env(safe-area-inset-right)); + bottom: max(1rem, env(safe-area-inset-bottom)); + /* 44px: comfortable touch target (WCAG 2.5.8 only requires 24px). */ + min-height: 44px; padding: 0.4rem 0.75rem; background-color: var(--color-surface); diff --git a/frontend/src/styles/HomePage.css b/frontend/src/styles/HomePage.css index 8186291..9ce14ac 100644 --- a/frontend/src/styles/HomePage.css +++ b/frontend/src/styles/HomePage.css @@ -6,7 +6,9 @@ justify-content: center; align-items: center; - padding: 2rem; + /* Extra bottom space: the pause-video button sits there and must not + cover the hero buttons on short phone screens. */ + padding: 2rem 2rem 4.5rem; text-align: center; position: relative;