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
13 changes: 8 additions & 5 deletions src/widgets/landing/video-section/VideoSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
* Only the video for the active viewport is mounted — no double-fetch.
* Aspect-ratio box reserved to prevent CLS during SSR → client transition.
* Reduced motion: autoplay suppressed; poster shown with native controls.
* Mobile: autoplay disabled entirely so preload="none" actually defers the
* 2.7MB fetch. Poster shown with native controls for tap-to-play affordance.
* Off-screen: IntersectionObserver pauses video when scrolled out of view
* and resumes when scrolled back in (skipped under reduced-motion).
* and resumes when scrolled back in (skipped under reduced-motion or mobile).
*/

'use client'
Expand Down Expand Up @@ -37,9 +39,10 @@ export function VideoSection() {
}, [])

// Pause the off-screen video to avoid wasting data/battery.
// Skip entirely under reduced-motion — videos aren't autoplaying anyway.
// Skip under reduced-motion or mobile — videos aren't autoplaying in those cases.
useEffect(() => {
if (prefersReducedMotion) return
if (isMobile) return
if (typeof window === 'undefined') return

const section = sectionRef.current
Expand All @@ -62,7 +65,7 @@ export function VideoSection() {

observer.observe(section)
return () => observer.disconnect()
}, [prefersReducedMotion])
}, [prefersReducedMotion, isMobile])

const videoSrc = isMobile ? '/video/voidpay-9x16-v2.mp4' : '/video/voidpay-16x9-v2.mp4'
const wrapperClassName = isMobile
Expand Down Expand Up @@ -98,11 +101,11 @@ export function VideoSection() {
src={videoSrc}
poster="/video/poster-scene5.webp"
muted
autoPlay={!prefersReducedMotion}
autoPlay={!prefersReducedMotion && !isMobile}
loop
playsInline
preload="none"
controls={prefersReducedMotion}
controls={prefersReducedMotion || isMobile}
aria-label="VoidPay product walkthrough: creating and paying a crypto invoice"
onPlay={handlePlay}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,25 @@ describe('VideoSection', () => {
expect(video).toHaveAttribute('controls')
})

it('mobile path: should NOT have autoplay attribute', () => {
stubMobileViewport()
render(<VideoSection />)
const video = document.querySelector('video')
// On mobile, autoPlay is disabled so preload="none" actually defers the fetch
expect(video).not.toHaveAttribute('autoplay')
})

it('mobile path: should show controls for tap-to-play affordance', () => {
stubMobileViewport()
render(<VideoSection />)
const video = document.querySelector('video')
expect(video).toHaveAttribute('controls')
})

// Desktop autoplay when reduced-motion is off cannot be tested in this suite:
// useReducedMotion is globally mocked to return true (vitest.setup.ts).
// The mobile-specific cases above cover the new branching logic.

it('should have an accessible aria-label on the video', () => {
render(<VideoSection />)
const video = document.querySelector('video')
Expand Down Expand Up @@ -236,7 +255,8 @@ describe('VideoSection', () => {

it('should register an IntersectionObserver when reduced-motion is off', () => {
// Note: global mock returns prefersReducedMotion=true, so observer is NOT registered.
// This test documents that the observer is skipped in reduced-motion mode.
// Observer is also skipped on mobile. This test documents that the observer is
// skipped in reduced-motion mode (global mock).
render(<VideoSection />)
// Under global reduced-motion mock (true), IntersectionObserver should NOT be called
expect(IntersectionObserver).not.toHaveBeenCalled()
Expand Down