DEV Community

Masui Masanori
Masui Masanori

Posted on

4

[ASP.NET Core][TypeScript] Try WebRTC

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

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay