이전에 Suno AI를 통해 생성한 음원 파일을 스트림으로 전달하는 API를 개발하였다.
각 음원에 대한 섬네일을 제공할 수 있도록 static 정적 파일 접근을 허용도 하였다.
GET /audio URI로 Request를 보내는 경우엔 음원 디렉토리를 조회하여 음원 리스트를 제공하도록 구현하였다.
반대로 ?filename={ filename } 쿼리 스트링을 통해 요청하면 해당 파일에 대한 음원을 스트림으로 응답받는다.
이전에 다룬 내용은 아래 링크에서 확인이 가능하다.
뮤직 플레이어 애플리케이션 API 개발
개발 중인 포트폴리오에 음악 재생이 가능한 뮤직 플레이어 애플리케이션을 개발하는 내용을 다룹니다. 이 아래부터는 평어체를 사용하며, 일기 작성하 듯 과정을 그려내 보고자 작성하였습니
jiwooproity.tistory.com
뮤직 플레이어 개발 준비
개발을 시작하기 전에 몇가지 준비가 필요하다.
포트폴리오 화면에 애플리케이션을 추가하기 위한 필수 과정이다.
현재 포트폴리오에서 애플리케이션은 내부적으로 객체 형태로 관리하고 있다.
<Screen /> 하위 컴포넌트로 <Applications /> 컴포넌트를 위치하고, "music" 이라는 키를 추가하면 렌더링되도록 하였다.
렌더링 전에 내부 로직으로 "music" 이라는 키를 가지고 아이콘, 컴포넌트, 독립적 class를 찾거나 가지게 된다.
<div className="screen-container" onMouseDown={playEffect} onMouseUp={playEffect}>
<Navigation />
<Applications />
<RenderApps />
<Doc />
<LockScreen />
</div>
이런식으로 각 필요한 컴포넌트들을 조합하여 사용한다.
<RenderApps /> 컴포넌트는 사용자 동작에 따라 필요한 렌더링 처리도 진행한다.
아이콘 추가
아이콘은 Mac 에 기본적으로 설치되는 아이콘을 활용했다.
하지만, 화면에 렌더링되는 크기로는 적합하지 않은 크기를 가지고 있다.
무려 크기가 400px이나 되는 것인데, 화면에서는 약 64px이면 충분히 좋은 품질로 제공이 가능한 상태이다.
포토샵을 통해 기존에 쓰고 있던 애플리케이션을 가져와 크기를 비교하고 동일한 크기로 수정했다.
애플리케이션 데이터 추가 작업
각 애플리케이션은 객체 형태로 관리되고 있다고 했다.
객체에 애플리케이션을 추가하는 과정에서 애플리케이션의 너비, 높이와 이름을 설정해 주어야 한다.
const WINDOWS = {
MEMO: "memo",
TERMINAL: "terminal",
FOLDER: "folder",
MUSIC: "music",
};
const WINDOW_LIST: { [key: string]: AppOptionsIF } = {
memo: {
name: "memo",
width: 1500,
height: 800,
},
terminal: {
name: "terminal",
width: 800,
height: 530,
},
folder: {
name: "folder",
width: 1000,
height: 600,
},
music: {
name: "music",
width: 1400,
height: 700,
},
};
애플리케이션 드래그 이벤트 타겟팅에 필요한 name을 설정해 주고,
width와 height을 추가하여 렌더링될 초기 컴포넌트 크기를 설정한다.
그 외에는 애플리케이션 아이콘을 더블 클릭하는 이벤트에서 애플리케이션 활성화 대상의 정적 데이터로 쓰일 WINDOWS를 수정했다.
추가하는 이유는 openApplication(WINDOW.MUSIC); 이런 식으로 활성화 로직에 필요한 데이터이기 때문에 추가한다.
애플리케이션 초기 레이아웃 설정
애플리케이션 컴포넌트 설계는 Compound Component 패턴을 활용했다.
컴포넌트를 나눌 때, 큰 범위로부터 나눠 Navigation, Buttons, Block, Body 형태로 나누었다.
각 나눠진 컴포넌트는 상위 Window 컴포넌트로부터 Context API를 통해 상태 값을 공유받는다.
return (
<Window name="music">
<Window.Navigation>
<Window.Buttons />
</Window.Navigation>
<Window.Body>
<AudioList select={select} data={audioList} onSelect={onSelect} />
<AudioController select={select} />
</Window.Body>
</Window>
);
여기서 <Window /> 컴포넌트의 props.name은 스타일 지정을 위한 고유의 이름이다.
해당 name을 통해 상태 값을 공유하고 "music-body-wrapper", "music-navigation-wrapper" 등 ..
각 애플리케이션에 대한 고유의 스타일을 설정하여 다양한 디자인이 가능하도록 하였다.
현재 Folder 애플리케이션을 개발하면서 추가한 Window.Block을 통해 각 영역을 나눌 수도 있다.
나누게 된다며 왼쪽 사이드바를 추가한다던지 추가적인 확장성을 고려할 수 있다.
애플리케이션 오디오 개발
애플리케이션 Body에 들어갈 컨텐츠는 List와 Controller로 나누어 음원 리스트와 음원을 컨트롤하는 UI를 제공하도록 하려고 한다.
음원 리스트는 어려울 것 없이 미리 구현해 둔 GET /audio URI를 통해 리스트를 조회하고 .map 배열 메서드로 뿌려주기만 하면 된다.
제일 중요한 건 음원을 컨트롤 하는 Controller 개발이다.
현재 F.S.D 아키텍처를 사용하는 만큼, 비즈니스 로직과 UI 컴포넌트를 독립적으로 관리하여 관심사를 나누어야 한다.
우선 Audio 스트림을 받아 실행할 <audio /> 태그를 사용하고 id를 "audio"로 주어 추후 접근을 고려하였다.
하지만, 모든 접근은 ref를 통해 내부 정보를 가져올 예정이라 audioRef도 따로 생성하였다.
audioRef는 따로 생성한 useAudio 커스텀 훅을 활용하여 관리할 예정이다.
<div className="music-controller">
<div className="music-control-box">
<img src={`/svgs/${playing ? "pause" : "play"}.svg`} onClick={onToggle} />
</div>
<div className="music-timeline">
<div className="music-time">{current}</div>
<div
ref={progressBarRef}
className="music-progress-bar"
onMouseMove={progressMove}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
onMouseLeave={mouseLeave}
>
<div ref={progressRef} className="music-active-progress" />
</div>
<div className="music-time">{duration}</div>
</div>
<audio ref={audioRef} id="audio" src={`https://api.jiwoo.so/audio?filename=${select}`} />
</div>
이처럼 Stream src를 연결하고 커스텀 훅으로 전달 받은 ref를 연결했다.
이제 마지막으로 연결된 ref ( refference ) 를 통해 DOM에 접근하여 관리하는 것을 다룰 것이다.
Audio Progress Bar 구현
Audio Progress Bar 를 구현하기 위해서 Audio 정보를 가지고 currentTime과 duration을 가지고 구해야 한다.
(현재 시간 / 길이) * 100 으로 연삭하여 Progress Bar 로 표현될 Width 를 구해줘야 한다.
const audioRef = useRef<HTMLAudioElement>(null);
const updateProgress = useCallback((currentTime: number, duration: number) => {
const progress = `${(currentTime / duration) * 100}%`;
audioRef.style.setProperty("width", progress);
}, [])
const timeUpdate = useCallback(() => {
const { currentTime, duration } = audioRef.current as HTMLAudioElement;
updateProgress(currentTime, duration);
}, [updateProgress]);
useEffect(() => {
const current = audioRef.current;
current.addEventListener("timeupdate", timeUpdate);
return () => {
current.removeEventListener("timeupdate", timeUpdate);
}
}, [timeUpdate]);
이렇게 audio 가 실행되면서 음원 시간이 업데이트된다면 timeUpdate를 통해 audioRef에 currentTime과 duration을 얻는다.
얻은 currentTime과 duration을 통해 updateProgress 함수로 인수를 전달해 주고, width를 업데이트한다.
Audio Play, Pause 구현
음원 재생, 일시정지 구현은 매우 심플하다.
Audio 와 연결된 ref를 통해 Audio의 play, pause 메서드를 호출하여 처리하면 된다.
const [playing, setPlaying] = useState(false);
const onToggle = () => {
if (select === "") return;
const current = audioRef.current as HTMLAudioElement;
current[playing ? "pause" : "play"]();
setPlaying((play) => !play);
};
const onPlay = () => {
audioRef.current?.play();
setPlaying(true);
};
const onPause = () => {
audioRef.current?.pause();
setPlaying(false);
};
나는 playing 상태 값을 추가하여 play, pause 아이콘을 나타내며 내부적으로 toggle과 같은 함수에서 참조할 수 있도록 했다.
Toggle과 Play, Pause 함수 네이밍으로 각 역할을 정확히 구분하고 네이밍에 맞는 역할만을 수행하도록 했다.
toggle 함수는 play, pause 아이콘 버튼을 클릭하였을 때, 실행 중인지 아닌지에 따라 동적으로 판단하여 처리할 수 있다.
<img src={`/svgs/${playing ? "pause" : "play"}.svg`} onClick={onToggle} />
음원 시간 포맷 변경 함수
audio 정보를 가져올 때, currentTime과 duration을 가져와 보면 정확한 분 : 초로 출력되지 않는다.
값이 0.6230000 이런 식으로 나오기 때문에 분 : 초를 직접 구해서 포맷을 만들어 주어야 한다.
const calculateTime = ({ time = 0 }: { time: number | undefined }) => {
const minute = String(Math.floor(time / 60)).padStart(2, "0");
const second = String(Math.floor(time % 60)).padStart(2, "0");
return `${minute}:${second}`;
};
현재 시간에서 60을 나누어 소수점을 버리면 현재 분을 알 수 있다. 반대로 60으로 나머지를 구하게 되면 현재 초 단위를 나타낼 수 있다.
문자열 padStart 메서드를 통해 "02:03" 형태로도 표현할 수 있도록 처리를 해 주었다.
Audio 로드 및 선택 재생 구현
Audio를 선택하면 Audio 를 입력받고 음원 데이터의 최대 길이 그리고 실행을 처리해야 안정적으로 구현할 수 있다.
따라서, 사용자가 음원을 선택했을 때 Audio가 준비되었는지 감지하고 데이터를 처리해 주어야 한다.
const updateProgress = useCallback((currentTime: number, duration: number) => {
const nowProgress = `${(currentTime / duration) * 100}%`;
progressRef.current?.style.setProperty("width", nowProgress);
const current = calculateTime({ time: currentTime });
setCurrent(current);
}, []);
const timeUpdate = useCallback(() => {
if (mouseDownRef.current && mouseMoveRef.current) return;
const { currentTime, duration } = audioRef.current as HTMLAudioElement;
updateProgress(currentTime, duration);
}, [updateProgress]);
const onPlay = () => {
audioRef.current?.play();
setPlaying(true);
};
const onReset = useCallback(() => {
onPlay();
timeUpdate();
}, [timeUpdate]);
// 추가된 코드
const onLoadAudio = useCallback(() => {
const { duration } = audioRef.current as HTMLAudioElement;
const time = calculateTime({ time: duration });
setDuration(time);
onReset();
}, [onReset]);
useEffect(() => {
const current = audioRef.current;
if (current) {
current.addEventListener("loadeddata", onLoadAudio);
}
return () => {
current?.removeEventListener("loadeddata", onLoadAudio);
};
}, [select, onLoadAudio]);
이처럼 loadeddata 이벤트리스너로 onLoadAudio 함수를 실행해 주고,
현재 음원 데이터의 길이를 가져오고 시간을 설정한다. 그러면 사용자는 안정적으로 음원의 최대 시간 길이를 제공받게 된다.
이후로는 onReset 함수를 통해 처음 실행하는 것을 고려하여 음원을 재생하는 함수를 실행하고 실행 시간을 재설정한다.
Progress Bar Range 마우스 컨트롤 구현
이제 마지막으로 마우스 이벤트로 듣고 싶은 구간을 재생하는 기능을 구현할 차례다.
Progress Bar를 조작하기 위해서는 Progress Bar의 offsetX와 너비를 가지고 계산해야 한다.
이어서 마우스를 벗어나는 경우, 마우스를 클릭하거나 떼는 경우, 그리고 마우스를 움직이는 경우에 대한 이벤트를 처리해야 한다.
const timeMoved = ({ type, nativeEvent }: MouseEvent) => {
if (progressBarRef.current && audioRef.current?.duration) {
const width = progressBarRef.current.clientWidth;
const ratio = nativeEvent.offsetX / width;
if (type === "mouseup") {
audioRef.current.currentTime = ratio * audioRef.current?.duration;
}
updateProgress(ratio * audioRef.current?.duration, audioRef.current.duration);
}
};
const mouseDown = () => {
mouseDownRef.current = true;
mouseMoveRef.current = true;
};
const mouseUp = (e: MouseEvent) => {
mouseDownRef.current = false;
mouseMoveRef.current = false;
timeMoved(e);
};
const mouseLeave = () => {
if (mouseDownRef.current) {
mouseDownRef.current = false;
}
mouseMoveRef.current = false;
};
const progressMove = (e: MouseEvent) => {
if (mouseDownRef.current && mouseMoveRef.current) {
timeMoved(e);
}
};
우선 mouseDown 과 mouseMove에 대한 ref를 가지고 현재 마우스 이벤트 상태를 체크해야 한다.
이유로는 중첩 이벤트가 발생하는 경우를 방지하고 필요한 동작만을 하도록 의도해야 하기 때문이다.
progressMoves 함수는 mouseMove에 대한 처리고 마우스가 눌리고 움직이는 경우 timeMoved 함수를 실행한다.
여기서 구현한 timeMoved 함수는 사용자가 클릭한 progress bar의 위치를 파악하고,
파악된 *offsetX를 기준으로 시간을 재설정한다.
*offsetX: 요소를 기준으로 특정 지점의 X 좌표
이어서 timeMoved로 넘겨받은 Event 객체의 event type 형태가 mouseup인 경우, 최종적으로 audio 실행 구간을 재설정한다.
여기서 mouseup 이벤트를 따로 처리한 이유는 progressBar 위치를 설정하면서 드래그로 실행 구간까지 설정된다면 ,,
사용자 입장에서 노래의 구간을 수정할 때, 끊기 듯이 노래가 나오는 현상이 발생한다.
이런 현상은 음원을 잠시 멈춰두었다가 이벤트가 완료되면 다시 실행하는 식으로 해결해도 되지만, 사용자 경험에 좋지 않다고 생각한다.
구간을 드래그로 수정하면서 노래는 계속 듣고 있는 상태로 수정이 가능한 것이 사용자 경험에 있어 가장 괜찮은 접근이라고 생각했다.
위 분기 처리를 통해 mouseup이 발생하기 전에 드래그가 지속된다면 progressBar 위치만 수정하고 노래는 재생된다.
그리고 mouseup 이벤트가 발생한다면 currentTime이 재설정되어 노래가 해당 구간에서부터 재생되고,
progressBar는 currentTime 구간 위치에 맞게 설정되어 사용자가 원하는 구간에서 부터 듣고 끊기지 않고 들을 수 있다.
마무리
AudioContext를 통해 인터렉션 Visualizer를 구현했던 때와 다르게 UI 상호작용을 위한 작업이다 보니,
또 다른 새로운 경험을 하게 되었다.
아직은 최종 결과물은 아니지만, 미리 작업된 비즈니스 로직들은 준비가 되었기 때문에 완성도 있는 애플리케이션을 구성할 수 있을 거 같다.
사용자 경험을 중요하게 생각하며, 인상 깊은 프로덕션을 제공하여 삶의 질 향상에 도움이 되고 세상에 기여하는 제품을 만들 수 있도록 노력하고자 한다.
'개발 일지 > 소개 포트폴리오' 카테고리의 다른 글
Iframe을 활용한 부팅 연출 구현하기 (0) | 2024.06.07 |
---|---|
뮤직 플레이어 애플리케이션 API 개발 (0) | 2024.04.21 |
ESBuild를 적용하고 빌드 타임을 단축하자 (1) | 2024.04.07 |
라이브러리 의존성 업데이트로 인한 이슈 발생 (0) | 2024.04.06 |
이벤트 버블링과 그려질 요소의 순서 이슈 (0) | 2024.04.05 |