@@ -8,7 +8,6 @@ import { Overlay } from "~/components/overlay";
88import { ScrollReveal } from "~/components/scroll-reveal" ;
99import { variants } from "./styles" ;
1010import { type HeroVideoProps , SECTION_HEIGHTS } from "./types" ;
11- import { calculateVideoHeight , getPlayerSize } from "./utils" ;
1211
1312// react-player v3 is ESM-only and lazy-loads individual players internally,
1413// so a plain dynamic import resolves cleanly. React.lazy here only defers the
@@ -37,29 +36,12 @@ export default function HeroVideo(props: HeroVideoProps) {
3736 ...rest
3837 } = props ;
3938
40- const id = rest [ "data-wv-id" ] ;
4139 const containerRef = useRef < HTMLDivElement > ( null ) ;
4240 const hoverTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
43- const [ size , setSize ] = useState ( ( ) => getPlayerSize ( id ) ) ;
4441 const [ playing , setPlaying ] = useState ( autoplay !== false ) ;
4542 const [ hovered , setHovered ] = useState ( false ) ;
4643 const [ hideContent , setHideContent ] = useState ( false ) ;
4744
48- // Calculate initial video height from intrinsic dimensions
49- const [ videoHeight , setVideoHeight ] = useState < number | null > ( ( ) => {
50- if ( isBrowser && containerRef . current ) {
51- const containerWidth = containerRef . current . getBoundingClientRect ( ) . width ;
52- return calculateVideoHeight ( video , containerWidth ) ;
53- }
54- // Fallback: calculate from video metadata if available
55- if ( isBrowser && video ?. width && video ?. height ) {
56- // Use viewport width as estimate for container width
57- const estimatedWidth = window . innerWidth ;
58- return calculateVideoHeight ( video , estimatedWidth ) ;
59- }
60- return null ;
61- } ) ;
62-
6345 // Content visible when: paused, or not hovered, or not hidden by delay
6446 let contentVisible = ! playing || ! hovered || ! hideContent ;
6547
@@ -85,10 +67,9 @@ export default function HeroVideo(props: HeroVideoProps) {
8567 setHideContent ( false ) ;
8668 }
8769
88- const desktopHeight = SECTION_HEIGHTS [ height ] || `${ heightOnDesktop } px` ;
70+ const sectionHeight = SECTION_HEIGHTS [ height ] || `${ heightOnDesktop } px` ;
8971 const sectionStyle : CSSProperties = {
90- "--desktop-height" : desktopHeight ,
91- ...( videoHeight ? { "--video-height" : `${ videoHeight } px` } : { } ) ,
72+ "--section-height" : sectionHeight ,
9273 "--gap-desktop" : `${ gap ?? 0 } px` ,
9374 "--gap-mobile" : ( gap ?? 0 ) <= 20 ? `${ gap ?? 0 } px` : `${ ( gap ?? 0 ) / 2 } px` ,
9475 } as CSSProperties ;
@@ -107,136 +88,59 @@ export default function HeroVideo(props: HeroVideoProps) {
10788 } ;
10889
10990 /**
110- * Measure actual video element height and adjust container to match.
111- * This corrects any discrepancy between calculated and actual rendered height.
91+ * Force muted + inline playback on the lazily-mounted <video> — required for
92+ * iOS to autoplay inline rather than going fullscreen. react-player mounts
93+ * the element asynchronously, so poll briefly until it appears. No height is
94+ * measured here: the container size is fixed in CSS and the video just covers
95+ * it (`object-cover`), so there is no measure→resize feedback loop.
11296 */
113- function syncVideoHeight ( ) {
114- if ( ! containerRef . current ) {
97+ useEffect ( ( ) => {
98+ if ( ! isBrowser || ! inView || ! containerRef . current ) {
11599 return ;
116100 }
101+ const container = containerRef . current ;
102+ let pollId : ReturnType < typeof setInterval > | null = null ;
103+ let attempts = 0 ;
117104
118- // Find the actual video or iframe element inside ReactPlayer
119- const mediaEl = containerRef . current . querySelector (
120- "video, iframe" ,
121- ) as HTMLElement | null ;
122-
123- if ( mediaEl instanceof HTMLVideoElement ) {
105+ function configure ( ) {
106+ const mediaEl = container . querySelector ( "video" ) ;
107+ if ( ! mediaEl ) {
108+ // Lazy player chunk / iframe embed not a <video> — retry briefly.
109+ attempts += 1 ;
110+ if ( attempts > 100 && pollId ) {
111+ clearInterval ( pollId ) ;
112+ }
113+ return ;
114+ }
115+ if ( pollId ) {
116+ clearInterval ( pollId ) ;
117+ pollId = null ;
118+ }
124119 mediaEl . muted = true ;
125120 mediaEl . defaultMuted = true ;
126121 mediaEl . playsInline = true ;
127122 mediaEl . setAttribute ( "muted" , "" ) ;
128123 mediaEl . setAttribute ( "playsinline" , "" ) ;
129124 mediaEl . setAttribute ( "webkit-playsinline" , "" ) ;
130-
131125 if ( playing ) {
132126 mediaEl . autoplay = true ;
133127 mediaEl . setAttribute ( "autoplay" , "" ) ;
134128 }
135-
136129 if ( loop !== false ) {
137130 mediaEl . loop = true ;
138131 mediaEl . setAttribute ( "loop" , "" ) ;
139132 }
140133 }
141134
142- if ( mediaEl instanceof HTMLVideoElement ) {
143- // Derive height from the INTRINSIC video dimensions, never from the
144- // rendered box: the container height is set from this value, and the
145- // video fills the container, so committing a rendered measurement can
146- // deadlock a wrong value (e.g. the 300x150 default before metadata
147- // loads — observed as an intermittently squashed hero).
148- if ( mediaEl . videoWidth > 0 && mediaEl . videoHeight > 0 ) {
149- const containerWidth =
150- containerRef . current . getBoundingClientRect ( ) . width ;
151- const intrinsicHeight =
152- ( containerWidth * mediaEl . videoHeight ) / mediaEl . videoWidth ;
153- commitVideoHeight ( intrinsicHeight ) ;
154- }
155- // Metadata not loaded yet — skip; loadedmetadata/ResizeObserver will
156- // re-trigger this sync.
157- return ;
158- }
159- if ( mediaEl ) {
160- // Iframe embeds (YouTube/Vimeo) size themselves via aspect-ratio
161- // styles — the rendered box is the only available signal.
162- const actualHeight = mediaEl . getBoundingClientRect ( ) . height ;
163- if ( actualHeight > 0 ) {
164- commitVideoHeight ( actualHeight ) ;
165- }
166- }
167- }
168- function commitVideoHeight ( next : number ) {
169- // Only update if significantly different (> 2px) to avoid jitter
170- setVideoHeight ( ( prev ) => {
171- if ( prev === null || Math . abs ( prev - next ) > 2 ) {
172- return next ;
173- }
174- return prev ;
175- } ) ;
176- }
135+ pollId = setInterval ( configure , 100 ) ;
136+ configure ( ) ;
177137
178- /**
179- * Recalculate video height on resize using intrinsic dimensions.
180- * This ensures the container always matches the video's actual displayed size.
181- */
182- function handleResize ( ) {
183- setSize ( getPlayerSize ( id ) ) ;
184- // First, try intrinsic calculation
185- if ( containerRef . current && video ?. width && video ?. height ) {
186- const containerWidth = containerRef . current . getBoundingClientRect ( ) . width ;
187- const calculatedHeight = calculateVideoHeight ( video , containerWidth ) ;
188- if ( calculatedHeight ) {
189- setVideoHeight ( calculatedHeight ) ;
190- }
191- }
192- // Then sync with actual video element after a brief delay
193- requestAnimationFrame ( syncVideoHeight ) ;
194- }
195- /**
196- * A single post-mount measurement is a race: if it lands before the video
197- * metadata loads, the <video> element reports the browser default 300x150
198- * and the container gets locked at ~150px (intermittent squashed hero).
199- * Watch for the media element (react-player mounts lazily) and keep the
200- * container in sync with a ResizeObserver — metadata load, player chrome,
201- * and breakpoint changes all resize the element and re-trigger the sync.
202- */
203- function watchMediaElement ( ) : ( ( ) => void ) | undefined {
204- if ( ! isBrowser || ! containerRef . current ) {
205- return undefined ;
206- }
207- let observer : ResizeObserver | null = null ;
208- let pollId : ReturnType < typeof setInterval > | null = null ;
209- let attempts = 0 ;
210- const attach = ( ) => {
211- const mediaEl = containerRef . current ?. querySelector ( "video, iframe" ) ;
212- if ( ! mediaEl ) {
213- // Lazy player chunk not mounted yet — retry briefly.
214- attempts += 1 ;
215- if ( attempts > 100 && pollId ) {
216- clearInterval ( pollId ) ;
217- }
218- return ;
219- }
220- if ( pollId ) {
221- clearInterval ( pollId ) ;
222- pollId = null ;
223- }
224- syncVideoHeight ( ) ;
225- observer = new ResizeObserver ( ( ) => syncVideoHeight ( ) ) ;
226- observer . observe ( mediaEl ) ;
227- if ( mediaEl instanceof HTMLVideoElement ) {
228- mediaEl . addEventListener ( "loadedmetadata" , syncVideoHeight ) ;
229- }
230- } ;
231- pollId = setInterval ( attach , 100 ) ;
232- attach ( ) ;
233138 return ( ) => {
234139 if ( pollId ) {
235140 clearInterval ( pollId ) ;
236141 }
237- observer ?. disconnect ( ) ;
238142 } ;
239- }
143+ } , [ inView , playing , loop ] ) ;
240144
241145 // Reset hideContent when video is paused (show content immediately)
242146 useEffect ( ( ) => {
@@ -258,17 +162,6 @@ export default function HeroVideo(props: HeroVideoProps) {
258162 } ;
259163 } , [ ] ) ;
260164
261- // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> --- IGNORE ---
262- useEffect ( ( ) => {
263- handleResize ( ) ;
264- window . addEventListener ( "resize" , handleResize ) ;
265- const stopWatching = inView ? watchMediaElement ( ) : undefined ;
266- return ( ) => {
267- window . removeEventListener ( "resize" , handleResize ) ;
268- stopWatching ?.( ) ;
269- } ;
270- } , [ inView , height , heightOnDesktop ] ) ;
271-
272165 return (
273166 < ScrollReveal
274167 as = "section"
@@ -282,12 +175,10 @@ export default function HeroVideo(props: HeroVideoProps) {
282175 onMouseEnter = { handleMouseEnter }
283176 onMouseLeave = { handleMouseLeave }
284177 className = { clsx (
285- "relative flex items-center justify-center overflow-hidden w-full" ,
286- videoHeight
287- ? "h-(--video-height) md:h-[min(var(--desktop-height),var(--video-height))]"
288- : "aspect-video md:aspect-auto md:h-(--desktop-height)" ,
289- "md:w-[max(var(--desktop-height)/9*16,100vw)]" ,
290- "md:translate-x-[min(0px,calc((var(--desktop-height)/9*16-100vw)/-2))]" ,
178+ // Full-bleed hero band: full width, fixed height. `container-type:size`
179+ // exposes the box to container-query units so the player below can
180+ // scale itself to cover the band (see its inline style).
181+ "relative w-full overflow-hidden h-(--section-height) @container-size" ,
291182 ) }
292183 >
293184 { inView && (
@@ -299,12 +190,21 @@ export default function HeroVideo(props: HeroVideoProps) {
299190 muted
300191 loop = { loop !== false }
301192 playsInline
302- width = { size . width }
303- height = { size . height }
304193 controls = { false }
305- onReady = { ( ) => {
306- // Sync container height with actual video element after render
307- requestAnimationFrame ( syncVideoHeight ) ;
194+ // Cover the band for ANY source. `object-fit: cover` only crops
195+ // native <video>; YouTube/Vimeo render an <iframe> in shadow DOM
196+ // that ignores it, so we instead size the player to the smallest
197+ // 16:9 box that still covers the container, then center it (the
198+ // band's `overflow-hidden` crops the excess). cqw/cqh resolve
199+ // against the band's own width/height.
200+ style = { {
201+ position : "absolute" ,
202+ top : "50%" ,
203+ left : "50%" ,
204+ transform : "translate(-50%, -50%)" ,
205+ width : "max(100cqw, calc(100cqh * 16 / 9))" ,
206+ height : "max(100cqh, calc(100cqw * 9 / 16))" ,
207+ objectFit : "cover" ,
308208 } }
309209 />
310210 </ Suspense >
0 commit comments