Update: working example near bottom.
This depends greatly on which browser you are using at the moment, due to an evolving spec.
In the specification and Firefox, peer connections are now fundamentally track-based, and do not depend on local stream associations. You have var sender = pc.addTrack(track, stream)
, pc.removeTrack(sender)
, and even sender.replaceTrack(track)
, the latter involving no renegotiation at all.
In Chrome you still have just pc.addStream
and pc.removeStream
, and removing a track from a local stream causes sending of it to cease, but adding it back didn't work. I had luck removing and re-adding the entire stream to the peer connection, followed by renegotiation.
Unfortunately, using adapter.js does not help here, as addTrack
is tricky to polyfill.
Renegotiation
Renegotiation is not starting over. All you need is:
pc.onnegotiationneeded = e => pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => signalingChannel.send(JSON.stringify({sdp: pc.localDescription})));
.catch(failed);
Once you add this, the peer connection automatically renegotiates when needed using your signaling channel. This even replaces the calls to createOffer
and friends you're doing now, a net win.
With this in place, you can add/remove tracks during a live connection, and it should "just work".
If that's not smooth enough, you can even pc.createDataChannel("yourOwnSignalingChannel")
Example
Here's an example of all of that (use https fiddle in Chrome):
var config = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
var signalingDelayMs = 0;
var dc, sc, pc = new RTCPeerConnection(config), live = false;
pc.onaddstream = e => v2.srcObject = e.stream;
pc.ondatachannel = e => dc? scInit(sc = e.channel) : dcInit(dc = e.channel);
var streams = [];
var haveGum = navigator.mediaDevices.getUserMedia({fake:true, video:true})
.then(stream => streams[1] = stream)
.then(() => navigator.mediaDevices.getUserMedia({ video: true }))
.then(stream => v1.srcObject = streams[0] = stream);
pc.oniceconnectionstatechange = () => update(pc.iceConnectionState);
var negotiating; // Chrome workaround
pc.onnegotiationneeded = () => {
if (negotiating) return;
negotiating = true;
pc.createOffer().then(d => pc.setLocalDescription(d))
.then(() => live && sc.send(JSON.stringify({ sdp: pc.localDescription })))
.catch(log);
};
pc.onsignalingstatechange = () => negotiating = pc.signalingState != "stable";
function scInit() {
sc.onmessage = e => wait(signalingDelayMs).then(() => {
var msg = JSON.parse(e.data);
if (msg.sdp) {
var desc = new RTCSessionDescription(JSON.parse(e.data).sdp);
if (desc.type == "offer") {
pc.setRemoteDescription(desc).then(() => pc.createAnswer())
.then(answer => pc.setLocalDescription(answer)).then(() => {
sc.send(JSON.stringify({ sdp: pc.localDescription }));
}).catch(log);
} else {
pc.setRemoteDescription(desc).catch(log);
}
} else if (msg.candidate) {
pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(log);
}
}).catch(log);
}
function dcInit() {
dc.onopen = () => {
live = true; update("Chat:"); chat.disabled = false; chat.select();
};
dc.onmessage = e => log(e.data);
}
function createOffer() {
button.disabled = true;
pc.onicecandidate = e => {
if (live) {
sc.send(JSON.stringify({ "candidate": e.candidate }));
} else if (!e.candidate) {
offer.value = pc.localDescription.sdp;
offer.select();
answer.placeholder = "Paste answer here";
}
};
dcInit(dc = pc.createDataChannel("chat"));
scInit(sc = pc.createDataChannel("signaling"));
};
offer.onkeypress = e => {
if (e.keyCode != 13 || pc.signalingState != "stable") return;
button.disabled = offer.disabled = true;
var obj = { type:"offer", sdp:offer.value };
pc.setRemoteDescription(new RTCSessionDescription(obj))
.then(() => pc.createAnswer()).then(d => pc.setLocalDescription(d))
.catch(log);
pc.onicecandidate = e => {
if (e.candidate) return;
if (!live) {
answer.focus();
answer.value = pc.localDescription.sdp;
answer.select();
} else {
sc.send(JSON.stringify({ "candidate": e.candidate }));
}
};
};
answer.onkeypress = e => {
if (e.keyCode != 13 || pc.signalingState != "have-local-offer") return;
answer.disabled = true;
var obj = { type:"answer", sdp:answer.value };
pc.setRemoteDescription(new RTCSessionDescription(obj)).catch(log);
};
chat.onkeypress = e => {
if (e.keyCode != 13) return;
dc.send(chat.value);
log("> " + chat.value);
chat.value = "";
};
function addTrack() {
pc.addStream(streams[0]);
flipButton.disabled = false;
removeAddButton.disabled = false;
}
var flipped = 0;
function flip() {
pc.getSenders()[0].replaceTrack(streams[flipped = 1 - flipped].getVideoTracks()[0])
.catch(log);
}
function removeAdd() {
if ("removeTrack" in pc) {
pc.removeTrack(pc.getSenders()[0]);
pc.addStream(streams[flipped = 1 - flipped]);
} else {
pc.removeStream(streams[flipped]);
pc.addStream(streams[flipped = 1 - flipped]);
}
}
var wait = ms => new Promise(resolve => setTimeout(resolve, ms));
var update = msg => div2.innerHTML = msg;
var log = msg => div.innerHTML += msg + "<br>";
<video id="v1" width="120" height="90" autoplay muted></video>
<video id="v2" width="120" height="90" autoplay></video><br>
<button id="button" onclick="createOffer()">Offer:</button>
<textarea id="offer" placeholder="Paste offer here"></textarea><br>
Answer: <textarea id="answer"></textarea><br>
<button id="button" onclick="addTrack()">AddTrack</button>
<button id="removeAddButton" onclick="removeAdd()" disabled>Remove+Add</button>
<button id="flipButton" onclick="flip()" disabled>ReplaceTrack (FF only)</button>
<div id="div"><p></div><br>
<table><tr><td><div id="div2">Not connected</div></td>
<td><input id="chat" disabled></input></td></tr></table><br>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…