WebRTC-Beispielanwendung mit Medienstreaming und Datenkanal

In t3n Heft 49 ist mein Artikel zum Thema WebRTC erschienen. Hier findet ihr die vollständige Beispiel-Anwendung dazu, inklusive einer ausführlichen Erklärung. Viel Erfolg beim Entwickeln!

Eine Videochat-App mit WebRTC implementieren

Anhand einer kleinen Beispielanwendung wollen wir uns die WebRTC-APIs einmal genauer anschauen. Unsere App soll zwei Teilnehmer mittels WebRTC zum Austausch von Chatnachrichten sowie zur direkten Kommunikation via Audio- und Videostreaming befähigen. Wir verwenden also sowohl die RTP Media API als auch die P2P Data API. Um die Komplexität ein wenig zu reduzieren, benutzen wir SignalMaster, einen OpenSource-Signaling-Server. Zudem wurden einige für das Verständnis unwichtige Teile ausgelassen - den vollständigen Code der App findet ihr unter https://github.com/beheist/webrtc-beispiel

Schritt 1: Setup

Wir legen uns ein Verzeichnis mit einer index.html an. Sie bekommt folgenden Inhalt:

<!DOCTYPE html> <html> <body> <div id="videoArea"> <video id="vidLocal" autoplay muted></video> <button id="btnStart">Start</button> <video id="vidRemote" autoplay></video> </div> <div id="chatArea"> <div id="displayChat"></div> <input type="text" id="enterChat" placeholder="Chatnachricht eingeben" disabled/> </div> <script src="node_modules/socket.io-client/dist/socket.io.js"></script> <script src="app.js"></script> </body> </html>

Als nächstes benötigen wir zwei JavaScript-Dependencies – unseren Signaling-Server sowie die Websocket-Library socket.io. Falls ihr den JavaScript-Paketmanager npm nicht installiert habt, solltet ihr das zuvor nachholen.

npm install signal-master@1.0.1 socket.io-client@2.0.3 

Zuletzt legen wir noch eine Datei namens app.js an. Hier hinein kommt unser JavaScript-Code. Der Signaling-Server kann mit folgendem Befehl gestartet werden:

node node_modules/signal-master/server.js 

Nun brauchen wir noch einen Mini-Webserver, um unsere index.html auszuliefern – PHP beispielsweise bringt bereits einen mit:

php -S 127.0.0.1:8081 

Die noch leere Anwendung ist jetzt unter http://127.0.0.1:8081 erreichbar. In der Browser-Konsole sollten keine Fehler sichtbar sein.  

Schritt 2: Signaling-Kanal für den Aufbau der WebRTC-Verbindung

Das erste Codestück ermöglicht die Kommunikation mit dem Signaling-Server und enthält einige globale Variablen und Hilfsfunktionen, die wir später brauchen werden.  Die Methode receiveMessage empfängt alle am Signaling-Socket eingehenden Nachrichten und löst eine entsprechende Aktion aus.

let localStream; let connection; let dataChannel; // Helper für das Errorlogging const logErrors = err => console.error(err); // Websockets-Verbindung herstellen const socket = io.connect('http://localhost:8888'); // Nachricht über Signaling-Kanal verschicken const sendMessage = message => { message = Object.assign({to: 'default'}, message); socket.emit('message', message); }; // Nachricht vom Signaling-Kanal empfangen const receiveMessage = message => { // Nachrichten vom eigenen Socket ignorieren if (message.from === socket.id) return; // Nachricht verarbeiten switch (message.type) { // Verbindungsaufbau wurde vom Partner angefordert case 'init': createConnection(); startConnection(false); break; // Ein SDP-Verbindungsangebot ist eingegangen – wir erstellen eine Antwort case 'offer': connection .setRemoteDescription(new RTCSessionDescription(message.descr)) .then(() => connection.createAnswer()) .then(answer => connection.setLocalDescription(new RTCSessionDescription(answer))) .then(() => sendMessage({type: 'answer', descr: connection.localDescription})) .catch(logErrors); break; // Eine SDP-Verbindungsantwort auf unsere Anfrage ist eingegangen. case 'answer': connection.setRemoteDescription(new RTCSessionDescription(message.descr)); break; // Der Partner hat eine mögliche Host-Port-Kombination ("ICE Candidate") gefunden case 'candidate': connection.addIceCandidate(new RTCIceCandidate({candidate: message.candidate})); break; } }; socket.on('message', receiveMessage);

Nun fehlen uns noch zwei Methoden für das Erstellen und Initialisieren der WebRTC-Verbindung.

// Verbindung erstellen const createConnection = () => { connection = new RTCPeerConnection(); }; // Verbindung initialisieren const startConnection = isCreator => { // Wenn wir mögliche Kommunikationsendpunkte finden, diese an den Partner weitergeben connection.onicecandidate = event => { if (event.candidate) { sendMessage({ type: 'candidate', candidate: event.candidate.candidate }); } }; // Falls wir der erste Teilnehmer sind, starten wir den Verbindungsaufbau if (isCreator) { connection .createOffer() .then(offer => connection.setLocalDescription(new RTCSessionDescription(offer))) .then(() => sendMessage({type: 'offer', descr: connection.localDescription})) .catch(logErrors); } };

Zuletzt fügen wir einen Click-Handler zu unserem Button hinzu, womit wir den Verbindungsaufbau anstoßen.

