간단하고 멋진 커스텀 오디오 플레이어를 만드는 방법

dante Yoon·2022년 7월 26일
8

react

목록 보기
4/19
post-thumbnail

본 글은 구체적인 스타일링을 어떻게 하는지가 아닌, 오디오 데이터를 api를 이용해 받아 그럴싸한 오디오 플레이어의 기본적인 구현 방법을 다룬다.

<audio> 태그를 사용하여 간단하게 음성 파일 재생하기

html의 audio 태그를 이용하면 간단하지만 멋진 오디오 플레이어를 브라우저에 렌더링 할 수 있다.

아무런 커스텀 스타일링을 하지 않아도 구간 이동, 음량, 재생 속도까지 조절할 수 있다.

audio 태그를 사용하는 방법은 다음과 같다.
먼저 재생하려고 하는 파일의 확장자를 명확하게 명시해야 하고 audio 태그의 src 속성으로 해당 파일의 경로를 명시해야 한다.

음성 파일을 서빙하는 곳과 웹 페이지를 서빙하는 서버가 동일한 경우에는 다음과 같이 코드를 작성해볼 수 있다.

 <audio
   controls
   src="/media/cc0-audio/t-rex-roar.mp3">
            Your browser does not support the
            <code>audio</code> element.
 </audio>

커스텀 오디오 플레이어 만들어보기

현업에서는 이보다는 좀 더 커스터마이징한 오디오 플레이어 구현을 요구받는 경우가 있다.

다시 한번 본 글은 구체적인 스타일링을 어떻게 하는지가 아닌, 오디오 데이터를 api를 이용해 받아 그럴싸한 오디오 플레이어의 기본적인 구현 방법을 다룬다는 점을 먼저 알린다.

nodejs 환경에서 비디오, 오디오 파일, 엑셀 파일등 서버를 통한 데이터 교환에 사용하는 파일 타입은 blob 이다. 여러분이 마주하는 거의 모든 상황에서 브라우저에 렌더링하기 위해 직접적으로 다루는 파일 타입은 십중팔구 blob일 것이다.

fetch / axios / blob

먼저 blob을 api 호출을 통해 받기 위해서는 fetch나 axios를 사용할텐데 각 라이브러리마다 response content type을 지정하는 방법이 다르므로 fetch, axios외에 다른 라이브러리를 사용한다면 해당 기술의 문서를 찾아보자.

fetch api 의 경우 response 타입이 Promise이기 때문에 json으로 뽑아내기 위해 보통 아래처럼 await 문을 써서 json으로 변환하여 코드에서 사용할 것이다.

async function renderList() {
 const json = await fetch("/give_me_json")
  .then(response => response.json());
 doSomeThingWithJson(json);
}

blob 파일을 받는데는 .json을 .blob으로 바꿔주기만 하면 된다.

async function renderFile() {
 const blob = await fetch("/give_me_blob")
  .then(response => response.blob());
 doSomeThingWithBlob(blob);
}

axios는 더 간단하다. 호출 파라메터의 responseType을 blob으로 지정하면 된다.

const audioFile = axios.request({
  method: "POST",
  baseURL: "/give_me_audio",
  ...
  responseType: 'blob'
})
});

이렇게 받은 blob 파일을 크롬 개발자 도구의 response 탭으로 살펴보면 다음과 같이 읽을 수 없게 표시되는데 잘 된 거다.

react custom audio component

컴포넌트를 나보다 더 잘만드는 사람도 많겠지만 리엑트 상태 변화에 따른 오디오 컴포넌트 재생을 위해 다음 처럼 간단한 마크업을 작성해봤다.

const CustomAudio = () => {
  return (
	<div>
      <div className="progress-container">
        <span id="progress"/>
      </div>
      <div className="description">
        <span>00:00</span>
        <span>04:40</span>
      </div>
   </div>
 )
}

구현해야 하는 것은 총 세가지다.

  • 오디오 재생/중지를 위한 상태 관리
  • 오디오 파일을 받아오기
  • 플레이어를 모방하기 위한 로직

상태 관리부터 살펴보자.

나는 커스텀 훅을 만들어 사용하려고 한다. 다음은 오디오 플레이어를 위한 useAudio 훅이다.

오디오 재생/중지를 위한 상태 관리

