|
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="utf-8"/>
- <title>Minimal WebRTC</title>
- <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
- <style>
- body.justVideo {
- margin: 0;
- overflow: hidden;
- }
- body.justVideo #status, body.justVideo form {
- display: none;
- }
- .qrcontainer {
- position: sticky;
- top: 0;
- right: 0;
- float: right;
- }
- .qrcontainer svg path {
- min-width: 30vw;
- max-width: 100vw;
- max-height: 50vh;
- }
- #selfView {
- position: fixed;
- right: 0;
- bottom: 0;
- border: 5px solid blue;
- }
- #unmute, #start {
- position: fixed;
- top: 0;
- width: 100%;
- font-size: 4em;
- }
- form {
- background: lightgray;
- border: solid gold 5px;
- display: table;
- }
- form label {
- display: table-row;
- }
- #status {
- display: table;
- }
- #status .log-entry {
- display: block;
- background: lightblue;
- border: solid black 2px;
- margin: 2px;
- }
- #status .log-entry:nth-child(odd) {
- background: lightyellow;
- }
- </style>
- <script type="text/javascript" src="static/js/qrcodegen.js"></script>
- <script type="text/javascript" src="static/js/lzma_worker.js"></script>
- <script type="text/javascript" src="static/js/prefixCompressor.js"></script>
- </head>
- <body>
- <a class="qrcontainer" id="qrcodelink" style="display:none;">
- <svg xmlns="http://www.w3.org/2000/svg" id="qrcode" style="width:20em; height:20em;" stroke="none">
- <rect width="100%" height="100%" fill="#FFFFFF"/>
- <path d="" fill="#000000"/>
- </svg>
- </a>
- <div class="qrcontainer" id="qrcodeObjContainer" style="display:none;">
- <svg xmlns="http://www.w3.org/2000/svg" id="qrcodeObj" style="width:20em; height:20em;" stroke="none">
- <rect width="100%" height="100%" fill="#FFFFFF"/>
- <path d="" fill="#000000"/>
- </svg> <br/>
- <textarea id="localOffer" readonly></textarea>
- <button id="copyLocalOffer">Copy</button>
- </div>
- <form name="settings">
- <label>
- Serverless mode:
- <input name="serverless" type="checkbox" value="true" />
- </label>
- <label>
- Recieve remote video:
- <select name="client-video">
- <option value="none">none</option>
- <option value="true">any camera</option>
- <option value="environment" selected>rear camera</option>
- <option value="user">front camera</option>
- <option value="screen">screen share</option>
- </select>
- </label>
- <label>
- Recieve remote audio:
- <input name="client-audio" type="checkbox" value="true" />
- </label>
- <label>
- Transmit video:
- <select name="host-video">
- <option value="none" selected>none</option>
- <option value="true">any camera</option>
- <option value="environment">rear camera</option>
- <option value="user">front camera</option>
- <option value="screen">screen share</option>
- </select>
- </label>
- <label>
- Transmit audio:
- <input name="host-audio" type="checkbox" value="true" />
- </label>
- <label>
- Debug mode (show log even after starting video):
- <input name="debug" type="checkbox" value="true" />
- </label>
- </form>
- <form id="serverlessOffer" style="display: none;" onsubmit="return false;">
- <label>
- Paste offer here:
- <textarea name="remoteOffer"></textarea>
- <button id="remoteOfferScan">Scan QR code</button>
- </label>
- </form>
- <div id="status"></div>
- <div id="videos">
- <button id="unmute" style="display:none;">Unmute</button>
- <button id="start" style="display:none;">Start Streaming</button>
- <video id="remoteView" width="100%" autoplay muted style="display:none;"></video>
- <video id="selfView" width="200" height="150" autoplay muted style="display:none;"></video>
- <video id="qrscan" width="100%" autoplay muted style="display:none;"></video>
- </div>
- <script type="module" >
- import * as b64 from "./static/js/base64.js";
- import QrScanner from "./static/js/qr-scanner.min.js";
- QrScanner.WORKER_PATH = "./static/js/qr-scanner-worker.min.js"
-
- async function initialize() {
- const create = (container, type) => container.appendChild(document.createElement(type));
- const QRGen = qrcodegen.QrCode;
-
- var compressor = null;
- async function getCompressor() {
- if (compressor == null) {
- compressor = await loadLZMAPrefixCompressor('static/js/prefix');
- }
- return compressor;
- }
-
- const body = document.querySelector("body");
- const out = document.getElementById("status");
- const qrcode = document.getElementById("qrcode");
- const qrcodelink = document.getElementById("qrcodelink");
- const qrcodeObjContainer = document.getElementById("qrcodeObjContainer");
- const qrcodeObj = document.getElementById("qrcodeObj");
- const remoteView = document.getElementById("remoteView");
- const selfView = document.getElementById("selfView");
- const qrscan = document.getElementById("qrscan");
- const start = document.getElementById("start");
- const unmute = document.getElementById("unmute");
- const form = document.forms["settings"];
- const remoteOfferForm = document.forms["serverlessOffer"];
- const remoteOffer = remoteOfferForm.elements["remoteOffer"];
- const remoteOfferScan = document.getElementById("remoteOfferScan");
- const localOfferArea = document.getElementById("localOffer");
- const localOfferCopyButton = document.getElementById("copyLocalOffer");
-
- function _log(str, tag) {
- const logEntry = document.createElement(tag);
- logEntry.innerText = str;
- logEntry.classList.add('log-entry');
- out.appendChild(logEntry);
- }
- function log(str) {
- _log(str, 'span');
- }
-
- function logPre(str) {
- _log(str.split('\\r\\n').join('\r\n'), 'pre');
- }
-
- log("Loading...");
-
- unmute.addEventListener("click", _ => {
- remoteView.muted = false;
- unmute.style.display = 'none';
- });
-
- var settings = undefined;
- function readSettingsForm(disableForm) {
- const obj = {};
- for (const el of form.elements) {
- obj[el.name] = el.type == 'checkbox' ? el.checked : el.value;
- if (disableForm) el.disabled = true;
- }
- return obj;
- }
-
- for (const el of form.elements) {
- el.addEventListener("change", e => {
- window.location.hash = JSON.stringify(readSettingsForm(false));
- if (e.target.name == 'serverless') {
- window.location.reload(false);
- }
- });
- }
-
- function isServerless() {
- return settings && 'serverless' in settings && settings.serverless;
- }
-
- function withHash(hash) {
- return window.location.href.split('#')[0] + '#' + hash;
- }
- function displayQR(qrcode, val) {
- const encodeFunc = val instanceof Uint8Array
- ? QRGen.encodeBinary
- : QRGen.encodeText;
- const qr = encodeFunc(val, QRGen.Ecc.MEDIUM);
- const code = qr.toSvgString(1);
- const viewBox = (/ viewBox="([^"]*)"/.exec(code))[1];
- const pathD = (/ d="([^"]*)"/.exec(code))[1];
- qrcode.setAttribute("viewBox", viewBox);
- qrcode.querySelector("path").setAttribute("d", pathD);
- }
- function displayQRUrl(url) {
- qrcodelink.href = url;
- displayQR(qrcode, url);
- qrcodelink.style.display = '';
- }
- function displayQRUrlForObj(obj) {
- const json = JSON.stringify(obj);
- log("Encoding message in QR code/link:");
- logPre(json);
- const compressed = compressor.compress(json);
- const encoded = b64.bytesToBase64(compressed);
- displayQRUrl(withHash('^' + encoded));
- }
- function displayQRCodeForObj(obj) {
- const json = JSON.stringify(obj);
- log("Encoding message in QR code for copy/paste:");
- logPre(json);
- const compressed = compressor.compress(json);
- const encoded = b64.bytesToBase64(compressed);
- displayQR(qrcodeObj, compressed);
- localOfferArea.value = encoded;
- localOfferCopyButton.onclick = _ => {
- localOfferArea.focus();
- localOfferArea.select();
- document.execCommand('copy');
- };
- qrcodeObjContainer.style.display = '';
- }
-
- let roomName = window.location.hash;
- let isHost = roomName === undefined || !roomName;
- if (roomName && roomName.startsWith("#{")) {
- roomName = undefined;
- isHost = true;
-
- try {
- const hash = decodeURI(window.location.hash);
- log("Reading settings from hash:");
- logPre(hash);
- settings = JSON.parse(hash.substring(1));
- for (const name in settings) {
- const el = form.elements[name];
- const value = settings[name];
- if (el.type == 'checkbox') {
- el.checked = value;
- } else {
- el.value = value;
- }
- }
- } catch (error) {
- log("Failed to read settings from hash: " + error);
- }
- }
- var firstMessage = undefined;
- if (roomName && roomName.startsWith("#^")) {
- roomName = undefined;
- isHost = false;
-
- try {
- const hash = decodeURI(window.location.hash);
- log("Reading first message from hash:");
- logPre(hash);
- log("Decoded base64:");
- const decoded = (await getCompressor()).decompress(b64.base64ToBytes(hash.substring(2)));
- logPre(decoded);
- firstMessage = decoded;
- settings = {serverless: true};
- } catch (error) {
- log("Failed to read message from hash: " + error);
- }
- }
-
- if (isHost) {
- // From https://stackoverflow.com/a/1349426
- function makeid(length) {
- var result = '';
- var characters = 'abcdefghijklmnopqrstuvwxyz';
- var charactersLength = characters.length;
- for (var i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * charactersLength));
- }
- return result;
- }
-
- if (!isServerless()) {
- roomName = makeid(8);
- displayQRUrl(withHash(roomName));
- }
- } else {
- if (!isServerless()) {
- roomName = roomName.substring(1);
- }
- qrcodelink.style.display = 'none';
- form.style.display = 'none';
- }
-
- if (isServerless()) {
- log("In serverless mode.");
- } else {
- log("Room: " + roomName);
- }
-
- var webSocket = undefined;
-
- function sendJson(data) {
- const toSend = JSON.stringify(data);
- const dcReady = dc && dc.readyState == "open";
- const method = dcReady ? "dataConnection" : "webSocket";
- log("Sending message via " + method + "...");
- logPre(toSend);
- if (isServerless() && !dcReady) {
- log("ERROR: Attempted to use webSocket in serverless mode.");
- return;
- }
- (dcReady ? dc : webSocket).send(toSend);
- }
-
- var pc = undefined;
- var dc = undefined;
-
- function sendOffer() {
- if (pc.iceGatheringState == "complete") {
- const sendFunc = !isServerless() || dc && dc.readyState == "open"
- ? sendJson
- : isHost
- ? displayQRUrlForObj
- : displayQRCodeForObj;
- sendFunc({
- description: pc.localDescription
- });
- }
- }
-
- function createRTCPeerConnection() {
- const pc = new RTCPeerConnection();
- log("Created RTCPeerConnection.");
-
- pc.onsignalingstatechange = e => {
- log("pc.onsignalingstatechange: " + pc.signalingState);
- }
- pc.oniceconnectionstatechange = e => {
- log("pc.oniceconnectionstatechange: " + pc.iceConnectionState);
- }
- pc.onicegatheringstatechange = async function(e) {
- log("pc.onicegatheringstatechange: " + pc.iceGatheringState);
- sendOffer();
- }
-
- // let the "negotiationneeded" event trigger offer generation
- // ... but only once icegathering is complete.
- pc.onnegotiationneeded = async function () {
- log("In pc.onnegotiationneeded...");
- const useOffer = (!settings || !('separateIce' in settings)
- || !settings.separateIce);
- await pc.setLocalDescription(
- await (useOffer ? pc.createOffer() : pc.createAnswer()));
- sendOffer();
- }
-
- pc.ontrack = ({streams: [stream]}) => {
- log("In pc.ontrack...");
-
- remoteView.srcObject = stream;
- log("Set srcObject");
- remoteView.play();
-
- if (stream.getVideoTracks().length > 0) {
- remoteView.style.display = '';
- videos.style.display = '';
- if (!settings.debug) {
- body.classList.add('justVideo');
- }
- }
- };
-
- pc.ondatachannel = e => {
- dc = e.channel;
- dc.onmessage = e => {
- receiveMessage({source: "dataChannel", data: e.data});
- }
- log('Data channel initialized.');
- qrcodeObjContainer.style.display = 'none';
- }
-
- if (isHost) {
- dc = pc.createDataChannel('data');
- dc.onmessage = e => {
- receiveMessage({source: "dataChannel", data: e.data});
- }
- dc.onopen = e => {
- remoteOffer.disabled = true;
- remoteOfferScan.disabled = true;
- log("Data channel open, sending settings...")
- settings = readSettingsForm(true);
- sendJson({settings});
- startStreamingWithErorrHandling(false);
- }
- }
-
- return pc;
- }
-
- // get a local stream, show it in a self-view and add it to be sent
- async function startStreaming(fromButton) {
- log('In startStreaming(fromButton=' + fromButton + ')...');
- const otherAudioSettings = isHost
- ? settings['client-audio']
- : settings['host-audio'];
- if (otherAudioSettings) {
- unmute.style.display = '';
- }
-
- const videoSettings = isHost
- ? settings['host-video']
- : settings['client-video'];
- log("videoSettings=" + videoSettings);
- const audioSettings = isHost
- ? settings['host-audio']
- : settings['client-audio'];
- log("audioSettings=" + audioSettings);
-
- if (videoSettings == 'screen' && !fromButton) {
- start.style.display = '';
- return;
- }
- start.style.display = 'none';
-
- const videoConstraints = videoSettings == 'none'
- ? false
- : videoSettings == 'true'
- ? true
- : { advanced: [{facingMode: videoSettings}] };
- log("Created videoConstraints: " + JSON.stringify(videoConstraints));
- if (!videoConstraints && !audioSettings) return;
-
- const stream = videoSettings == 'screen'
- ? await navigator.mediaDevices.getDisplayMedia({
- audio: audioSettings,
- video: true
- })
- : await navigator.mediaDevices.getUserMedia({
- audio: audioSettings,
- video: videoConstraints
- });
- log("Created stream.");
- if (videoConstraints) {
- selfView.srcObject = stream;
- selfView.style.display = '';
- }
- for (const track of stream.getTracks()) {
- log("Added track.");
- pc.addTrack(track, stream);
- }
- log('End of startStreaming(), creating answer...');
- if (settings && 'separateIce' in settings && settings.separateIce) {
- await pc.setLocalDescription(await pc.createAnswer());
- }
- }
- function startStreamingWithErorrHandling(fromButton) {
- try {
- startStreaming(fromButton)
- .then(() => {
- log("startStreaming() finished.");
- })
- .catch(e => {
- log("startStreaming() errored: " + e.message);
- });
- } catch (e) {
- log("Error in startStreaming(): " + e);
- }
- }
-
- start.addEventListener("click", _ => {
- startStreamingWithErorrHandling(true)
- });
-
- async function receiveMessage(e) {
- qrcode.style.display = 'none';
- log("In receiveMessage from " + e.source + "...");
- logPre(e.data);
- const data = JSON.parse(e.data);
- if (data.ready) {
- // Ready message means client is open and ready for connection.
- pc = createRTCPeerConnection();
- } else if (data.settings) {
- settings = data.settings;
- startStreamingWithErorrHandling(false);
- } else if (data.description) {
- try {
- if (pc == undefined) pc = createRTCPeerConnection();
- await pc.setRemoteDescription(data.description);
- if (data.description.type == "offer") {
- log("Got an offer...");
- if (!settings || !('separateIce' in settings) || !settings.separateIce) {
- await pc.setLocalDescription(await pc.createAnswer());
- sendOffer();
- } else {
- log("separateIce mode, so delaying answer.");
- }
- }
- } catch (e) {
- log("Error accepting remote offer/answer: " + e);
- }
- }
- };
-
- function createWebSocket() {
- const webSocket = new WebSocket(
- 'ws' + (window.location.protocol == 'https:' ? 's' : '') + '://'
- + window.location.host
- + '/camera/ws/' + (isHost ? 'host' : 'client') + '/'
- + roomName
- + '/'
- );
- log("Created WebSocket.");
-
- webSocket.onclose = function(e) {
- log('WebSocket closed unexpectedly: ' + e);
- };
- webSocket.onerror = function(e) {
- log('WebSocket error: ' + e);
- };
-
- webSocket.onmessage = e => {
- receiveMessage({source: "webSocket", data: e.data});
- }
-
- return webSocket;
- }
-
- if (!isServerless()) {
- webSocket = createWebSocket();
-
- if (!isHost) {
- // To make serverless and server mode more similar,
- // always make the first RTCPeerConnection offer from the host,
- // so here just notify the host to start the process.
- webSocket.onopen = _ => sendJson({ready: true});
- }
- } else if (isHost) {
- await getCompressor();
- pc = createRTCPeerConnection();
- remoteOffer.value = '';
- remoteOffer.onchange = _ => {
- try {
- const decoded = b64.base64ToBytes(remoteOffer.value);
- const decompressed = compressor.decompress(decoded);
- receiveMessage({source: 'textarea', data: decompressed});
- } catch (e) {
- log("Error decoding remote offer: " + e);
- }
- };
- remoteOfferScan.onclick = _ => {
- qrscan.style.display = '';
- const qrScanner = new QrScanner(qrscan, (result) => {
- qrscan.style.display = 'none';
- log('Decoded qr code.');
- try {
- const decompressed = compressor.decompress(new Uint8Array(result.binaryData));
- receiveMessage({source: 'qr', data: decompressed});
- qrScanner.destroy();
- } catch(error) {
- log('Error interpreting QR code: ' + error);
- }
- });
- qrScanner.start();
- };
- remoteOfferForm.style.display = '';
- } else {
- receiveMessage({source: 'URL', data: firstMessage});
- }
-
- log("Finished <script> block.");
- }
- initialize();
- </script>
- </body>
- </html>
|