document.getElementById('btnStart').onclick = e => { socket.emit('join', 'default', (message, {clients}) => { // Wenn wir keine Clients haben, ist noch kein Teilnehmer außer uns da und wir warten if (Object.keys(clients).length === 0) return; // Wir haben einen Partner und initiieren die Verbindung sendMessage({ type: 'init' }); createConnection(); startConnection(true); }); };

Damit kann jetzt eine WebRTC-Verbindung aufgebaut werden. Wenn ihr nun in zwei Browserfenstern http://127.0.0.1:8081 öffnet und jeweils auf Start klickt, wird die Verbindung hergestellt. Da wir noch keine Daten übertragen, ist noch keine Reaktion zu sehen. Das lässt sich jedoch über die Entwicklertools ändern. Öffnet chrome://webrtc-internals (Chrome) bzw. about:webrtc (Firefox), um Informationen über die aufgebaute WebRTC-Verbindung anzuzeigen.

Schritt 3: Videostreaming über die WebRTC-Verbindung

Fügen wir nun die Funktionen für das Videostreaming hinzu. In der Methode createConnection muss eine weitere Zeile ergänzt werden, die den Stream der Gegenstelle verfügbar macht:

// Verbindung erstellen const createConnection = () => { connection = new RTCPeerConnection(); connection.addStream(localStream); };

Die Gegenstelle erhalt dann ein Event, in dem wir den Stream mit dem Video-Element im HTML verknüpfen können. Folgende Zeilen ergänzen wir dazu in der Methode startConnection:

// Verbindung initialisieren const startConnection = isCreator => { // Wenn wir mögliche Kommunikationsendpunkte finden, diese an den Partner weitergeben connection.onicecandidate = event => { if (event.candidate) { sendMessage({ type: 'candidate', candidate: event.candidate.candidate }); } }; // Wenn die Gegenseite einen Stream hinzufügt, diesen an das video-element hängen connection.onaddstream = (e) => { document.getElementById('vidRemote').src = window.URL.createObjectURL(e.stream); }; // Falls wir der erste Teilnehmer sind, starten wir den Verbindungsaufbau if (isCreator) { connection .createOffer() .then(offer => connection.setLocalDescription(new RTCSessionDescription(offer))) .then(() => sendMessage({type: 'offer', descr: connection.localDescription})) .catch(logErrors); } };

Zudem wollen wir beim Klick auf den Button jetzt zunächst auf den lokalen Videostream zugreifen, bevor wir die Verbindung aufbauen. Daher muss unser Klick-Handler jetzt so aussehen.

document.getElementById('btnStart').onclick = (e) => { // Per GetUserMedia auf den Videostream zugreifen navigator.mediaDevices.getUserMedia({ audio: true, video: true }) .then(stream => { document.getElementById('vidLocal').src = window.URL.createObjectURL(stream); localStream = stream; socket.emit('join', 'default', (message, {clients}) => { // Wenn wir keine Clients haben, warten wir hier if (Object.keys(clients).length === 0) return; // Wir haben einen Partner und initiieren die Verbindung sendMessage({ type: 'init' }); createConnection(); startConnection(true); }); }) .catch(logErrors); };

Diese kleinen Ergänzungen reichen aus, um eine vollständige WebRTC-Videokonferenz zu implementieren! Nach einem Klick auf den Button in beiden Browserfenstern sollten nun bereits zwei Video-Elemente mit dem Kamerabild der Webcam zu sehen sein. Achtung: Vorher den Ton ausstellen oder Kopfhörer anschließen, sonst kann es zu Rückkopplungen kommen.

Schritt 4: WebRTC-Datenkanal für Chatnachrichten nutzen

Auch der WebRTC-Datenkanal lässt sich mit nur wenigen Schritten nutzen. Wir erweitern nochmals unsere createConnection-Methode. Sie sieht nun so aus:

const startConnection = (isCreator) => { connection.onicecandidate = (event) => { if (event.candidate) { sendMessage({ type: 'candidate', candidate: event.candidate.candidate }); } }; connection.onaddstream = (e) => { document.getElementById('vidRemote').src = window.URL.createObjectURL(e.stream); }; if (isCreator) { // Datenkanal anlegen dataChannel = connection.createDataChannel('chat'); onDataChannelCreated(dataChannel); // Offer verschicken connection .createOffer() .then(offer => connection.setLocalDescription(new RTCSessionDescription(offer))) .then(() => sendMessage({type: 'offer', descr: connection.localDescription})) .catch(logErrors); } else { // Wenn wir nicht der Initiator sind, reagieren wir nur auf das Anlegen eines Datenkanals connection.ondatachannel = function (event) { dataChannel = event.channel; onDataChannelCreated(dataChannel); }; } };

Die hier benutzte Methode onDataChannelCreated müssen wir ebenfalls noch anlegen.

const onDataChannelCreated = channel => { // Sobald der Datenkanal verfügbar ist, Chateingaben zulassen channel.onopen = () => { const enterChat = document.getElementById('enterChat'); enterChat.disabled = false; enterChat.onkeyup = (keyevent) => { // Bei "Enter" absenden if (keyevent.keyCode === 13) { dataChannel.send(enterChat.value); appendChatMessage('Du', enterChat.value); enterChat.value = ''; } } }; channel.onmessage = (message) => appendChatMessage('Peer', message.data); }; const appendChatMessage = (name, text) => { const displayChat = document.getElementById('displayChat'); const time = new Date().toString('H:i:s'); displayChat.innerHTML = `<p>${name} - ${time}<br>${text}</p>` + displayChat.innerHTML; };

Damit ist unsere Beispielanwendung mit Videokonferenz und Chat über den Datenkanal bereits vollständig. Weniger als 200 Zeilen Code reichen mittlerweile aus, um eine komplette Videostreaming-App zu entwickeln – die Leistungsfähigkeit von WebRTC lässt sich hieran schon sehr gut ablesen.  

Ich hoffe, diese Beispiel-App hilft euch bei der Entwicklung eigener WebRTC-Anwendungen. Den vollständigen Code für die App findet ihr auf Github: https://github.com/beheist/webrtc-beispiel. Ich freue mich über Feedback!