Sitemap

"Scroll to Scrub" Videos

7 min readNov 16, 2023

--

A plane taking off. Because I need a header image, right?

🔥 Update: You can now just use the custom web component! 🔥

Hot on the heels of the last episode, here’s a bonus way to show off video content that puts the user in control 🕹

Let me kick off by showing you the end result; a video which the user can scrub through by scrolling the page:

This example also uses the “zoom-on-scroll” effect detailed in the last post.

If you don’t code, that’s the post. Hope you liked it. Mind how you go.

For the benefit of those who do code, I’m now going to tip-toe through the javascript which makes this happen.

The Manager Class

A pattern I use a lot is one of a class which manages one area of behaviour, keeping the code encapsulated in its own place, and with a single point of entry. Here, the ‘manager class’ is called ScrubVideoManager.

When the DOM content loads, we create a new instance of this manager class:

document.addEventListener("DOMContentLoaded", function () {
new ScrubVideoManager();
});

Upon instantiation, the ScrubVideoManager performs the following initialisations:

1. Get a list of all the videos we want to handle

this.scrubVideoWrappers = document.querySelectorAll(".scrub-video-wrapper");

ScrubVideoManager can handle any number of videos, so we make a list of all those that are found on the page and store them in a class property scrubVideoWrappers.

2. Create an IntersectionObserver

// Create the intersectionObserver
const observer = new IntersectionObserver(
this.intersectionObserverCallback,
{ threshold: 1 }
);

// Add a pointer to the manager class so we can refer to it later
observer.context = this;

Here we create an IntersectionObserver, passing a class method intersectionObserverCallback as the callback. This callback will be called every time the IntersectionObserver detects a change, i.e. when one of our video wrappers scrolls in or out of the viewport. I’ll come back to this callback, as there is still some stuff to prepare.

3. Setup the video wrappers

We’re going to cache some data on each of the videos in an array (scrubVideoWrappersData) so that we only have to query the DOM the minimum number of times, improving performance.

Here we initialise this cache to an empty array:

// Initialise empty cache of wrapper data
this.scrubVideoWrappersData = [];

Now we will loop through the list of video wrappers and set them up:

this.scrubVideoWrappers.forEach((wrapper, index) => {
// Attach IntersectionObserver
observer.observe(wrapper.querySelector(".scrub-video-container"));

// Store the numerical index for this wrapper in
// a data attribute on the element
wrapper.setAttribute("data-scrub-video-index", index);

// Store reference to video DOM element in the data cache
const video = wrapper.querySelector("video");
this.scrubVideoWrappersData[index] = {
video: video
};

// Force load video
this.fetchVideo(video);
});

The data-scrub-video-index attribute we will use later to tie the video wrapper up with its entry in the scrubVideoWrappersData cache.

We also add a new item to the scrubVideoWrappersData cache for this video wrapper, and store the video DOM element in the cache so we only have to query the DOM for it once.

Lastly, we ‘force load’ the video (more on this later).

4. Store the (current) locations of the video wrappers

// Store positions of all the wrapper elements
this.updateWrapperPositions();

We will need to know where the video wrappers start and end on the page, so that we can determine how far through the wrapper the user has scrolled, and consequently how far through the video we should scrub.

updateWrapperPositions() {
// Get new positions of video wrappers
this.scrubVideoWrappers.forEach((wrapper, index) => {
const clientRect = wrapper.getBoundingClientRect();
const top = clientRect.y + window.scrollY;
const bottom = clientRect.bottom - window.innerHeight + window.scrollY;

this.scrubVideoWrappersData[index].top = top;
this.scrubVideoWrappersData[index].bottom = bottom;
});
}

We will also make sure the positions are kept up to date by calling this method again whenever the window is resized:

window.addEventListener("resize", () => {
this.updateWrapperPositions();
});

5. The IntersectionObserver callback

This is the method which is called every time the IntersectionObserver detects a change for any of the observed elements, namely that they scroll in or out of the viewport.

intersectionObserverCallback(entries, observer) {
entries.forEach((entry) => {
// Is the item currently in the viewport?
const isWithinViewport = entry.intersectionRatio === 1;

// Add class 'in-view' to element if
// it is within the viewport
entry.target.classList.toggle("in-view", isWithinViewport);

if (isWithinViewport) {
// Record the index of the current item as the
// 'activeVideoWrapper'
observer.context.activeVideoWrapper =
entry.target.parentNode.getAttribute(
"data-scrub-video-index"
);
} else {
// There is no current activeVideoWrapper
observer.context.activeVideoWrapper = null;
}
});
}

In the definition of the callback, we have specified two arguments: entries (the list of observed changes), and more unusually observer, which is a reference to the observer we just created.

After we created that observer, we added a property context, which was a pointer back to the ScrubVideoManager class itself. We can now use that reference to update a property of the ScrubVideoManager class, activeVideoWrapper from inside the IntersectionObserver callback.

