Skip to content

Commit 16f9d69

Browse files
committed
new post
1 parent fd5c5f2 commit 16f9d69

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

_posts/2023-11-29-videoCarousel.md

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
---
2+
title: From Messy to Manageable - How I Improved Apple's Video Carousel Implementation
3+
date: 2023-11-29 03:04:28 +0800
4+
categories: [front-end, react]
5+
tags: [react, gsap, refactor, apple]
6+
---
7+
8+
Hey how's it going? 👋 I wanted to share a little victory from my recent project where I was following along with a tutorial to clone Apple's website. You know that feeling when you're coding along with a video and something just doesn't feel quite right? That's exactly what happened to me.
9+
10+
## The Starting Point
11+
The original implementation worked, but as I was coding it, I kept thinking "this state management feels... messy." Besides, users couldn't jump to a specific video when clicking the indicators. Not great for user experience!
12+
13+
One part that really bugged me was how the video controls were handled. The original code's event handling felt like a game of dominos - one action would trigger another, and sometimes you'd lose track of what caused what. Here's what I mean:
14+
15+
```javascript
16+
// Original state management
17+
const [video, setVideo] = useState({
18+
isEnd: false, // This felt redundant
19+
startPlay: false,
20+
videoId: 0,
21+
isLastVideo: false,
22+
isPlaying: false,
23+
});
24+
25+
const [loadedData, setLoadedData] = useState([]); // Separate state for loaded videos
26+
27+
// Original event handling - spot the chain reaction!
28+
const handleProcess = (type, i) => {
29+
switch (type) {
30+
case "video-end":
31+
setVideo((pre) => ({ ...pre, isEnd: true, videoId: i + 1 }));
32+
break;
33+
case "video-last":
34+
setVideo((pre) => ({ ...pre, isLastVideo: true }));
35+
break;
36+
case "video-reset":
37+
setVideo((pre) => ({ ...pre, videoId: 0, isLastVideo: false }));
38+
break;
39+
case "pause":
40+
setVideo((pre) => ({ ...pre, isPlaying: !pre.isPlaying }));
41+
break;
42+
case "play":
43+
setVideo((pre) => ({ ...pre, isPlaying: !pre.isPlaying }));
44+
break;
45+
}
46+
};
47+
48+
// And then in the UI...
49+
<img
50+
onClick={
51+
isLastVideo
52+
? () => handleProcess("video-reset")
53+
: !isPlaying
54+
? () => handleProcess("play")
55+
: () => handleProcess("pause")
56+
}
57+
/>;
58+
```
59+
60+
I simplified this into something more straightforward:
61+
62+
```js
63+
const [state, setState] = useState({
64+
videoId: 0,
65+
isPlaying: false,
66+
startPlay: false,
67+
isLastVideo: false,
68+
loadedVideos: [], // Brought loadedVideos into main state
69+
});
70+
71+
// New and improved control flow
72+
const handleVideo = (type, i) => {
73+
switch (type) {
74+
case "select": // New! Direct video selection
75+
setState(prev => ({ ...prev, videoId: i, isPlaying: true, isLastVideo: false }));
76+
break;
77+
case "toggle": // Combined play/pause into one action
78+
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }));
79+
break;
80+
case "end": // More intuitive handling of video end
81+
if (i === hightlightsSlides.length - 1) {
82+
setState(prev => ({ ...prev, isLastVideo: true, isPlaying: false }));
83+
} else {
84+
setState(prev => ({ ...prev, videoId: i + 1 }));
85+
}
86+
break;
87+
case "reset":
88+
videoRef.current[videoId].currentTime = 0;
89+
setState(prev => ({ ...prev, videoId: 0, isLastVideo: false, isPlaying: true }));
90+
break;
91+
}
92+
};
93+
94+
// Clean UI implementation
95+
<img
96+
src={isLastVideo ? replayImg : !isPlaying ? playImg : pauseImg}
97+
onClick={() => handleVideo(isLastVideo ? "reset" : "toggle")}
98+
/>
99+
```
100+
101+
## Tackling the Progress Bar Chaos
102+
103+
The progress bar was my next target. Looking at the original code, I felt a bit overwhelmed - there was this massive useEffect hook handling all the animation logic. It was like trying to juggle while riding a unicycle! 😅
104+
105+
106+
```js
107+
// The original progress bar beast
108+
useEffect(() => {
109+
let currentProgress = 0;
110+
let span = videoSpanRef.current;
111+
112+
if (span[videoId]) {
113+
// animation to move the indicator
114+
let anim = gsap.to(span[videoId], {
115+
onUpdate: () => {
116+
// get the progress of the video
117+
const progress = Math.ceil(anim.progress() * 100);
118+
119+
if (progress != currentProgress) {
120+
currentProgress = progress;
121+
122+
// set the width of the progress bar
123+
gsap.to(videoDivRef.current[videoId], {
124+
width:
125+
window.innerWidth < 760
126+
? "10vw" // mobile
127+
: window.innerWidth < 1200
128+
? "10vw" // tablet
129+
: "4vw", // laptop
130+
});
131+
132+
// set the background color of the progress bar
133+
gsap.to(span[videoId], {
134+
width: `${currentProgress}%`,
135+
backgroundColor: "white",
136+
});
137+
}
138+
},
139+
140+
// when the video is ended, replace the progress bar with the indicator and change the background color
141+
onComplete: () => {
142+
if (isPlaying) {
143+
gsap.to(videoDivRef.current[videoId], {
144+
width: "12px",
145+
});
146+
gsap.to(span[videoId], {
147+
backgroundColor: "#afafaf",
148+
});
149+
}
150+
},
151+
});
152+
153+
if (videoId == 0) {
154+
anim.restart();
155+
}
156+
157+
// update the progress bar
158+
const animUpdate = () => {
159+
anim.progress(
160+
videoRef.current[videoId].currentTime /
161+
hightlightsSlides[videoId].videoDuration
162+
);
163+
};
164+
165+
if (isPlaying) {
166+
// ticker to update the progress bar
167+
gsap.ticker.add(animUpdate);
168+
} else {
169+
// remove the ticker when the video is paused (progress bar is stopped)
170+
gsap.ticker.remove(animUpdate);
171+
}
172+
}
173+
}, [videoId, startPlay]);
174+
```
175+
176+
So, I took a deep breath and broke it down into bite-sized pieces:
177+
178+
```js
179+
// Ahh, much cleaner now! No more nested animation callbacks.
180+
const updateProgress = () => {
181+
const video = videoRef.current[videoId];
182+
const progressBar = progressRef.current[videoId];
183+
const progressBarContainer = progressBarRef.current[videoId];
184+
185+
if (video && progressBar && progressBarContainer && isPlaying) {
186+
const progress = (video.currentTime / video.duration) * 100;
187+
188+
gsap.to(progressBarContainer, {
189+
width:
190+
window.innerWidth < 760
191+
? "10vw"
192+
: window.innerWidth < 1200
193+
? "10vw"
194+
: "4vw",
195+
});
196+
197+
gsap.to(progressBar, {
198+
width: `${progress}%`,
199+
backgroundColor: "white",
200+
});
201+
}
202+
};
203+
204+
// The useEffect is now focused solely on managing the animation ticker
205+
useEffect(() => {
206+
if (isPlaying) {
207+
const updateTicker = () => updateProgress();
208+
gsap.ticker.add(updateTicker);
209+
return () => gsap.ticker.remove(updateTicker);
210+
}
211+
}, [videoId, isPlaying]);
212+
213+
// Separate reset logic, now called explicitly when needed
214+
const resetProgressBar = (index) => {
215+
if (progressRef.current[index] && progressBarRef.current[index]) {
216+
gsap.to(progressBarRef.current[index], {
217+
width: "12px",
218+
duration: 1,
219+
ease: "power2.out",
220+
});
221+
gsap.to(progressRef.current[index], {
222+
width: "100%",
223+
backgroundColor: "#afafaf",
224+
});
225+
}
226+
};
227+
```
228+
229+
Like a messy drawer got some organizers, now everything has its place. Need to update the progress? There's a function for that. Need to reset it? There's a clean function for that too. No more digging through nested callbacks or trying to understand complex animation logic!
230+
231+
## Last but not Least
232+
233+
Another small but satisfying improvement was how the videos themselves are handled when switching between them:
234+
235+
```js
236+
// Original version: If you clicked another indicator while the video was unfinished,
237+
// you would see two progress bars growing simultaneously and the playback sequence
238+
// completely out of order!
239+
useEffect(() => {
240+
if (loadedData.length > 3) {
241+
if (!isPlaying) {
242+
videoRef.current[videoId].pause();
243+
} else {
244+
startPlay && videoRef.current[videoId].play();
245+
}
246+
}
247+
}, [startPlay, videoId, isPlaying, loadedData]);
248+
249+
// My version: traverse the videos to give them the right behavior, no one left behind.
250+
useEffect(() => {
251+
if (loadedVideos.length >= videoRef.current.length) {
252+
videoRef.current.forEach((video, i) => {
253+
if (i === videoId && startPlay) {
254+
isPlaying ? video.play() : video.pause();
255+
} else {
256+
video.pause();
257+
video.currentTime = 0;
258+
resetProgressBar(i);
259+
}
260+
});
261+
}
262+
}, [startPlay, videoId, isPlaying, loadedVideos]);
263+
264+
```
265+
266+
Now when you switch videos, everything gets reset properly - the old video stops and rewinds, the progress bar resets, and the new video starts playing. No more weird states where multiple videos might be playing at once! 🎉
267+
268+
## A Little Change Goes a Long Way
269+
270+
Never thought tweaking a few state variables and reshuffling some functions could make such a difference. Goes to show that even experienced developers can overcomplicate things. So don't be afraid to question and remember: KISS (Keep It Simple, Stupid)! 🌟

serve.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
bundle exec jekyll serve

0 commit comments

Comments
 (0)