ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Social Media 만들기 - 22) Call 기능 만들기 ( Web RTC사용하기 (peer))
    NODE.JS 2021. 8. 8. 23:00

    이번에는 web rtc를 사용해서 

    사용자간의 통화를 실시간으로 주고받을 수 있도록 설정할 것이다. 

     

    먼저 서버는  npm i peer, 클라이언트는 npm i peerjs 해서 peer 사용을 가능하도록 해준다. 

     

     

    이제 peer 정보를 서버에서 설정해주고, 

    클라이언트에서는 실행후, socket 처럼 정보를 state에 저장해준다. 

     

    server.js

    const {PeerServer} = require('peer')
    
    //Create peer server
    PeerServer({port:3001, path:'/'})

     

    peer와 함께 call의 정보도 저장할 것이다. 

     

    call constant

    export const CALL = 'CALL'
    
    export const PEER = 'PEER'

    callReducer

    import { CALL, PEER } from "../_constants/callConstants"
    
    export const callReducer = (state=null, action)=>{
        switch(action.type){
            case CALL:
                return action.payload
    
            default:
                return state
        }
    }
    
    export const peerReducer = (state=null, action)=>{
        switch(action.type){
            case PEER:
                return action.payload
    
            default:
                return state
        }
    }

    store.js

        call:callReducer,
    
    peer:peerReducer

     

    app.js

    import Peer from 'peerjs'
    import { PEER } from './_constants/callConstants';  
      
      useEffect(() => {
        const newPeer = new Peer(undefined,{
          host:'/', port:'3001'
        })
        dispatch({
          type:PEER,
          payload:newPeer
        })
      }, [])

     

     

    peer정보가 잘 저장되었다. 

    call을 실행시키면 call의 정보도 다음과 같이 잘 들어온다. 

     

    이제 message>rightSide에서 

    콜 버튼을 누르면,

    call modal이 실행되도로 설정해준다. 

    이때, call은 새로운 msg를 정해서 call에 대한 정보를 보내준다. 

    (audio -> video:false, video-> video:true)

    caller 는 전화를 거는 사람, 

    callUser = 전화를 받는 사람(userInfo.user)이다. 

    전화를 받는 사람은 받는사람의 call state 상태도 변화시켜야 하기 때문에, socket을 보낸다. 

        const caller=({video})=>{
            const {_id, avatar, username, fullname} = user
            const msg = {
                sender: userInfo.user._id,
                recipient:_id,
                avatar,
                username, 
                fullname,
                video
            }
            dispatch({
                type:CALL,
                payload:msg
            })
        }
    
        const callUser = ({video})=>{
            const {_id, avatar, username, fullname} = userInfo.user
            const msg = {
                sender: _id,
                recipient:user._id,
                avatar,
                username, 
                fullname,
                video
            }
            if(peer.open){
                msg.peerId = peer._id
            }
            socket.emit('callUser', msg)
    
            
    
        }
    
        const handleAudioCall=() =>{
            caller({video:false})
            callUser({video:false})
        }
    
        const handleVideoCall= () =>{
            caller({video:true})
            callUser({video:true})
    
        }
    
    
    
    
                <div className="message_header" style={{cursor:'pointer'}}>
                    {
                        user.length !== 0 &&
                        <UserCard user={user} id = {id}>
                            <div>
                                <i className="fas fa-phone-alt" onClick={handleAudioCall}></i>
                                <i className="fas fa-video mx-3" onClick={handleVideoCall}></i>
                                <i className="fas fa-trash text-danger" onClick={handleDeleteConversation}></i>
                            </div>
                        </UserCard>
                    }

     

     

     

     

    call user를 소켓으로 emit 시켰다. 

    socket server로 가서 cal user를 받아준다. 

    소켓에 연결된 users목록에서 call을 거는 사람과 받는 사람들 모두 call: 통화상대정보를 넣어준다.

    상대방이 이미 통화중이라면 userBusy소켓을 emit하고, 

    통화중이 아니라면 상대방의 call 정보를 callTolient로 보낸다. 

        //Call
        socket.on('callUser', data=>{
            users = users.map(user=>user.id===data.sender? {...user, call:data.recipient} : user )
            const client = users.find(user=>user.id ===data.recipient)
    
            // console.log('oldUsers:' + users)
            if(client){
                if(client.call){
                    socket.emit('userBusy',data)
                    users=users.map(user=>user.id===data.sender?{...user, call:null }: user )
                }else{
                    users = users.map(user=>user.id===data.recipient? {...user, call:data.sender}: user)
                    socket.to(`${client.socketId}`).emit('callUserToClient', data)
                }
            }
    
        })

     

     

    socketClient

    전화를 받는 사람도 call 정보를 넣어준다. 

        const call = useSelector(state => state.call)
    
        //Call User
        useEffect(() => {
            socket.on('callUserToClient', data=>{
                dispatch({
                    type:CALL,
                    payload:data
                })
            })
    
            return () => socket.off('callUserToClient')
    
        }, [socket, dispatch])
    
        useEffect(() => {
            socket.on('userBusy', data=>{
                dispatch({
                    type:ALERT,
                    payload:{error:`${call.username} is busy.`}
                })
            })
            return () =>socket.off('userBusy')
    
        }, [socket, dispatch, call])

     

    call modal을 만들자. 

    사용자가 callmodal 되는 순간부터 시간을 측정하고, 

    상대편이 전화를 받으면 다시 시간을 0부터 시작하도록 설정했고, 

    상대방이 전화를 받지않은채 15초가 지나면 call end시키도록 설정했다. 

    peer 정보를 이용해서 현재의 stream을 보내주고 서버를 통하지 않고 그대로 상대편도 stream을 받는다.

    navigator.mdeiadevices.getUserMedia를 통해서 나의 지금 미디어를 stream만들고 

    만들어진 stream을 현재 비디오가 위치할 ref.current에 srcObject로 설정해주고 play시킨다. 

     

    스트림에 따른 track들은 track 스테잇에 저장해주고, 

    새로운콜은 peer.call(peerId, stream)을 실행해서 newCall.on으로 상대방 비디오도 가져와서 보여주도록 한다. 

     

    useEffect를 이용해서 걸려온 전화가 있다면, peer.con을 이용해서 전화를받고, 

    나의 상태는 나의 스트림을 이용해서 설정하고, 

    새로운 콜을 newcall.answer로 받아서 상대방 정보도 newcall.on으로 받아준다. 

    call 이 끝나면, peer.removeListener('call')해서 지워준다. 

     

    비디오는 useRef를 video div에 걸어줘서 해당 화면src가 보이도록 잘 설정했다. 

     

     

    https://developer.mozilla.org/ko/docs/Web/API/MediaDevices/getUserMedia

     

    MediaDevices.getUserMedia() - Web API | MDN

    MediaDevices 인터페이스의 getUserMedia() 메서드는 사용자에게 미디어 입력 장치 사용 권한을 요청하며, 사용자가 수락하면 요청한 미디어 종류의 트랙을 포함한 MediaStream (en-US)을 반환합니다.

    developer.mozilla.org

    https://peerjs.com/

     

    PeerJS - Simple peer-to-peer with WebRTC

    The PeerJS library PeerJS simplifies WebRTC peer-to-peer data, video, and audio calls. PeerJS wraps the browser's WebRTC implementation to provide a complete, configurable, and easy-to-use peer-to-peer connection API. Equipped with nothing but an ID, a pee

    peerjs.com

     

    import React, { useCallback, useEffect, useRef, useState } from 'react'
    import { useDispatch, useSelector } from 'react-redux'
    import { addMessage } from '../../_actions/messageActions'
    import { CALL } from '../../_constants/callConstants'
    import { ALERT } from '../../_constants/globalConstants'
    import Avatar from '../Avatar'
    import ring from '../../audio/ring.mp3'
    
    function CallModal() {
    
        const userLogin = useSelector(state => state.userLogin)
        const {userInfo} = userLogin
        const call = useSelector(state => state.call)
        const peer = useSelector(state => state.peer)
        const socket = useSelector(state => state.socket)
        
        const [hours, setHours] = useState(0)
        const [mins, setMins] = useState(0)
        const [seconds, setSeconds] = useState(0)
        const [total, setTotal] = useState(0)
        
        const [answer, setAnswer] = useState(false)
    
        const [tracks, setTracks] = useState(null)
        const [newCall, setNewCall] = useState(null)
    
        const callerVideo = useRef()
        const receiverVideo = useRef()
        
        const dispatch = useDispatch()
    
        useEffect(() => {
            const setTime=() =>{
                setTotal(t=>t+1)
                setTimeout(setTime, 1000)
            }
            setTime()
            return () => setTotal(0)
        }, [])
    
        useEffect(() => {
            setSeconds(total%60)
            setMins(parseInt(total/60))
            setHours(parseInt(total/3600))
        }, [total])
    
    
        useEffect(() => {
            if(answer){
                setTotal(0)
            }else{
                const timer = setTimeout(()=>{
                    tracks && tracks.forEach(track=>track.stop())
                    socket.emit('endCall', {...call, times:0})
                    addCallMessage(call, 0)
                    dispatch({
                        type:CALL,
                        payload:null
                    })
                },15000)
                return () => clearTimeout(timer)
            }
        }, [answer, call, socket, dispatch, tracks, addCallMessage])
    
    
    
        const openStream = async(video)=>{
            let stream = null;
            const config = {audio:true, video}
            stream = await navigator.mediaDevices.getUserMedia(config)
     
            return stream
        }
    
        const playStream = (tag, stream)=>{
            let video = tag;
            video.srcObject = stream;
            video.play()
        }
    
        const handleAnswer=() =>{
            openStream(call.video).then(stream=>{
                // callerVideo.current.srcObject = stream
                // callerVideo.current.play()
    
                playStream(callerVideo.current, stream)
                const track = stream.getTracks()
                setTracks(track)
                const newCall = peer.call(call.peerId, stream);
                newCall.on('stream', function(remoteStream) {
                    // receiverVideo.current.srcObject = remoteStream
                    // receiverVideo.current.play()
                    playStream(receiverVideo.current, remoteStream)
                });
                setAnswer(true)
                setNewCall(newCall)
            })
    
        }
    
        useEffect(() => {
            peer.on('call', newCall=>{
                openStream(call.video).then(stream=>{
                    if(callerVideo.current){
                        playStream(callerVideo.current, stream)
                    }
                    const track = stream.getTracks()
                    setTracks(track)
                    newCall.answer(stream)
                    newCall.on('stream', function(remoteStream) {
                        // Show stream in some video/canvas element.
                        if(receiverVideo.current){
                            playStream(receiverVideo.current, remoteStream)
                        }
                    });
    
                    setAnswer(true)
                    setNewCall(newCall)
                })
            })
    
            return () => peer.removeListener('call')
        }, [peer, call.video])
    
    
    
        return (
            <div className="call_modal">
                <div className="call_box" style={{display: (answer && call.video)? 'none':'flex'}}>
                    <div className="text-center" style={{padding:'40px 0'}}>
                        <Avatar src={call.avatar} size="supper-avatar"/>
                        <h4>{call.username}</h4>
                        <h6>{call.fullname}</h6>
                        {
                            answer
                            ? <div className="timer">
                                <span>{hours.toString().length<2 ? '0'+ hours : hours }</span>
                                <span>:</span>
                                <span>{mins.toString().length<2 ? '0'+ mins : mins }</span>
                                <span>:</span>
                                <span>{seconds.toString().length<2 ? '0' + seconds : seconds}</span>
                            </div>
                            :   <div>
                                {
                                    call.video
                                    ? <span>calling video...</span>
                                    : <span>calling audio...</span>
                                }
                                </div>
                        }
                    </div>
    
                    <div className="timer">
                        <small>{mins.toString().length<2 ? '0'+ mins : mins }</small>
                        <small>:</small>
                        <small>{seconds.toString().length<2 ? '0' + seconds : seconds}</small>
                    </div>
    
                    <div className="call_menu">
                        <button className="material-icons text-danger" onClick={handleEndCall}>
                            call_end
                        </button>
                        
                        {
                            (call.recipient ===userInfo.user._id && !answer) && 
                                <>
                                {
                                    call.video
                                    ? <button className="material-icons text-success" onClick={handleAnswer}>videocam</button>
                                    : <button className="material-icons text-success" onClick={handleAnswer}>call</button>
                                }
                                </>
                        }
                    </div>
    
                    
    
                </div>
    
                <div className="show_video" style={{opacity: answer && call.video? '1': '0'}}>
                        <video ref={callerVideo} className="caller_video"></video>
                        <video ref={receiverVideo} className="receiver_video"></video>
                        <div className="timer">
                            <span>{hours.toString().length<2 ? '0'+ hours : hours }</span>
                            <span>:</span>
                            <span>{mins.toString().length<2 ? '0'+ mins : mins }</span>
                            <span>:</span>
                            <span>{seconds.toString().length<2 ? '0' + seconds : seconds}</span>
                        </div>
                        <button className="material-icons text-danger end_call" onClick={handleEndCall}>
                            call_end
                        </button>
    
                    </div>
            </div>
        )
    }
    
    export default CallModal

     

     

    이제 endCall도 받아주자. 

    추가로 사용자가 disconnect하면 해당 콜을 하고 있던 사람의 call정보도 사라지도록 설정해준다. 

     

    socketServer

           socket.on('disconnect', ()=>{
            const data = users.find(user=>user.socketId === socket.id)
            if(data){
                const clients = users.filter(user=>data.followers.find(item=>item._id ===user.id))
                if(clients.length>0){
                    clients.forEach(client=>{
                        socket.to(`${client.socketId}`).emit('checkUserOffline', data.id)
                    })
                }
                if(data.call){
                    const callUser = users.find(user=>user.id===data.call)
                    if(callUser){
                        console.log({callUser})
                        users = users.map(user=>user.id ===callUser.id ? {...user, call:null}:user)
                        socket.to(`${callUser.socketId}`).emit('callerDisconnect')
                    }
                }
            }
            users = users.filter(user=>user.socketId !== socket.id)
            // console.log({users})
        })
       
       socket.on('endCall', data=>{
            const client = users.find(user=>user.id===data.sender)
            console.log({sender:client})
            if(client){
                socket.to(`${client.socketId}`).emit('endCallToClient', data)
                users=users.map(user=>user.id===client.id?{...user, call:null }: user )
                if(client.call){
                    const clientCall = users.find(user=>user.id===client.call)
                    console.log({receiver:clientCall})
                    clientCall && socket.to(`${clientCall.socketId}`).emit('endCallToClient', data)
                    users=users.map(user=>user.id===data.recipient?{...user, call:null }: user )
                }
            }
    
            console.log({endUser:users})
        })

     

    socket.client

    전화가 끊겨지면 상대방도 끊겨지게끔 설정한다. 

        useEffect(() => {
            socket.on('endCallToClient', data=>{
    
                dispatch({
                    type:CALL,
                    payload:null
                })
            })
    
            return () =>socket.off('endCallToClient')
        }, [socket, dispatch])

     

     

    callmodal에서 정보를 받아준다. 

    disconnect도 받아주고 콜 끊는 것들도 설정해준다. 

    추가로 콜이 종료되면 메세지를 생성해준다. 

        const addCallMessage =useCallback((call, times)=>{
            if(call.recipient !== userInfo.user._id){
    
                const msg = {
                    sender: call.sender,
                    recipient:call.recipient,
                    text:'',
                    media:[],
                    call:{video:call.video, times},
                    createdAt:new Date().toISOString()
                }
                dispatch(addMessage({msg}))
            }
        },[userInfo, dispatch])
        
        const handleEndCall= () =>{
            tracks && tracks.forEach(track=>track.stop())
            
            if(newCall) newCall.close()
            let times = answer? total: 0
            socket.emit('endCall', {...call, times})
            addCallMessage(call, times)
            dispatch({
                type:CALL,
                payload:null
            })
        }
        
            useEffect(() => {
            if(answer){
                setTotal(0)
            }else{
                const timer = setTimeout(()=>{
                    tracks && tracks.forEach(track=>track.stop())
                    socket.emit('endCall', {...call, times:0})
                    addCallMessage(call, 0)
                    dispatch({
                        type:CALL,
                        payload:null
                    })
                },15000)
                return () => clearTimeout(timer)
            }
        }, [answer, call, socket, dispatch, tracks, addCallMessage])
    
    
        useEffect(() => {
            socket.on('endCallToClient', data =>{
                tracks && tracks.forEach(track=>track.stop())
                if(newCall) newCall.close()
                addCallMessage(data, data.times)
                dispatch({
                    type:CALL,
                    payload:null
                })
            })
            return () => socket.off('endCallToClient')
        }, [socket, dispatch, tracks, newCall, addCallMessage])
    
    
    
    
        useEffect(() => {
            socket.on('callerDisconnect',() =>{
                tracks && tracks.forEach(track=>track.stop())
                let times = answer ? total : 0
                addCallMessage(call, times)
                dispatch({
                    type:CALL,
                    payload:null
                })
                dispatch({
                    type:ALERT,
                    payload:{error:'The user disconnected this line.'}
                })
            })
            return () =>socket.off('callerDisconnect')
    
        }, [socket, dispatch, tracks, call, addCallMessage, answer, total])

     

     

    messageAction을 다음과 같이 수정해준다. 

         // 메세지 추가할때 call도 같이 보내준다. 
          
          const res = await axios.post('/api/message', {sender:msg.sender, recipient:msg.recipient, text:msg.text, media:imgArr, call:msg.call}, {
                headers:{authorization:`Bearer ${userInfo.token}`}
            })
            
            
            
            //대화 가져올때도 call정보 가져온다. 
            
                    res.data.conversations.forEach(item=>(
                item.recipients.forEach(receiver=>{
                    if(receiver._id!==userInfo.user._id){
                        newArr.push({...receiver, text:item.text, media:item.media, call:item.call})
                    }
                })
            ))

    messageReducer에 메세지 정보 추가할때 call 정보도 추가해준다. 

            case MESSAGE_ADD_SUCCESS:
                return {...state, 
                        loading:false, 
                        data:state.data.map(item=> 
                            item._id === action.payload.recipient || item._id ===action.payload.sender
                            ? {
                                ...item,
                                messages:[...item.messages, action.payload],
                                result:item.result + 1
                            }
                            : item
                        ),
                        users:state.users.map(user=>
                                                user._id ===action.payload.recipient || user._id ===action.payload.sender
                                                ?{...user, text:action.payload.text, media:action.payload.media, call:action.payload.call}     
                                                : user
                                            )}

    messageRouter에서도 콜정보 받는다. 

    messageRouter.post('/', auth, async(req, res)=>{
        try{
            const {sender, recipient, text, media, call} = req.body
    
            if(!recipient || (!text.trim() && media.length===0 && !call))  return;
    
            const newConversation = await Conversation.findOneAndUpdate({
                $or:[
                    {recipients:[sender, recipient]},
                    {recipients:[recipient, sender]}
                ]
            },{
                recipients:[sender, recipient],
                text, media,call
            },{new:true, upsert:true})
    
    
            const newMessage = new Message({
                conversation:newConversation,
                sender,
                recipient,
                text,
                media,
                call
            })
            await newMessage.save()
    
            res.json({
                msg:'Create Success.'
            })

     

     

    이제 메세지 보여줄때도 call 정보라면 새로 디스플레이해서 보여주자. 

    display.js

     {
                        msg.call &&
                        <button className="btn d-flex align-items-center py-3" style={{background:'#eee', borderRadius:'10px'}}>
                            <span className="material-icons font-weight-bold mr-1" style={{fontSize:'2.5rem', color: msg.call.times ===0? 'crimson':'green'}}>
                                {
                                    msg.call.times ===0
                                    ? msg.call.video 
                                        ? 'videocam_off'
                                        : 'phone_disabled'
                                    :msg.call.video 
                                        ? 'video_camera_front'
                                        : 'call'
                                }
                            </span>
    
                            <div className="text-left">
                                <h6>{msg.call.video ? 'Video Call' : 'Audio Call'}</h6>
                                <small>
                                    {
                                        msg.call.times >0 
                                        ? <Times total = {msg.call.times}/>
                                        : new Date(msg.call.times).toLocaleTimeString()
                                    }
                                </small>
                            </div>
                        </button>
                    }
    
                    {
                        msg.text && 
                        <div className="chat_text">
                            {msg.text}
                        </div>
    
                    }

     

    times.js

    import React from 'react'
    
    function Times({total}) {
        return (
            <div>
                <span>
                    {
                        (parseInt(total/3600)).toString().length<2
                        ? '0' + (parseInt(total/3600))
                        : (parseInt(total/3600))
                    }
                </span>
                <span>:</span>
                <span>
                    {
                        (parseInt(total/60)).toString().length<2
                        ? '0' + (parseInt(total/60))
                        : (parseInt(total/60))
                    }
                </span>
                <span>:</span>
                <span>
                    {
                        (total%60).toString().length<2
                        ? '0' + (total%60)+'s'
                        :(total%60)+'s'
                    }
                </span>
            </div>
        )
    }
    
    export default Times

     

    call_modal.css

    .call_modal{
        position: fixed;
        top: 0;
        left: 0;
        z-index: 9;
        background: #0008;
        width: 100%;
        height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .call_box{
        width: 100%;
        max-width: 400px;
        background: darkblue;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-direction: column;
        color: white;
        padding: 50px 0;
        border-radius: 5px;
        box-shadow: 0 0 5px darkblue;
    }
    .call_box h4, .call_box h6{
        margin-top: 5px;
    }
    .call_menu{
        min-width: 280px;
        width: 100%;
        display: flex;
        justify-content: space-around;
    }
    .call_menu button{
        font-size: 2rem;
        background: #eee;
        padding: 15px;
        border-radius: 50%;
        cursor: pointer;
        border: none;
        outline: none;
    }
    .call_modal .timer{
        transform: translateY(-15px);
    }
    .call_modal .show_video{
        position: fixed;
        top:0;
        left: 0;
        width: 100%;
        height: 100vh;
        pointer-events: none;
    }
    .call_modal .receiver_video{
        width: 100%;
        height: 100%;
    }
    .call_modal .caller_video{
        position: absolute;
        top: 0;
        right: 0;
        width: 300px;
        max-height: 250px;
        border-radius: 5px;
        border: 1px solid crimson;
        z-index: 10;
    }
    .call_modal .end_call{
        position: absolute;
        bottom: 100px;
        left: 50%;
        transform: translateX(-50%);
        pointer-events: initial;
        font-size: 2rem;
        background: #eee;
        padding: 15px;
        border-radius: 50%;
        cursor: pointer;
        border: none;
        outline: none;
    }
    .call_modal .time_video{
        position: absolute;
        bottom: 170px;
        left: 50%;
        transform: translateX(-50%);
        color: white;
    }

     

     

    잘나온다. 

     

     

     

     

     

    추가로 계속 영상통화는 play() 때문인지,

    firefox에서의 모드가 안맞아서인지, 

    통화를 실행하면 다음과같이 된다. 

    이때 navigator ...의 사이트에 추가된 정보를 이용해서 활요하면 나의 stream은 되지만, 

    상대방의 stream은 또 재생이 안된다.

    (ㅠㅠ)

    이문제를 해결하려면 navigator스트림을 저장하는 방식을 새롭게 해야하는 것 같다. 

Designed by Tistory.