기술/웹 개발

[react/tailwind css] 자체적으로 audio player 구현하기

하기싫지만어떡해해야지 2024. 10. 6. 20:25

이번에 맡게된 외주 개발 작업에서

1. 사용자가 mediaRecorder로 녹음한거 재생

2. mp3 파일 재생

을 할 오디오 플레이어를 구현해야했다

 

Figma상 디자인은 이랬다

 

 

따라서 대충 슥 봤을 때

크게 구현해야하는 기능은

 

1. audioURL 생성해서 audio component에서 재생가능하게하기

2. play, pause 할 때마다 아이콘 바뀌고

audio 재생했다가 멈췄다가해주기

3. audio 전체 길이와 현재 재생 중인 시간

계산해서 progress bar 만들어주기

 

였다

 

 

 처음에는 audioBlob이나 URL을

생성하지 않고

바로 audioRef를 넣어서

재생하게 해줬는데

이유는 모르겠지만 재생에 필요한

셋업을 하는 시간이 너무 오래 걸려서..

 

audioRef -> audioBlob -> URL

로 변경한 뒤

그걸 audio에 넣어주기로 했다

 

그러니 화면이 렌더링되자마자

바로 재생이 가능해졌다

 

나는 CustomAudioPlayer라는

컴포넌트를 따로 생성해줬다

 

우선 audioBlob까지만 만들어서

파라미터로 넘겨주면

CustomAudioPlayer

함수 내부에서

blob을 URL로 만들어주기로했다

 

const CustomAudioPlayer = ({audioBlob }) => {
  const [audioURL, setAudioURL] = useState(null);

  useEffect(() => {
    if (audioBlob) {
      const url = URL.createObjectURL(audioBlob);
      setAudioURL(url);

      return () => URL.revokeObjectURL(url);
    }
  }, [audioBlob]);
  
  return(
      <div className="flex flex-col items-center bg-transparent max-w-md min-w-[500px] w-full mt-5 mb-5">
      	<audio id="audio-player" src={audioURL} preload="metadata"/>
      </div>
  )

 

useEffect로

audioBlob을 받아오면

URL을 생성해

audioURL 변수에 넣어주었다

 

그런다음 <audio>를 생성해서

src에 URL을 넣어주면 된다

 

preload = "metadata"는

audio component 생성과 함께

metadata를 셋팅하게해달라는?

그런 역할인 듯 했다

 

 

이제 그 다음은 play와 pause 기능을

구현해보자

 

우선 현재 play인지 아닌지를

체크해줄 flag가 필요하다

 

그래서 위에 

const [isPlaying, setIsPlaying] = useState(false);

로 설정해준다

 

처음에 audioPlayer가 렌더링 됐을 때는

재생이 되면 안되므로 처음 값은 false로 해준다

 

그다음 재생할 때 실행되는 함수를

구현해준다

  const handlePlay = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.play();
      setIsPlaying(true);
    }
  };

 

나는 audio의 id를 'audio-player'로 해놓았다

그런다음 audioElement가 존재하면

이를 play하게 해준다

 

그런 다음 isPlaying flag를

현재 재생이 되고 있으므로 true로 바꿔준다

 

  const handlePause = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.pause();
      setIsPlaying(false);
    }
  };

 

pause도 동일하게

audioElement를 찾아서 pause하게 해주고

isPlaying을 false로 바꿔주게한다

 

 

이렇게 handlePlay와 handlePause 함수를

정의해줬으면

재생버튼을 만들어서

isPlaying에 따라 함수들이 실행되게 구현해주자

 

    <div className="flex items-center bg-transparent w-full">
        <button
            onClick={isPlaying ? handlePause : handlePlay}
            className="relative z-10"
            style={{ cursor: 'pointer' }} 
          >
            <img
              src={isPlaying ? pauseIcon : playIcon}
              alt={isPlaying ? 'Pause' : 'Play'}
              className="object-contain" 
              style={{ maxHeight: '32px', maxWidth: '32px' }} 
            />
        </button>
    </div>

 