const useAudio = () => {
  const [audio, setAudio] = useState(new Audio()); // audio 엘리먼트다
  const [play, setPlay] = useState(false); // 오디오 플레이어의 재생 여부를 나타내는 상태 값이다.
  const [source, setSource] = useState(); // 재생할 오디오 소스 값이다.

  useEffect(() => {
    fetch("/api/get_audio") 
      .then((res) => res.blob())
      .then((blob) => URL.createObjectURL(blob))
      .then((url) => {
        setSource(url);
        setAudio(new Audio(url));
      });
  }, []);

  useEffect(() => {
    return () => {
      if (source) {
        URL.revokeObjectURL(source);
      }
    };
  }, [source]);

  useEffect(() => {
    if (play) {
      audio.play();
    } else {
      audio.pause();
    }
  }, [play]);

  return {
    play,
    audio,
    source,
    toggle: () => setPlay((prev) => !prev)
  };
};

오디오 파일을 받아오기

fetch audio

오디오 파일을 받아오는 부분을 보자

  useEffect(() => {
    fetch("/api/get_audio") 
      .then((res) => res.blob())
      .then((blob) => URL.createObjectURL(blob))
      .then((url) => {
        setSource(url);
        setAudio(new Audio(url));
      });
  }, []);

앞서 말한대로 blob타입으로 데이터를 받아와 URL.createObjectURL를 통해 blob 파일을 audio 엘리먼트의 src에 대입할 수 있는 소스 url로 바꿔준다. eventListener와 동일하게 명시적으로 리소스를 해제해주지 않는 한 브라우저는 생명주기 동안 해당 url 값을 들고 있으므로, 컴포넌트 언마운트 시 해당 url 리소스를 메모리에서 해제해야 한다. 이 부분은 그 다음 useEffect 블럭에 등장한다.


  useEffect(() => {
    return () => {
      if (source) {
        URL.revokeObjectURL(source);
      }
    };
  }, [source]);

꼭 source를 상태 값으로 가져가지 않아도 좋다.
굳이 커스텀 훅을 호출하는 쪽에서 source를 사용하지 않는데 상태로 추가하는게 싫다면
useRef를 이용하는 것도 방법이 될 수 있다.

마지막 useEffect 블록과 useAudio 커스텀 훅 리턴 값이 뭔지 보자

...
useEffect(() => {
  if (play) {
    audio.play();
  } else {
    audio.pause();
  }
}, [play]);

return {
  play,
  audio,
  source,
  toggle: () => setPlay((prev) => !prev)
};

상태 값을 리턴하고 toggle 함수를 통해 play, pause 상태를 바꾼다.
play 상태 값 변경에 따른 audio 객체의 음원 실행은 useAudio 훅에 위임하고 사용자 측에서는 실행 및 정지를 위해서는 toggle 함수만 호출하면 된다.

이제 훅을 사용하는 컴포넌트를 보자.

CustomAudio 리엑트 컴포넌트

이제 CustomAudio에서 커스텀 훅을 호출하자.

const CustomAudio = () => {
  const { audio, toggle, play } = useAudio();
  const progressRef = useRef(null);
  
  return (
	<div>
      <button onClick={handleClick}>{play ? "중지" : "재생"}</button>
      <div className="progress-container" ref={progressRef}>
        <span id="progress"/>
      </div>
      <div className="description">
        <span>00:00</span>
        <span>04:40</span>
      </div>
   </div>
 )
}

플레이어를 모방하기 위한 로직

앞서 봤던 audio 태그의 플레이어 처럼 커스텀 플레이어를 구현하기 위해서는
재생 및 실행 상태에서 진행 바를 마우스로 이동할 수 있어야 하고 경과 시간을 표시해야 한다.

플로우를 크게 두가지로 나눠 보면 다음과 같은데

  • 유저 액션
  • 그에 따른 앱 구동

아래 그림에서 왼쪽 그룹을 유저 액션, 나머지를 그에 따른 앱 구동이라고 정의한다.
그리고 앱 구동을 위한 조건은 유저 액션 플로우가 화살표 방향으로 동작할 때만 작동한다.

예를 들어 mouse down 이 선점되지 않은 mousemove 이벤트는 앱 구동을 유발하지 않는다.

마우스 움직임에 따라 UI 변경을 일으키기 위해 mousemove, mouseup, mosedown 이벤트를 추가한다.

