Camera connectivity established!
* Two users can activate their cameras locally and then connect them together with WebRTC with video and audio support working! * Limitation: users need to be broadcasting video themselves before they can connect to someone's camera. If the offerer doesn't add tracks of their own, the SDP offer doesn't request video channels; so even though the answerer adds their tracks to the connection, they aren't used by the offerer. * As currently implemented, the offerer's camera feed will also appear on screen for the answerer - every video connection opens the feed both ways. Compared to the previous commit (where clients shared SDP messages but not ICE candidates or anything further) the fixes and learnings were: * The back-end was trying to relay candidate messages, but there was a JSON marshalling error (json object casted into a string) - changed the Message type to map[string]interface{} and both sides could exchange ICE candidates. * Both sides needed to add their video tracks to the connection so that there was anything of value to be sent over the WebRTC channel. Other changes: * Server will send a ping message every minute to connected WebSockets.
This commit is contained in:
parent
5dbe938780
commit
3f756c5318
5
pkg/config.go
Normal file
5
pkg/config.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
package barertc
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const PingInterval = 60 * time.Second
|
|
@ -15,8 +15,8 @@ type Message struct {
|
||||||
OpenSecret string `json:"openSecret,omitempty"`
|
OpenSecret string `json:"openSecret,omitempty"`
|
||||||
|
|
||||||
// Parameters sent on WebRTC signaling messages.
|
// Parameters sent on WebRTC signaling messages.
|
||||||
Candidate string `json:"candidate,omitempty"` // candidate
|
Candidate map[string]interface{} `json:"candidate,omitempty"` // candidate
|
||||||
Description string `json:"description,omitempty"` // sdp
|
Description map[string]interface{} `json:"description,omitempty"` // sdp
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -30,6 +30,7 @@ const (
|
||||||
ActionRing = "ring" // receiver of a WebRTC open request
|
ActionRing = "ring" // receiver of a WebRTC open request
|
||||||
|
|
||||||
// Actions sent by server only
|
// Actions sent by server only
|
||||||
|
ActionPing = "ping"
|
||||||
ActionWhoList = "who" // server pushes the Who List
|
ActionWhoList = "who" // server pushes the Who List
|
||||||
ActionPresence = "presence" // a user joined or left the room
|
ActionPresence = "presence" // a user joined or left the room
|
||||||
ActionError = "error" // ChatServer errors
|
ActionError = "error" // ChatServer errors
|
||||||
|
|
|
@ -20,6 +20,7 @@ type Subscriber struct {
|
||||||
VideoActive bool
|
VideoActive bool
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
messages chan []byte
|
messages chan []byte
|
||||||
closeSlow func()
|
closeSlow func()
|
||||||
}
|
}
|
||||||
|
@ -30,7 +31,7 @@ func (sub *Subscriber) ReadLoop(s *Server) {
|
||||||
for {
|
for {
|
||||||
msgType, data, err := sub.conn.Read(sub.ctx)
|
msgType, data, err := sub.conn.Read(sub.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("ReadLoop error: %+v", err)
|
log.Error("ReadLoop error(%s): %+v", sub.Username, err)
|
||||||
s.DeleteSubscriber(sub)
|
s.DeleteSubscriber(sub)
|
||||||
s.Broadcast(Message{
|
s.Broadcast(Message{
|
||||||
Action: ActionPresence,
|
Action: ActionPresence,
|
||||||
|
@ -119,11 +120,12 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
// CloseRead starts a goroutine that will read from the connection
|
// CloseRead starts a goroutine that will read from the connection
|
||||||
// until it is closed.
|
// until it is closed.
|
||||||
// ctx := c.CloseRead(r.Context())
|
// ctx := c.CloseRead(r.Context())
|
||||||
ctx, _ := context.WithCancel(r.Context())
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
|
||||||
sub := &Subscriber{
|
sub := &Subscriber{
|
||||||
conn: c,
|
conn: c,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
messages: make(chan []byte, s.subscriberMessageBuffer),
|
messages: make(chan []byte, s.subscriberMessageBuffer),
|
||||||
closeSlow: func() {
|
closeSlow: func() {
|
||||||
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
|
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
|
||||||
|
@ -134,6 +136,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
// defer s.DeleteSubscriber(sub)
|
// defer s.DeleteSubscriber(sub)
|
||||||
|
|
||||||
go sub.ReadLoop(s)
|
go sub.ReadLoop(s)
|
||||||
|
pinger := time.NewTicker(PingInterval)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg := <-sub.messages:
|
case msg := <-sub.messages:
|
||||||
|
@ -141,7 +144,13 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case timestamp := <-pinger.C:
|
||||||
|
sub.SendJSON(Message{
|
||||||
|
Action: ActionPing,
|
||||||
|
Message: timestamp.Format(time.RFC3339),
|
||||||
|
})
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
pinger.Stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -179,6 +188,12 @@ func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
|
||||||
// DeleteSubscriber removes a subscriber from the server.
|
// DeleteSubscriber removes a subscriber from the server.
|
||||||
func (s *Server) DeleteSubscriber(sub *Subscriber) {
|
func (s *Server) DeleteSubscriber(sub *Subscriber) {
|
||||||
log.Error("DeleteSubscriber: %s", sub.Username)
|
log.Error("DeleteSubscriber: %s", sub.Username)
|
||||||
|
|
||||||
|
// Cancel its context to clean up the for-loop goroutine.
|
||||||
|
if sub.cancel != nil {
|
||||||
|
sub.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
s.subscribersMu.Lock()
|
s.subscribersMu.Lock()
|
||||||
delete(s.subscribers, sub)
|
delete(s.subscribers, sub)
|
||||||
s.subscribersMu.Unlock()
|
s.subscribersMu.Unlock()
|
||||||
|
|
|
@ -125,7 +125,7 @@ const app = Vue.createApp({
|
||||||
// Response for the opener to begin WebRTC connection.
|
// Response for the opener to begin WebRTC connection.
|
||||||
const secret = msg.openSecret;
|
const secret = msg.openSecret;
|
||||||
console.log("OPEN: connect to %s with secret %s", msg.username, secret);
|
console.log("OPEN: connect to %s with secret %s", msg.username, secret);
|
||||||
this.ChatClient(`Connecting to stream for ${msg.username}.`);
|
this.ChatClient(`onOpen called for ${msg.username}.`);
|
||||||
|
|
||||||
this.startWebRTC(msg.username, true);
|
this.startWebRTC(msg.username, true);
|
||||||
},
|
},
|
||||||
|
@ -137,6 +137,11 @@ const app = Vue.createApp({
|
||||||
|
|
||||||
this.startWebRTC(msg.username, false);
|
this.startWebRTC(msg.username, false);
|
||||||
},
|
},
|
||||||
|
onUserExited(msg) {
|
||||||
|
// A user has logged off the server. Clean up any WebRTC connections.
|
||||||
|
delete(this.WebRTC.streams[msg.username]);
|
||||||
|
delete(this.WebRTC.pc[msg.username]);
|
||||||
|
},
|
||||||
|
|
||||||
// Handle messages sent in chat.
|
// Handle messages sent in chat.
|
||||||
onMessage(msg) {
|
onMessage(msg) {
|
||||||
|
@ -149,7 +154,8 @@ const app = Vue.createApp({
|
||||||
// Dial the WebSocket connection.
|
// Dial the WebSocket connection.
|
||||||
dial() {
|
dial() {
|
||||||
console.log("Dialing WebSocket...");
|
console.log("Dialing WebSocket...");
|
||||||
const conn = new WebSocket(`ws://${location.host}/ws`);
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const conn = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
|
|
||||||
conn.addEventListener("close", ev => {
|
conn.addEventListener("close", ev => {
|
||||||
this.ws.connected = false;
|
this.ws.connected = false;
|
||||||
|
@ -193,6 +199,11 @@ const app = Vue.createApp({
|
||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
break;
|
break;
|
||||||
case "presence":
|
case "presence":
|
||||||
|
// TODO: make a dedicated leave event
|
||||||
|
if (msg.message.indexOf("has exited the room!") > -1) {
|
||||||
|
// Clean up data about this user.
|
||||||
|
this.onUserExited(msg);
|
||||||
|
}
|
||||||
this.pushHistory({
|
this.pushHistory({
|
||||||
action: msg.action,
|
action: msg.action,
|
||||||
username: msg.username,
|
username: msg.username,
|
||||||
|
@ -235,14 +246,21 @@ const app = Vue.createApp({
|
||||||
let pc = new RTCPeerConnection(configuration);
|
let pc = new RTCPeerConnection(configuration);
|
||||||
this.WebRTC.pc[username] = pc;
|
this.WebRTC.pc[username] = pc;
|
||||||
|
|
||||||
|
// Create a data channel so we have something to connect over even if
|
||||||
|
// the local user is not broadcasting their own camera.
|
||||||
|
let dataChannel = pc.createDataChannel("data");
|
||||||
|
dataChannel.addEventListener("open", event => {
|
||||||
|
// beginTransmission(dataChannel);
|
||||||
|
})
|
||||||
|
|
||||||
// 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
|
// 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
|
||||||
// message to the other peer through the signaling server.
|
// message to the other peer through the signaling server.
|
||||||
pc.onicecandidate = event => {
|
pc.onicecandidate = event => {
|
||||||
this.ChatClient("On ICE Candidate called");
|
console.error("WebRTC OnICECandidate called!", event);
|
||||||
console.log(event);
|
// this.ChatClient("On ICE Candidate called!");
|
||||||
console.log(event.candidate);
|
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
this.ChatClient(`Send ICE candidate: ${event.candidate}`);
|
// this.ChatClient(`Send ICE candidate: ${JSON.stringify(event.candidate)}`);
|
||||||
|
console.log("Sending candidate to websockets:", event.candidate);
|
||||||
this.ws.conn.send(JSON.stringify({
|
this.ws.conn.send(JSON.stringify({
|
||||||
action: "candidate",
|
action: "candidate",
|
||||||
username: username,
|
username: username,
|
||||||
|
@ -253,8 +271,9 @@ const app = Vue.createApp({
|
||||||
|
|
||||||
// If the user is offerer let the 'negotiationneeded' event create the offer.
|
// If the user is offerer let the 'negotiationneeded' event create the offer.
|
||||||
if (isOfferer) {
|
if (isOfferer) {
|
||||||
this.ChatClient("Sending offer:");
|
this.ChatClient("We are the offerer - set up onNegotiationNeeded");
|
||||||
pc.onnegotiationneeded = () => {
|
pc.onnegotiationneeded = () => {
|
||||||
|
console.error("WebRTC OnNegotiationNeeded called!");
|
||||||
this.ChatClient("Negotiation Needed, creating WebRTC offer.");
|
this.ChatClient("Negotiation Needed, creating WebRTC offer.");
|
||||||
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
|
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
|
||||||
};
|
};
|
||||||
|
@ -262,6 +281,8 @@ const app = Vue.createApp({
|
||||||
|
|
||||||
// When a remote stream arrives.
|
// When a remote stream arrives.
|
||||||
pc.ontrack = event => {
|
pc.ontrack = event => {
|
||||||
|
this.ChatServer("ON TRACK CALLED!!!");
|
||||||
|
console.error("WebRTC OnTrack called!", event);
|
||||||
const stream = event.streams[0];
|
const stream = event.streams[0];
|
||||||
|
|
||||||
// Do we already have it?
|
// Do we already have it?
|
||||||
|
@ -270,13 +291,28 @@ const app = Vue.createApp({
|
||||||
this.WebRTC.streams[username].id !== stream.id) {
|
this.WebRTC.streams[username].id !== stream.id) {
|
||||||
this.WebRTC.streams[username] = stream;
|
this.WebRTC.streams[username] = stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.ChatServer("Setting <video> srcObject for "+username);
|
||||||
|
let $ref = document.getElementById(`videofeed-${username}`);
|
||||||
|
console.log("Video elem:", $ref);
|
||||||
|
$ref.srcObject = stream;
|
||||||
|
// this.$refs[`videofeed-${username}`].srcObject = stream;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// If we were already broadcasting video, send our stream to
|
// If we were already broadcasting video, send our stream to
|
||||||
// the connecting user.
|
// the connecting user.
|
||||||
if (!isOfferer && this.webcam.active) {
|
// TODO: currently both users need to have video on for the connection
|
||||||
|
// to succeed - if offerer doesn't addTrack it won't request a video channel
|
||||||
|
// and so the answerer (who has video) won't actually send its
|
||||||
|
if (this.webcam.active) {
|
||||||
this.ChatClient(`Sharing our video stream to ${username}.`);
|
this.ChatClient(`Sharing our video stream to ${username}.`);
|
||||||
this.webcam.stream.getTracks().forEach(track => pc.addTrack(track, this.webcam.stream));
|
let stream = this.webcam.stream;
|
||||||
|
stream.getTracks().forEach(track => {
|
||||||
|
console.error("Add stream track to WebRTC", stream, track);
|
||||||
|
pc.addTrack(track, stream)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are the offerer, begin the connection.
|
// If we are the offerer, begin the connection.
|
||||||
|
@ -295,7 +331,7 @@ const app = Vue.createApp({
|
||||||
this.ws.conn.send(JSON.stringify({
|
this.ws.conn.send(JSON.stringify({
|
||||||
action: "sdp",
|
action: "sdp",
|
||||||
username: username,
|
username: username,
|
||||||
description: JSON.stringify(pc.localDescription),
|
description: pc.localDescription,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
console.error,
|
console.error,
|
||||||
|
@ -305,12 +341,16 @@ const app = Vue.createApp({
|
||||||
|
|
||||||
// Handle inbound WebRTC signaling messages proxied by the websocket.
|
// Handle inbound WebRTC signaling messages proxied by the websocket.
|
||||||
onCandidate(msg) {
|
onCandidate(msg) {
|
||||||
if (this.WebRTC.pc[msg.username] == undefined) return;
|
console.error("onCandidate() called:", msg);
|
||||||
|
if (this.WebRTC.pc[msg.username] == undefined) {
|
||||||
|
console.error("DID NOT FIND RTCPeerConnection for username:", msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let pc = this.WebRTC.pc[msg.username];
|
let pc = this.WebRTC.pc[msg.username];
|
||||||
|
|
||||||
// Add the new ICE candidate.
|
// Add the new ICE candidate.
|
||||||
console.log("Add ICE candidate: %s", msg.candidate);
|
console.log("Add ICE candidate: %s", msg.candidate);
|
||||||
this.ChatClient(`Received an ICE candidate from ${username}: ${msg.candidate}`);
|
// this.ChatClient(`Received an ICE candidate from ${msg.username}: ${JSON.stringify(msg.candidate)}`);
|
||||||
pc.addIceCandidate(
|
pc.addIceCandidate(
|
||||||
new RTCIceCandidate(
|
new RTCIceCandidate(
|
||||||
msg.candidate,
|
msg.candidate,
|
||||||
|
@ -320,16 +360,34 @@ const app = Vue.createApp({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSDP(msg) {
|
onSDP(msg) {
|
||||||
if (this.WebRTC.pc[msg.username] == undefined) return;
|
console.error("onSDP() called:", msg);
|
||||||
|
if (this.WebRTC.pc[msg.username] == undefined) {
|
||||||
|
console.error("DID NOT FIND RTCPeerConnection for username:", msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let pc = this.WebRTC.pc[msg.username];
|
let pc = this.WebRTC.pc[msg.username];
|
||||||
let description = JSON.parse(msg.description);
|
let message = msg.description;
|
||||||
|
|
||||||
// Add the new ICE candidate.
|
// Add the new ICE candidate.
|
||||||
console.log("Set description: %s", description);
|
console.log("Set description:", message);
|
||||||
this.ChatClient(`Received a Remote Description from ${msg.username}: ${msg.description}.`);
|
this.ChatClient(`Received a Remote Description from ${msg.username}: ${JSON.stringify(msg.description)}.`);
|
||||||
pc.setRemoteDescription(new RTCSessionDescription(description), () => {
|
pc.setRemoteDescription(new RTCSessionDescription(message), () => {
|
||||||
// When receiving an offer let's answer it.
|
// When receiving an offer let's answer it.
|
||||||
if (pc.remoteDescription.type === 'offer') {
|
if (pc.remoteDescription.type === 'offer') {
|
||||||
|
console.error("Webcam:", this.webcam);
|
||||||
|
|
||||||
|
// Add our local video tracks to the connection.
|
||||||
|
// if (this.webcam.active) {
|
||||||
|
// this.ChatClient(`Sharing our video stream to ${msg.username}.`);
|
||||||
|
// let stream = this.webcam.stream;
|
||||||
|
// stream.getTracks().forEach(track => {
|
||||||
|
// console.error("Add stream track to WebRTC", stream, track);
|
||||||
|
// pc.addTrack(track, stream)
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
this.ChatClient(`setRemoteDescription callback. Offer recieved - sending answer. Cam active? ${this.webcam.active}`);
|
||||||
|
console.warn("Creating answer now");
|
||||||
pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient);
|
pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient);
|
||||||
}
|
}
|
||||||
}, console.error);
|
}, console.error);
|
||||||
|
@ -368,6 +426,14 @@ const app = Vue.createApp({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We need to broadcast video to connect to another.
|
||||||
|
// TODO: because if the offerer doesn't add video tracks they
|
||||||
|
// won't request video support so the answerer's video isn't sent
|
||||||
|
if (!this.webcam.active) {
|
||||||
|
this.ChatServer("You will need to turn your own camera on first before you can connect to " + user.username + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.sendOpen(user.username);
|
this.sendOpen(user.username);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -108,8 +108,14 @@
|
||||||
autoplay muted>
|
autoplay muted>
|
||||||
x
|
x
|
||||||
</video>
|
</video>
|
||||||
<div class="feed">
|
<video class="feed"
|
||||||
y
|
v-for="(stream, username) in WebRTC.streams"
|
||||||
|
v-bind:key="username"
|
||||||
|
:id="'videofeed-'+username"
|
||||||
|
autoplay muted>
|
||||||
|
</video>
|
||||||
|
<div v-for="(stream, username) in WebRTC.streams" class="feed">
|
||||||
|
[[username]] - [[stream]]
|
||||||
</div>
|
</div>
|
||||||
<div class="feed">
|
<div class="feed">
|
||||||
y
|
y
|
||||||
|
|
Loading…
Reference in New Issue
Block a user