使用React手把手教你写bilibili视频播放器

753 阅读3分钟

记录学习过程,如有错误欢迎指出

输出一下我的观点

video标签元素样式都挺丑的,我相信大家不会反对吧.

那就让我们一起美化它!

分析结构

Screenshot 2022-10-30 at 16.55.48.png

  • 视频本身
  • 控制台
    • 播放暂停按钮
    • 时间戳
    • 音量
    • 全屏
    • 倍数
  • 进度条

目录结构

我比较小喜欢styled-components

npm install styled-components
src/
    App.tsx
    styled.ts
    utils/
    components/

开搞

import { Wrap, Video } from './style';
export const App(){

//后续必然会用到ref
const wrapRef = useRef(null) as React.RefObject<HTMLDivElement> | null;
const videoRef = useRef(null) as React.RefObject<HTMLVideoElement> | null;
  
return 
    (
    <Wrap ref={wrapRef}>
        <Video
            ref={videoRef}
            preload="metadata"
            >
            <source src="videoUrl" />
        </Video>
    </Wrap>
    )
}

实现点击视频播放/暂停

...
const [isPlay, setIsPlay] = useState(false);

// 播放暂停
const playOrPause = () => {
    if (videoRef !== null && videoRef.current) {
      // 暂停
      let flagCopy = { ...flag };
      if (isPlay) {
        videoRef.current.pause();
        setIsPlay(false);
      } else {
        // 播放
        videoRef.current.play();
        setIsPlay(true);
      }
      setFlag(flagCopy);
    }
};
  
return 
    (
    <Wrap ref={wrapRef}>
        <Video
            ...
            onClick={playOrPause}
            >
            <source src="videoUrl" />
        </Video>
    </Wrap>
    )
}

播放暂停按钮

PausePlay无任何逻辑,仅是导出的播放和暂停按钮的svg

...
    {/* 播放 暂停 */}
    <div onClick={playOrPause}>
      {isPlay ? <Pause></Pause> : <Play></Play>}
    </div>
...

时间戳

梳理一下:时间戳 = 当前视频播放数据 + / + 视频总时间

想要获取到视频的时间肯定是要等视频第一帧加载完毕的时候.

import {...,StampTime} from 'style.ts'

...
const [sharedState, setSharedState] = useState({
    height: props.high, //传入的视频高度
    width: props.long, //传入的视频宽度
    offsetLeft: 0, //wrap的左偏移量
    offsetWidth: 0,  //wrap的宽度 === 传入的视频高度
    duration: 0, //视频总时间 单位为秒
    formatDuration: '00:00:00', //格式化后的总时间
  });

// 视频加载第一帧后获取各种信息
const getInfo = async () => {
    if (
      wrapRef !== null &&
      wrapRef.current &&
      videoRef !== null &&
      videoRef.current
    ) {
      let shared = { ...sharedState };
      shared.offsetLeft = wrapRef.current.offsetLeft;
      shared.offsetWidth = wrapRef.current.offsetWidth;
      shared.duration = videoRef.current.duration;
      shared.formatDuration = await formatTime(videoRef.current.duration);
      setSharedState(shared);
      let flagCopy = { ...flag };
      flagCopy.volume = videoRef.current.volume;
      videoRef.current.pause();
      setIsPlay(false);
      setFlag(flagCopy);
    }
};

...
    <Video
        ...
        onClick={playOrPause}
        onLoadedData={getInfo}
        >
        <source src="videoUrl" />
    </Video>
    {/* 时间戳 */}
    <StampTime>
      {flag.formatCurrentTime} / {sharedState.formatDuration}
    </StampTime>
...

进度条

  • 进度条:
    • 背景进度条 (为缓冲到的那根)
    • 缓存进度条 (在我们看视频时,进度条上一般都有一根灰色的进度条在前进)
    • 进度条 (当前时间映射到进度条上的比例)
import {...,Bar,BackBar,ProgressBar,BufferBar} from 'style.ts'

...
const [barState, setBarState] = useState({
    height: '2px',
    bufferWidth: '0px',
    progressWidth: '0px',
  });

/**
* bar相关
*/

