import { MutableRefObject, ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import Hls from "hls.js"; import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { LuPause, LuPlay } from "react-icons/lu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { MdForward10, MdReplay10, MdVolumeDown, MdVolumeMute, MdVolumeOff, MdVolumeUp, } from "react-icons/md"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Slider } from "../ui/slider-volume"; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, MediaError.MEDIA_ERR_DECODE, ]; type HlsVideoPlayerProps = { className: string; children?: ReactNode; videoRef: MutableRefObject; currentSource: string; onClipEnded?: () => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; }; export default function HlsVideoPlayer({ className, children, videoRef, currentSource, onClipEnded, onPlayerLoaded, onTimeUpdate, onPlaying, }: HlsVideoPlayerProps) { // playback const hlsRef = useRef(); const [useHlsCompat, setUseHlsCompat] = useState(false); useEffect(() => { if (!videoRef.current) { return; } if (videoRef.current.canPlayType(HLS_MIME_TYPE)) { return; } else if (Hls.isSupported()) { setUseHlsCompat(true); } }, [videoRef]); useEffect(() => { if (!videoRef.current) { return; } const currentPlaybackRate = videoRef.current.playbackRate; if (!useHlsCompat) { videoRef.current.src = currentSource; videoRef.current.load(); return; } if (!hlsRef.current) { hlsRef.current = new Hls(); hlsRef.current.attachMedia(videoRef.current); } hlsRef.current.loadSource(currentSource); videoRef.current.playbackRate = currentPlaybackRate; }, [videoRef, hlsRef, useHlsCompat, currentSource]); // controls const [isPlaying, setIsPlaying] = useState(true); const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState(); const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); const onKeyboardShortcut = useCallback( (key: string, down: boolean, repeat: boolean) => { if (!videoRef.current) { return; } switch (key) { case "ArrowLeft": if (down) { const currentTime = videoRef.current.currentTime; if (currentTime) { videoRef.current.currentTime = Math.max(0, currentTime - 5); } } break; case "ArrowRight": if (down) { const currentTime = videoRef.current.currentTime; if (currentTime) { videoRef.current.currentTime = currentTime + 5; } } break; case "m": if (down && !repeat && videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } break; case " ": if (down && videoRef.current) { if (videoRef.current.paused) { videoRef.current.play(); } else { videoRef.current.pause(); } } break; } }, // only update when preview only changes // eslint-disable-next-line react-hooks/exhaustive-deps [videoRef.current], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "m", " "], onKeyboardShortcut, ); return (
{ setControls(true); } : undefined } onMouseOut={ isDesktop ? () => { setControls(controlsOpen); } : undefined } onClick={isDesktop ? undefined : () => setControls(!controls)} >
); } type VideoControlsProps = { video: HTMLVideoElement | null; isPlaying: boolean; show: boolean; controlsOpen: boolean; setControlsOpen: (open: boolean) => void; }; function VideoControls({ video, isPlaying, show, controlsOpen, setControlsOpen, }: VideoControlsProps) { const playbackRates = useMemo(() => { if (isSafari) { return [0.5, 1, 2]; } else { return [0.5, 1, 2, 4, 8, 16]; } }, []); const onReplay = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); const currentTime = video?.currentTime; if (!video || !currentTime) { return; } video.currentTime = Math.max(0, currentTime - 10); }, [video], ); const onSkip = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); const currentTime = video?.currentTime; if (!video || !currentTime) { return; } video.currentTime = currentTime + 10; }, [video], ); const onTogglePlay = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (!video) { return; } if (isPlaying) { video.pause(); } else { video.play(); } }, [isPlaying, video], ); // volume control const VolumeIcon = useMemo(() => { if (!video || video?.muted) { return MdVolumeOff; } else if (video.volume <= 0.33) { return MdVolumeMute; } else if (video.volume <= 0.67) { return MdVolumeDown; } else { return MdVolumeUp; } // only update when specific fields change // eslint-disable-next-line react-hooks/exhaustive-deps }, [video?.volume, video?.muted]); if (!video || !show) { return; } return (
{ e.stopPropagation(); video.muted = !video.muted; }} /> {video.muted == false && ( (video.volume = value[0])} /> )}
{isPlaying ? ( ) : ( )}
{ setControlsOpen(open); }} > {`${video.playbackRate}x`} (video.playbackRate = parseInt(rate))} > {playbackRates.map((rate) => ( {rate}x ))}
); }