DEV Community

loading...

[ASP.NET Core][TypeScript] Try WebRTC

Masui Masanori
Programmer, husband, father I love C#, TypeScript, etc.
・5 min read

Intro

This time, I try video chatting with WebRTC.

I use the ASP.NET Core application what was created last time as a server-side application.

And I referred these samples to create this WebRTC sample.

I didn't add any new packages into client-side or server-side.

Use Camera and Mic

To use Camera ans Mic from Web browsers, I use "getUserMedia".

Index.cshtml

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Hello WebRTC</title>
        <meta charset="utf-8">
    </head>
    <body>
...
        <div id="webrtc_sample_area">
            <button id="offer_button" onclick="Page.sendOffer()">Offer</button>
            <button id="hangup_button" onclick="Page.hangUp()">Hang Up</button>
            <video id="local_video" muted>Video stream not available.</video>
            <video id="received_video" autoplay>Video stream not available.</video>
        </div>
        <script src="js/main.js"></script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

main.page.ts

import { WebRtcController } from "./webrtc-controller";

let rtcSample = new WebRtcController();

function init(){
    rtcSample = new WebRtcController();
    rtcSample.initVideo();
}
init();
Enter fullscreen mode Exit fullscreen mode

webrtc-controller.ts

export class WebRtcController {
  private webcamStream: MediaStream|null = null;

  public initVideo(){
      const localVideo = document.getElementById('local_video') as HTMLVideoElement;
      let streaming = false;
      // after being UserMedia available, set Video element's size.
      localVideo.addEventListener('canplay', ev => {
          if (streaming === false) {
            const width = 320;
            const height = localVideo.videoHeight / (localVideo.videoWidth/width);          
            localVideo.setAttribute('width', width.toString());
            localVideo.setAttribute('height', height.toString());
            streaming = true;
          }
        }, false);
      navigator.mediaDevices.getUserMedia({ video: true, audio: true })
        .then(stream => {
            this.webcamStream = stream;
            localVideo.srcObject = stream;
            localVideo.play();
            streaming = true;
        })
        .catch(err => console.error(`An error occurred: ${err}`));
  }
}
Enter fullscreen mode Exit fullscreen mode

RTCPeerConnection

After setting media devices enabled, I can connect with other clients through "RTCPeerConnection".

In this sample, the flow of operations like below.
Alt Text

webrtc-controller.ts

import { Candidate } from "./candidate";
import { VideoOffer } from "./video-offer";

export class WebRtcController {
    private wsConnection: WebSocket|null = null;
    private myPeerConnection: RTCPeerConnection|null = null;
    private webcamStream: MediaStream|null = null;