const mouseEnterBar = () => {
    let flagCopy = { ...flag };
    flagCopy.isEnterBar = true;
    let barStateCopy = { ...barState };
    barStateCopy.height = '4px';
    setBarState(barStateCopy);
    setFlag(flagCopy);
  };

  const mouseLeaveBar = () => {
    if (!flag.isEnterBar) return;
    let flagCopy = { ...flag };
    flagCopy.isEnterBar = false;
    let barStateCopy = { ...barState };
    barStateCopy.height = '2px';
    let DetailVideoStateCopy = { ...DetailVideoState };
    DetailVideoStateCopy.display = 'none';
    setDetailVideoState(DetailVideoStateCopy);
    setBarState(barStateCopy);
    setFlag(flagCopy);
  };

  const mouseInBarMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    if (!flag.isEnterBar) return false;
    if (DetailVideoRef !== null && DetailVideoRef.current) {
      let res = getDetailLeft(sharedState.offsetLeft + 20, e.pageX);
      let px = getForwordPx(sharedState.offsetLeft + 20, e.pageX);
      let time = forwordPxToVideoTime(
        px,
        sharedState.duration,
        sharedState.offsetWidth - 40
      );
      let DetailVideoStateCopy = { ...DetailVideoState };
      DetailVideoRef.current.currentTime = time;
      DetailVideoStateCopy.left = res + 'px';
      DetailVideoStateCopy.display = 'block';
      setDetailVideoState(DetailVideoStateCopy);
    }
    return false;
  };

  const mouseDownBar = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    let flagCopy = { ...flag };
    flagCopy.isCurrentClickBar = true;
    setFlag(flagCopy);
    let BarStateCopy = { ...barState };
    let px = getForwordPx(sharedState.offsetLeft + 20, e.pageX);
    BarStateCopy.progressWidth = px + 'px';
    setBarState(BarStateCopy);
    let time = forwordPxToVideoTime(
      px,
      sharedState.duration,
      sharedState.offsetWidth - sharedState.offsetLeft - 40
    );
    if (videoRef !== null && videoRef.current) {
      videoRef.current.currentTime = time;
    }
  };

  const mouseUpBar = () => {
    let flagCopy = { ...flag };
    flagCopy.isCurrentClickBar = false;
    if (isPlay === false) {
      setIsPlay(true);
      if (videoRef !== null && videoRef.current) {
        videoRef.current.play();
      }
    }
    setFlag(flagCopy);
};


...
{/* 进度条 */}
      <Bar
        onMouseEnter={mouseEnterBar}
        onMouseLeave={mouseLeaveBar}
        onMouseMove={e => {
          mouseInBarMove(e);
        }}
        onMouseDown={e => {
          mouseDownBar(e);
        }}
        onMouseUp={mouseUpBar}
      >
        {/* 进度条 */}
        <ProgressBar
          style={{
            width: barState.progressWidth,
            height: barState.height,
            backgroundColor: sharedState.progressColor,
          }}
        ></ProgressBar>
        {/* 缓冲条 */}
        <BufferBar
          style={{
            width: barState.bufferWidth,
            height: barState.height,
            backgroundColor: sharedState.bufferColor,
          }}
        ></BufferBar>
        {/* 背景条 */}
        <BackBar style={{ height: barState.height }}></BackBar>
      </Bar>
...

音量


/**
* 音量控制
*/
  const increaseVolume = () => {
    if (videoRef !== null && videoRef.current) {
      videoRef.current.muted = false;
      if (videoRef.current.volume === 1) {
      } else {
        videoRef.current.volume = (videoRef.current.volume * 10 + 1) / 10;
      }
      let flagCopy = { ...flag };
      flagCopy.volume = videoRef.current.volume;
      setFlag(flagCopy);
    }
  };
  const decreaseVolume = () => {
    if (videoRef !== null && videoRef.current) {
      videoRef.current.muted = false;
      if (videoRef.current.volume === 0) {
        if (!videoRef.current.muted) {
          videoRef.current.muted = true;
        }
      } else {
        videoRef.current.volume = (videoRef.current.volume * 10 - 1) / 10;
      }
      let flagCopy = { ...flag };
      flagCopy.volume = videoRef.current.volume;
      setFlag(flagCopy);
    }
  };
  const volumeChange = () => {
    if (!nonStateDep.isShowVolumeBack) {
      let nonStateDepCopy = { ...nonStateDep };
      nonStateDepCopy.isShowVolumeBack = true;
      setNonStateDep(nonStateDepCopy);
    }
    if (timer.current.volumeTimer) {
      clearTimeout(timer.current.volumeTimer);
    }
    timer.current.volumeTimer = setTimeout(() => {
      let nonStateDepCopy = { ...nonStateDep };
      nonStateDepCopy.isShowVolumeBack = false;
      setNonStateDep(nonStateDepCopy);
    }, 2000);
  };

