Peer-to-peer WebRTC connection with minimal setup. https://apps.aweirdimagination.net/camera/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

602 lines
20 KiB

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8"/>
  5. <title>Minimal WebRTC</title>
  6. <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
  7. <style>
  8. body.justVideo {
  9. margin: 0;
  10. overflow: hidden;
  11. }
  12. body.justVideo #status, body.justVideo form {
  13. display: none;
  14. }
  15. .qrcontainer {
  16. position: sticky;
  17. top: 0;
  18. right: 0;
  19. float: right;
  20. }
  21. .qrcontainer svg path {
  22. min-width: 30vw;
  23. max-width: 100vw;
  24. max-height: 50vh;
  25. }
  26. #selfView {
  27. position: fixed;
  28. right: 0;
  29. bottom: 0;
  30. border: 5px solid blue;
  31. }
  32. #unmute, #start {
  33. position: fixed;
  34. top: 0;
  35. width: 100%;
  36. font-size: 4em;
  37. }
  38. form {
  39. background: lightgray;
  40. border: solid gold 5px;
  41. display: table;
  42. }
  43. form label {
  44. display: table-row;
  45. }
  46. #status {
  47. display: table;
  48. }
  49. #status .log-entry {
  50. display: block;
  51. background: lightblue;
  52. border: solid black 2px;
  53. margin: 2px;
  54. }
  55. #status .log-entry:nth-child(odd) {
  56. background: lightyellow;
  57. }
  58. </style>
  59. <script type="text/javascript" src="static/js/qrcodegen.js"></script>
  60. <script type="text/javascript" src="static/js/lzma_worker.js"></script>
  61. <script type="text/javascript" src="static/js/prefixCompressor.js"></script>
  62. </head>
  63. <body>
  64. <a class="qrcontainer" id="qrcodelink" style="display:none;">
  65. <svg xmlns="http://www.w3.org/2000/svg" id="qrcode" style="width:20em; height:20em;" stroke="none">
  66. <rect width="100%" height="100%" fill="#FFFFFF"/>
  67. <path d="" fill="#000000"/>
  68. </svg>
  69. </a>
  70. <div class="qrcontainer" id="qrcodeObjContainer" style="display:none;">
  71. <svg xmlns="http://www.w3.org/2000/svg" id="qrcodeObj" style="width:20em; height:20em;" stroke="none">
  72. <rect width="100%" height="100%" fill="#FFFFFF"/>
  73. <path d="" fill="#000000"/>
  74. </svg> <br/>
  75. <textarea id="localOffer" readonly></textarea>
  76. <button id="copyLocalOffer">Copy</button>
  77. </div>
  78. <form name="settings">
  79. <label>
  80. Serverless mode:
  81. <input name="serverless" type="checkbox" value="true" />
  82. </label>
  83. <label>
  84. Recieve remote video:
  85. <select name="client-video">
  86. <option value="none">none</option>
  87. <option value="true">any camera</option>
  88. <option value="environment" selected>rear camera</option>
  89. <option value="user">front camera</option>
  90. <option value="screen">screen share</option>
  91. </select>
  92. </label>
  93. <label>
  94. Recieve remote audio:
  95. <input name="client-audio" type="checkbox" value="true" />
  96. </label>
  97. <label>
  98. Transmit video:
  99. <select name="host-video">
  100. <option value="none" selected>none</option>
  101. <option value="true">any camera</option>
  102. <option value="environment">rear camera</option>
  103. <option value="user">front camera</option>
  104. <option value="screen">screen share</option>
  105. </select>
  106. </label>
  107. <label>
  108. Transmit audio:
  109. <input name="host-audio" type="checkbox" value="true" />
  110. </label>
  111. <label>
  112. Debug mode (show log even after starting video):
  113. <input name="debug" type="checkbox" value="true" />
  114. </label>
  115. </form>
  116. <form id="serverlessOffer" style="display: none;" onsubmit="return false;">
  117. <label>
  118. Paste offer here:
  119. <textarea name="remoteOffer"></textarea>
  120. <button id="remoteOfferScan">Scan QR code</button>
  121. </label>
  122. </form>
  123. <div id="status"></div>
  124. <div id="videos">
  125. <button id="unmute" style="display:none;">Unmute</button>
  126. <button id="start" style="display:none;">Start Streaming</button>
  127. <video id="remoteView" width="100%" autoplay muted style="display:none;"></video>
  128. <video id="selfView" width="200" height="150" autoplay muted style="display:none;"></video>
  129. <video id="qrscan" width="100%" autoplay muted style="display:none;"></video>
  130. </div>
  131. <script type="module" >
  132. import * as b64 from "./static/js/base64.js";
  133. import QrScanner from "./static/js/qr-scanner.min.js";
  134. QrScanner.WORKER_PATH = "./static/js/qr-scanner-worker.min.js"
  135. async function initialize() {
  136. const create = (container, type) => container.appendChild(document.createElement(type));
  137. const QRGen = qrcodegen.QrCode;
  138. var compressor = null;
  139. async function getCompressor() {
  140. if (compressor == null) {
  141. compressor = await loadLZMAPrefixCompressor('static/js/prefix');
  142. }
  143. return compressor;
  144. }
  145. const body = document.querySelector("body");
  146. const out = document.getElementById("status");
  147. const qrcode = document.getElementById("qrcode");
  148. const qrcodelink = document.getElementById("qrcodelink");
  149. const qrcodeObjContainer = document.getElementById("qrcodeObjContainer");
  150. const qrcodeObj = document.getElementById("qrcodeObj");
  151. const remoteView = document.getElementById("remoteView");
  152. const selfView = document.getElementById("selfView");
  153. const qrscan = document.getElementById("qrscan");
  154. const start = document.getElementById("start");
  155. const unmute = document.getElementById("unmute");
  156. const form = document.forms["settings"];
  157. const remoteOfferForm = document.forms["serverlessOffer"];
  158. const remoteOffer = remoteOfferForm.elements["remoteOffer"];
  159. const remoteOfferScan = document.getElementById("remoteOfferScan");
  160. const localOfferArea = document.getElementById("localOffer");
  161. const localOfferCopyButton = document.getElementById("copyLocalOffer");
  162. function _log(str, tag) {
  163. const logEntry = document.createElement(tag);
  164. logEntry.innerText = str;
  165. logEntry.classList.add('log-entry');
  166. out.appendChild(logEntry);
  167. }
  168. function log(str) {
  169. _log(str, 'span');
  170. }
  171. function logPre(str) {
  172. _log(str.split('\\r\\n').join('\r\n'), 'pre');
  173. }
  174. log("Loading...");
  175. unmute.addEventListener("click", _ => {
  176. remoteView.muted = false;
  177. unmute.style.display = 'none';
  178. });
  179. var settings = undefined;
  180. function readSettingsForm(disableForm) {
  181. const obj = {};
  182. for (const el of form.elements) {
  183. obj[el.name] = el.type == 'checkbox' ? el.checked : el.value;
  184. if (disableForm) el.disabled = true;
  185. }
  186. return obj;
  187. }
  188. for (const el of form.elements) {
  189. el.addEventListener("change", e => {
  190. window.location.hash = JSON.stringify(readSettingsForm(false));
  191. if (e.target.name == 'serverless') {
  192. window.location.reload(false);
  193. }
  194. });
  195. }
  196. function isServerless() {
  197. return settings && 'serverless' in settings && settings.serverless;
  198. }
  199. function withHash(hash) {
  200. return window.location.href.split('#')[0] + '#' + hash;
  201. }
  202. function displayQR(qrcode, val) {
  203. const encodeFunc = val instanceof Uint8Array
  204. ? QRGen.encodeBinary
  205. : QRGen.encodeText;
  206. const qr = encodeFunc(val, QRGen.Ecc.MEDIUM);
  207. const code = qr.toSvgString(1);
  208. const viewBox = (/ viewBox="([^"]*)"/.exec(code))[1];
  209. const pathD = (/ d="([^"]*)"/.exec(code))[1];
  210. qrcode.setAttribute("viewBox", viewBox);
  211. qrcode.querySelector("path").setAttribute("d", pathD);
  212. }
  213. function displayQRUrl(url) {
  214. qrcodelink.href = url;
  215. displayQR(qrcode, url);
  216. qrcodelink.style.display = '';
  217. }
  218. function displayQRUrlForObj(obj) {
  219. const json = JSON.stringify(obj);
  220. log("Encoding message in QR code/link:");
  221. logPre(json);
  222. const compressed = compressor.compress(json);
  223. const encoded = b64.bytesToBase64(compressed);
  224. displayQRUrl(withHash('^' + encoded));
  225. }
  226. function displayQRCodeForObj(obj) {
  227. const json = JSON.stringify(obj);
  228. log("Encoding message in QR code for copy/paste:");
  229. logPre(json);
  230. const compressed = compressor.compress(json);
  231. const encoded = b64.bytesToBase64(compressed);
  232. displayQR(qrcodeObj, compressed);
  233. localOfferArea.value = encoded;
  234. localOfferCopyButton.onclick = _ => {
  235. localOfferArea.focus();
  236. localOfferArea.select();
  237. document.execCommand('copy');
  238. };
  239. qrcodeObjContainer.style.display = '';
  240. }
  241. let roomName = window.location.hash;
  242. let isHost = roomName === undefined || !roomName;
  243. if (roomName && roomName.startsWith("#{")) {
  244. roomName = undefined;
  245. isHost = true;
  246. try {
  247. const hash = decodeURI(window.location.hash);
  248. log("Reading settings from hash:");
  249. logPre(hash);
  250. settings = JSON.parse(hash.substring(1));
  251. for (const name in settings) {
  252. const el = form.elements[name];
  253. const value = settings[name];
  254. if (el.type == 'checkbox') {
  255. el.checked = value;
  256. } else {
  257. el.value = value;
  258. }
  259. }
  260. } catch (error) {
  261. log("Failed to read settings from hash: " + error);
  262. }
  263. }
  264. var firstMessage = undefined;
  265. if (roomName && roomName.startsWith("#^")) {
  266. roomName = undefined;
  267. isHost = false;
  268. try {
  269. const hash = decodeURI(window.location.hash);
  270. log("Reading first message from hash:");
  271. logPre(hash);
  272. log("Decoded base64:");
  273. const decoded = (await getCompressor()).decompress(b64.base64ToBytes(hash.substring(2)));
  274. logPre(decoded);
  275. firstMessage = decoded;
  276. settings = {serverless: true};
  277. } catch (error) {
  278. log("Failed to read message from hash: " + error);
  279. }
  280. }
  281. if (isHost) {
  282. // From https://stackoverflow.com/a/1349426
  283. function makeid(length) {
  284. var result = '';
  285. var characters = 'abcdefghijklmnopqrstuvwxyz';
  286. var charactersLength = characters.length;
  287. for (var i = 0; i < length; i++) {
  288. result += characters.charAt(Math.floor(Math.random() * charactersLength));
  289. }
  290. return result;
  291. }
  292. if (!isServerless()) {
  293. roomName = makeid(8);
  294. displayQRUrl(withHash(roomName));
  295. }
  296. } else {
  297. if (!isServerless()) {
  298. roomName = roomName.substring(1);
  299. }
  300. qrcodelink.style.display = 'none';
  301. form.style.display = 'none';
  302. }
  303. if (isServerless()) {
  304. log("In serverless mode.");
  305. } else {
  306. log("Room: " + roomName);
  307. }
  308. var webSocket = undefined;
  309. function sendJson(data) {
  310. const toSend = JSON.stringify(data);
  311. const dcReady = dc && dc.readyState == "open";
  312. const method = dcReady ? "dataConnection" : "webSocket";
  313. log("Sending message via " + method + "...");
  314. logPre(toSend);
  315. if (isServerless() && !dcReady) {
  316. log("ERROR: Attempted to use webSocket in serverless mode.");
  317. return;
  318. }
  319. (dcReady ? dc : webSocket).send(toSend);
  320. }
  321. var pc = undefined;
  322. var dc = undefined;
  323. function sendOffer() {
  324. if (pc.iceGatheringState == "complete") {
  325. const sendFunc = !isServerless() || dc && dc.readyState == "open"
  326. ? sendJson
  327. : isHost
  328. ? displayQRUrlForObj
  329. : displayQRCodeForObj;
  330. sendFunc({
  331. description: pc.localDescription
  332. });
  333. }
  334. }
  335. function createRTCPeerConnection() {
  336. const pc = new RTCPeerConnection();
  337. log("Created RTCPeerConnection.");
  338. pc.onsignalingstatechange = e => {
  339. log("pc.onsignalingstatechange: " + pc.signalingState);
  340. }
  341. pc.oniceconnectionstatechange = e => {
  342. log("pc.oniceconnectionstatechange: " + pc.iceConnectionState);
  343. }
  344. pc.onicegatheringstatechange = async function(e) {
  345. log("pc.onicegatheringstatechange: " + pc.iceGatheringState);
  346. sendOffer();
  347. }
  348. // let the "negotiationneeded" event trigger offer generation
  349. // ... but only once icegathering is complete.
  350. pc.onnegotiationneeded = async function () {
  351. log("In pc.onnegotiationneeded...");
  352. const useOffer = (!settings || !('separateIce' in settings)
  353. || !settings.separateIce);
  354. await pc.setLocalDescription(
  355. await (useOffer ? pc.createOffer() : pc.createAnswer()));
  356. sendOffer();
  357. }
  358. pc.ontrack = ({streams: [stream]}) => {
  359. log("In pc.ontrack...");
  360. remoteView.srcObject = stream;
  361. log("Set srcObject");
  362. remoteView.play();
  363. if (stream.getVideoTracks().length > 0) {
  364. remoteView.style.display = '';
  365. videos.style.display = '';
  366. if (!settings.debug) {
  367. body.classList.add('justVideo');
  368. }
  369. }
  370. };
  371. pc.ondatachannel = e => {
  372. dc = e.channel;
  373. dc.onmessage = e => {
  374. receiveMessage({source: "dataChannel", data: e.data});
  375. }
  376. log('Data channel initialized.');
  377. qrcodeObjContainer.style.display = 'none';
  378. }
  379. if (isHost) {
  380. dc = pc.createDataChannel('data');
  381. dc.onmessage = e => {
  382. receiveMessage({source: "dataChannel", data: e.data});
  383. }
  384. dc.onopen = e => {
  385. remoteOffer.disabled = true;
  386. remoteOfferScan.disabled = true;
  387. log("Data channel open, sending settings...")
  388. settings = readSettingsForm(true);
  389. sendJson({settings});
  390. startStreamingWithErorrHandling(false);
  391. }
  392. }
  393. return pc;
  394. }
  395. // get a local stream, show it in a self-view and add it to be sent
  396. async function startStreaming(fromButton) {
  397. log('In startStreaming(fromButton=' + fromButton + ')...');
  398. const otherAudioSettings = isHost
  399. ? settings['client-audio']
  400. : settings['host-audio'];
  401. if (otherAudioSettings) {
  402. unmute.style.display = '';
  403. }
  404. const videoSettings = isHost
  405. ? settings['host-video']
  406. : settings['client-video'];
  407. log("videoSettings=" + videoSettings);
  408. const audioSettings = isHost
  409. ? settings['host-audio']
  410. : settings['client-audio'];
  411. log("audioSettings=" + audioSettings);
  412. if (videoSettings == 'screen' && !fromButton) {
  413. start.style.display = '';
  414. return;
  415. }
  416. start.style.display = 'none';
  417. const videoConstraints = videoSettings == 'none'
  418. ? false
  419. : videoSettings == 'true'
  420. ? true
  421. : { advanced: [{facingMode: videoSettings}] };
  422. log("Created videoConstraints.");
  423. if (!videoConstraints && !audioSettings) return;
  424. const stream = videoSettings == 'screen'
  425. ? await navigator.mediaDevices.getDisplayMedia({
  426. audio: audioSettings,
  427. video: true
  428. })
  429. : await navigator.mediaDevices.getUserMedia({
  430. audio: audioSettings,
  431. video: videoConstraints
  432. });
  433. log("Created stream.");
  434. if (videoConstraints) {
  435. selfView.srcObject = stream;
  436. selfView.style.display = '';
  437. }
  438. for (const track of stream.getTracks()) {
  439. log("Added track.");
  440. pc.addTrack(track, stream);
  441. }
  442. log('End of startStreaming(), creating answer...');
  443. if (settings && 'separateIce' in settings && settings.separateIce) {
  444. await pc.setLocalDescription(await pc.createAnswer());
  445. }
  446. }
  447. function startStreamingWithErorrHandling(fromButton) {
  448. try {
  449. startStreaming(fromButton)
  450. .then(() => {
  451. log("startStreaming() finished.");
  452. })
  453. .catch(e => {
  454. log("startStreaming() errored: " + e.message);
  455. });
  456. } catch (e) {
  457. log("Error in startStreaming(): " + e);
  458. }
  459. }
  460. start.addEventListener("click", _ => {
  461. startStreamingWithErorrHandling(true)
  462. });
  463. async function receiveMessage(e) {
  464. qrcode.style.display = 'none';
  465. log("In receiveMessage from " + e.source + "...");
  466. logPre(e.data);
  467. const data = JSON.parse(e.data);
  468. if (data.ready) {
  469. // Ready message means client is open and ready for connection.
  470. pc = createRTCPeerConnection();
  471. } else if (data.settings) {
  472. settings = data.settings;
  473. startStreamingWithErorrHandling(false);
  474. } else if (data.description) {
  475. try {
  476. if (pc == undefined) pc = createRTCPeerConnection();
  477. await pc.setRemoteDescription(data.description);
  478. if (data.description.type == "offer") {
  479. log("Got an offer...");
  480. if (!settings || !('separateIce' in settings) || !settings.separateIce) {
  481. await pc.setLocalDescription(await pc.createAnswer());
  482. sendOffer();
  483. } else {
  484. log("separateIce mode, so delaying answer.");
  485. }
  486. }
  487. } catch (e) {
  488. log("Error accepting remote offer/answer: " + e);
  489. }
  490. }
  491. };
  492. function createWebSocket() {
  493. const webSocket = new WebSocket(
  494. 'ws' + (window.location.protocol == 'https:' ? 's' : '') + '://'
  495. + window.location.host
  496. + '/camera/ws/' + (isHost ? 'host' : 'client') + '/'
  497. + roomName
  498. + '/'
  499. );
  500. log("Created WebSocket.");
  501. webSocket.onclose = function(e) {
  502. log('WebSocket closed unexpectedly: ' + e);
  503. };
  504. webSocket.onerror = function(e) {
  505. log('WebSocket error: ' + e);
  506. };
  507. webSocket.onmessage = e => {
  508. receiveMessage({source: "webSocket", data: e.data});
  509. }
  510. return webSocket;
  511. }
  512. if (!isServerless()) {
  513. webSocket = createWebSocket();
  514. if (!isHost) {
  515. // To make serverless and server mode more similar,
  516. // always make the first RTCPeerConnection offer from the host,
  517. // so here just notify the host to start the process.
  518. webSocket.onopen = _ => sendJson({ready: true});
  519. }
  520. } else if (isHost) {
  521. await getCompressor();
  522. pc = createRTCPeerConnection();
  523. remoteOffer.value = '';
  524. remoteOffer.onchange = _ => {
  525. try {
  526. const decoded = b64.base64ToBytes(remoteOffer.value);
  527. const decompressed = compressor.decompress(decoded);
  528. receiveMessage({source: 'textarea', data: decompressed});
  529. } catch (e) {
  530. log("Error decoding remote offer: " + e);
  531. }
  532. };
  533. remoteOfferScan.onclick = _ => {
  534. qrscan.style.display = '';
  535. const qrScanner = new QrScanner(qrscan, (result) => {
  536. qrscan.style.display = 'none';
  537. log('Decoded qr code.');
  538. try {
  539. const decompressed = compressor.decompress(new Uint8Array(result.binaryData));
  540. receiveMessage({source: 'qr', data: decompressed});
  541. qrScanner.destroy();
  542. } catch(error) {
  543. log('Error interpreting QR code: ' + error);
  544. }
  545. });
  546. qrScanner.start();
  547. };
  548. remoteOfferForm.style.display = '';
  549. } else {
  550. receiveMessage({source: 'URL', data: firstMessage});
  551. }
  552. log("Finished <script> block.");
  553. }
  554. initialize();
  555. </script>
  556. </body>
  557. </html>