    public updateWebSocket(ws: WebSocket|null) {
        this.wsConnection = ws;
    }
    public initVideo(){
        const localVideo = document.getElementById('local_video') as HTMLVideoElement;
        let streaming = false;
        // after being UserMedia available, set Video element's size.
        localVideo.addEventListener('canplay', ev => {
            if (streaming === false) {
                const width = 320;
                const height = localVideo.videoHeight / (localVideo.videoWidth/width);          
                localVideo.setAttribute('width', width.toString());
                localVideo.setAttribute('height', height.toString());
                streaming = true;
            }
            }, false);
        navigator.mediaDevices.getUserMedia({ video: true, audio: true })
            .then(stream => {
                this.webcamStream = stream;
                localVideo.srcObject = stream;
                localVideo.play();
                streaming = true;
            })
            .catch(err => console.error(`An error occurred: ${err}`));
    }
    public invite() {
        // Create RTCPeerConnection and add local media stream.
        this.myPeerConnection = this.createPeerConnection();
        if (this.webcamStream == null) {
            return;
        }    
        this.webcamStream.getTracks().forEach(
            track => {
                if (this.myPeerConnection == null || this.webcamStream == null) {
                    return;
                }
                this.myPeerConnection.addTrack(track, this.webcamStream);
            });
    }
    public async handleVideoOfferMsg(payload: VideoOffer) {
        if (payload.sdp == null) {
            return;
        }  
        if (this.myPeerConnection == null) {
            this.myPeerConnection = this.createPeerConnection();
            if (this.myPeerConnection == null) {
                return;
            }
        }
        const remoteDescription = new RTCSessionDescription(payload.sdp);
        if (this.myPeerConnection.signalingState != "stable") {

            await Promise.all([
                this.myPeerConnection.setLocalDescription({type: 'rollback'}),
                this.myPeerConnection.setRemoteDescription(remoteDescription)
            ]);
            return;
        }
        await this.myPeerConnection.setRemoteDescription(remoteDescription);

        if (this.webcamStream == null) {
            try {
                this.webcamStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
            } catch(err) {
                console.error(err);
                return;
            }
            const localVideo = document.getElementById('local_video') as HTMLVideoElement;
            localVideo.srcObject = this.webcamStream; 
        }
        try {
            this.webcamStream.getTracks().forEach(
                track => {
                if (this.myPeerConnection != null && this.webcamStream != null) {
                    this.myPeerConnection.addTrack(track, this.webcamStream);    
                }   
            });
        } catch(err) {
            console.error(err);
        }
        await this.myPeerConnection.setLocalDescription(await this.myPeerConnection.createAnswer());

        this.sendToServer({
            type: 'video-answer',
            sdp: this.myPeerConnection.localDescription,
        });
    }
    public async handleVideoAnswerMsg(msg: VideoOffer) {
        if (msg.sdp == null || this.myPeerConnection == null) {
            return;
        }
        const remoteDescription = new RTCSessionDescription(msg.sdp);
        await this.myPeerConnection.setRemoteDescription(remoteDescription)
          .catch(err => console.error(err));
    }
    public async handleNewICECandidateMsg(msg: Candidate) {
        if (msg.candidate == null || this.myPeerConnection == null) {
            return;
        }
        const candidate = new RTCIceCandidate(msg.candidate);  
        try {
            await this.myPeerConnection.addIceCandidate(candidate)
        } catch(err) {
            console.error(err);
        }
    }
    public closeVideoCall() {
        const localVideo = document.getElementById('local_video') as HTMLVideoElement;
        if (localVideo == null) {
            return;
        }

        if (this.myPeerConnection) {
            this.myPeerConnection.ontrack = null;
            this.myPeerConnection.onicecandidate = null;
            this.myPeerConnection.oniceconnectionstatechange = null;
            this.myPeerConnection.onsignalingstatechange = null;
            this.myPeerConnection.onnegotiationneeded = null;

            if (localVideo.srcObject) {
                localVideo.pause();
                (localVideo.srcObject as MediaStream).getTracks().forEach(track => track.stop());
            }  
            this.myPeerConnection.close();
            this.myPeerConnection = null;
            this.webcamStream = null;
        }
    }
    private createPeerConnection(): RTCPeerConnection {
        const newPeerConnection = new RTCPeerConnection({
            iceServers: [{
                urls: `stun:stun.l.google.com:19302`,  // A STUN server              
            }]
        });
        newPeerConnection.onicecandidate = (ev) => this.handleICECandidateEvent(ev, this);
        newPeerConnection.oniceconnectionstatechange = (ev) => this.handleICEConnectionStateChangeEvent(ev, newPeerConnection);
        newPeerConnection.onsignalingstatechange = (ev) => this.handleSignalingStateChangeEvent(ev, newPeerConnection);
        newPeerConnection.onnegotiationneeded = () => this.handleNegotiationNeededEvent(newPeerConnection);
        newPeerConnection.ontrack = this.handleTrackEvent;
        return newPeerConnection;
    }
    private sendToServer(msg: VideoOffer|Candidate) {
        if (this.wsConnection == null) {
            return;
        }
        this.wsConnection.send(JSON.stringify(msg));
    }
    private async handleNegotiationNeededEvent(connection: RTCPeerConnection) {
        if (connection == null) {
            return;
        }
        try {
            const offer = await connection.createOffer();
            if (connection.signalingState != 'stable') {
                return;
            }
            await connection.setLocalDescription(offer);

            this.sendToServer({
                type: 'video-offer',
                sdp: connection.localDescription,
            });
        } catch(err) {
            console.error(err);
        };
    }
    private handleICECandidateEvent(event: RTCPeerConnectionIceEvent, self: WebRtcController) {
        if (event.candidate) {
            self.sendToServer({
                type: 'new-ice-candidate',
                candidate: event.candidate
            });
        }
    }
    private handleICEConnectionStateChangeEvent(event: Event, connection: RTCPeerConnection) {
        if (connection == null) {
            return;
        }    
        switch(connection.iceConnectionState) {
        case 'closed':
        case 'failed':
        case 'disconnected':
            this.closeVideoCall();
            break;
        }
    }
    private handleSignalingStateChangeEvent(event: Event, connection: RTCPeerConnection) {
        if (connection == null) {
            return;
        }    
        switch(connection.signalingState) {
            case 'closed':
                this.closeVideoCall();
                break;
        }
    }
    private handleTrackEvent(event: RTCTrackEvent) {
        const receivedVideo = document.getElementById('received_video') as HTMLVideoElement;
        receivedVideo.srcObject = event.streams[0];
    }
}
Enter fullscreen mode Exit fullscreen mode

