React Context API & 컴파운드 패턴으로 커스텀 Audio 컴포넌트 만들기

미연·2024년 12월 27일
0

Before & After

작성 의도/목적

  • react-audio-player 라이브러리로 Audio를 관리하고 있었는데, 접속하는 기기마다 Audio 태그 UI가 깨져 보이는 현상이 있었음 → 디자인 커스텀 된 Audio 컴포넌트 관리 필요
  • 부모와 자식 컴포넌트 간의 상태를 전역적으로 공유할 수 있는 React Context API가 필요했음 (Play, Stop, Volume 조절 등..)
  • 관심사가 뚜렷하게 분리되어 재사용/결합할 때 매우 유용한 컴파운트 패턴을 적용함

코드

1. Audio 컴포넌트 호출 태그

props로 컬러나 스타일, audio 파일 Url, 속도 조절 버튼 유무를 설정할 수 있도록 했다.

   <Audio
         color={color}
         style={styledCode}
         audioUrl={audioUrl}
         hasSpeedButton={true}
    />

2. Audio.tsx

컴파운드 패턴으로 만든 컴포넌트 조각들을 호출한 뒤 결합하여 하나의 오디오 컴포넌트로 보여주도록 했다.

  • AudioPlayer: Context.Provider의 wrapper
  • AudioPlayer.PlayStatus: play / pause / stop 버튼 관리 UI
  • AudioPlayer.PlayTime: 현재 재생 시간 / 총 재생 시간 UI
  • AudioPlayer.PlayBar : 재생 progress bar UI
  • AudioPlayer.Volume : 볼륨 조절 제어 UI
  • AudioPlayer.DownloadButton : 오디오 파일 다운로드 버튼 UI
  • AudioPlayer.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>
  );

3. AudioContext.tsx 코드 전문

컴파운드 패턴으로 컴포넌트 조각들을 선언했다. 각 컴포넌트마다 고유의 기능을 명확히 구현하는 것이 목표였다.

  • Q. ReactAudioPlayer를 선언해놓고 disable: none 처리를 한 이유
  • A. Audio를 라이브러리로 제어하되, 이 디자인을 사용하지 않고 직접 커스텀한 디자인으로 사용할 것이기 때문이다.
  • ios 웹으로 접근시, 볼륨 조절이 제어가 되지 않는 이슈가 있었다. 따라서 볼륨 조절 대신 mute / unmute 기능을 추가했다.
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;
profile
FE Developer

0개의 댓글