3 <script type=
"text/javascript" src=
"webrtc_test_utilities.js"></script>
4 <script type=
"text/javascript">
6 return document
.getElementById(id
);
9 var gFirstConnection
= null;
10 var gSecondConnection
= null;
11 var gTestWithoutMsid
= false;
12 var gLocalStream
= null;
15 var gRemoteStreams
= {};
17 // Default transform functions, overridden by some test cases.
18 var transformSdp = function(sdp
) { return sdp
; };
19 var transformRemoteSdp = function(sdp
) { return sdp
; };
20 var transformCandidate = function(candidate
) { return candidate
; };
21 var onLocalDescriptionError = function(error
) { };
23 // When using external SDES, the crypto key is chosen by javascript.
24 var EXTERNAL_SDES_LINES
= {
25 'audio': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
26 'inline:PS1uQCVeeCFCanVmcjkpPywjNWhcYD0mXXtxaVBR',
27 'video': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
28 'inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj',
29 'data': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
30 'inline:NzB4d1BINUAvLEw6UzF3WSJ+PSdFcGdUJShpX1Zj'
33 // When using GICE, the ICE credentials can be chosen by javascript.
34 var EXTERNAL_GICE_UFRAG
= '1234567890123456';
35 var EXTERNAL_GICE_PWD
= '123456789012345678901234';
37 setAllEventsOccuredHandler(function() {
38 document
.title
= 'OK';
41 // Test that we can setup call with an audio and video track.
42 function call(constraints
) {
43 createConnections(null);
44 navigator
.webkitGetUserMedia(constraints
,
45 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
46 waitForVideo('remote-view-1');
47 waitForVideo('remote-view-2');
50 // First calls without streams on any connections, and then adds a stream
51 // to peer connection 1 which gets sent to peer connection 2. We must wait
52 // for the first negotiation to complete before starting the second one, which
53 // is why we wait until the connection is stable before re-negotiating.
54 function callEmptyThenAddOneStreamAndRenegotiate(constraints
) {
55 createConnections(null);
57 waitForConnectionToStabilize(gFirstConnection
);
58 navigator
.webkitGetUserMedia(constraints
,
59 addStreamToTheFirstConnectionAndNegotiate
, printGetUserMediaError
);
60 // Only the first connection is sending here.
61 waitForVideo('remote-view-2');
64 // First makes a call between pc1 and pc2, and then makes a call between pc3
65 // and pc4 where the remote streams from pc1 and pc2 will be used as the local
66 // streams of pc3 and pc4.
67 function callAndForwardRemoteStream(constraints
) {
68 createConnections(null);
69 navigator
.webkitGetUserMedia(constraints
,
70 addStreamToBothConnectionsAndNegotiate
,
71 printGetUserMediaError
);
72 var gotRemoteStream1
= false;
73 var gotRemoteStream2
= false;
75 var onRemoteStream1 = function() {
76 gotRemoteStream1
= true;
77 maybeCallEstablished();
80 var onRemoteStream2 = function() {
81 gotRemoteStream2
= true;
82 maybeCallEstablished();
85 var maybeCallEstablished = function() {
86 if (gotRemoteStream1
&& gotRemoteStream2
) {
91 var onCallEstablished = function() {
92 thirdConnection
= createConnection(null, 'remote-view-3');
93 thirdConnection
.addStream(gRemoteStreams
['remote-view-1']);
95 fourthConnection
= createConnection(null, 'remote-view-4');
96 fourthConnection
.addStream(gRemoteStreams
['remote-view-2']);
98 negotiateBetween(thirdConnection
, fourthConnection
);
100 waitForVideo('remote-view-3');
101 waitForVideo('remote-view-4');
104 // Do the forwarding after we have received video.
105 detectVideoPlaying('remote-view-1', onRemoteStream1
);
106 detectVideoPlaying('remote-view-2', onRemoteStream2
);
109 // Test that we can setup call with an audio and video track and
110 // simulate that the remote peer don't support MSID.
111 function callWithoutMsidAndBundle() {
112 createConnections(null);
113 transformSdp
= removeBundle
;
114 transformRemoteSdp
= removeMsid
;
115 gTestWithoutMsid
= true;
116 navigator
.webkitGetUserMedia({audio
: true, video
: true},
117 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
118 waitForVideo('remote-view-1');
119 waitForVideo('remote-view-2');
122 // Test that we can't setup a call with an unsupported video codec
123 function negotiateUnsupportedVideoCodec() {
124 createConnections(null);
125 transformSdp
= removeVideoCodec
;
126 navigator
.webkitGetUserMedia({audio
: true, video
: true},
127 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
128 onLocalDescriptionError = function(error
) {
129 var expectedMsg
= 'SetLocalDescription failed: Failed to' +
130 ' update session state: ERROR_CONTENT';
131 expectEquals(expectedMsg
, error
);
133 // Got the right message, test succeeded.
134 document
.title
= 'OK';
138 // Test that we can't setup a call if one peer does not support encryption
139 function negotiateNonCryptoCall() {
140 createConnections(null);
141 transformSdp
= removeCrypto
;
142 navigator
.webkitGetUserMedia({audio
: true, video
: true},
143 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
144 onLocalDescriptionError = function(error
) {
145 var expectedMsg
= 'SetLocalDescription failed: Called with a SDP without'
146 + ' crypto enabled.';
147 expectEquals(expectedMsg
, error
);
149 // Got the right message, test succeeded.
150 document
.title
= 'OK';
154 // Test that we can setup call with legacy settings.
155 function callWithLegacySdp() {
156 transformSdp = function(sdp
) {
157 return removeBundle(useGice(useExternalSdes(sdp
)));
159 transformCandidate
= addGiceCredsToCandidate
;
161 'mandatory': {'RtpDataChannels': true, 'DtlsSrtpKeyAgreement': false}
163 setupDataChannel({reliable
: false});
164 navigator
.webkitGetUserMedia({audio
: true, video
: true},
165 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
166 waitForVideo('remote-view-1');
167 waitForVideo('remote-view-2');
170 // Test only a data channel.
171 function callWithDataOnly() {
172 createConnections({optional
:[{RtpDataChannels
: true}]});
173 setupDataChannel({reliable
: false});
177 function callWithSctpDataOnly() {
178 createConnections({optional
: [{DtlsSrtpKeyAgreement
: true}]});
179 setupSctpDataChannel({reliable
: true});
183 // Test call with audio, video and a data channel.
184 function callWithDataAndMedia() {
185 createConnections({optional
:[{RtpDataChannels
: true}]});
186 setupDataChannel({reliable
: false});
187 navigator
.webkitGetUserMedia({audio
: true, video
: true},
188 addStreamToBothConnectionsAndNegotiate
,
189 printGetUserMediaError
);
190 waitForVideo('remote-view-1');
191 waitForVideo('remote-view-2');
194 function callWithSctpDataAndMedia() {
195 createConnections({optional
: [{DtlsSrtpKeyAgreement
: true}]});
196 setupSctpDataChannel({reliable
: true});
197 navigator
.webkitGetUserMedia({audio
: true, video
: true},
198 addStreamToBothConnectionsAndNegotiate
,
199 printGetUserMediaError
);
200 waitForVideo('remote-view-1');
201 waitForVideo('remote-view-2');
205 // Test call with a data channel and later add audio and video.
206 function callWithDataAndLaterAddMedia() {
207 createConnections({optional
:[{RtpDataChannels
: true}]});
208 setupDataChannel({reliable
: false});
211 // Set an event handler for when the data channel has been closed.
212 setAllEventsOccuredHandler(function() {
213 // When the video is flowing the test is done.
214 setAllEventsOccuredHandler(function() {
215 document
.title
= 'OK';
217 navigator
.webkitGetUserMedia({audio
: true, video
: true},
218 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
219 waitForVideo('remote-view-1');
220 waitForVideo('remote-view-2');
224 // Test that we can setup call and send DTMF.
225 function callAndSendDtmf(tones
) {
226 createConnections(null);
227 navigator
.webkitGetUserMedia({audio
: true, video
: true},
228 addStreamToBothConnectionsAndNegotiate
, printGetUserMediaError
);
229 var onCallEstablished = function() {
231 var localAudioTrack
= gLocalStream
.getAudioTracks()[0];
232 var dtmfSender
= gFirstConnection
.createDTMFSender(localAudioTrack
);
233 dtmfSender
.ontonechange
= onToneChange
;
234 dtmfSender
.insertDTMF(tones
);
235 // Wait for the DTMF tones callback.
236 document
.title
= 'Waiting for dtmf...';
238 var waitDtmf
= setInterval(function() {
239 if (gSentTones
== tones
) {
240 clearInterval(waitDtmf
);
246 // Do the DTMF test after we have received video.
247 detectVideoPlaying('remote-view-2', onCallEstablished
);
250 // Test call with a new Video MediaStream that has been created based on a
251 // stream generated by getUserMedia.
252 function callWithNewVideoMediaStream() {
253 createConnections(null);
254 navigator
.webkitGetUserMedia({audio
: true, video
: true},
255 createNewVideoStreamAndAddToBothConnections
, printGetUserMediaError
);
256 waitForVideo('remote-view-1');
257 waitForVideo('remote-view-2');
260 // Test call with a new Video MediaStream that has been created based on a
261 // stream generated by getUserMedia. When Video is flowing, an audio track
262 // is added to the sent stream and the video track is removed. This
263 // is to test that adding and removing of remote tracks on an existing
264 // mediastream works.
265 function callWithNewVideoMediaStreamLaterSwitchToAudio() {
266 createConnections(null);
267 navigator
.webkitGetUserMedia({audio
: true, video
: true},
268 createNewVideoStreamAndAddToBothConnections
, printGetUserMediaError
);
270 waitForVideo('remote-view-1');
271 waitForVideo('remote-view-2');
273 // Set an event handler for when video is playing.
274 setAllEventsOccuredHandler(function() {
275 // Add an audio track to the local stream and remove the video track and
276 // then renegotiate. But first - setup the expectations.
277 local_stream
= gFirstConnection
.getLocalStreams()[0];
279 remote_stream_1
= gFirstConnection
.getRemoteStreams()[0];
280 // Add an expected event that onaddtrack will be called on the remote
281 // mediastream received on gFirstConnection when the audio track is
284 remote_stream_1
.onaddtrack = function(){
285 expectEquals(remote_stream_1
.getAudioTracks()[0].id
,
286 local_stream
.getAudioTracks()[0].id
);
290 // Add an expectation that the received video track is removed from
293 remote_stream_1
.onremovetrack = function() {
297 // Add an expected event that onaddtrack will be called on the remote
298 // mediastream received on gSecondConnection when the audio track is
300 remote_stream_2
= gSecondConnection
.getRemoteStreams()[0];
302 remote_stream_2
.onaddtrack = function() {
303 expectEquals(remote_stream_2
.getAudioTracks()[0].id
,
304 local_stream
.getAudioTracks()[0].id
);
308 // Add an expectation that the received video track is removed from
309 // gSecondConnection.
311 remote_stream_2
.onremovetrack = function() {
314 // When all the above events have occurred- the test pass.
315 setAllEventsOccuredHandler(function() { document
.title
= 'OK'; });
317 local_stream
.addTrack(gLocalStream
.getAudioTracks()[0]);
318 local_stream
.removeTrack(local_stream
.getVideoTracks()[0]);
320 }); // End of setAllEventsOccuredHandler.
323 // This function is used for setting up a test that:
324 // 1. Creates a data channel on |gFirstConnection| and sends data to
325 // |gSecondConnection|.
326 // 2. When data is received on |gSecondConnection| a message
327 // is sent to |gFirstConnection|.
328 // 3. When data is received on |gFirstConnection|, the data
329 // channel is closed. The test passes when the state transition completes.
330 function setupDataChannel(params
) {
331 var sendDataString
= "send some text on a data channel."
332 firstDataChannel
= gFirstConnection
.createDataChannel(
333 "sendDataChannel", params
);
334 expectEquals('connecting', firstDataChannel
.readyState
);
336 // When |firstDataChannel| transition to open state, send a text string.
337 firstDataChannel
.onopen = function() {
338 expectEquals('open', firstDataChannel
.readyState
);
339 firstDataChannel
.send(sendDataString
);
342 // When |firstDataChannel| receive a message, close the channel and
343 // initiate a new offer/answer exchange to complete the closure.
344 firstDataChannel
.onmessage = function(event
) {
345 expectEquals(event
.data
, sendDataString
);
346 firstDataChannel
.close();
350 // When |firstDataChannel| transition to closed state, the test pass.
352 firstDataChannel
.onclose = function() {
353 expectEquals('closed', firstDataChannel
.readyState
);
357 // Event handler for when |gSecondConnection| receive a new dataChannel.
358 gSecondConnection
.ondatachannel = function (event
) {
359 var secondDataChannel
= event
.channel
;
361 // When |secondDataChannel| receive a message, send a message back.
362 secondDataChannel
.onmessage = function(event
) {
363 expectEquals(event
.data
, sendDataString
);
364 expectEquals('open', secondDataChannel
.readyState
);
365 secondDataChannel
.send(sendDataString
);
370 // SCTP data channel setup is slightly different then RTP based
371 // channels. Due to a bug in libjingle, we can't send data immediately
372 // after channel becomes open. So for that reason in SCTP,
373 // we are sending data from second channel, when ondatachannel event is
374 // received. So data flow happens 2 -> 1 -> 2.
375 function setupSctpDataChannel(params
) {
376 var sendDataString
= "send some text on a data channel."
377 firstDataChannel
= gFirstConnection
.createDataChannel(
378 "sendDataChannel", params
);
379 expectEquals('connecting', firstDataChannel
.readyState
);
381 // When |firstDataChannel| transition to open state, send a text string.
382 firstDataChannel
.onopen = function() {
383 expectEquals('open', firstDataChannel
.readyState
);
386 // When |firstDataChannel| receive a message, send message back.
387 // initiate a new offer/answer exchange to complete the closure.
388 firstDataChannel
.onmessage = function(event
) {
389 expectEquals('open', firstDataChannel
.readyState
);
390 expectEquals(event
.data
, sendDataString
);
391 firstDataChannel
.send(sendDataString
);
395 // Event handler for when |gSecondConnection| receive a new dataChannel.
396 gSecondConnection
.ondatachannel = function (event
) {
397 var secondDataChannel
= event
.channel
;
398 secondDataChannel
.send(sendDataString
);
400 // When |secondDataChannel| receive a message, close the channel and
401 // initiate a new offer/answer exchange to complete the closure.
402 secondDataChannel
.onmessage = function(event
) {
403 expectEquals(event
.data
, sendDataString
);
404 expectEquals('open', secondDataChannel
.readyState
);
405 secondDataChannel
.close();
409 // When |secondDataChannel| transition to closed state, the test pass.
411 secondDataChannel
.onclose = function() {
412 expectEquals('closed', secondDataChannel
.readyState
);
418 // Test call with a stream that has been created by getUserMedia, clone
419 // the stream to a cloned stream, send them via the same peer connection.
420 function addTwoMediaStreamsToOneConnection() {
421 createConnections(null);
422 navigator
.webkitGetUserMedia({audio
: true, video
: true},
423 CloneStreamAndAddTwoStreamstoOneConnection
, printGetUserMediaError
);
426 function onToneChange(tone
) {
427 gSentTones
+= tone
.tone
;
428 document
.title
= gSentTones
;
431 function createConnections(constraints
) {
432 gFirstConnection
= createConnection(constraints
, 'remote-view-1');
433 expectEquals('stable', gFirstConnection
.signalingState
);
435 gSecondConnection
= createConnection(constraints
, 'remote-view-2');
436 expectEquals('stable', gSecondConnection
.signalingState
);
439 function createConnection(constraints
, remoteView
) {
440 var pc
= new webkitRTCPeerConnection(null, constraints
);
441 pc
.onaddstream = function(event
) {
442 onRemoteStream(event
, remoteView
);
447 function displayAndRemember(localStream
) {
448 var localStreamUrl
= webkitURL
.createObjectURL(localStream
);
449 $('local-view').src
= localStreamUrl
;
451 gLocalStream
= localStream
;
454 // Called if getUserMedia fails.
455 function printGetUserMediaError(error
) {
456 document
.title
= 'getUserMedia request failed with code ' + error
.code
;
459 // Called if getUserMedia succeeds and we want to send from both connections.
460 function addStreamToBothConnectionsAndNegotiate(localStream
) {
461 displayAndRemember(localStream
);
462 gFirstConnection
.addStream(localStream
);
463 gSecondConnection
.addStream(localStream
);
467 // Called if getUserMedia succeeds when we want to send from one connection.
468 function addStreamToTheFirstConnectionAndNegotiate(localStream
) {
469 displayAndRemember(localStream
);
470 gFirstConnection
.addStream(localStream
);
474 function verifyHasOneAudioAndVideoTrack(stream
) {
475 expectEquals(1, stream
.getAudioTracks().length
);
476 expectEquals(1, stream
.getVideoTracks().length
);
479 // Called if getUserMedia succeeds, then clone the stream, send two streams
480 // from one peer connection.
481 function CloneStreamAndAddTwoStreamstoOneConnection(localStream
) {
482 displayAndRemember(localStream
);
483 var clonedStream
= new webkitMediaStream();
484 clonedStream
.addTrack(localStream
.getVideoTracks()[0]);
485 clonedStream
.addTrack(localStream
.getAudioTracks()[0]);
486 gFirstConnection
.addStream(localStream
);
487 gFirstConnection
.addStream(clonedStream
);
489 // Verify the local streams are correct.
490 expectEquals(2, gFirstConnection
.getLocalStreams().length
);
491 verifyHasOneAudioAndVideoTrack(gFirstConnection
.getLocalStreams()[0]);
492 verifyHasOneAudioAndVideoTrack(gFirstConnection
.getLocalStreams()[1]);
494 // The remote side should receive two streams. After that, verify the
495 // remote side has the correct number of streams and tracks.
498 gSecondConnection
.onaddstream = function(event
) {
501 setAllEventsOccuredHandler(function() {
502 // Negotiation complete, verify remote streams on the receiving side.
503 expectEquals(2, gSecondConnection
.getRemoteStreams().length
);
504 verifyHasOneAudioAndVideoTrack(gSecondConnection
.getRemoteStreams()[0]);
505 verifyHasOneAudioAndVideoTrack(gSecondConnection
.getRemoteStreams()[1]);
507 document
.title
= "OK";
513 // Called if getUserMedia succeeds when we want to send a modified
514 // MediaStream. A new MediaStream is created and the video track from
515 // |localStream| is added.
516 function createNewVideoStreamAndAddToBothConnections(localStream
) {
517 displayAndRemember(localStream
);
518 var new_stream
= new webkitMediaStream();
519 new_stream
.addTrack(localStream
.getVideoTracks()[0]);
520 gFirstConnection
.addStream(new_stream
);
521 gSecondConnection
.addStream(new_stream
);
525 function negotiate() {
526 negotiateBetween(gFirstConnection
, gSecondConnection
);
529 function negotiateBetween(caller
, callee
) {
530 // Not stable = negotiation is ongoing. The behavior of re-negotiating while
531 // a negotiation is ongoing is more or less undefined, so avoid this.
532 if (caller
.signalingState
!= 'stable')
533 throw 'You can only negotiate when the connection is stable!';
535 connectOnIceCandidate(caller
, callee
);
539 onOfferCreated(offer
, caller
, callee
);
543 function onOfferCreated(offer
, caller
, callee
) {
544 offer
.sdp
= transformSdp(offer
.sdp
);
545 caller
.setLocalDescription(offer
, null, onLocalDescriptionError
);
547 expectEquals('have-local-offer', caller
.signalingState
);
548 receiveOffer(offer
.sdp
, caller
, callee
);
551 function receiveOffer(offerSdp
, caller
, callee
) {
552 offerSdp
= transformRemoteSdp(offerSdp
);
554 var parsedOffer
= new RTCSessionDescription({ type
: 'offer',
556 callee
.setRemoteDescription(parsedOffer
);
557 callee
.createAnswer(function (answer
) {
558 onAnswerCreated(answer
, caller
, callee
);
560 expectEquals('have-remote-offer', callee
.signalingState
);
563 function removeMsid(offerSdp
) {
564 offerSdp
= offerSdp
.replace(/a=msid-semantic.*\r\n/g, '');
565 offerSdp
= offerSdp
.replace('a=mid:audio\r\n', '');
566 offerSdp
= offerSdp
.replace('a=mid:video\r\n', '');
567 offerSdp
= offerSdp
.replace(/a=ssrc.*\r\n/g, '');
571 function removeVideoCodec(offerSdp
) {
572 offerSdp
= offerSdp
.replace('a=rtpmap:100 VP8/90000\r\n',
573 'a=rtpmap:100 XVP8/90000\r\n');
577 function removeCrypto(offerSdp
) {
578 offerSdp
= offerSdp
.replace(/a=crypto.*\r\n/g, 'a=Xcrypto\r\n');
579 offerSdp
= offerSdp
.replace(/a=fingerprint.*\r\n/g, '');
583 function removeBundle(sdp
) {
584 return sdp
.replace(/a=group:BUNDLE .*\r\n/g, '');
587 function useGice(sdp
) {
588 sdp
= sdp
.replace(/t=.*\r\n/g, function(subString
) {
589 return subString
+ 'a=ice-options:google-ice\r\n';
591 sdp
= sdp
.replace(/a=ice-ufrag:.*\r\n/g,
592 'a=ice-ufrag:' + EXTERNAL_GICE_UFRAG
+ '\r\n');
593 sdp
= sdp
.replace(/a=ice-pwd:.*\r\n/g,
594 'a=ice-pwd:' + EXTERNAL_GICE_PWD
+ '\r\n');
598 function useExternalSdes(sdp
) {
599 // Remove current crypto specification.
600 sdp
= sdp
.replace(/a=crypto.*\r\n/g, '');
601 sdp
= sdp
.replace(/a=fingerprint.*\r\n/g, '');
602 // Add external crypto. This is not compatible with |removeMsid|.
603 sdp
= sdp
.replace(/a=mid:(\w+)\r\n/g, function(subString
, group
) {
604 return subString
+ EXTERNAL_SDES_LINES
[group
] + '\r\n';
609 function onAnswerCreated(answer
, caller
, callee
) {
610 answer
.sdp
= transformSdp(answer
.sdp
);
611 callee
.setLocalDescription(answer
);
612 expectEquals('stable', callee
.signalingState
);
613 receiveAnswer(answer
.sdp
, caller
);
616 function receiveAnswer(answerSdp
, caller
) {
617 answerSdp
= transformRemoteSdp(answerSdp
);
618 var parsedAnswer
= new RTCSessionDescription({ type
: 'answer',
620 caller
.setRemoteDescription(parsedAnswer
);
621 expectEquals('stable', caller
.signalingState
);
624 function connectOnIceCandidate(caller
, callee
) {
625 caller
.onicecandidate = function(event
) { onIceCandidate(event
, callee
); }
626 callee
.onicecandidate = function(event
) { onIceCandidate(event
, caller
); }
629 function addGiceCredsToCandidate(candidate
) {
630 return candidate
.trimRight() +
631 ' username ' + EXTERNAL_GICE_UFRAG
+ ' password ' + EXTERNAL_GICE_PWD
;
634 function onIceCandidate(event
, target
) {
635 if (event
.candidate
) {
636 var candidate
= new RTCIceCandidate(event
.candidate
);
637 candidate
.candidate
= transformCandidate(candidate
.candidate
);
638 target
.addIceCandidate(candidate
);
642 function onRemoteStream(e
, target
) {
643 if (gTestWithoutMsid
&& e
.stream
.id
!= "default") {
644 document
.title
= 'a default remote stream was expected but instead ' +
645 e
.stream
.id
+ ' was received.';
648 gRemoteStreams
[target
] = e
.stream
;
649 var remoteStreamUrl
= webkitURL
.createObjectURL(e
.stream
);
650 var remoteVideo
= $(target
);
651 remoteVideo
.src
= remoteStreamUrl
;
659 <td>Local Preview
</td>
660 <td>Remote Stream for Connection
1</td>
661 <td>Remote Stream for Connection
2</td>
662 <td>Remote Stream for Connection
3</td>
663 <td>Remote Stream for Connection
4</td>
666 <td><video width=
"320" height=
"240" id=
"local-view"
667 autoplay=
"autoplay"></video></td>
668 <td><video width=
"320" height=
"240" id=
"remote-view-1"
669 autoplay=
"autoplay"></video></td>
670 <td><video width=
"320" height=
"240" id=
"remote-view-2"
671 autoplay=
"autoplay"></video></td>
672 <td><video width=
"320" height=
"240" id=
"remote-view-3"
673 autoplay=
"autoplay"></video></td>
674 <td><video width=
"320" height=
"240" id=
"remote-view-4"
675 autoplay=
"autoplay"></video></td>
676 <!-- Canvases are named after their corresponding video elements. -->
677 <td><canvas width=
"320" height=
"240" id=
"remote-view-1-canvas"
678 style=
"display:none"></canvas></td>
679 <td><canvas width=
"320" height=
"240" id=
"remote-view-2-canvas"
680 style=
"display:none"></canvas></td>
681 <td><canvas width=
"320" height=
"240" id=
"remote-view-3-canvas"
682 style=
"display:none"></canvas></td>
683 <td><canvas width=
"320" height=
"240" id=
"remote-view-4-canvas"
684 style=
"display:none"></canvas></td>