For multiple projects, I had to add a simple video component with play/pause button (Always include at least basic video controls for accessibility) and a buffering loader. It is not hard to detect the buffering state, but it can be tricky to get everything right.
Therefore, I created a simple component which I now copy from project to project with slight style adjustments. Jump to the code if you are not interested in how it's made.
Demo
Here you can see the final version (it might be easier to see the functionality if you open it in a separate tab).
How it works
The solution is simple and relies on four video events:
waiting
- playback has stopped because of a temporary lack of datapause
- playback has been pausedplay
- playback has begunplaying
- playback is ready to start after having been paused or delayed due to lack of data
We are going to use waiting
event to set our loading flag to true
and all other events to set it to false
.
If the video is already playing, and buffering happens, we'll get events fired in this order:
"waiting"
"playing"
This is all fine and dandy, but if the video is paused, and we play it things get a little messy. Now, if buffering happens, we'll get these events fired:
"play"
"playing"
"waiting"
"playing"
All of these events are fired in a very quick succession and alter the state too fast leading to some bad UX. The secret is to add a short debounce time to avoid it. What I found to work best is 200ms for waiting
event and 50ms for all others.
Code
I'm using React with hooks, but the same thing can be easily ported to React class or vanilla JavaScript.
import React, { useEffect, useState, useRef } from 'react';
const PLAYING_DEBOUNCE_TIME = 50;
const WAITING_DEBOUNCE_TIME = 200;
const Video = ({ src, ...props }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isWaiting, setIsWaiting] = useState(false);
const isWaitingTimeout = useRef(null);
const isPlayingTimeout = useRef(null);
const videoElementRef = useRef();
useEffect(() => {
if (!videoElementRef.current) {
return;
}
const waitingHandler = () => {
clearTimeout(isWaitingTimeout.current);
isWaitingTimeout.current = setTimeout(() => {
setIsWaiting(true);
}, WAITING_DEBOUNCE_TIME);
};
const playHandler = () => {
clearTimeout(isWaitingTimeout.current);
clearTimeout(isPlayingTimeout.current);
isPlayingTimeout.current = setTimeout(() => {
setIsPlaying(true);
setIsWaiting(false);
}, PLAYING_DEBOUNCE_TIME);
};
const pauseHandler = () => {
clearTimeout(isWaitingTimeout.current);
clearTimeout(isPlayingTimeout.current);
isPlayingTimeout.current = setTimeout(() => {
setIsPlaying(false);
setIsWaiting(false);
}, PLAYING_DEBOUNCE_TIME);
};
const element = videoElementRef.current;
element.addEventListener("waiting", waitingHandler);
element.addEventListener("play", playHandler);
element.addEventListener("playing", playHandler);
element.addEventListener("pause", pauseHandler);
return () => {
clearTimeout(isWaitingTimeout.current);
clearTimeout(isPlayingTimeout.current);
element.removeEventListener("waiting", waitingHandler);
element.removeEventListener("play", playHandler);
element.removeEventListener("playing", playHandler);
element.removeEventListener("pause", pauseHandler);
};
}, [videoElementRef]);
const handlePlayPauseClick = () => {
if (videoElementRef.current) {
if (isPlaying) {
videoElementRef.current.pause();
} else {
videoElementRef.current.play();
}
}
};
return (
<div className="SimpleVideo">
<video {...props} ref={videoElementRef} src={src} className="SimpleVideo-video" />
<button onClick={handlePlayPauseClick} className="SimpleVideo-playPause">
{isPlaying ? "Pause" : "Play"}
{isWaiting && <span className="SimpleVideo-loader">Buffering</span>}
</button>
</div>
);
};