Get foreground tab on Android
[chromium-blink-merge.git] / content / test / data / media / peerconnection-call.html
blob2bd7433b3e84f0395b247373994b8ef2356374f2
1 <html>
2 <head>
3 <script type="text/javascript" src="webrtc_test_utilities.js"></script>
4 <script type="text/javascript">
5 $ = function(id) {
6 return document.getElementById(id);
7 };
9 var gFirstConnection = null;
10 var gSecondConnection = null;
11 var gTestWithoutMsid = false;
12 var gLocalStream = null;
13 var gSentTones = '';
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';
39 });
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);
56 negotiate();
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) {
87 onCallEstablished();
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;
160 createConnections({
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});
174 negotiate();
177 function callWithSctpDataOnly() {
178 createConnections({optional: [{DtlsSrtpKeyAgreement: true}]});
179 setupSctpDataChannel({reliable: true});
180 negotiate();
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});
209 negotiate();
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() {
230 // Send DTMF tones.
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...';
237 addExpectedEvent();
238 var waitDtmf = setInterval(function() {
239 if (gSentTones == tones) {
240 clearInterval(waitDtmf);
241 eventOccured();
243 }, 100);
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
282 // received.
283 addExpectedEvent();
284 remote_stream_1.onaddtrack = function(){
285 expectEquals(remote_stream_1.getAudioTracks()[0].id,
286 local_stream.getAudioTracks()[0].id);
287 eventOccured();
290 // Add an expectation that the received video track is removed from
291 // gFirstConnection.
292 addExpectedEvent();
293 remote_stream_1.onremovetrack = function() {
294 eventOccured();
297 // Add an expected event that onaddtrack will be called on the remote
298 // mediastream received on gSecondConnection when the audio track is
299 // received.
300 remote_stream_2 = gSecondConnection.getRemoteStreams()[0];
301 addExpectedEvent();
302 remote_stream_2.onaddtrack = function() {
303 expectEquals(remote_stream_2.getAudioTracks()[0].id,
304 local_stream.getAudioTracks()[0].id);
305 eventOccured();
308 // Add an expectation that the received video track is removed from
309 // gSecondConnection.
310 addExpectedEvent();
311 remote_stream_2.onremovetrack = function() {
312 eventOccured();
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]);
319 negotiate();
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();
347 negotiate();
350 // When |firstDataChannel| transition to closed state, the test pass.
351 addExpectedEvent();
352 firstDataChannel.onclose = function() {
353 expectEquals('closed', firstDataChannel.readyState);
354 eventOccured();
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();
406 negotiate();
409 // When |secondDataChannel| transition to closed state, the test pass.
410 addExpectedEvent();
411 secondDataChannel.onclose = function() {
412 expectEquals('closed', secondDataChannel.readyState);
413 eventOccured();
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);
444 return pc;
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);
464 negotiate();
467 // Called if getUserMedia succeeds when we want to send from one connection.
468 function addStreamToTheFirstConnectionAndNegotiate(localStream) {
469 displayAndRemember(localStream);
470 gFirstConnection.addStream(localStream);
471 negotiate();
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.
496 addExpectedEvent();
497 addExpectedEvent();
498 gSecondConnection.onaddstream = function(event) {
499 eventOccured();
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";
510 negotiate();
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);
522 negotiate();
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);
537 caller.createOffer(
538 function (offer) {
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',
555 sdp: offerSdp });
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, '');
568 return offerSdp;
571 function removeVideoCodec(offerSdp) {
572 offerSdp = offerSdp.replace('a=rtpmap:100 VP8/90000\r\n',
573 'a=rtpmap:100 XVP8/90000\r\n');
574 return offerSdp;
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, '');
580 return offerSdp;
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');
595 return sdp;
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';
606 return sdp;
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',
619 sdp: answerSdp });
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.';
646 return;
648 gRemoteStreams[target] = e.stream;
649 var remoteStreamUrl = webkitURL.createObjectURL(e.stream);
650 var remoteVideo = $(target);
651 remoteVideo.src = remoteStreamUrl;
654 </script>
655 </head>
656 <body>
657 <table border="0">
658 <tr>
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>
664 </tr>
665 <tr>
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>
685 </tr>
686 </table>
687 </body>
688 </html>