위와 같이 button 부분을 구현해줬다

 

우선 위에 icon들의 경로를 각각

playIcon, pauseIcon로 선언해줬고

 

Figma UI상

재생과 일시정지는 한 개의 버튼으로

실행이 되므로

button을 1개만 만들었다

 

그리고 만약 isPlaying이 true라면

버튼을 눌렀을 때 handlePause가 실행되도록

isPlaying false라면 버튼을 눌렀을 때

handlePlay가 실행되도록 구현해줬다

 

그다음 아이콘의 src도 동일하게

isPlaying이 true면

일시정지 모양의 img가 뜨도록

isPlaying이 false면

재생 모양의 img가 뜨도록 해줬다

 

이 부분을 구현한 전체 코드는

아래와 같다

const CustomAudioPlayer = ({audioBlob, duration }) => {
  const [audioURL, setAudioURL] = useState(null);
  // playing, pause 체크할 flag
  const [isPlaying, setIsPlaying] = useState(false);

  // icon 가져올 경로
  const playIcon = IconPaths.CUSTOM_AUDIO_PLAYER_PLAY;
  const pauseIcon = IconPaths.CUSTOM_AUDIO_PLAYER_PAUSE;

  useEffect(() => {
    if (audioBlob) {
      const url = URL.createObjectURL(audioBlob);
      setAudioURL(url);

      return () => URL.revokeObjectURL(url);
    }
  }, [audioBlob]);

  // play 구현
  const handlePlay = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.play();
      setIsPlaying(true);
    }
  };

  // Pause 구현
  const handlePause = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.pause();
      setIsPlaying(false);
    }
  };

  return (
    <div className="flex flex-col items-center bg-transparent max-w-md min-w-[500px] w-full mt-5 mb-5">
      <audio id="audio-player" src={audioURL} preload="metadata"/>
      
      <div className="flex items-center bg-transparent w-full">
        <button
            onClick={isPlaying ? handlePause : handlePlay}
            className="relative z-10"
            style={{ cursor: 'pointer' }} 
          >
            <img
              src={isPlaying ? pauseIcon : playIcon}
              alt={isPlaying ? 'Pause' : 'Play'}
              className="object-contain" 
              style={{ maxHeight: '32px', maxWidth: '32px' }} 
            />
          </button>
      </div>
    </div>
  );
};

 

 

이제 재생이 진행되는 만큼

검은색 progress bar가 진행되도록

progress를 구현해줘야한다

 

우선 progress는 width를 조정하는

방식으로 진행했는데

progress = (현재재생시간 / 전체길이) * 100

에 맞게 현재재생시간이 변할때마다 실시간으로

progress bar의 길이를 변경하도록

구현해야겠다고 생각했다

 

이를 위해서는

audioURL의 전체 길이를 알아야했는데

audioElement.duration으로

손쉽게 받아올 수 있을줄 알았으나..

 

우리는 user가 MediaRecorder로 녹음한

파일을 재생해줘야했는데

계속 이 duration이 Infinity로 뜨는 것이다

 

미췬듯이 구글링을 한 결과

MediaRecorder는 duration을

받아올 수 없다고 한다..

 

MediaRecorder에서 duration을 해주려면

ffmpeg로 다시 설정해주거나

다른 recorder를 사용해줘야했는데

이제와서 구현해놨던 녹음 기능을

전부다 바꾸기에는 일이 너무 커져서

결국 user가 녹음을 시작하고 끝내는

시각을 받아와 그 차이를 구해서

duration을 따로 계산해줬다 ㅠ

 

그래서 CustomAudioPlayer의

파라미터로 duration을 추가해줬다

 

그렇다면 이제 전체 길이인 duration은

해결이 되었으니까

현재 재생시간만 받아올 수 있으면 된다

 

이는 audioElement.currentTime으로