video-offer.ts

export type VideoOffer = {
    type: 'video-offer'|'video-answer',
    sdp: RTCSessionDescription|null,
};
Enter fullscreen mode Exit fullscreen mode

candidate.ts

export type Candidate = {
    type: 'new-ice-candidate',
    candidate: RTCIceCandidateInit|null
};
Enter fullscreen mode Exit fullscreen mode

main.page.ts

import { WebRtcController } from "./webrtc-controller";
import { WebsocketMessage } from "./websocket-message";

let ws: WebSocket|null = null;
let rtcSample = new WebRtcController();

export function connect() {
    ws = new WebSocket(`wss://${window.location.hostname}:5001/ws`);
    ws.onopen = () => sendMessage({
            event: 'message',
            type: 'text',
            data: 'connected',
        });
    ws.onmessage = async data => {
        const message = JSON.parse(data.data);
        console.log(message);
        switch(message.type) {
            case 'text':
                addReceivedMessage(message.data);
                break;
            case 'video-offer':
                console.log("VideoOffer");
                await rtcSample.handleVideoOfferMsg(message);
                break;
            case 'video-answer':
                await rtcSample.handleVideoAnswerMsg(message);
                break;
            case 'new-ice-candidate':
                await rtcSample.handleNewICECandidateMsg(message);
                break;
            default:
                console.log('not found');
                break;
        }
    };
    rtcSample.updateWebSocket(ws);
}
export function send() {
    const messageArea = document.getElementById('input_message') as HTMLTextAreaElement;
    sendMessage({
        event: 'message',
        type: 'text',
        data: messageArea.value,
    });
}
export function close() {
    if(ws == null) {
        console.warn('WebSocket was null');
        return;
    }
    rtcSample.updateWebSocket(null);
    ws.close();
    ws = null;
}
export function sendOffer() {
    rtcSample.invite();
}
export function hangUp() {
    rtcSample.closeVideoCall();
}
function addReceivedMessage(message: string) {
    const receivedMessageArea = document.getElementById('received_text_area') as HTMLElement;
    const child = document.createElement('div');
    child.textContent = message;
    receivedMessageArea.appendChild(child);
}
function sendMessage(message: WebsocketMessage) {
    if (ws == null) {
        console.warn('WebSocket was null');
        return;
    }
    ws.send(JSON.stringify(message));
}
function init(){
    rtcSample = new WebRtcController();
    rtcSample.initVideo();
}
init();
Enter fullscreen mode Exit fullscreen mode

DOMException

Because I didn't stop User1's own WebSocket message, I got an exception on connecting.

DOMException: Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': 
Failed to set remote answer sdp: Called in wrong state: have-remote-offer
...
Enter fullscreen mode Exit fullscreen mode

So I stopped receiveing sender own WebSocket messages.

WebSocketHolder.cs

...
        private async Task EchoAsync(WebSocket webSocket)
        {
            try
            {
                // for sending data
                byte[] buffer = new byte[1024 * 4];

                while(true)
                {
                    WebSocketReceiveResult result = await webSocket.ReceiveAsync(
                        new ArraySegment<byte>(buffer), cancel);
                    if(result.CloseStatus.HasValue)
                    {
                        await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, cancel);
                        clients.TryRemove(clients.First(w => w.Value == webSocket));
                        webSocket.Dispose();
                        break;
                    }
                    // Send to all clients
                    foreach(var c in clients)
                    {
                        if(c.Value == webSocket) {
                            continue;
                        }
                        await c.Value.SendAsync(
                            new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancel);
                    }
                }           
            }
Enter fullscreen mode Exit fullscreen mode

Discussion (0)