webrtc.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  3. * You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. // WebRTC support -- Note that this relies on parts of the interface code that usually goes in ui.js
  5. define(["require", "jquery", "util", "session", "ui", "peers", "storage", "windowing"], function (require, $, util, session, ui, peers, storage, windowing) {
  6. var webrtc = util.Module("webrtc");
  7. var assert = util.assert;
  8. session.RTCSupported = !!(window.mozRTCPeerConnection ||
  9. window.webkitRTCPeerConnection ||
  10. window.RTCPeerConnection);
  11. if (session.RTCSupported && $.browser.mozilla && parseInt($.browser.version, 10) <= 19) {
  12. // In a few versions of Firefox (18 and 19) these APIs are present but
  13. // not actually usable
  14. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=828839
  15. // Because they could be pref'd on we'll do a quick check:
  16. try {
  17. (function () {
  18. var conn = new window.mozRTCPeerConnection();
  19. })();
  20. } catch (e) {
  21. session.RTCSupported = false;
  22. }
  23. }
  24. var mediaConstraints = {
  25. mandatory: {
  26. OfferToReceiveAudio: true,
  27. OfferToReceiveVideo: false
  28. }
  29. };
  30. if (window.mozRTCPeerConnection) {
  31. mediaConstraints.mandatory.MozDontOfferDataChannel = true;
  32. }
  33. var URL = window.webkitURL || window.URL;
  34. var RTCSessionDescription = window.mozRTCSessionDescription || window.webkitRTCSessionDescription || window.RTCSessionDescription;
  35. var RTCIceCandidate = window.mozRTCIceCandidate || window.webkitRTCIceCandidate || window.RTCIceCandidate;
  36. function makePeerConnection() {
  37. // Based roughly off: https://github.com/firebase/gupshup/blob/gh-pages/js/chat.js
  38. if (window.webkitRTCPeerConnection) {
  39. return new webkitRTCPeerConnection({
  40. "iceServers": [{"url": "stun:stun.l.google.com:19302"}]
  41. }, {
  42. "optional": [{"DtlsSrtpKeyAgreement": true}]
  43. });
  44. }
  45. if (window.mozRTCPeerConnection) {
  46. return new mozRTCPeerConnection({
  47. // Or stun:124.124.124..2 ?
  48. "iceServers": [{"url": "stun:23.21.150.121"}]
  49. }, {
  50. "optional": []
  51. });
  52. }
  53. throw new util.AssertionError("Called makePeerConnection() without supported connection");
  54. }
  55. function ensureCryptoLine(sdp) {
  56. if (! window.mozRTCPeerConnection) {
  57. return sdp;
  58. }
  59. var sdpLinesIn = sdp.split('\r\n');
  60. var sdpLinesOut = [];
  61. // Search for m line.
  62. for (var i = 0; i < sdpLinesIn.length; i++) {
  63. sdpLinesOut.push(sdpLinesIn[i]);
  64. if (sdpLinesIn[i].search('m=') !== -1) {
  65. sdpLinesOut.push("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
  66. }
  67. }
  68. sdp = sdpLinesOut.join('\r\n');
  69. return sdp;
  70. }
  71. function getUserMedia(options, success, failure) {
  72. failure = failure || function (error) {
  73. console.error("Error in getUserMedia:", error);
  74. };
  75. (navigator.getUserMedia ||
  76. navigator.mozGetUserMedia ||
  77. navigator.webkitGetUserMedia ||
  78. navigator.msGetUserMedia).call(navigator, options, success, failure);
  79. }
  80. /****************************************
  81. * getUserMedia Avatar support
  82. */
  83. session.on("ui-ready", function () {
  84. $("#togetherjs-self-avatar").click(function () {
  85. var avatar = peers.Self.avatar;
  86. if (avatar) {
  87. $preview.attr("src", avatar);
  88. }
  89. ui.displayToggle("#togetherjs-avatar-edit");
  90. });
  91. if (! session.RTCSupported) {
  92. $("#togetherjs-avatar-edit-rtc").hide();
  93. }
  94. var avatarData = null;
  95. var $preview = $("#togetherjs-self-avatar-preview");
  96. var $accept = $("#togetherjs-self-avatar-accept");
  97. var $cancel = $("#togetherjs-self-avatar-cancel");
  98. var $takePic = $("#togetherjs-avatar-use-camera");
  99. var $video = $("#togetherjs-avatar-video");
  100. var $upload = $("#togetherjs-avatar-upload");
  101. $takePic.click(function () {
  102. if (! streaming) {
  103. startStreaming();
  104. return;
  105. }
  106. takePicture();
  107. });
  108. function savePicture(dataUrl) {
  109. avatarData = dataUrl;
  110. $preview.attr("src", avatarData);
  111. $accept.attr("disabled", null);
  112. }
  113. $accept.click(function () {
  114. peers.Self.update({avatar: avatarData});
  115. ui.displayToggle("#togetherjs-no-avatar-edit");
  116. // FIXME: these probably shouldn't be two elements:
  117. $("#togetherjs-participants-other").show();
  118. $accept.attr("disabled", "1");
  119. });
  120. $cancel.click(function () {
  121. ui.displayToggle("#togetherjs-no-avatar-edit");
  122. // FIXME: like above:
  123. $("#togetherjs-participants-other").show();
  124. });
  125. var streaming = false;
  126. function startStreaming() {
  127. getUserMedia({
  128. video: true,
  129. audio: false
  130. },
  131. function(stream) {
  132. streaming = true;
  133. $video[0].src = URL.createObjectURL(stream);
  134. $video[0].play();
  135. },
  136. function(err) {
  137. // FIXME: should pop up help or something in the case of a user
  138. // cancel
  139. console.error("getUserMedia error:", err);
  140. }
  141. );
  142. }
  143. function takePicture() {
  144. assert(streaming);
  145. var height = $video[0].videoHeight;
  146. var width = $video[0].videoWidth;
  147. width = width * (session.AVATAR_SIZE / height);
  148. height = session.AVATAR_SIZE;
  149. var $canvas = $("<canvas>");
  150. $canvas[0].height = session.AVATAR_SIZE;
  151. $canvas[0].width = session.AVATAR_SIZE;
  152. var context = $canvas[0].getContext("2d");
  153. context.arc(session.AVATAR_SIZE/2, session.AVATAR_SIZE/2, session.AVATAR_SIZE/2, 0, Math.PI*2);
  154. context.closePath();
  155. context.clip();
  156. context.drawImage($video[0], (session.AVATAR_SIZE - width) / 2, 0, width, height);
  157. savePicture($canvas[0].toDataURL("image/png"));
  158. }
  159. $upload.on("change", function () {
  160. var reader = new FileReader();
  161. reader.onload = function () {
  162. // FIXME: I don't actually know it's JPEG, but it's probably a
  163. // good enough guess:
  164. var url = "data:image/jpeg;base64," + util.blobToBase64(this.result);
  165. convertImage(url, function (result) {
  166. savePicture(result);
  167. });
  168. };
  169. reader.onerror = function () {
  170. console.error("Error reading file:", this.error);
  171. };
  172. reader.readAsArrayBuffer(this.files[0]);
  173. });
  174. function convertImage(imageUrl, callback) {
  175. var $canvas = $("<canvas>");
  176. $canvas[0].height = session.AVATAR_SIZE;
  177. $canvas[0].width = session.AVATAR_SIZE;
  178. var context = $canvas[0].getContext("2d");
  179. var img = new Image();
  180. img.src = imageUrl;
  181. // Sometimes the DOM updates immediately to call
  182. // naturalWidth/etc, and sometimes it doesn't; using setTimeout
  183. // gives it a chance to catch up
  184. setTimeout(function () {
  185. var width = img.naturalWidth || img.width;
  186. var height = img.naturalHeight || img.height;
  187. width = width * (session.AVATAR_SIZE / height);
  188. height = session.AVATAR_SIZE;
  189. context.drawImage(img, 0, 0, width, height);
  190. callback($canvas[0].toDataURL("image/png"));
  191. });
  192. }
  193. });
  194. /****************************************
  195. * RTC support
  196. */
  197. function audioButton(selector) {
  198. ui.displayToggle(selector);
  199. if (selector == "#togetherjs-audio-incoming") {
  200. $("#togetherjs-audio-button").addClass("togetherjs-animated").addClass("togetherjs-color-alert");
  201. } else {
  202. $("#togetherjs-audio-button").removeClass("togetherjs-animated").removeClass("togetherjs-color-alert");
  203. }
  204. }
  205. session.on("ui-ready", function () {
  206. $("#togetherjs-audio-button").click(function () {
  207. if ($("#togetherjs-rtc-info").is(":visible")) {
  208. windowing.hide();
  209. return;
  210. }
  211. if (session.RTCSupported) {
  212. enableAudio();
  213. } else {
  214. windowing.show("#togetherjs-rtc-not-supported");
  215. }
  216. });
  217. if (! session.RTCSupported) {
  218. audioButton("#togetherjs-audio-unavailable");
  219. return;
  220. }
  221. audioButton("#togetherjs-audio-ready");
  222. var audioStream = null;
  223. var accepted = false;
  224. var connected = false;
  225. var $audio = $("#togetherjs-audio-element");
  226. var offerSent = null;
  227. var offerReceived = null;
  228. var offerDescription = false;
  229. var answerSent = null;
  230. var answerReceived = null;
  231. var answerDescription = false;
  232. var _connection = null;
  233. var iceCandidate = null;
  234. function enableAudio() {
  235. accepted = true;
  236. storage.settings.get("dontShowRtcInfo").then(function (dontShow) {
  237. if (! dontShow) {
  238. windowing.show("#togetherjs-rtc-info");
  239. }
  240. });
  241. if (! audioStream) {
  242. startStreaming(connect);
  243. return;
  244. }
  245. if (! connected) {
  246. connect();
  247. }
  248. toggleMute();
  249. }
  250. ui.container.find("#togetherjs-rtc-info .togetherjs-dont-show-again").change(function () {
  251. storage.settings.set("dontShowRtcInfo", this.checked);
  252. });
  253. function error() {
  254. console.warn.apply(console, arguments);
  255. var s = "";
  256. for (var i=0; i<arguments.length; i++) {
  257. if (s) {
  258. s += " ";
  259. }
  260. var a = arguments[i];
  261. if (typeof a == "string") {
  262. s += a;
  263. } else {
  264. var repl;
  265. try {
  266. repl = JSON.stringify(a);
  267. } catch (e) {
  268. }
  269. if (! repl) {
  270. repl = "" + a;
  271. }
  272. s += repl;
  273. }
  274. }
  275. audioButton("#togetherjs-audio-error");
  276. // FIXME: this title doesn't seem to display?
  277. $("#togetherjs-audio-error").attr("title", s);
  278. }
  279. function startStreaming(callback) {
  280. getUserMedia(
  281. {
  282. video: false,
  283. audio: true
  284. },
  285. function (stream) {
  286. audioStream = stream;
  287. attachMedia("#togetherjs-local-audio", stream);
  288. if (callback) {
  289. callback();
  290. }
  291. },
  292. function (err) {
  293. // FIXME: handle cancel case
  294. if (err && err.code == 1) {
  295. // User cancel
  296. return;
  297. }
  298. error("getUserMedia error:", err);
  299. }
  300. );
  301. }
  302. function attachMedia(element, media) {
  303. element = $(element)[0];
  304. console.log("Attaching", media, "to", element);
  305. if (window.mozRTCPeerConnection) {
  306. element.mozSrcObject = media;
  307. element.play();
  308. } else {
  309. element.autoplay = true;
  310. element.src = URL.createObjectURL(media);
  311. }
  312. }
  313. function getConnection() {
  314. assert(audioStream);
  315. if (_connection) {
  316. return _connection;
  317. }
  318. try {
  319. _connection = makePeerConnection();
  320. } catch (e) {
  321. error("Error creating PeerConnection:", e);
  322. throw e;
  323. }
  324. _connection.onaddstream = function (event) {
  325. console.log("got event", event, event.type);
  326. attachMedia($audio, event.stream);
  327. audioButton("#togetherjs-audio-active");
  328. };
  329. _connection.onstatechange = function () {
  330. // FIXME: this doesn't seem to work:
  331. // Actually just doesn't work on Firefox
  332. console.log("state change", _connection.readyState);
  333. if (_connection.readyState == "closed") {
  334. audioButton("#togetherjs-audio-ready");
  335. }
  336. };
  337. _connection.onicecandidate = function (event) {
  338. if (event.candidate) {
  339. session.send({
  340. type: "rtc-ice-candidate",
  341. candidate: {
  342. sdpMLineIndex: event.candidate.sdpMLineIndex,
  343. sdpMid: event.candidate.sdpMid,
  344. candidate: event.candidate.candidate
  345. }
  346. });
  347. }
  348. };
  349. _connection.addStream(audioStream);
  350. return _connection;
  351. }
  352. function addIceCandidate() {
  353. if (iceCandidate) {
  354. console.log("adding ice", iceCandidate);
  355. _connection.addIceCandidate(new RTCIceCandidate(iceCandidate));
  356. }
  357. }
  358. function connect() {
  359. var connection = getConnection();
  360. if (offerReceived && (! offerDescription)) {
  361. connection.setRemoteDescription(
  362. new RTCSessionDescription({
  363. type: "offer",
  364. sdp: offerReceived
  365. }),
  366. function () {
  367. offerDescription = true;
  368. addIceCandidate();
  369. connect();
  370. },
  371. function (err) {
  372. error("Error doing RTC setRemoteDescription:", err);
  373. }
  374. );
  375. return;
  376. }
  377. if (! (offerSent || offerReceived)) {
  378. connection.createOffer(function (offer) {
  379. console.log("made offer", offer);
  380. offer.sdp = ensureCryptoLine(offer.sdp);
  381. connection.setLocalDescription(
  382. offer,
  383. function () {
  384. session.send({
  385. type: "rtc-offer",
  386. offer: offer.sdp
  387. });
  388. offerSent = offer;
  389. audioButton("#togetherjs-audio-outgoing");
  390. },
  391. function (err) {
  392. error("Error doing RTC setLocalDescription:", err);
  393. },
  394. mediaConstraints
  395. );
  396. }, function (err) {
  397. error("Error doing RTC createOffer:", err);
  398. });
  399. } else if (! (answerSent || answerReceived)) {
  400. // FIXME: I might have only needed this due to my own bugs, this might
  401. // not actually time out
  402. var timeout = setTimeout(function () {
  403. if (! answerSent) {
  404. error("createAnswer Timed out; reload or restart browser");
  405. }
  406. }, 2000);
  407. connection.createAnswer(function (answer) {
  408. answer.sdp = ensureCryptoLine(answer.sdp);
  409. clearTimeout(timeout);
  410. connection.setLocalDescription(
  411. answer,
  412. function () {
  413. session.send({
  414. type: "rtc-answer",
  415. answer: answer.sdp
  416. });
  417. answerSent = answer;
  418. },
  419. function (err) {
  420. clearTimeout(timeout);
  421. error("Error doing RTC setLocalDescription:", err);
  422. },
  423. mediaConstraints
  424. );
  425. }, function (err) {
  426. error("Error doing RTC createAnswer:", err);
  427. });
  428. }
  429. }
  430. function toggleMute() {
  431. // FIXME: implement. Actually, wait for this to be implementable - currently
  432. // muting of localStreams isn't possible
  433. // FIXME: replace with hang-up?
  434. }
  435. session.hub.on("rtc-offer", function (msg) {
  436. if (offerReceived || answerSent || answerReceived || offerSent) {
  437. abort();
  438. }
  439. offerReceived = msg.offer;
  440. if (! accepted) {
  441. audioButton("#togetherjs-audio-incoming");
  442. return;
  443. }
  444. function run() {
  445. var connection = getConnection();
  446. connection.setRemoteDescription(
  447. new RTCSessionDescription({
  448. type: "offer",
  449. sdp: offerReceived
  450. }),
  451. function () {
  452. offerDescription = true;
  453. addIceCandidate();
  454. connect();
  455. },
  456. function (err) {
  457. error("Error doing RTC setRemoteDescription:", err);
  458. }
  459. );
  460. }
  461. if (! audioStream) {
  462. startStreaming(run);
  463. } else {
  464. run();
  465. }
  466. });
  467. session.hub.on("rtc-answer", function (msg) {
  468. if (answerSent || answerReceived || offerReceived || (! offerSent)) {
  469. abort();
  470. // Basically we have to abort and try again. We'll expect the other
  471. // client to restart when appropriate
  472. session.send({type: "rtc-abort"});
  473. return;
  474. }
  475. answerReceived = msg.answer;
  476. assert(offerSent);
  477. assert(audioStream);
  478. var connection = getConnection();
  479. connection.setRemoteDescription(
  480. new RTCSessionDescription({
  481. type: "answer",
  482. sdp: answerReceived
  483. }),
  484. function () {
  485. answerDescription = true;
  486. // FIXME: I don't think this connect is ever needed?
  487. connect();
  488. },
  489. function (err) {
  490. error("Error doing RTC setRemoteDescription:", err);
  491. }
  492. );
  493. });
  494. session.hub.on("rtc-ice-candidate", function (msg) {
  495. iceCandidate = msg.candidate;
  496. if (offerDescription || answerDescription) {
  497. addIceCandidate();
  498. }
  499. });
  500. session.hub.on("rtc-abort", function (msg) {
  501. abort();
  502. if (! accepted) {
  503. return;
  504. }
  505. if (! audioStream) {
  506. startStreaming(function () {
  507. connect();
  508. });
  509. } else {
  510. connect();
  511. }
  512. });
  513. session.hub.on("hello", function (msg) {
  514. // FIXME: displayToggle should be set due to
  515. // _connection.onstatechange, but that's not working, so
  516. // instead:
  517. audioButton("#togetherjs-audio-ready");
  518. if (accepted && (offerSent || answerSent)) {
  519. abort();
  520. connect();
  521. }
  522. });
  523. function abort() {
  524. answerSent = answerReceived = offerSent = offerReceived = null;
  525. answerDescription = offerDescription = false;
  526. _connection = null;
  527. $audio[0].removeAttribute("src");
  528. }
  529. });
  530. return webrtc;
  531. });