Skip to content

Commit 77f61b9

Browse files
committed
fix: prevent intersection observer loop
1 parent 3460fac commit 77f61b9

1 file changed

Lines changed: 83 additions & 65 deletions

File tree

src/components/featured.tsx

Lines changed: 83 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import useVideos from '@/components/hooks/use-videos.ts'
22
import { BREAKPOINTS } from '@/constants.ts'
33
import { getMyList, toggleToMyList } from '@/utils/my-list'
4-
import { Button, buttonStyles, Carousel, Heading, Link, type CarouselApi } from 'ui'
4+
import type { CarouselApi } from 'ui'
5+
import { Button, buttonStyles, Carousel, Heading, Link } from 'ui'
56

67
import { IconCheck, IconMute, IconPlus, IconVolumeFull } from '@intentui/icons'
78
import clsx from 'clsx'
89
import Autoplay from 'embla-carousel-autoplay'
10+
import type { UseEmblaCarouselType } from 'embla-carousel-react'
911
import { useEffect, useRef, useState, type ComponentProps } from 'react'
1012

1113
interface Poster {
@@ -40,7 +42,7 @@ function Featured({ items }: FeaturedProps) {
4042
const [api, setApi] = useState<CarouselApi>()
4143
const [myList, setMyList] = useState(getMyList())
4244

43-
const [slide, setSlide] = useState(1)
45+
const [slide, setSlide] = useState(0)
4446
const [videos, setVideos] = useVideos(items, (item) => item.id)
4547
const [playing, setPlaying] = useState(Object.fromEntries(items.map((item) => [item.id, false])))
4648
const [timeouts, setTimeouts] = useState<NodeJS.Timeout[]>([])
@@ -93,6 +95,8 @@ function Featured({ items }: FeaturedProps) {
9395
slide.ref.setAttribute('data-timeout', 'true')
9496

9597
const fadeIn = setTimeout(() => {
98+
if (index != api.selectedScrollSnap()) return
99+
96100
slide.video.ref.play()
97101
slide.video.ref.volume = 0
98102

@@ -149,66 +153,74 @@ function Featured({ items }: FeaturedProps) {
149153
const intersectionObserver = new IntersectionObserver(
150154
(entries) => {
151155
entries.forEach((entry) => {
152-
setIntersecting(entry.isIntersecting)
153-
if (entry.isIntersecting) return
156+
const isCurrentlyIntersecting = entry.isIntersecting
157+
158+
if (intersecting !== isCurrentlyIntersecting) {
159+
setIntersecting(isCurrentlyIntersecting)
154160

155-
const slide = {
156-
id: $slides[index].getAttribute('data-id') as string,
157-
ref: $slides[index],
158-
timeout: $slides[index].getAttribute('data-timeout') === 'true' ? true : false,
161+
if (isCurrentlyIntersecting) {
162+
fadeInOut(index)
163+
return
164+
}
159165

160-
video: {
161-
ref: $slides[index].querySelector('video') as HTMLVideoElement,
162-
duration: items[index].duration,
163-
fadeInDelay: items[index].fadeInDelay,
164-
fadeOutDelay: items[index].fadeOutDelay,
165-
},
166-
} as const
166+
const slide = {
167+
id: $slides[index].getAttribute('data-id') as string,
168+
ref: $slides[index],
169+
timeout: $slides[index].getAttribute('data-timeout') === 'true' ? true : false,
167170

168-
for (const timeout of timeouts) clearTimeout(timeout)
171+
video: {
172+
ref: $slides[index].querySelector('video') as HTMLVideoElement,
173+
duration: items[index].duration,
174+
fadeInDelay: items[index].fadeInDelay,
175+
fadeOutDelay: items[index].fadeOutDelay,
176+
},
177+
} as const
169178

170-
const _playing = { ...playing }
179+
for (const timeout of timeouts) clearTimeout(timeout)
171180

172-
for (const $slide of $slides) {
173-
const id = $slide.getAttribute('data-id') as string
174-
const $video = $slide.querySelector('video') as HTMLVideoElement
181+
const _playing = { ...playing }
175182

176-
$video.pause()
177-
$video.volume = 0
178-
$video.currentTime = 0
179-
$slide.setAttribute('data-timeout', 'false')
183+
for (const $slide of $slides) {
184+
const id = $slide.getAttribute('data-id') as string
185+
const $video = $slide.querySelector('video') as HTMLVideoElement
180186

181-
_playing[id] = false
182-
}
187+
$video.pause()
188+
$video.volume = 0
189+
$video.currentTime = 0
190+
$slide.setAttribute('data-timeout', 'false')
183191

184-
setPlaying(_playing)
185-
186-
const fadeOut = setTimeout(
187-
() => {
188-
setPlaying((prev) => ({ ...prev, [slide.id]: false }))
189-
190-
const steps: number = 50
191-
const duration: number = parseInt(slide.video.ref.getAttribute('data-fade-in-out') as string)
192-
const interval: number = duration / steps
193-
const decrement: number = slide.video.ref.volume / steps
194-
195-
const fadeOutVolume = setInterval(() => {
196-
const newVolume: number = slide.video.ref.volume - decrement
197-
198-
if (newVolume > 0) {
199-
slide.video.ref.volume = Math.max(0, newVolume)
200-
} else {
201-
slide.video.ref.pause()
202-
slide.video.ref.volume = 0
203-
slide.video.ref.currentTime = 0
204-
clearInterval(fadeOutVolume as NodeJS.Timeout)
205-
}
206-
}, interval)
207-
},
208-
slide.video.fadeInDelay + slide.video.duration - slide.video.fadeOutDelay
209-
)
210-
211-
setTimeouts([fadeOut])
192+
_playing[id] = false
193+
}
194+
195+
setPlaying(_playing)
196+
197+
const fadeOut = setTimeout(
198+
() => {
199+
setPlaying((prev) => ({ ...prev, [slide.id]: false }))
200+
201+
const steps: number = 50
202+
const duration: number = parseInt(slide.video.ref.getAttribute('data-fade-in-out') as string)
203+
const interval: number = duration / steps
204+
const decrement: number = slide.video.ref.volume / steps
205+
206+
const fadeOutVolume = setInterval(() => {
207+
const newVolume: number = slide.video.ref.volume - decrement
208+
209+
if (newVolume > 0) {
210+
slide.video.ref.volume = Math.max(0, newVolume)
211+
} else {
212+
slide.video.ref.pause()
213+
slide.video.ref.volume = 0
214+
slide.video.ref.currentTime = 0
215+
clearInterval(fadeOutVolume as NodeJS.Timeout)
216+
}
217+
}, interval)
218+
},
219+
slide.video.fadeInDelay + slide.video.duration - slide.video.fadeOutDelay
220+
)
221+
222+
setTimeouts([fadeOut])
223+
}
212224
})
213225
},
214226
{ threshold: 0.5 }
@@ -218,17 +230,21 @@ function Featured({ items }: FeaturedProps) {
218230

219231
intersectionObserver.observe($carousel)
220232

221-
fadeInOut(index)
222233
setSlide(index)
223234

224-
api.on('select', (event) => {
235+
const handleAPISelect = (event: NonNullable<UseEmblaCarouselType[1]>) => {
225236
const index = event.selectedScrollSnap()
226237

227238
fadeInOut(index)
228239
setSlide(index)
229-
})
240+
}
241+
242+
api.on('select', handleAPISelect)
230243

231-
return () => intersectionObserver.disconnect()
244+
return () => {
245+
intersectionObserver.disconnect()
246+
api.off('select', handleAPISelect)
247+
}
232248
}, [api, intersecting, playing, timeouts])
233249

234250
useEffect(() => {
@@ -259,7 +275,7 @@ function Featured({ items }: FeaturedProps) {
259275
return (
260276
<>
261277
<Carousel
262-
className='**:select-none'
278+
className='select-none'
263279
opts={{ loop: true, slidesToScroll: 1 }}
264280
setApi={setApi}
265281
plugins={[autoPlay.current]}
@@ -277,7 +293,8 @@ function Featured({ items }: FeaturedProps) {
277293
return (
278294
<Carousel.Item
279295
id={item.id}
280-
aria-label={item.highlight}
296+
textValue={`${item.highlight}: "${item.title}"`}
297+
aria-label={playing[item.id] ? `${videos[item.id].muted ? 'Activar' : 'Silenciar'} sonido` : ''}
281298
data-id={item.id}
282299
onAction={() => setVideos.toggleMuted(item.id)}
283300
>
@@ -286,18 +303,18 @@ function Featured({ items }: FeaturedProps) {
286303
<div className='absolute inset-0 grid w-full grid-cols-[1fr_3rem] p-2 sm:p-3 lg:grid-cols-[1fr_6rem] lg:p-7 lg:pt-3.5 lg:pr-3.5'>
287304
<div className='mt-auto'>
288305
<header className='relative flex w-full flex-col gap-1 *:relative *:w-fit *:before:absolute *:before:-top-2 *:before:-left-6 *:before:-z-[1] *:before:block *:before:h-[calc(100%_+_1rem)] *:before:w-[calc(100%_+_3rem)] *:before:bg-neutral-950 *:before:blur-2xl *:before:sm:blur-3xl lg:gap-4'>
289-
<span className='dark:text-fg text-bg bg-highlight/50 relative w-fit rounded-full px-2 text-[0.5rem] font-medium lg:px-4 lg:text-base'>
306+
<span className='dark:text-fg text-bg bg-highlight/50 relative w-fit rounded-full px-2 text-[0.5rem] font-medium sm:text-xs lg:px-4 lg:text-base'>
290307
{item.highlight}
291308
</span>
292309
<Heading
293-
className='dark:text-fg text-bg relative flex w-fit sm:text-2xl lg:text-4xl'
310+
className='dark:text-fg text-bg relative flex w-fit text-balance sm:text-2xl lg:text-4xl'
294311
tracking='wider'
295312
level={i ? 2 : 1}
296313
>
297314
{item.title}
298315
</Heading>
299316
<div>
300-
<p className='dark:text-fg/80 text-bg/80 line-clamp-2 text-xs font-normal sm:text-lg lg:text-2xl'>
317+
<p className='dark:text-fg/80 text-bg/80 line-clamp-2 text-sm font-normal text-pretty sm:text-lg lg:text-2xl'>
301318
{item.description}
302319
</p>
303320
</div>
@@ -344,7 +361,7 @@ function Featured({ items }: FeaturedProps) {
344361
'dark:text-fg/50 text-bg/50 relative ml-auto size-4 transition-opacity delay-400 duration-600 ease-in-out *:absolute *:size-full *:transition-all before:absolute before:-top-1.5 before:-left-2 before:-z-[1] before:block before:size-[calc(100%_+_1rem)] before:bg-neutral-950 before:blur-2xl sm:size-6 md:size-7 lg:size-8',
345362
playing[item.id] && videos[item.id].volumeIcon ? 'opacity-100' : 'opacity-0'
346363
)}
347-
aria-hidden='true'
364+
aria-hidden
348365
>
349366
<IconMute className={videos[item.id].muted ? 'scale-100 opacity-100' : 'scale-0 opacity-0'} />
350367
<IconVolumeFull
@@ -360,7 +377,7 @@ function Featured({ items }: FeaturedProps) {
360377
muted={videos[item.id].muted}
361378
poster={item.poster.src}
362379
preload='metadata'
363-
aria-hidden={!playing[item.id]}
380+
aria-hidden
364381
data-fade-in-out='1000'
365382
loop
366383
playsInline
@@ -396,6 +413,7 @@ function Featured({ items }: FeaturedProps) {
396413
)}
397414
alt={item.poster.alt}
398415
src={item.poster.src}
416+
aria-hidden={playing[item.id]}
399417
/>
400418
</div>
401419
</article>

0 commit comments

Comments
 (0)