ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [5~6일차] 주요 오류 발생사항 수정
    프로젝트 개발/뮤직플레이어 웹앱 2021. 2. 12. 00:10
    반응형

    소스코드를 대량으로 수정했다. 하드웨어상에서 재생/일시정지 지원을 위해 이벤트리스너를 설정하면서 겪게된 생각지도 못한 문제, 로그인 이후 재생상태에서 로그아웃했을때도 계속 재생되는 문제등을 수정했다.

     

    audio 태그에 이벤트리스너를 설정한뒤 다음 음악재생처리 과정에서 Hook에 대한 값이 제대로 변경되어있지 문제를 겪었고 원인과 해결방법에 대해선 별도의 포스트로 정리하였다.

     

    https://ddochea.tistory.com/93

     

    [React Hook] 컴포넌트 내 Effect Hook(useEffect) 사용 개념정리

    함수형 컴포넌트는 내부에 선언된 상태 훅(State Hook)의 변화나 렌더링 상황에 따라 함수형 컴포넌트 자체가 재 호출된다. 그래서 함수형 컴포넌트내에 단순히 변수를 선언하거나, 함수를 호출하

    ddochea.tistory.com

    https://ddochea.tistory.com/94

     

    [React Hook] 상태 훅(State Hook)을 EventListener에 사용시 유의사항

    개인프로젝트에서 HTMLAudioElement 를 선언하여 해당 객체에 이벤트 리스너를 심어 음악재생을 관리하는 기능을 구현하던 중 겪은 현상을 정리하고자 이 글을 작성하였다. 초기 소스는 아래와 같이

    ddochea.tistory.com

    로그아웃 문제는 MusicPlayer 컴포넌트 내 선언한 audio 상태 훅(State Hook)을 화면을 나간시점에선 제거가 되어야하는데 제거가 되지 않아 생긴 문제였다. Vue에선 lifecycle 상에 beforeDestory가 존재하지만, React에선 어떻게 해야하는지 아직 모르겠다.

     

    어차피 태그없이 객체를 쓴다고해서 blob URL을 확인 못하는 것도 아니기 때문에 테스트 때와 마찬가지로 useRef를 사용하여 문제를 해결했다.

     

    전체 소스는 아래와 같다.

     

    import React, { useEffect, useRef, useState } from 'react';
    import firebase from "firebase/app";
    import "firebase/storage";
    import "firebase/auth";
    
    import { useAuthState } from "react-firebase-hooks/auth";
    
    import localforage from "localforage";
    
    // import logo from './logo.svg';
    import './App.css';
    
    import { createStyles, makeStyles, Theme, useTheme } from '@material-ui/core/styles';
    import { AppBar, Fab, Card, CardContent, IconButton, Toolbar, Typography, CardHeader, Button } from '@material-ui/core';
    
    // https://material-ui.com/components/material-icons/#material-icons
    
    // 이 방식은 빌드 및 테스트 초기 로딩이 느린 단점이 있음.
    // import { PlaylistPlay, PlayArrow, Pause, SkipNext, SkipPrevious } from "@material-ui/icons";
    
    import PlaylistPlay from '@material-ui/icons/PlaylistPlay';
    import PlayArrow from "@material-ui/icons/PlayArrowRounded";
    import Pause from "@material-ui/icons/Pause";
    import SkipNext from "@material-ui/icons/SkipNext";
    import SkipPrevious from "@material-ui/icons/SkipPrevious";
    import Repeat from "@material-ui/icons/Repeat";
    import Shuffle from "@material-ui/icons/Shuffle";
    
    import { firebaseConfig } from "./firebaseConfig";
    import { Media } from "./models/media";
    
    firebase.initializeApp(firebaseConfig);
    
    const auth = firebase.auth();
    const storage = firebase.storage().ref();
    
    localforage.config({
      storeName: "media"
    });
    
    const useStyles = makeStyles((theme: Theme) =>
      createStyles({
        root: {
          display: 'flex',
          bottom: 0,
        },
        appBar: {
          top: 'auto',
          bottom: 0,
          paddingTop: theme.spacing(2)
        },
        controls: {
          alignItems: 'center',
          flexGrow: 1 // 해당 영역과 함께 나란히 놓인 다른 태그들을 양끝으로 밀어낸다(?)
        },
        card : {
          margin: theme.spacing(1),
          minHeight: 400
        },
        Icon: {
          height: 32,
          width: 32
        }
      }),
    );
    
    function App() {
      const [user] = useAuthState(auth); // firebase auth 기능 사용을 위한 Hook
    
      return (
        <div className="App">
          { user ? <MusicPlayer /> : <SignIn /> }
        </div>
        );
    }
    
    export default App;
    
    /**
     * 로그인 버튼(화면)
     */
    function SignIn() {
      const signInWithGoogle = () => {
        const provider = new firebase.auth.GoogleAuthProvider();
        auth.signInWithPopup(provider);
      }
      return (<Button color={'primary'} onClick={signInWithGoogle}>Sign in with Google</Button>)
    }
    /**
     * 로그아웃 버튼
     */
    function SignOut() {
      return auth.currentUser && (
        <Button color={'inherit'} onClick={() => auth.signOut()}>Sign out</Button>
      )
    }
    
    interface MusicListProp {
      playList : Media[]
    }
    
    /**
     * 음악 리스트 화면
     */
    function MusicList({ playList } : MusicListProp) {
    
    }
    
    /**
     * 음악 재생 화면 
     */
    function MusicPlayer() {
      const classes = useStyles();
      const theme = useTheme();
    
      const [isPlay, setIsPlay] = useState(false);
      const [isRepeat, setIsRepeat] = useState(false);
      const [isSuffle, setIsSuffle] = useState(false);
    
      const [currentPlayIdx, setCurrentPlayIdx] = useState(-1);
      const [playIndexes, setPlayIndexes] = useState(new Array<number>());
      const [playList, setPlayList] = useState(new Array<string>());
      const [totalTime, setTotalTime] = useState(1); // 초기값을 0으로 주면 바로 다음 음악재생
      const [currentTime, setCurrentTime] = useState(0);
    
      // const audio = new Audio(); // 이렇게 선언할 경우 MusicPlayer 내 다른 state값이 변경될때마다 매번 생성됨.
      // const [audio] = useState(new Audio()); // 재생중 로그아웃시 객체가 살아남아있다. 어차피 DevTools 네트워크탭에서 blob URL 확인이 가능하므로 그냥 useRef 쓰도록 하자.
      const audioRef = useRef(new Audio);
    
      function init() {
        console.log(`init ${MusicPlayer.name}`);
        storage.listAll().then(result => {
          const mediaArray : string[] = [];
          const playIdx : number[] = [];
          let i = 0;
          result.items.forEach(item => {
            mediaArray.push(item.name);
            playIdx.push(i);
            i++;
          });
          setPlayList(mediaArray);
          setPlayIndexes(playIdx);
          setCurrentPlayIdx(0);
        });
        const audio = audioRef.current;
        audio.addEventListener('timeupdate', function() {
          //#region NOTE : // EventListener 안에선 State 값을 사용할 수 없다. 해당 상태값이 이벤트안에선 정상적으로 인지되지 않는다.
          // if(currentTime >= totalTime) {
          //   console.log('do next');
          //   setNext();
          // }
          //#endregion
          setCurrentTime(audio.currentTime);
          setTotalTime(audio.duration);
        });
        audio.addEventListener('play', function() {
          // H/W에서 제어했을때도 State의 변경값 확인이 필요하므로 eventlistener 필요
          setIsPlay(true); // 이벤트리스너 안에선 고정값만 사용가능.
        });
        audio.addEventListener('pause', function() {
          setIsPlay(false);
        });
      }
    
      useEffect(() => {
        init();
      }, []); // [] 내용물이 없으면 최초 1회만 호출
    
      useEffect(() => {
        if(currentPlayIdx !== -1){
          const audio = audioRef.current;
          URL.revokeObjectURL(audio.src);
          const key = playList[playIndexes[currentPlayIdx]];
          localforage.getItem(key)
          .then(item => {
            if(item) {
              play(item); 
            }
            else {
              return storage.child(key).getDownloadURL()
              .then(url => {
                if(url) {
                  return fetch(url);
                }
              })
              .then(res => {
                if(res) {
                  return res.blob();
                }
              })
              .then(blob => {
                if(blob) {
                  console.log(`call blob`);
                  console.log(blob);
                  localforage.setItem(key, blob);
                  play(blob);
                }
              });
            }
          });
          const play = (item : any) => {
             if(item){
              audio.src = URL.createObjectURL(item);
              if(isPlay) audio.play();
            }
          }
        }
      }, [currentPlayIdx])
    
      useEffect(() => {
        if(currentTime >= totalTime) {
          setNext();
        }
      }, [currentTime, totalTime]);
    
      function setNext() {
        let nextIdx = currentPlayIdx + 1;
        if(nextIdx >= playIndexes.length){
          nextIdx = 0;
          if(!isRepeat){
            const audio = audioRef.current;
            audio.pause();
            setIsPlay(false);
          }
        }
        setCurrentPlayIdx(nextIdx);
      }
      
      return (
        <>
        <audio ref={audioRef} hidden={true} />
        <Card className={classes.card}>
            <CardHeader>
              <Typography>Test</Typography>
            </CardHeader>
            <CardContent>
              <Typography>{currentPlayIdx}</Typography>
              <Typography>{currentTime} / {totalTime}</Typography>
            </CardContent>
          </Card>
          <AppBar position={'fixed'} className={classes.appBar}>
            <Toolbar>
            <IconButton color={ isRepeat? 'inherit' : 'default' } aria-label="loop" onClick={() => setIsRepeat(!isRepeat)}>
              <Repeat />
            </IconButton>
            <div className={classes.controls}>
            <IconButton aria-label="previous" onClick={() => audioRef.current.currentTime += 30}>
              {theme.direction === 'rtl' ? 
              <SkipNext className={classes.Icon} /> : 
              <SkipPrevious className={classes.Icon} />
              }
            </IconButton>
            <Fab color={'secondary'} aria-label="play/pause" onClick={() => isPlay? audioRef.current.pause() : audioRef.current.play() }>
              {
                isPlay? 
                <Pause className={classes.Icon} /> : 
                <PlayArrow className={classes.Icon} />
              }
            </Fab>
            <IconButton aria-label="next" onClick={setNext}>
              {theme.direction === 'rtl' ? 
              <SkipPrevious className={classes.Icon} /> : 
              <SkipNext className={classes.Icon} />}
            </IconButton>
          </div>
          <IconButton color={isSuffle? 'inherit' : 'default'} aria-label="shuffle" onClick={() => setIsSuffle(!isSuffle)}>
            <Shuffle />
          </IconButton>
          </Toolbar>
          <Toolbar>
            <IconButton edge='start' color="inherit" aria-label="menu">
              <PlaylistPlay />
            </IconButton>
            <SignOut />
          </Toolbar>
          </AppBar>
        </>
      );
    }

    현재 구현된 단계는 음악파일이 캐싱되어있지 않으면 storage에서 받아 재생하고, 있으면 blob 파일을 바로 꺼내 재생하는 기능과 다음 음악재생, 마지막 음악까지 재생하면 맨 처음으로 되돌아가는 기능까지 구현되었다.

     

    이전(previous)버튼에 audioRef.current.currentTime += 30 가 있는 이유는 테스트를 위한 임시코드이다.

    미완작이긴 하지만 출퇴근할때마다 사용은 가능한 수준까지 되었다.

    설 연휴 전에 끝내는걸 목표로 진행해봐야겠다.

    반응형

    댓글

Designed by Tistory.