diff --git a/samples/js/apprtc/app.yaml b/samples/js/apprtc/app.yaml
index 8da5040b7c..445a95a953 100644
--- a/samples/js/apprtc/app.yaml
+++ b/samples/js/apprtc/app.yaml
@@ -14,6 +14,9 @@ handlers:
- url: /js
static_dir: js
+- url: /css
+ static_dir: css
+
- url: /.*
script: apprtc.app
secure: always
diff --git a/samples/js/apprtc/css/main.css b/samples/js/apprtc/css/main.css
new file mode 100644
index 0000000000..7228e5c253
--- /dev/null
+++ b/samples/js/apprtc/css/main.css
@@ -0,0 +1,95 @@
+ a:link { color: #ffffff; }
+ a:visited {color: #ffffff; }
+ html, body {
+ background-color: #000000;
+ height: 100%;
+ font-family: Verdana, Arial, Helvetica, sans-serif;
+ }
+ body {
+ margin: 0;
+ padding: 0;
+ }
+ #container {
+ background-color: #000000;
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ margin: 0px auto;
+ -webkit-perspective: 1000;
+ }
+ #card {
+ -webkit-transition-property: rotation;
+ -webkit-transition-duration: 2s;
+ -webkit-transform-style: preserve-3d;
+ }
+ #local {
+ position: absolute;
+ width: 100%;
+ -webkit-transform: scale(-1, 1);
+ -webkit-backface-visibility: hidden;
+ }
+ #remote {
+ position: absolute;
+ width: 100%;
+ -webkit-transform: rotateY(180deg);
+ -webkit-backface-visibility: hidden;
+ }
+ #mini {
+ position: absolute;
+ height: 30%;
+ width: 30%;
+ bottom: 32px;
+ right: 4px;
+ -webkit-transform: scale(-1, 1);
+ opacity: 1.0;
+ }
+ #localVideo {
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ -webkit-transition-property: opacity;
+ -webkit-transition-duration: 2s;
+ }
+ #remoteVideo {
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ -webkit-transition-property: opacity;
+ -webkit-transition-duration: 2s;
+ }
+ #miniVideo {
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ -webkit-transition-property: opacity;
+ -webkit-transition-duration: 2s;
+ }
+ #footer {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ height: 28px;
+ background-color: #3F3F3F;
+ color: rgb(255, 255, 255);
+ font-size: 13px; font-weight: bold;
+ line-height: 28px;
+ text-align: center;
+ }
+ #hangup {
+ font-size: 13px; font-weight: bold;
+ color: #FFFFFF;
+ width: 128px;
+ height: 24px;
+ background-color: #808080;
+ border-style: solid;
+ border-color: #FFFFFF;
+ margin: 2px;
+ }
+ #logo {
+ display: block;
+ top: 4;
+ right: 4;
+ position: absolute;
+ float: right;
+ opacity: 0.5;
+ }
diff --git a/samples/js/apprtc/index.html b/samples/js/apprtc/index.html
index 3d40d910df..470395e12f 100644
--- a/samples/js/apprtc/index.html
+++ b/samples/js/apprtc/index.html
@@ -1,624 +1,50 @@
-
+
WebRTC Reference App
-
-
-
+
+
+
+
+
+
+
-
+
diff --git a/samples/js/apprtc/js/main.js b/samples/js/apprtc/js/main.js
new file mode 100644
index 0000000000..89a2bfdc1c
--- /dev/null
+++ b/samples/js/apprtc/js/main.js
@@ -0,0 +1,541 @@
+ var localVideo;
+ var miniVideo;
+ var remoteVideo;
+ var localStream;
+ var remoteStream;
+ var channel;
+ var pc;
+ var socket;
+ var xmlhttp;
+ var started = false;
+ var turnDone = false;
+ var channelReady = false;
+ // Set up audio and video regardless of what devices are present.
+ var sdpConstraints = {'mandatory': {
+ 'OfferToReceiveAudio': true,
+ 'OfferToReceiveVideo': true }};
+ var isVideoMuted = false;
+ var isAudioMuted = false;
+ var aspectRatio;
+
+ function initialize() {
+ console.log('Initializing; room=' + roomKey + '.');
+ card = document.getElementById('card');
+ localVideo = document.getElementById('localVideo');
+ // Reset localVideo display to center.
+ localVideo.addEventListener("loadedmetadata", function(){
+ aspectRatio = this.videoWidth / this.videoHeight;
+ window.onresize();
+ });
+ miniVideo = document.getElementById('miniVideo');
+ remoteVideo = document.getElementById('remoteVideo');
+ resetStatus();
+ // NOTE: AppRTCClient.java searches & parses this line; update there when
+ // changing here.
+ openChannel();
+ maybeRequestTurn();
+ doGetUserMedia();
+ }
+
+ function openChannel() {
+ console.log('Opening channel.');
+ var channel = new goog.appengine.Channel(channelToken);
+ var handler = {
+ 'onopen': onChannelOpened,
+ 'onmessage': onChannelMessage,
+ 'onerror': onChannelError,
+ 'onclose': onChannelClosed
+ };
+ socket = channel.open(handler);
+ }
+
+ function maybeRequestTurn() {
+ for (var i = 0, len = pcConfig.iceServers.length; i < len; i++) {
+ if (pcConfig.iceServers[i].url.substr(0, 5) === 'turn:') {
+ turnDone = true;
+ return;
+ }
+ }
+
+ var currentDomain = document.domain;
+ if (currentDomain.search('localhost') === -1 &&
+ currentDomain.search('apprtc.appspot.com') === -1) {
+ // Not authorized domain. Try with default STUN instead.
+ turnDone = true;
+ return;
+ }
+
+ // No TURN server. Get one from computeengineondemand.appspot.com.
+ xmlhttp = new XMLHttpRequest();
+ xmlhttp.onreadystatechange = onTurnResult;
+ xmlhttp.open('GET', turnUrl, true);
+ xmlhttp.send();
+ }
+
+ function onTurnResult() {
+ if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
+ var turnServer = JSON.parse(xmlhttp.responseText);
+ pcConfig.iceServers.push({
+ 'url': 'turn:' + turnServer.username + '@' + turnServer.turn,
+ 'credential': turnServer.password
+ });
+ } else {
+ console.log("Request for TURN server failed.")
+ }
+ // If TURN request failed, continue the call with default STUN.
+ turnDone = true;
+ }
+
+ function resetStatus() {
+ if (!initiator) {
+ setStatus('Waiting for someone to join: \
+ ' + roomLink + '');
+ } else {
+ setStatus('Initializing...');
+ }
+ }
+
+ function doGetUserMedia() {
+ // Call into getUserMedia via the polyfill (adapter.js).
+ try {
+ getUserMedia(mediaConstraints, onUserMediaSuccess,
+ onUserMediaError);
+ console.log('Requested access to local media with mediaConstraints:\n' +
+ ' \'' + JSON.stringify(mediaConstraints) + '\'');
+ } catch (e) {
+ alert('getUserMedia() failed. Is this a WebRTC capable browser?');
+ console.log('getUserMedia failed with exception: ' + e.message);
+ }
+ }
+
+ function createPeerConnection() {
+ try {
+ // Create an RTCPeerConnection via the polyfill (adapter.js).
+ pc = new RTCPeerConnection(pcConfig, pcConstraints);
+ pc.onicecandidate = onIceCandidate;
+ console.log('Created RTCPeerConnnection with:\n' +
+ ' config: \'' + JSON.stringify(pcConfig) + '\';\n' +
+ ' constraints: \'' + JSON.stringify(pcConstraints) + '\'.');
+ } catch (e) {
+ console.log('Failed to create PeerConnection, exception: ' + e.message);
+ alert('Cannot create RTCPeerConnection object; \
+ WebRTC is not supported by this browser.');
+ return;
+ }
+ pc.onaddstream = onRemoteStreamAdded;
+ pc.onremovestream = onRemoteStreamRemoved;
+ }
+
+ function maybeStart() {
+ if (!started && localStream && channelReady && turnDone) {
+ setStatus('Connecting...');
+ console.log('Creating PeerConnection.');
+ createPeerConnection();
+ console.log('Adding local stream.');
+ pc.addStream(localStream);
+ started = true;
+ // Caller initiates offer to peer.
+ if (initiator)
+ doCall();
+ } else {
+ setTimeout(maybeStart, 100);
+ }
+ }
+
+ function setStatus(state) {
+ document.getElementById('footer').innerHTML = state;
+ }
+
+ function doCall() {
+ var constraints = mergeConstraints(offerConstraints, sdpConstraints);
+ console.log('Sending offer to peer, with constraints: \n' +
+ ' \'' + JSON.stringify(constraints) + '\'.')
+ pc.createOffer(setLocalAndSendMessage, null, constraints);
+ }
+
+ function doAnswer() {
+ console.log('Sending answer to peer.');
+ pc.createAnswer(setLocalAndSendMessage, null, sdpConstraints);
+ }
+
+ function mergeConstraints(cons1, cons2) {
+ var merged = cons1;
+ for (var name in cons2.mandatory) {
+ merged.mandatory[name] = cons2.mandatory[name];
+ }
+ merged.optional.concat(cons2.optional);
+ return merged;
+ }
+
+ function setLocalAndSendMessage(sessionDescription) {
+ // Set Opus as the preferred codec in SDP if Opus is present.
+ sessionDescription.sdp = preferOpus(sessionDescription.sdp);
+ pc.setLocalDescription(sessionDescription);
+ sendMessage(sessionDescription);
+ }
+
+ function sendMessage(message) {
+ var msgString = JSON.stringify(message);
+ console.log('C->S: ' + msgString);
+ // NOTE: AppRTCClient.java searches & parses this line; update there when
+ // changing here.
+ path = '/message?r=' + roomKey + '&u=' + me;
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', path, true);
+ xhr.send(msgString);
+ }
+
+ function processSignalingMessage(message) {
+ var msg = JSON.parse(message);
+
+ if (msg.type === 'offer') {
+ // Callee creates PeerConnection
+ if (!initiator && !started)
+ maybeStart();
+ // Set Opus in Stereo, if stereo enabled.
+ if (stereo)
+ msg.sdp = addStereo(msg.sdp);
+ pc.setRemoteDescription(new RTCSessionDescription(msg));
+ doAnswer();
+ } else if (msg.type === 'answer' && started) {
+ // Set Opus in Stereo, if stereo enabled.
+ if (stereo)
+ msg.sdp = addStereo(msg.sdp);
+ pc.setRemoteDescription(new RTCSessionDescription(msg));
+ } else if (msg.type === 'candidate' && started) {
+ var candidate = new RTCIceCandidate({sdpMLineIndex: msg.label,
+ candidate: msg.candidate});
+ pc.addIceCandidate(candidate);
+ } else if (msg.type === 'bye' && started) {
+ onRemoteHangup();
+ }
+ }
+
+ function onChannelOpened() {
+ console.log('Channel opened.');
+ channelReady = true;
+ }
+ function onChannelMessage(message) {
+ console.log('S->C: ' + message.data);
+ processSignalingMessage(message.data);
+ }
+ function onChannelError() {
+ console.log('Channel error.');
+ }
+ function onChannelClosed() {
+ console.log('Channel closed.');
+ }
+
+ function onUserMediaSuccess(stream) {
+ console.log('User has granted access to local media.');
+ // Call the polyfill wrapper to attach the media stream to this element.
+ attachMediaStream(localVideo, stream);
+ localVideo.style.opacity = 1;
+ localStream = stream;
+ // Caller creates PeerConnection.
+ if (initiator) maybeStart();
+ }
+
+ function onUserMediaError(error) {
+ console.log('Failed to get access to local media. Error code was ' +
+ error.code);
+ alert('Failed to get access to local media. Error code was ' +
+ error.code + '.');
+ }
+
+ function onIceCandidate(event) {
+ if (event.candidate) {
+ sendMessage({type: 'candidate',
+ label: event.candidate.sdpMLineIndex,
+ id: event.candidate.sdpMid,
+ candidate: event.candidate.candidate});
+ } else {
+ console.log('End of candidates.');
+ }
+ }
+
+ function onRemoteStreamAdded(event) {
+ console.log('Remote stream added.');
+ reattachMediaStream(miniVideo, localVideo);
+ attachMediaStream(remoteVideo, event.stream);
+ remoteStream = event.stream;
+ waitForRemoteVideo();
+ }
+
+ function onRemoteStreamRemoved(event) {
+ console.log('Remote stream removed.');
+ }
+
+ function onHangup() {
+ console.log('Hanging up.');
+ transitionToDone();
+ stop();
+ // will trigger BYE from server
+ socket.close();
+ }
+
+ function onRemoteHangup() {
+ console.log('Session terminated.');
+ initiator = 0;
+ transitionToWaiting();
+ stop();
+ }
+
+ function stop() {
+ started = false;
+ isAudioMuted = false;
+ isVideoMuted = false;
+ pc.close();
+ pc = null;
+ }
+
+ function waitForRemoteVideo() {
+ // Call the getVideoTracks method via adapter.js.
+ videoTracks = remoteStream.getVideoTracks();
+ if (videoTracks.length === 0 || remoteVideo.currentTime > 0) {
+ transitionToActive();
+ } else {
+ setTimeout(waitForRemoteVideo, 100);
+ }
+ }
+
+ function transitionToActive() {
+ remoteVideo.style.opacity = 1;
+ card.style.webkitTransform = 'rotateY(180deg)';
+ setTimeout(function() { localVideo.src = ''; }, 500);
+ setTimeout(function() { miniVideo.style.opacity = 1; }, 1000);
+ // Reset window display according to the asperio of remote video.
+ window.onresize();
+ setStatus('');
+ }
+
+ function transitionToWaiting() {
+ card.style.webkitTransform = 'rotateY(0deg)';
+ setTimeout(function() {
+ localVideo.src = miniVideo.src;
+ miniVideo.src = '';
+ remoteVideo.src = '' }, 500);
+ miniVideo.style.opacity = 0;
+ remoteVideo.style.opacity = 0;
+ resetStatus();
+ }
+
+ function transitionToDone() {
+ localVideo.style.opacity = 0;
+ remoteVideo.style.opacity = 0;
+ miniVideo.style.opacity = 0;
+ setStatus('You have left the call. \
+ Click here to rejoin.');
+ }
+
+ function enterFullScreen() {
+ container.webkitRequestFullScreen();
+ }
+
+ function toggleVideoMute() {
+ // Call the getVideoTracks method via adapter.js.
+ videoTracks = localStream.getVideoTracks();
+
+ if (videoTracks.length === 0) {
+ console.log('No local video available.');
+ return;
+ }
+
+ if (isVideoMuted) {
+ for (i = 0; i < videoTracks.length; i++) {
+ videoTracks[i].enabled = true;
+ }
+ console.log('Video unmuted.');
+ } else {
+ for (i = 0; i < videoTracks.length; i++) {
+ videoTracks[i].enabled = false;
+ }
+ console.log('Video muted.');
+ }
+
+ isVideoMuted = !isVideoMuted;
+ }
+
+ function toggleAudioMute() {
+ // Call the getAudioTracks method via adapter.js.
+ audioTracks = localStream.getAudioTracks();
+
+ if (audioTracks.length === 0) {
+ console.log('No local audio available.');
+ return;
+ }
+
+ if (isAudioMuted) {
+ for (i = 0; i < audioTracks.length; i++) {
+ audioTracks[i].enabled = true;
+ }
+ console.log('Audio unmuted.');
+ } else {
+ for (i = 0; i < audioTracks.length; i++){
+ audioTracks[i].enabled = false;
+ }
+ console.log('Audio muted.');
+ }
+
+ isAudioMuted = !isAudioMuted;
+ }
+
+ // Ctrl-D: toggle audio mute; Ctrl-E: toggle video mute.
+ // On Mac, Command key is instead of Ctrl.
+ // Return false to screen out original Chrome shortcuts.
+ document.onkeydown = function() {
+ if (navigator.appVersion.indexOf('Mac') != -1) {
+ if (event.metaKey && event.keyCode === 68) {
+ toggleAudioMute();
+ return false;
+ }
+ if (event.metaKey && event.keyCode === 69) {
+ toggleVideoMute();
+ return false;
+ }
+ } else {
+ if (event.ctrlKey && event.keyCode === 68) {
+ toggleAudioMute();
+ return false;
+ }
+ if (event.ctrlKey && event.keyCode === 69) {
+ toggleVideoMute();
+ return false;
+ }
+ }
+ }
+
+ // Set Opus as the default audio codec if it's present.
+ function preferOpus(sdp) {
+ var sdpLines = sdp.split('\r\n');
+
+ // Search for m line.
+ for (var i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].search('m=audio') !== -1) {
+ var mLineIndex = i;
+ break;
+ }
+ }
+ if (mLineIndex === null)
+ return sdp;
+
+ // If Opus is available, set it as the default in m line.
+ for (var i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].search('opus/48000') !== -1) {
+ var opusPayload = extractSdp(sdpLines[i], /:(\d+) opus\/48000/i);
+ if (opusPayload)
+ sdpLines[mLineIndex] = setDefaultCodec(sdpLines[mLineIndex],
+ opusPayload);
+ break;
+ }
+ }
+
+ // Remove CN in m line and sdp.
+ sdpLines = removeCN(sdpLines, mLineIndex);
+
+ sdp = sdpLines.join('\r\n');
+ return sdp;
+ }
+
+ // Set Opus in stereo if stereo is enabled.
+ function addStereo(sdp) {
+ var sdpLines = sdp.split('\r\n');
+
+ // Find opus payload.
+ for (var i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].search('opus/48000') !== -1) {
+ var opusPayload = extractSdp(sdpLines[i], /:(\d+) opus\/48000/i);
+ break;
+ }
+ }
+
+ // Find the payload in fmtp line.
+ for (var i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].search('a=fmtp') !== -1) {
+ var payload = extractSdp(sdpLines[i], /a=fmtp:(\d+)/ );
+ if (payload === opusPayload) {
+ var fmtpLineIndex = i;
+ break;
+ }
+ }
+ }
+ // No fmtp line found.
+ if (fmtpLineIndex === null)
+ return sdp;
+
+ // Append stereo=1 to fmtp line.
+ sdpLines[fmtpLineIndex] = sdpLines[fmtpLineIndex].concat(' stereo=1');
+
+ sdp = sdpLines.join('\r\n');
+ return sdp;
+ }
+
+ function extractSdp(sdpLine, pattern) {
+ var result = sdpLine.match(pattern);
+ return (result && result.length == 2)? result[1]: null;
+ }
+
+ // Set the selected codec to the first in m line.
+ function setDefaultCodec(mLine, payload) {
+ var elements = mLine.split(' ');
+ var newLine = new Array();
+ var index = 0;
+ for (var i = 0; i < elements.length; i++) {
+ if (index === 3) // Format of media starts from the fourth.
+ newLine[index++] = payload; // Put target payload to the first.
+ if (elements[i] !== payload)
+ newLine[index++] = elements[i];
+ }
+ return newLine.join(' ');
+ }
+
+ // Strip CN from sdp before CN constraints is ready.
+ function removeCN(sdpLines, mLineIndex) {
+ var mLineElements = sdpLines[mLineIndex].split(' ');
+ // Scan from end for the convenience of removing an item.
+ for (var i = sdpLines.length-1; i >= 0; i--) {
+ var payload = extractSdp(sdpLines[i], /a=rtpmap:(\d+) CN\/\d+/i);
+ if (payload) {
+ var cnPos = mLineElements.indexOf(payload);
+ if (cnPos !== -1) {
+ // Remove CN payload from m line.
+ mLineElements.splice(cnPos, 1);
+ }
+ // Remove CN line in sdp
+ sdpLines.splice(i, 1);
+ }
+ }
+
+ sdpLines[mLineIndex] = mLineElements.join(' ');
+ return sdpLines;
+ }
+
+ // Send BYE on refreshing(or leaving) a demo page
+ // to ensure the room is cleaned for next session.
+ window.onbeforeunload = function() {
+ sendMessage({type: 'bye'});
+ }
+
+ // Set the video diplaying in the center of window.
+ window.onresize = function(){
+ if (remoteVideo.style.opacity === '1') {
+ aspectRatio = remoteVideo.videoWidth/remoteVideo.videoHeight;
+ } else if (localVideo.style.opacity === '1') {
+ aspectRatio = localVideo.videoWidth/localVideo.videoHeight;
+ } else {
+ return;
+ }
+
+ var innerHeight = this.innerHeight;
+ var innerWidth = this.innerWidth;
+ var videoWidth = innerWidth < aspectRatio * window.innerHeight ?
+ innerWidth : aspectRatio * window.innerHeight;
+ var videoHeight = innerHeight < window.innerWidth / aspectRatio ?
+ innerHeight : window.innerWidth / aspectRatio;
+ containerDiv = document.getElementById("container");
+ containerDiv.style.width = videoWidth + "px";
+ containerDiv.style.height = videoHeight + "px";
+ containerDiv.style.left = (innerWidth - videoWidth) / 2 + "px";
+ containerDiv.style.top = (innerHeight - videoHeight) / 2 + "px";
+ };