components/video/index.tsx
import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
import cn from 'classnames';
import FullScreenIcon from 'components/icons/full-screen';
import PauseIcon from 'components/icons/pause';
import PlayIcon from 'components/icons/play';
import styles from './video.module.scss';
/**
* Pads a given number with leading zeros until it reaches a certain length.
* @param num - The number to pad.
* @param size - The desired number of digits.
* @return The padded number. Note that if the given `num` already has a length
* equal to or greater than the requested `size`, nothing will happen.
* @see {@link https://stackoverflow.com/a/2998822}
*/
function pad(num: number, size: number): string {
let str = num.toString();
while (str.length < size) str = `0${str}`;
return str;
}
/**
* Converts milliseconds to a readable timestamp.
* @param seconds - The seconds to convert into a timestamp.
* @return The formatted timestamp (MM:SS).
* @example
* secondsToTimestamp(24.78); // Returns '00:25'
*/
function secondsToTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${pad(mins, 2)}:${pad(secs, 2)}`;
}
function toggleSelection(value: string): void {
const style = (document.body.style as unknown) as Record<string, string>;
style['user-select'] = value;
style['-webkit-user-select'] = value;
style['-moz-user-select'] = value;
style['-ms-user-select'] = value;
}
export interface VideoProps {
mp4: string;
webm: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
}
export default function Video({
mp4,
webm,
poster,
autoplay,
muted,
loop,
}: VideoProps): JSX.Element {
const ref = useRef<HTMLVideoElement>(null);
const [visible, setVisible] = useState<boolean>(false);
const onEnter = useCallback(() => setVisible(true), []);
const onLeave = useCallback(() => setVisible(false), []);
const [playing, setPlaying] = useState<boolean>(autoplay || false);
const togglePlayback = useCallback(async () => {
if (!ref.current) return;
if (ref.current.paused) {
await ref.current.play();
setPlaying(true);
} else {
ref.current.pause();
setPlaying(false);
}
}, []);
const toggleFullscreen = useCallback(async () => {
if (ref.current) await ref.current.requestFullscreen();
}, []);
const [progress, setProgress] = useState<number>(0);
const [dragging, setDragging] = useState<boolean>(false);
const updateProgress = useCallback(() => {
if (dragging || !ref.current?.currentTime || !ref.current?.duration) return;
setProgress(ref.current.currentTime / ref.current.duration);
}, [dragging]);
useEffect(() => {
if (!ref.current || !ref.current.duration) return;
const updated = progress * ref.current.duration;
if (progress === 1) setPlaying(false);
if (Math.round(updated) === Math.round(ref.current.currentTime)) return;
ref.current.currentTime = progress * ref.current.duration;
}, [progress]);
const updateDrag = useCallback(
(event: MouseEvent) => {
if (!dragging) return;
const bounds = event.currentTarget.getBoundingClientRect();
const updated = (event.clientX - bounds.left) / bounds.width;
setProgress(Math.min(1, Math.max(0, updated)));
},
[dragging]
);
const endDrag = useCallback(
(event: MouseEvent) => {
setDragging(false);
toggleSelection('');
updateDrag(event);
},
[updateDrag]
);
const startDrag = useCallback(
(event: MouseEvent) => {
setDragging(true);
toggleSelection('none');
updateDrag(event);
},
[updateDrag]
);
return (
<figure
className={styles.wrapper}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
<div className={styles.main}>
<div className={styles.container}>
<video
ref={ref}
poster={poster}
onTimeUpdate={updateProgress}
onDurationChange={updateProgress}
preload={autoplay ? 'none' : 'auto'}
autoPlay={autoplay}
muted={muted}
loop={loop}
playsInline
>
<source src={webm} type='video/webm' />
<source src={mp4} type='video/mp4' />
<track default kind='captions' srcLang='en' />
</video>
<div className={cn(styles.controls, { [styles.visible]: visible })}>
<button
className={styles.play}
type='button'
onClick={togglePlayback}
>
{playing && <PauseIcon />}
{!playing && <PlayIcon />}
</button>
<div className={styles.time}>
{secondsToTimestamp(ref.current?.currentTime || 0)}
</div>
<div className={styles.progress}>
<div
onMouseUp={endDrag}
onMouseLeave={endDrag}
onMouseDown={startDrag}
onMouseMove={updateDrag}
className={styles.dragHandler}
/>
<progress value={progress * 100} max='100' />
<div
style={{ left: `${progress * 100}%` }}
className={styles.handle}
/>
</div>
<div className={styles.time}>
{secondsToTimestamp(ref.current?.duration || 0)}
</div>
<button
className={styles.fullscreen}
type='button'
onClick={toggleFullscreen}
>
<FullScreenIcon />
</button>
</div>
</div>
</div>
</figure>
);
}