useEffect(() => {
    //监听上下键
    window.onkeydown = (e: KeyboardEvent) => {
      switch (e.code) {
        case 'Space':
          break;
        case 'ArrowUp':
          increaseVolume();
          break;
        case 'ArrowDown':
          decreaseVolume();
          break;
      }
    }
    // clear cyle
    return () => {
      window.onkeydown = null;
    };
  }, []);

...
      {/* 音量 */}
      {nonStateDep.isShowVolumeBack ? (
        <VolumeBack>
          <Volume>
            {flag.volume === 0 ? (
              <svg
                className="icon"
                viewBox="0 0 1024 1024"
                version="1.1"
                xmlns="http://www.w3.org/2000/svg"
                p-id="1770"
                width="30"
                height="30"
              >
                <path ...
                  fill="#000000"
                  p-id="1771"
                ></path>
              </svg>
            ) : (
              <svg
                className="icon"
                viewBox="0 0 1024 1024"
                version="1.1"
                xmlns="http://www.w3.org/2000/svg"
                p-id="1403"
                width="30"
                height="30"
              >
                <path ...
                  p-id="1404"
                  fill="#000000"
                ></path>
              </svg>
            )}
            {flag.volume === 0 ? '静音' : flag.volume * 100 + '%'}
          </Volume>
        </VolumeBack>
      ) : null}
...

视频预览

描述:就是鼠标移到进度条时,可以显示出鼠标指向的视频时间的画面

实现方法:

  • 方法1:canvas截图
  • 方法2:隐藏一个video标签,移入进度条的时候显示,移除隐藏(这种方法有个弊端,就是会重复请求视频,相当于用户看一个视频的时候,实际开了两个视频.所以这个隐藏video应该为其src填入一个压缩后的视频链接)

推荐一个视频压缩网站ocompress.com/cn/video

我这里实现的方法2.

鼠标移入进度条,获取当前鼠标位置对应的视频时间,将这个视频时间赋给预览图video的currentTime

import {...,DetailVideo} from 'style.ts'

...
const DetailVideoRef = useRef(null) as React.RefObject<
    HTMLVideoElement
  > | null;
...

{/* 预览图 */}
  <DetailVideo
    ref={DetailVideoRef}
    muted
    style={{
      left: DetailVideoState.left,
      display: DetailVideoState.display,
    }}
  >
    <source src={sharedState.minVideoUrl} />
  </DetailVideo>

loading

主要事件onWaiting,onPlaying

// 等待缓冲
const waitLoadVideo = () => {
    if (videoRef !== null && videoRef.current) {
      // 暂停
      let flagCopy = { ...flag };
      setIsPlay(false);
      flagCopy.isWaitBuffer = true;
      setFlag(flagCopy);
      let nonStateDepCopy = { ...nonStateDep };
      nonStateDepCopy.isShowLoadingBack = true;
      setNonStateDep(nonStateDepCopy);
    }
};

// 等待缓存结束
const waitEnd = () => {
    if (videoRef !== null && videoRef.current) {
      // 暂停
      let flagCopy = { ...flag };
      setIsPlay(true);
      flagCopy.isWaitBuffer = false;
      setFlag(flagCopy);
      let nonStateDepCopy = { ...nonStateDep };
      nonStateDepCopy.isShowLoadingBack = false;
      setNonStateDep(nonStateDepCopy);
    }
};

...
    <Video
        ...
        onWaiting={waitLoadVideo}
        onPlaying={waitEnd}
    >
    </Viode>
    {/* loading */}
      {nonStateDep.isShowLoadingBack ? (
        <Load>{flag.isWaitBuffer ? <Loading></Loading> : null}</Load>
      ) : null}
...

全屏 & 倍数

这个就留给大家自己思考吧

ps:不要使用video自带的全屏事件,会覆盖我们自己的样式.

倍数借助属性:playbackRate.

源码

//尚未完工,切换用于工作项目
npm i react-customize-player

github:github.com/caro1xxx/re…

记录学习过程,如有错误欢迎指出