손쉽게 가져올 수 있다

 

const [currentTime, setCurrentTime] = useState(0);

  useEffect(() => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      const onTimeUpdate = () => {
        setCurrentTime(audioElement.currentTime);
      };

      audioElement.addEventListener('timeupdate', onTimeUpdate);

      return () => {
        audioElement.removeEventListener('timeupdate', onTimeUpdate);
      };
    }
  }, [audioURL]);

 

우선 위에 currentTime을

useState로 설정해주었다

 

그 다음 audio component에 있는

timeupdate 이벤트리스너를 이용하기로했다

 

시간이 변경되면 호출되는 이벤트리스터 같았다

 

따라서 onTimeUpdate 메소드로

시간이 변경될 때마다

currentTime에 현재 audioElement가

재생이 되고있는 시간이 담기도록 해줬다

 

useEffect를 사용해서

audioURL이 셋팅되면

이 이벤트리스너를 설정하도록 해주고

끝나면 다시 이벤트리스너를 remove해줘서

clean up하도록 해줬다

 

const progress = currentTime <= duration ? (currentTime / duration) * 100 : 100;

 

그 다음 넓이를 지정해줄

progress를 위와 같이 구현해줬는데

앞에 currentTime <= duration

조건문을 달아준 이유는

나는 정확한 audio의 duration이 아닌

user가 녹음을 시작하고 끝내는 길이를

계산해서 넣어준 duration 값이었으므로

혹시나 현재 재생 시간이

duration을 넘었을 때는 그냥

100으로 가도록 설정해준 것이다

 

아무튼 위와 같이 progress를 선언해준 뒤

아래와 같이 width를 %로 progress 값을 넣어줬다

 

그리고 progress bar와 같이 따라가는

동그란 아이콘은

왼쪽 위치를 progress만큼 옮겨서

같이 따라가게 해줬다

// 전체 길이를 나타내는 회색 bar
<div className="flex-1 h-2 bg-gray-200 rounded ml-4 relative">
  // audio가 재생됨에 따라 변하는 검은색 bar
  <div
    className="h-full bg-black rounded"
    style={{ width: `${progress}%` }}
  />
  // 검은색 bar와 함께 따라가는 동그란 아이콘
  <img
    src={IconPaths.CUSTOM_AUDIO_PLAYER_PROGRESS_CIRCLE}
    alt="Progress"
    className="absolute transform -translate-x-1/2 -translate-y-1/2"
    style={{
      left: `${progress}%`, 
      top: '50%', 
      width: '25px',  
      height: '25px',
    }}
  />
</div>

 

 

이정도까지만 해줬더니

생각한대로 CustomAudioPlayer가

구현이 되었다

 

 

처음 렌더링 되었을 때 모습

 

 

재생 중일 때 모습

 

아이콘이 정상적으로 변하고

progress bar도

제대로 따라가는 것을 확인할 수 있다

(아이콘에 파란 border는

그냥 클릭한 채로 캡처해서 뜨는거

코드와는 아무상관없다)

 

 

이대로 해줬더니 생긴 문제는

audio재생이 모두 끝났을 때

다시 처음 모습으로 돌아가게해주고싶은데

progress bar가 끝까지 진행된 그상태로

그냥 멈춰버린다는 점이었다

 

따라서 audio가 play를 끝냈을 때

다시 처음모습으로 돌아가도록

로직을 추가했다

 

const handleEnded = () => {
    const audioElement = document.getElementById('audio-player');
    setIsPlaying(false);
    audioElement.currentTime = 0;
  };
 
audioElement.addEventListener('ended', handleEnded);

 

아래와 같이

audio가 끝이 났을 때

isPlaying이 false가 되도록 해주고

currentTime을 0으로 다시 바꿔주었다

 

그런 다음 audio 컴포넌트의

ended 이벤트 리스너를 사용해서

handleEnded를 추가해주었다

 

이렇게 해주면 audio의 재생이 끝났을 때