컴포넌트에 어떤 로직들이 추가되었는지 보자.
각 이벤트를 서로 다른 이벤트 핸들러에서 참조하기 위해 각 이벤트 이름을 prefix로 한 ref들을 선언하는 방식으로 구현했다.

...
const mouseDownedRef = useRef(false);
const mouseMovedRef = useRef(false);

  const mouseMoveHandler = (e: MouseEvent) => {
    if (mouseDownedRef.current && mouseMovedRef.current) {
      timeHandler(e);
    }
  };

const mouseDownHandler = (e: MouseEvent) => {
  mouseDownedRef.current = true;
  mouseMovedRef.current = true;
};

const mouseLeaveHandler = () => {
  if (mouseDownedRef.current) {
    mouseDownedRef.current = false;
  }
  mouseMovedRef.current = false;
};

const mouseUpHandler = (e: MouseEvent) => {
  mouseDownedRef.current = false;
  mouseMovedRef.current = false;
  timeHandler(e);
};

...

앞서 나타냈던 플로우를 위해 timeHandler 언제 호출되는지만 한번 살펴보고 timeHandler 넘어가자.

timeHandler MouseEvent의 offsetX 값을 참조해 엘리먼트 전체 넓이 대비 x의 좌표값을 받아 audio 타임을 조절한다.

const timeHandler = ({offsetX}) => {
  if (progressRef.current && audio.duration) {
        const fullWidth = progressRef.current.clientWidth;
        const ratio = value / fullWidth;
        audio.currentTime = ratio * audio.duration;
  }
}

이제 아래와 같은 progress 바를 그려야 하는데, 어떻게 그려야 할까?

HTMLAudioELement의 currentTime와 duration을 이용해 그려보자.

audio 엘리먼트에는 timeupdate 이벤트 리스너가 있어 currentTime이 변경될 때를 구독할 수 있다. ui를 그리기에 최적의 장소임이 분명하다.


/**
* @desc progress bar의 달성도 ui를 담당하는 함수 
*/
const drawProgress = (currTime, totalTime) => {
      targetRef.current.style.width = `${(currTime / totalTime) * 100}%`;
}

const timeupdateHandler = () => {
    const { currentTime, duration } = audio;
    drawProgress(currentTime, duration);
  };

useEffect(() => {
  if(source){
    audio.addEventListener('timeupdate',() => {
      const { currentTime, duration } = audio;
      
      if (targetRef.current) {
        targetRef.current.style.width = `${(currentTime / duration) * 100}%`;
      }
    });
  }
},[audio,source])

위 같이 한다면 이제 이벤트 핸들러에 따라 아래와 같은 움직이는 progress bar를 볼 수 있다.

후기(?)

상태를 선언하고 이에 따른 로직 구동을 하는 방식으로 구현해봤다.
작성해보니 useEffect로 상태관리 변경을 관찰하여 로직 구동하는 방식에서
action dispatcher를 이용한 로직 구동방식으로 변경하는 것이 더 좋겠다는 생각이 든다.

앱이 커지면 커질 수록 상태 관찰에 따른 세부 로직 수정은 여러 사람이 유지보수를 하는데 있어 큰 비용을 지불해야 할 것 같다.

이번에 작성한 커스텀 엘리먼트에서 크게 두가지의 개선점을 생각해볼 수 있는데
1. rxJS와 같은 리엑티브 프로그래밍을 이용한다면 보다 간단하게 이벤트 핸들링을 할 수 있을 것 같다.
2. useEffect가 아닌 다른 방식, 이를테면 onClick handler와 같은 이벤트 디스패치를 이용하는 것이 더욱 명확하고 예상치 못한 버그 양산을 막을 수 있을 것 같다.

그래도 쓸만하니까.

profile
성장을 향한 작은 몸부림의 흔적들

7개의 댓글

comment-user-thumbnail
2022년 7월 26일

재밌네요!! 보고 한번 따라해볼게요..!

2개의 답글
comment-user-thumbnail
2022년 7월 28일

넘 재밌고 유익하네요.. 도전..!

1개의 답글
comment-user-thumbnail
2024년 1월 5일

progressbar 가 마우스이벤트로 적용되는 width 값이 transition 속성도 없는데 transition처럼 움직일까요?

답글 달기