And so on this line, if we have scrolled to a video and made it active, we set ScrubVideoManager.activeVideoWrapper to a reference to this video for use later:

// Record the index of the current item as the
// 'activeVideoWrapper'
observer.context.activeVideoWrapper =
entry.target.parentNode.getAttribute("data-scrub-video-index");

6. Handle window scroll events

Lastly, and most definitely not leastly, we want respond to the user’s scroll events, scrubbing through the active video (if there is one), and if not, doing nothing.

handleScrollEvent = function (event) {
// Is there are currently active video wrapper?
if (this.activeVideoWrapper) {
// Get the cached data for this video wrapper
const activeWrapperData = this.scrubVideoWrappersData[
this.activeVideoWrapper
];

const top = activeWrapperData.top;
const bottom = activeWrapperData.bottom;
const video = activeWrapperData.video;

// This gives a number between 0 and very nearly 1 to
// represent how far the user has scrolled through
// the active video container
const progress = Math.max(
Math.min((window.scrollY - top) / (bottom - top), 0.998),
0
);

// How far through the video do we need to scrub for this
// amount of scroll progress?
const seekTime = progress * video.duration;

// Skip the video to the correct time
video.currentTime = seekTime;
}
};

So we get the cached data for the currently active video wrapper, use the top and bottom properties along with the window.scrollY property to work out how far through the video the user has scrolled.

We then work out the seek time in seconds which corresponds to that progress, and skip the video to that time.

The progress is clamped at a maximum of 0.998 instead of 1 to prevent it seeking past the end of the video due to rounding errors.

We attach this method to the window’s scroll event back in the ScrubVideoManager constructor:

document.addEventListener("scroll", (event) => {
this.handleScrollEvent(event);
});

You could use an animation library like GSAP to handle the seeking stuff for you, but in this case it’s simple enough to hand-roll.

7. ‘Force loading’ the videos

A final detail…

When a browser loads and plays a video in the ‘normal’ manner, it won’t generally load all of the video straight away, but instead will load the first part of the file (which includes the video metadata), and then only load the rest as the video plays and as the browser thinks is appropriate.

That won’t really do for us here. We need the scrolling to be nice and smooth, and can’t wait for bits to be lazy-loaded, because the user will have scrolled past the video by the time they load.

So we need to load the whole video as early as possible.

The video element supports a preload attribute, but it’s not of any use to us in this situation, as there is no value which would force the browser to download the whole file straight away (and anyway, browsers treat the attribute as a ‘mere hint’).

So we use the Fetch API to download the video, make it into an objectURL, and attach it to the video element.

fetchVideo(videoElement) {
const src = videoElement.getAttribute("src");

// Get the video
fetch(src)
.then((response) => response.blob())
.then((response) => {
// Create a data url containing the video raw data
const objectURL = URL.createObjectURL(response);

// Attach the
videoElement.setAttribute("src", objectURL);
});
}

Obviously, you wouldn’t be advised to use this trick for huge videos; you should try to keep the file sizes to a minimum.

Bonus content: encoding the videos

Digital videos are (massive simplification incoming 🙈) encoded and compressed in such a way that only the differences from the previous frame is recorded, and to keep things on a straight path every now and then a ‘keyframe’ which has the full frame data is included.

This means that when you skip to (seek) a point in the video which is not a keyframe, the decoder has to work back to the previous keyframe, and replay the following non-keyframe frames until it gets to the point you wanted to seek to. This isn’t normally a massive deal, as you don’t do all that much seeking in comparison to the amount of time you spend playing.

But here we never actually play the video, we only seek.

The result is that unless there are a lot of keyframes, seeking is slow, and as a result, this technique is choppy and janky, particularly on some browsers which have a complex relationship with h.264 (I’m talking about you Firefox 😐) .

For Chrome, Edge, and Safari, it seems to be fine to have a keyframe every five frames, but on Firefox you need one every two frames or it comes out as janky as hell. This makes for a much bigger file size, of course.

I leave it as an exercise for the reader to serve a different video file to Firefox.

Here’s what I use to encode the videos:

ffmpeg -i input.mp4   \ # input file name
-vf scale=1280:-1 \ # width of output movie file in pixels
-movflags faststart \ # it never hurts
-vcodec libx264 \ # h.264 encoder
-crf 18 \ # quality setting, 18 = excellent
-g 2 \ # keyframe every x frames
-pix_fmt yuv420p \ # pixel format
-an \ # no audio (you won't hear it anyway)
output.mp4 # outfile file name

--

--

Chris How
Chris How

Written by Chris How

Full stack developer, Laravel, WordPress, PHP, Javascript/Typescript, CSS. Based in Canary Islands, Spain. Github: chrishow

No responses yet