react-audio-player
라이브러리로 Audio를 관리하고 있었는데, 접속하는 기기마다 Audio 태그 UI가 깨져 보이는 현상이 있었음 → 디자인 커스텀 된 Audio 컴포넌트 관리 필요React Context API
가 필요했음 (Play, Stop, Volume 조절 등..)컴파운트 패턴
을 적용함props로 컬러나 스타일, audio 파일 Url, 속도 조절 버튼 유무를 설정할 수 있도록 했다.
<Audio
color={color}
style={styledCode}
audioUrl={audioUrl}
hasSpeedButton={true}
/>
컴파운드 패턴으로 만든 컴포넌트 조각들을 호출한 뒤 결합하여 하나의 오디오 컴포넌트로 보여주도록 했다.
AudioPlayer
: Context.Provider의 wrapperAudioPlayer.PlayStatus
: play / pause / stop 버튼 관리 UIAudioPlayer.PlayTime
: 현재 재생 시간 / 총 재생 시간 UIAudioPlayer.PlayBar
: 재생 progress bar UIAudioPlayer.Volume
: 볼륨 조절 제어 UIAudioPlayer.DownloadButton
: 오디오 파일 다운로드 버튼 UIAudioPlayer.SpeedSetting
: 오디오 빠르기 조절 버튼 UI// context components
import AudioPlayer from './AudioContext';
return (
<AudioPlayer color={color} audioUrl={audioUrl} style={style}>
{/* play status component */}
<AudioPlayer.PlayStatus />
{/* play time component */}
<AudioPlayer.PlayTime />
{/* play progress component */}
<AudioPlayer.PlayBar />
{/* audio volume component */}
<AudioPlayer.Volume />
{/* audio mp3 download button */}
<AudioPlayer.DownloadButton audioUrl={audioUrl} />
{/* speed setting button */}
{hasSpeedButton && <AudioPlayer.SpeedSetting />}
</AudioPlayer>
);
컴파운드 패턴으로 컴포넌트 조각들을 선언했다. 각 컴포넌트마다 고유의 기능을 명확히 구현하는 것이 목표였다.
disable: none
처리를 한 이유const AudioContext = createContext<AudioContextProps>(defaultAudioContext);
function AudioPlayer({ color, audioUrl, style, children }: Props) {
const theme: Theme = useTheme();
const colorSet = theme.palette[color];
// react-audio-player ref
const playerRef = useRef<ReactAudioPlayer>();
// react-audio-player ref.current state
const [audioPlayer, setAudioPlayer] = useState<HTMLAudioElement | null>(null);
// audio player의 현재 & 총 재생 시간 state
const [currentTime, setCurrentTime] = useState<number>(0);
const [totalTime, setTotalTime] = useState<number>(0);
// audio player의 소리 state
const [volume, setVolume] = useState<number>(0.5);
// audio player의 재생 상태
const [playStatus, setPlayStatus] = useState<AudioPlayerStatus>('pause');
// init react-audio-player ref
useEffect(() => {
if (!playerRef?.current) return;
setAudioPlayer(playerRef?.current.audioEl.current);
}, [playerRef]);
return (
<AudioContext.Provider
value={{
colorSet,
audioPlayer,
playStatus,
setPlayStatus,
currentTime,
totalTime,
volume,
setVolume,
}}
>
{/* react-audio-player 라이브러리 */}
<ReactAudioPlayer
style={{ display: 'none' }}
src={audioUrl}
ref={(element) => {
if (element) playerRef.current = element;
}}
listenInterval={100}
volume={volume}
onPlay={(event: Event) => setPlayStatus(event.type as AudioPlayerStatus)}
onPause={(event: Event) => setPlayStatus(event.type as AudioPlayerStatus)}
onListen={(time: number) => {
if (!time) return;
setCurrentTime(time);
}}
onSeeked={(event: Event) => {
const target = event.target as HTMLAudioElement;
setCurrentTime(target.currentTime as number);
}}
onError={async () => {
if (!audioPlayer) return;
await audioPlayer.play();
}}
onAbort={async () => {
if (!audioPlayer) return;
await audioPlayer.play();
}}
onLoadedMetadata={(event: Event) => {
const target = event.target as HTMLAudioElement;
setTotalTime(target.duration);
}}
onEnded={() => setPlayStatus('pause')}
controls
controlsList="noplaybackrate nodownload"
/>
{/* custom mock UI */}
{audioPlayer ? (
<AudioProgressWrapper style={style}>{children}</AudioProgressWrapper>
) : (
<FallbackUI />
)}
</AudioContext.Provider>
);
}
// audio bar background components
const AudioProgressWrapper = ({
style,
children,
}: {
style?: React.CSSProperties;
children: ReactNode;
}) => (
<Stack
sx={{
...style,
}}
>
{children}
</Stack>
);
// play status components
const PlayStatus = memo(() => {
const { playStatus, setPlayStatus, audioPlayer } = useContext(AudioContext);
const togglePlayStatus = () => {
if (playStatus === 'play') {
audioPlayer?.pause();
setPlayStatus('pause');
} else {
audioPlayer?.play();
setPlayStatus('play');
}
};
const icon = playStatus === 'play' ? 'pause' : 'play';
return (
<Button onClick={togglePlayStatus}>
<Iconify icon={icon} />
</Button>
);
});
const PlayTime = () => {
const { audioPlayer, currentTime, totalTime } = useContext(AudioContext);
const isLoading = Boolean(audioPlayer);
return (
<Stack direction="row">
<Typography variant="body2">
{isLoading ? getAudioSecondsTime(currentTime) : '0:00'}
</Typography>
<Box>/</Box>
<Typography variant="body2">
{isLoading ? getAudioSecondsTime(totalTime) : '0:00'}
</Typography>
</Stack>
);
};
const PlayBar = () => {
const { colorSet, audioPlayer, currentTime, totalTime } = useContext(AudioContext);
return (
<Slider
aria-label="time-indicator"
valueLabelDisplay="off"
value={currentTime}
min={0}
max={totalTime}
onChange={(_, value) => {
if (!audioPlayer) return;
audioPlayer.currentTime = value;
}}
onError={() => {
if (!audioPlayer) return;
audioPlayer.play();
}}
sx={{
// 커스텀한 style 코드
}},
}}
/>
);
};
const Volume = memo(() => {
const { colorSet, audioPlayer, volume, setVolume } = useContext(AudioContext);
const agent = navigator.userAgent.toLowerCase();
const isIOS = _.includes(agent, 'ipad') || _.includes(agent, 'iphone');
const isTablet = navigator.maxTouchPoints === 5;
const isMuted = audioPlayer.muted;
const [isTooltipOn, setIsTooltipOn] = useState<boolean>(false);
const MAKE_DECIMAL_NUMBER = 100;
const DEFAULT_SOUND_VALUE = 50;
const preventHorizontalKeyboardNavigation = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
}
}, []);
const controlMute = useCallback(() => {
if (audioPlayer) {
audioPlayer.muted = !isMuted;
}
}, [audioPlayer, isMuted]);
const icon = isMuted ? 'volume-up' : 'volume-off';
return isIOS || isTablet ? (
<IconWrapper
onClick={() => controlMute()}
>
<Iconify icon={icon} />
</IconWrapper>
) : (
<Tooltip
title={
<Slider
defaultValue={DEFAULT_SOUND_VALUE}
value={volume * MAKE_DECIMAL_NUMBER}
aria-label="volume"
onKeyDown={preventHorizontalKeyboardNavigation}
min={0}
max={MAKE_DECIMAL_NUMBER}
onChange={(event: Event) => {
if (!audioPlayer) return;
const target = event.target as HTMLInputElement;
const { value } = target;
setVolume(Number(value) / MAKE_DECIMAL_NUMBER);
}}
sx={{
// 커스텀 된 styled 코드
}}
/>
}
open={isTooltipOn}
onClose={() => setIsTooltipOn(false)}
disableHoverListener
>
<IconWrapper
onClick={() => setIsTooltipOn(!isTooltipOn)}
>
<Iconify icon={'volume-up'} />
</IconWrapper>
</Tooltip>
);
});
const DownloadButton = memo(({ audioUrl }: { audioUrl: string }) => (
<Button
LinkComponent={'a'}
href={audioUrl}
>
<Iconify
icon={'download'}
/>
</Button>
));
const SpeedSetting = memo(() => {
const { audioPlayer } = useContext(AudioContext);
const speedButtonRef = useRef<HTMLButtonElement | null>(null);
const [isDropdownOn, setIsDropdownOn] = useState<boolean>(false);
const SPEED_LIST = [0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1];
const menus = _.map(SPEED_LIST, (speed) => {
const menu = {
value: speed,
selectSpeed: () => {
if (audioPlayer) {
audioPlayer.playbackRate = speed;
handleClose();
}
},
};
return menu;
});
const handleClose = useCallback(() => {
setIsDropdownOn(false);
}, []);
return (
<>
<Stack direction="row">
<Button
ref={speedButtonRef}
onClick={() => setIsDropdownOn(!isDropdownOn)}
>
<Iconify icon={'speed'} />
</Button>
<Stack direction="row">
<Typography variant="body2">
x
</Typography>
<Typography variant="body2">
{audioPlayer?.playbackRate.toFixed(2)}
</Typography>
</Stack>
</Stack>
<Menu
id="speed"
keepMounted
anchorEl={speedButtonRef?.current}
open={isDropdownOn}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
{_.map(menus, (menu) => (
<MenuItem key={menu.value} onClick={menu.selectSpeed}>
<Typography variant="body2">
x {menu.value.toFixed(2)}
</Typography>
</MenuItem>
))}
</Menu>
</>
);
});
AudioPlayer.PlayStatus = PlayStatus;
AudioPlayer.PlayTime = PlayTime;
AudioPlayer.PlayBar = PlayBar;
AudioPlayer.Volume = Volume;
AudioPlayer.DownloadButton = DownloadButton;
AudioPlayer.SpeedSetting = SpeedSetting;
export default AudioPlayer;