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.
 
 
 
 

583 lines
19 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. await pc.setLocalDescription(await pc.createOffer());
  353. sendOffer();
  354. }
  355. pc.ontrack = ({streams: [stream]}) => {
  356. log("In pc.ontrack...");
  357. remoteView.srcObject = stream;
  358. log("Set srcObject");
  359. remoteView.play();
  360. if (stream.getVideoTracks().length > 0) {
  361. remoteView.style.display = '';
  362. videos.style.display = '';
  363. if (!settings.debug) {
  364. body.classList.add('justVideo');
  365. }
  366. }
  367. };
  368. pc.ondatachannel = e => {
  369. dc = e.channel;
  370. dc.onmessage = e => {
  371. receiveMessage({source: "dataChannel", data: e.data});
  372. }
  373. log('Data channel initialized.');
  374. qrcodeObjContainer.style.display = 'none';
  375. }
  376. if (isHost) {
  377. dc = pc.createDataChannel('data');
  378. dc.onmessage = e => {
  379. receiveMessage({source: "dataChannel", data: e.data});
  380. }
  381. dc.onopen = e => {
  382. remoteOffer.disabled = true;
  383. remoteOfferScan.disabled = true;
  384. log("Data channel open, sending settings...")
  385. settings = readSettingsForm(true);
  386. sendJson({settings});
  387. startStreamingWithErorrHandling(false);
  388. }
  389. }
  390. return pc;
  391. }
  392. // get a local stream, show it in a self-view and add it to be sent
  393. async function startStreaming(fromButton) {
  394. log('In startStreaming(fromButton=' + fromButton + ')...');
  395. const otherAudioSettings = isHost
  396. ? settings['client-audio']
  397. : settings['host-audio'];
  398. if (otherAudioSettings) {
  399. unmute.style.display = '';
  400. }
  401. const videoSettings = isHost
  402. ? settings['host-video']
  403. : settings['client-video'];
  404. log("videoSettings=" + videoSettings);
  405. const audioSettings = isHost
  406. ? settings['host-audio']
  407. : settings['client-audio'];
  408. log("audioSettings=" + audioSettings);
  409. if (videoSettings == 'screen' && !fromButton) {
  410. start.style.display = '';
  411. return;
  412. }
  413. start.style.display = 'none';
  414. const videoConstraints = videoSettings == 'none'
  415. ? false
  416. : videoSettings == 'true'
  417. ? true
  418. : { advanced: [{facingMode: videoSettings}] };
  419. log("Created videoConstraints.");
  420. if (!videoConstraints && !audioSettings) return;
  421. const stream = videoSettings == 'screen'
  422. ? await navigator.mediaDevices.getDisplayMedia({
  423. audio: audioSettings,
  424. video: true
  425. })
  426. : await navigator.mediaDevices.getUserMedia({
  427. audio: audioSettings,
  428. video: videoConstraints
  429. });
  430. log("Created stream.");
  431. if (videoConstraints) {
  432. selfView.srcObject = stream;
  433. selfView.style.display = '';
  434. }
  435. for (const track of stream.getTracks()) {
  436. log("Added track.");
  437. pc.addTrack(track, stream);
  438. }
  439. }
  440. function startStreamingWithErorrHandling(fromButton) {
  441. startStreaming(fromButton)
  442. .then(() => {
  443. log("startStreaming() finished.");
  444. })
  445. .catch(e => {
  446. log("startStreaming() errored: " + e.message);
  447. });
  448. }
  449. start.addEventListener("click", _ => {
  450. startStreamingWithErorrHandling(true)
  451. });
  452. async function receiveMessage(e) {
  453. qrcode.style.display = 'none';
  454. log("In receiveMessage from " + e.source + "...");
  455. logPre(e.data);
  456. const data = JSON.parse(e.data);
  457. if (data.ready) {
  458. // Ready message means client is open and ready for connection.
  459. pc = createRTCPeerConnection();
  460. } else if (data.settings) {
  461. settings = data.settings;
  462. startStreamingWithErorrHandling(false);
  463. } else if (data.description) {
  464. if (pc == undefined) pc = createRTCPeerConnection();
  465. await pc.setRemoteDescription(data.description);
  466. if (data.description.type == "offer") {
  467. log("Got an offer...");
  468. await pc.setLocalDescription(await pc.createAnswer());
  469. sendOffer();
  470. }
  471. }
  472. };
  473. function createWebSocket() {
  474. const webSocket = new WebSocket(
  475. 'ws' + (window.location.protocol == 'https:' ? 's' : '') + '://'
  476. + window.location.host
  477. + '/camera/ws/' + (isHost ? 'host' : 'client') + '/'
  478. + roomName
  479. + '/'
  480. );
  481. log("Created WebSocket.");
  482. webSocket.onclose = function(e) {
  483. log('WebSocket closed unexpectedly: ' + e);
  484. };
  485. webSocket.onerror = function(e) {
  486. log('WebSocket error: ' + e);
  487. };
  488. webSocket.onmessage = e => {
  489. receiveMessage({source: "webSocket", data: e.data});
  490. }
  491. return webSocket;
  492. }
  493. if (!isServerless()) {
  494. webSocket = createWebSocket();
  495. if (!isHost) {
  496. // To make serverless and server mode more similar,
  497. // always make the first RTCPeerConnection offer from the host,
  498. // so here just notify the host to start the process.
  499. webSocket.onopen = _ => sendJson({ready: true});
  500. }
  501. } else if (isHost) {
  502. await getCompressor();
  503. pc = createRTCPeerConnection();
  504. remoteOffer.value = '';
  505. remoteOffer.onchange = _ => {
  506. try {
  507. const decoded = b64.base64ToBytes(remoteOffer.value);
  508. const decompressed = compressor.decompress(decoded);
  509. receiveMessage({source: 'textarea', data: decompressed});
  510. } catch (e) {
  511. log("Error decoding remote offer: " + e);
  512. }
  513. };
  514. remoteOfferScan.onclick = _ => {
  515. qrscan.style.display = '';
  516. const qrScanner = new QrScanner(qrscan, (result) => {
  517. qrscan.style.display = 'none';
  518. log('Decoded qr code.');
  519. try {
  520. const decompressed = compressor.decompress(new Uint8Array(result.binaryData));
  521. receiveMessage({source: 'qr', data: decompressed});
  522. qrScanner.destroy();
  523. } catch(error) {
  524. log('Error interpreting QR code: ' + error);
  525. }
  526. });
  527. qrScanner.start();
  528. };
  529. remoteOfferForm.style.display = '';
  530. } else {
  531. receiveMessage({source: 'URL', data: firstMessage});
  532. }
  533. log("Finished <script> block.");
  534. }
  535. initialize();
  536. </script>
  537. </body>
  538. </html>