React-自定义音频播放组件

237 阅读2分钟

研究了一天终于完美实现了UI图的设计

实现的效果:

image.png

image.png

image.png

代码如下:

import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from 'react'
import clsx from 'clsx'

import { useStores } from '@/hooks'

import styles from './index.less'

const AudioPlay = (props, ref) => {
  const { src, duration } = props
  
  const [isPlay, setIsPlay] = useState(false)
  const [isCanplay, setIsCanplay] = useState(false) // 是否可以播放

  const [currentTime, setCurrentTime] = useState(0) // 当前播放进度时间,单位秒
  const [totalTime, setTotalTime] = useState(0) // 总时长,单位秒

  const audioRef = useRef(null)

  useEffect(() => {
    if (duration) {
      setTotalTime(duration < 1 ? 1 : duration) // 小于1秒,会显示成00:00,至少1秒
      setIsCanplay(true)
    }
  }, [duration])

  useEffect(() => {
    const audio = audioRef.current
    if (audio) {
      audio.addEventListener('loadedmetadata', onLoadedMetadata, false)
      audio.addEventListener('timeupdate', onTimeUpdate, false)
      audio.addEventListener('ended', onEnd, false)
      audio.addEventListener('pause', onPause, false)
    }

    return () => {
      const audio = audioRef.current
      if (audio) {
        audio.removeEventListener('loadedmetadata', onLoadedMetadata)
        audio.removeEventListener('timeupdate', onTimeUpdate)
        audio.removeEventListener('ended', onEnd)
        audio.removeEventListener('pause', onPause)
        audio.pause()
      }
    }
  }, [])

  const formatSecond = (time) => {
    const second = Math.floor(time % 60)
    const minute = Math.floor(time / 60)
    return `${minute}:${second >= 10 ? second : `0${second}`}`
  }

  const onLoadedMetadata = () => {
    if (audioRef.current) {
      const { duration } = audioRef.current
      setTotalTime(duration < 1 ? 1 : duration) // 小于1秒,会显示成00:00,至少1秒
      setIsCanplay(true)
    }
  }

  const onTimeUpdate = () => {
    if (audioRef.current) {
      const { currentTime, duration } = audioRef.current
      setCurrentTime(currentTime)
      if (currentTime >= duration) {
        pauseAudio()
      }
    }
  }

  const onEnd = () => {
    setIsPlay(false)
    window.setTimeout(() => {
      setCurrentTime(0)
    }, 300)
  }

  const onPause = () => {
    setIsPlay(false)
  }

  const playAudio = () => {
    const audioList = document.querySelectorAll('.kb-audio audio')
    audioList.forEach((v) => {
      if (audioRef.current !== v) {
        v.pause() // 暂停其他音频
        v.currentTime = 0 // 进度回到0
      }
    })
    if (audioRef.current) {
      audioRef.current.play()
      setIsPlay(true)
    }
  }

  const pauseAudio = () => {
    if (audioRef.current) {
      audioRef.current.pause()
      setIsPlay(false)
    }
  }

  const changeTime = (e) => {
    const { value } = e.target
    const audio = audioRef.current
    if (audio) {
      setCurrentTime(value)
      audio.currentTime = value
      if (value === audioRef.current.duration) {
        setIsPlay(false)
      }
    }
  }

  // 暴露 audio 和一些方法给外部
  useImperativeHandle(ref, () => ({
    audio: audioRef.current,
    isPlay,
    isCanplay,
    currentTime,
    totalTime,
    playAudio,
    pauseAudio,
  }))

  return (
    <div className={clsx(styles['basic-audio'], 'kb-audio')} onClick={(e) => e.stopPropagation()}>
      <audio src={src || null} ref={audioRef} preload="auto">
        <track src={src || null} kind="captions" />
      </audio>

      <div className={styles['audio-control-wrap']}>
        <div className={styles['audio-control']}>
          {!isCanplay ? (
            <div className={styles['audio-loading']}></div>
          ) : (
            <>
              {isPlay ? (
                <div className={styles['audio-pause']} onClick={pauseAudio}></div>
              ) : (
                <div className={styles['audio-play']} onClick={playAudio}></div>
              )}
            </>
          )}
        </div>

        <div className={styles['audio-progress']}>
          <div className={styles['audio-progress-bg']}>
            <div
              className={styles['audio-progress-bar']}
              style={{ width: `${(currentTime / totalTime) * 100}%` }}
            ></div>
          </div>
          <input
            title="音频进度"
            type="range"
            step="0.01"
            max={totalTime}
            value={currentTime}
            onChange={changeTime}
            disabled={!isCanplay}
            className={styles['audio-progress']}
          />
        </div>

        <span className={styles['audio-time']}>
          {formatSecond(currentTime)}/{formatSecond(totalTime)}
        </span>
      </div>
    </div>
  )
}

export default forwardRef(AudioPlay)

.basic-audio {
  width: 435px;
  height: 48px;
  background: #e6f4ff;
  border-radius: 7px;
  padding-left: 20px;
  padding-right: 15px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  .audio-control-wrap {
    flex: 1;
    display: flex;
    justify-content: space-between;
    align-items: center;
    .audio-control {
      .audio-loading {
        width: 20px;
        height: 20px;
        border: 4px solid #5671ee;
        border-top-color: transparent;
        border-radius: 100%;
        animation: circle infinite 1s linear;
      }

      // 转转转动画
      @keyframes circle {
        0% {
          transform: rotate(0);
        }
        100% {
          transform: rotate(360deg);
        }
      }

      .audio-pause {
        width: 20px;
        height: 26px;
        background: url('@{cdnDomain}fe/xh-sls/common/audio-pause.png') no-repeat center center;
        background-size: 100% 100%;
        cursor: pointer;
        &:hover,
        &:active {
          opacity: 0.9;
        }
      }
      .audio-play {
        width: 20px;
        height: 26px;
        background: url('@{cdnDomain}fe/xh-sls/common/audio-play.png') no-repeat center center;
        background-size: 100% 100%;
        cursor: pointer;
        &:hover,
        &:active {
          opacity: 0.9;
        }
      }
    }

    .audio-time {
      font-weight: bold;
      font-size: 14px;
      color: #5671ee;
    }

    .audio-progress {
      flex: 1;
      margin-left: 20px;
      margin-right: 15px;
      position: relative;
      z-index: 1;
      display: flex;
      justify-content: center;
      align-items: center;
      .audio-progress-bg {
        height: 14px;
        position: absolute;
        left: 0;
        top: 50%;
        transform: translateY(-50%);
        z-index: 1;
        width: 100%;
        background: rgba(56, 89, 245, 0.21);
        border-radius: 7px;
        padding: 2px 3px;

        .audio-progress-bar {
          height: 100%;
          background-color: #5671ee;
          border-top-left-radius: 7px;
          border-bottom-left-radius: 7px;
        }
      }

      // 进度条样式
      [type='range'] {
        position: relative;
        z-index: 2;
        -webkit-appearance: none;
        appearance: none;
        margin: 0;
        outline: 0;
        width: 100%;
        height: 14px;
        background-color: transparent;
      }

      /* 设置滑块颜色 */
      [type='range']::-webkit-slider-thumb {
        appearance: none;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        background-color: #ffffff;
        border: 2px solid #5671ee;
        cursor: pointer;
      }
      [type='range']::-moz-range-thumb {
        appearance: none;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        background-color: #ffffff;
        border: 2px solid #5671ee;
        cursor: pointer;
      }
      [type='range']::-ms-thumb {
        appearance: none;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        background-color: #ffffff;
        border: 2px solid #5671ee;
        cursor: pointer;
      }
    }
  }
}