handleEnded 함수가 실행되게된다

 

 

이렇게 구현해준다음

재생해주니

audio가 끝나면 정상적으로

처음 상태로 돌아가는 것을 확인했다

 

아래는 CustomAudioPlayer의

전체코드이다

import React, { useState, useEffect } from 'react';
import { IconPaths } from '../statics';

const CustomAudioPlayer = ({audioBlob, duration }) => {
  const [audioURL, setAudioURL] = useState(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);

  const playIcon = IconPaths.CUSTOM_AUDIO_PLAYER_PLAY;
  const pauseIcon = IconPaths.CUSTOM_AUDIO_PLAYER_PAUSE;

  useEffect(() => {
    if (audioBlob) {
      const url = URL.createObjectURL(audioBlob);
      setAudioURL(url);

      return () => URL.revokeObjectURL(url);
    }
  }, [audioBlob]);


  useEffect(() => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      const onTimeUpdate = () => {
        setCurrentTime(audioElement.currentTime);
      };

      const handleEnded = () => {
        const audioElement = document.getElementById('audio-player');
        setIsPlaying(false);
        audioElement.currentTime = 0;
      };

      audioElement.addEventListener('timeupdate', onTimeUpdate);
      audioElement.addEventListener('ended', handleEnded);

      return () => {
        audioElement.removeEventListener('timeupdate', onTimeUpdate);
        audioElement.removeEventListener('ended', handleEnded);
      };
    }
  }, [audioURL]);

  const handlePlay = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.play();
      setIsPlaying(true);
    }
  };

  const handlePause = () => {
    const audioElement = document.getElementById('audio-player');
    if (audioElement) {
      audioElement.pause();
      setIsPlaying(false);
    }
  };

  const progress = currentTime <= duration ? (currentTime / duration) * 100 : 100;

  return (
    <div className="flex flex-col items-center bg-transparent max-w-md w-full mt-5 mb-5">
      <audio id="audio-player" src={audioURL} preload="metadata"/>
      
      <div className="flex items-center bg-transparent w-full">
        <button
            onClick={isPlaying ? handlePause : handlePlay}
            className="relative z-10"
            style={{ cursor: 'pointer' }} 
          >
            <img
              src={isPlaying ? pauseIcon : playIcon}
              alt={isPlaying ? 'Pause' : 'Play'}
              className="object-contain" 
              style={{ maxHeight: '32px', maxWidth: '32px' }} 
            />
          </button>
        
        <div className="flex-1 h-2 bg-gray-200 rounded ml-4 relative">
          <div
            className="h-full bg-black rounded"
            style={{ width: `${progress}%` }}
          />
          <img
            src={IconPaths.CUSTOM_AUDIO_PLAYER_PROGRESS_CIRCLE}
            alt="Progress"
            className="absolute transform -translate-x-1/2 -translate-y-1/2"
            style={{
              left: `${progress}%`, 
              top: '50%', 
              width: '25px',  
              height: '25px',
            }}
          />
        </div>
      </div>
    </div>
  );
};

export default CustomAudioPlayer;

 

위와 같이 CustomAudioPlayer을 구현해줬고

렌더링하는 js파일에서는

아래와 같이 작성해줬다

  const AnswerContainer = ({ userRecordingAudioBlob, userRecordingDuration }) => {
  
    return (
      <ContentContainerBox>
         <CustomAudioPlayer audioBlob={userRecordingAudioBlob} duration={userRecordingDuration} />
      </ContentContainerBox>
    );
  };

 

사실 user가 클릭한 버튼에 따라

다른 audioBlob을 재생하도록 해줬는데

그부분까지 포함하니 코드가 너무 길어져서

우선 이런식으로 데이터를 주고받아서

CustomAudioPlayer를 구현했다는 것만

가져왔다

 

 

아무튼 나름 재밌지만 힘들었던

CustomAudioPlayer 구현하기 끝-!