diff --git a/src/widgets/landing/video-section/VideoSection.tsx b/src/widgets/landing/video-section/VideoSection.tsx index 5986d7e2..a3a79c46 100644 --- a/src/widgets/landing/video-section/VideoSection.tsx +++ b/src/widgets/landing/video-section/VideoSection.tsx @@ -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' @@ -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 @@ -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 @@ -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} /> diff --git a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx index fdd34eba..6fe4e9d0 100644 --- a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx +++ b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx @@ -172,6 +172,25 @@ describe('VideoSection', () => { expect(video).toHaveAttribute('controls') }) + it('mobile path: should NOT have autoplay attribute', () => { + stubMobileViewport() + render() + 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() + 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() const video = document.querySelector('video') @@ -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() // Under global reduced-motion mock (true), IntersectionObserver should NOT be called expect(IntersectionObserver).not.toHaveBeenCalled()