Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added frontend/public/hero/egg-sm.mp4
Binary file not shown.
Binary file added frontend/public/hero/egg.mp4
Binary file not shown.
Binary file added frontend/public/hero/fry-sm.mp4
Binary file not shown.
Binary file added frontend/public/hero/fry.mp4
Binary file not shown.
Binary file added frontend/public/hero/oil-sm.mp4
Binary file not shown.
Binary file added frontend/public/hero/oil.mp4
Binary file not shown.
Binary file added frontend/public/hero/steam-sm.mp4
Binary file not shown.
Binary file added frontend/public/hero/steam.mp4
Binary file not shown.
11 changes: 9 additions & 2 deletions frontend/src/components/HeroVideo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand All @@ -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))
}
})
Expand Down
36 changes: 31 additions & 5 deletions frontend/src/components/HeroVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <source> 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 <source> 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,
Expand Down Expand Up @@ -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])
Expand All @@ -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 (
Expand All @@ -138,17 +158,23 @@ export default function HeroVideo() {
/>
{!skip && ready && (
<video
// key: a new clip means a new <source> list, which browsers only
// re-evaluate on a fresh element.
key={clip}
ref={videoRef}
className="hero-media-item"
src={videoUrl(base, clip)}
poster={poster}
autoPlay
muted
playsInline
preload="metadata"
onTimeUpdate={preloadNext}
onEnded={() => setIndex((index + 1) % CLIPS.length)}
/>
>
{FORMATS.map(({ ext, type }) => (
<source key={ext} src={videoUrl(base, clip, ext)} type={type} />
))}
</video>
)}
</div>
{/* Outside the aria-hidden wrapper so assistive tech can reach it. */}
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/styles/HeroVideo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/styles/HomePage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down