├── public ├── spinner.png ├── index.html ├── fscreen.js ├── ports.js └── WebRTCClient.js ├── app.yaml ├── package.json ├── elm.json ├── src ├── UI.elm ├── Layout2D.elm └── Main.elm ├── .gitignore ├── README.md ├── server.js └── LICENSE /public/spinner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpizenberg/elm-allo/HEAD/public/spinner.png -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # [START appengine_websockets_yaml] 2 | runtime: nodejs 3 | env: flex 4 | 5 | # Use only a single instance, so that this local-memory-only chat app will work 6 | # consistently with multiple users. To work across multiple instances, an 7 | # extra-instance messaging system or data store would be needed. 8 | manual_scaling: 9 | instances: 1 10 | 11 | network: 12 | session_affinity: true 13 | # [END appengine_websockets_yaml] 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "allo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "prestart": "elm make src/Main.elm --optimize --output=public/Main.js", 8 | "start": "node server.js" 9 | }, 10 | "author": "", 11 | "license": "MPL-2.0", 12 | "engines": { 13 | "node": "12.x" 14 | }, 15 | "dependencies": { 16 | "elm": "^0.19.1-3", 17 | "express": "^4.17.1", 18 | "ws": "^7.3.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.5", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3", 13 | "feathericons/elm-feather": "1.4.0", 14 | "mdgriffith/elm-ui": "1.1.6" 15 | }, 16 | "indirect": { 17 | "elm/svg": "1.0.1", 18 | "elm/time": "1.0.0", 19 | "elm/url": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Allo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/UI.elm: -------------------------------------------------------------------------------- 1 | module UI exposing (..) 2 | 3 | import Element 4 | 5 | 6 | 7 | -- Layout 8 | 9 | 10 | spacing : Int 11 | spacing = 12 | 10 13 | 14 | 15 | minVideoHeight : Int 16 | minVideoHeight = 17 | 120 18 | 19 | 20 | controlButtonSize : Element.DeviceClass -> Float 21 | controlButtonSize deviceClass = 22 | case deviceClass of 23 | Element.Phone -> 24 | 28 25 | 26 | _ -> 27 | 48 28 | 29 | 30 | joinButtonSize : Int 31 | joinButtonSize = 32 | 100 33 | 34 | 35 | leaveButtonSize : Int 36 | leaveButtonSize = 37 | 80 38 | 39 | 40 | copyButtonSize : Int 41 | copyButtonSize = 42 | 48 43 | 44 | 45 | joinButtonBlur : Float 46 | joinButtonBlur = 47 | 10 48 | 49 | 50 | 51 | -- Color 52 | 53 | 54 | lightGrey : Element.Color 55 | lightGrey = 56 | Element.rgb255 187 187 187 57 | 58 | 59 | darkGrey : Element.Color 60 | darkGrey = 61 | Element.rgb255 50 50 50 62 | 63 | 64 | green : Element.Color 65 | green = 66 | Element.rgb255 39 203 139 67 | 68 | 69 | red : Element.Color 70 | red = 71 | Element.rgb255 203 60 60 72 | 73 | 74 | darkRed : Element.Color 75 | darkRed = 76 | Element.rgb255 70 20 20 77 | 78 | 79 | white : Element.Color 80 | white = 81 | Element.rgb255 255 255 255 82 | 83 | 84 | black : Element.Color 85 | black = 86 | Element.rgb255 0 0 0 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Elm stuff 2 | elm-stuff 3 | 4 | # Compilation of Main.elm 5 | Main.js 6 | 7 | # Secure key and certificate 8 | server.pem 9 | 10 | # Created by https://www.gitignore.io/api/node 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Typescript v1 declaration files 52 | typings/ 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'npm pack' 64 | *.tgz 65 | 66 | # Yarn Integrity file 67 | .yarn-integrity 68 | 69 | # dotenv environment variables file 70 | .env 71 | 72 | 73 | # End of https://www.gitignore.io/api/node 74 | -------------------------------------------------------------------------------- /src/Layout2D.elm: -------------------------------------------------------------------------------- 1 | module Layout2D exposing (fixedGrid) 2 | 3 | {-| Some layout strategies to place participants 4 | -} 5 | 6 | 7 | {-| Given an available space of size width x height, 8 | a fixed aspect ratio of the blocks to position and their number, 9 | return the most appropriate number of columns and rows. 10 | -} 11 | fixedGrid : Float -> Float -> Float -> Int -> ( ( Int, Int ), ( Float, Float ) ) 12 | fixedGrid width height ratio n = 13 | let 14 | horizontalScore = 15 | fixedScore width height ratio n 1 n 16 | 17 | ( columns, rows, cellWidth ) = 18 | fixedGridRec width height ratio n ( n, 1, horizontalScore ) 19 | in 20 | ( ( columns, rows ), ( cellWidth, cellWidth / ratio ) ) 21 | 22 | 23 | fixedGridRec : Float -> Float -> Float -> Int -> ( Int, Int, Float ) -> ( Int, Int, Float ) 24 | fixedGridRec width height ratio n ( c, r, score ) = 25 | if c <= 1 then 26 | ( c, r, score ) 27 | 28 | else 29 | let 30 | c_ = 31 | c - 1 32 | 33 | r_ = 34 | if modBy c_ n == 0 then 35 | n // c_ 36 | 37 | else 38 | n // c_ + 1 39 | 40 | score_ = 41 | fixedScore width height ratio c_ r_ n 42 | in 43 | if score_ > score then 44 | fixedGridRec width height ratio n ( c_, r_, score_ ) 45 | 46 | else 47 | ( c, r, score ) 48 | 49 | 50 | {-| The score corresponds to the width of one tile. 51 | -} 52 | fixedScore : Float -> Float -> Float -> Int -> Int -> Int -> Float 53 | fixedScore width height ratio columns rows n = 54 | -- If the current config is wider than available space 55 | if ratio * toFloat columns / toFloat rows > width / height then 56 | width / toFloat columns 57 | 58 | else 59 | ratio * height / toFloat rows 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Allo 2 | 3 | Videoconference WebRTC example with an Elm frontend. 4 | Requires https. 5 | More info in the [post on Elm discourse][discourse]. 6 | 7 | ![screenshots][screenshots] 8 | 9 | [discourse]: https://discourse.elm-lang.org/t/elm-allo-a-webrtc-and-elm-videoconference-example/5809 10 | [screenshots]: https://mpizenberg.github.io/resources/elm-allo/screenshots.jpg 11 | 12 | ## Setup and Run 13 | 14 | ```shell 15 | # Install node packages 16 | npm install 17 | 18 | # Start the server 19 | npm start 20 | ``` 21 | 22 | ## Heroku setup 23 | 24 | ```shell 25 | git clone https://github.com/mpizenberg/elm-allo.git 26 | cd elm-allo 27 | heroku login 28 | heroku create 29 | git push heroku master 30 | ``` 31 | 32 | ## Nginx Reverse Proxy Configuration 33 | 34 | ```nginx 35 | http { 36 | server { ... } 37 | server { ... } 38 | server { 39 | server_name subdomain.domain.com; 40 | location / { 41 | # Special treatment to handle WebSocket hop-by-hop headers 42 | proxy_http_version 1.1; 43 | proxy_set_header Upgrade $http_upgrade; 44 | proxy_set_header Connection "upgrade"; 45 | 46 | # Standard proxy 47 | proxy_pass http://localhost:8443; 48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 49 | proxy_set_header X-Forwarded-Proto $scheme; 50 | } 51 | # ... stuff added by Certbot for SSL 52 | } 53 | } 54 | ``` 55 | 56 | ## WebRTC Negotiation 57 | 58 | Establishing a connection between two peers requires first 59 | a negotiation between each client. 60 | The server has the broker's role by relaying all messages between those peers. 61 | 62 | All the WebRTC-specific code, including negotiation, 63 | is located in the `public/WebRTCClient.js` file. 64 | A high-level API is provided with the `WebRTCCLient()` function. 65 | Intermediate-level APIs are also provided with the `SignalingSocket()` 66 | and `PeerConnection()` functions. 67 | The core of the negotiation logic lives inside the `Peerconnection()` function. 68 | Two negotiation algorithms are implemented, but only one is activated. 69 | 70 | The first negotiation algorithm follows a simple caller/callee pattern. 71 | It corresponds to the `simpleNegotiation()` function. 72 | 73 | The second negotiation algorithm is called perfect negotiation. 74 | Both peers are considered equally and it tries to handle peer changes of states 75 | without glare (signaling collisions). 76 | A [very useful blog post][perfect-negotiation] by Jan-Ivar Bruaroey 77 | introduces the perfect negotiation pattern. 78 | Unfortunately, [browsers still have some issues][not-so-perfect] preventing 79 | the usage of this pattern so it has been deactivated in our code. 80 | This corresponds to the `perfectNegotiation()` function. 81 | 82 | [perfect-negotiation]: https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/ 83 | [not-so-perfect]: https://stackoverflow.com/questions/61956693/webrtc-perfect-negotiation-issues 84 | -------------------------------------------------------------------------------- /public/fscreen.js: -------------------------------------------------------------------------------- 1 | function Fscreen() { 2 | const key = { 3 | fullscreenEnabled: 0, 4 | fullscreenElement: 1, 5 | requestFullscreen: 2, 6 | exitFullscreen: 3, 7 | fullscreenchange: 4, 8 | fullscreenerror: 5, 9 | fullscreen: 6, 10 | }; 11 | 12 | const webkit = [ 13 | "webkitFullscreenEnabled", 14 | "webkitFullscreenElement", 15 | "webkitRequestFullscreen", 16 | "webkitExitFullscreen", 17 | "webkitfullscreenchange", 18 | "webkitfullscreenerror", 19 | "-webkit-full-screen", 20 | ]; 21 | 22 | const moz = [ 23 | "mozFullScreenEnabled", 24 | "mozFullScreenElement", 25 | "mozRequestFullScreen", 26 | "mozCancelFullScreen", 27 | "mozfullscreenchange", 28 | "mozfullscreenerror", 29 | "-moz-full-screen", 30 | ]; 31 | 32 | const ms = [ 33 | "msFullscreenEnabled", 34 | "msFullscreenElement", 35 | "msRequestFullscreen", 36 | "msExitFullscreen", 37 | "MSFullscreenChange", 38 | "MSFullscreenError", 39 | "-ms-fullscreen", 40 | ]; 41 | 42 | // so it doesn't throw if no window or document 43 | const document = 44 | typeof window !== "undefined" && typeof window.document !== "undefined" 45 | ? window.document 46 | : {}; 47 | 48 | const vendor = 49 | ("fullscreenEnabled" in document && Object.keys(key)) || 50 | (webkit[0] in document && webkit) || 51 | (moz[0] in document && moz) || 52 | (ms[0] in document && ms) || 53 | []; 54 | 55 | return { 56 | requestFullscreen: (element) => element[vendor[key.requestFullscreen]](), 57 | requestFullscreenFunction: (element) => 58 | element[vendor[key.requestFullscreen]], 59 | get exitFullscreen() { 60 | return document[vendor[key.exitFullscreen]].bind(document); 61 | }, 62 | get fullscreenPseudoClass() { 63 | return ":" + vendor[key.fullscreen]; 64 | }, 65 | addEventListener: (type, handler, options) => 66 | document.addEventListener(vendor[key[type]], handler, options), 67 | removeEventListener: (type, handler, options) => 68 | document.removeEventListener(vendor[key[type]], handler, options), 69 | get fullscreenEnabled() { 70 | return Boolean(document[vendor[key.fullscreenEnabled]]); 71 | }, 72 | set fullscreenEnabled(val) {}, 73 | get fullscreenElement() { 74 | return document[vendor[key.fullscreenElement]]; 75 | }, 76 | set fullscreenElement(val) {}, 77 | get onfullscreenchange() { 78 | return document[`on${vendor[key.fullscreenchange]}`.toLowerCase()]; 79 | }, 80 | set onfullscreenchange(handler) { 81 | return (document[ 82 | `on${vendor[key.fullscreenchange]}`.toLowerCase() 83 | ] = handler); 84 | }, 85 | get onfullscreenerror() { 86 | return document[`on${vendor[key.fullscreenerror]}`.toLowerCase()]; 87 | }, 88 | set onfullscreenerror(handler) { 89 | return (document[ 90 | `on${vendor[key.fullscreenerror]}`.toLowerCase() 91 | ] = handler); 92 | }, 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /public/ports.js: -------------------------------------------------------------------------------- 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 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/ 4 | 5 | activatePorts = (app, containerSize, WebRTCClient) => { 6 | // Catch all uncaught errors 7 | window.onerror = (msg, url, lineNumber, colNumber, err) => { 8 | console.error(err); 9 | app.ports.error.send( 10 | "Uncaught error file " + url + " line " + lineNumber + "\n" + msg 11 | ); 12 | return false; 13 | }; 14 | 15 | // Also exceptions happening in promises. 16 | window.onunhandledrejection = (e) => { 17 | app.ports.error.send("Unhandled promise rejection:\n" + e.reason); 18 | }; 19 | 20 | // Inform the Elm app when its container div gets resized. 21 | window.addEventListener("resize", () => 22 | app.ports.resize.send(containerSize()) 23 | ); 24 | 25 | // Fullscreen 26 | fscreen = Fscreen(); 27 | app.ports.requestFullscreen.subscribe(() => { 28 | try { 29 | if (fscreen.fullscreenElement != null) return; 30 | fscreen.requestFullscreen(document.documentElement); 31 | } catch (err) { 32 | console.error("Fullscreen is not supported"); 33 | } 34 | }); 35 | app.ports.exitFullscreen.subscribe(() => { 36 | try { 37 | if (fscreen.fullscreenElement == null) return; 38 | fscreen.exitFullscreen(); 39 | } catch (err) { 40 | console.error("Fullscreen is not supported"); 41 | } 42 | }); 43 | 44 | // WebRTC 45 | app.ports.readyForLocalStream.subscribe(async (localVideoId) => { 46 | try { 47 | let { localStream, join, leave, setMic, setCam } = await WebRTCClient({ 48 | audio: true, 49 | video: { 50 | facingMode: "user", 51 | width: 320, 52 | height: 240, 53 | }, 54 | iceServers: [{ urls: "stun:stun.l.google.com:19302" }], 55 | onRemoteConnected: (id) => 56 | app.ports.log.send("Remote connected: " + id), 57 | onRemoteDisconnected: app.ports.remoteDisconnected.send, 58 | onUpdatedStream: app.ports.updatedStream.send, 59 | onError: app.ports.error.send, 60 | onLog: app.ports.log.send, 61 | }); 62 | 63 | // Set the local stream to the associated video. 64 | let localVideo = document.getElementById(localVideoId); 65 | localVideo.srcObject = localStream; 66 | 67 | // Join / leave a call 68 | app.ports.joinCall.subscribe(join); 69 | app.ports.leaveCall.subscribe(leave); 70 | 71 | // On / Off microphone and video 72 | app.ports.mute.subscribe(setMic); 73 | app.ports.hide.subscribe(setCam); 74 | } catch (error) { 75 | console.error(error); 76 | app.ports.error.send("ports.js l:65\n" + error.toString()); 77 | } 78 | }); 79 | 80 | // Update the srcObject of a video with a stream. 81 | // Wait one frame to give time to the VDOM to create the video object. 82 | app.ports.videoReadyForStream.subscribe(({ id, stream }) => { 83 | requestAnimationFrame(() => { 84 | const video = document.getElementById(id); 85 | if (video.srcObject) return; 86 | video.srcObject = stream; 87 | }); 88 | }); 89 | 90 | // Copy to clipboard. 91 | app.ports.copyToClipboard.subscribe((str) => 92 | navigator.clipboard.writeText(str) 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const fs = require("fs"); 3 | const express = require("express"); 4 | const WebSocket = require("ws"); 5 | 6 | const PORT = process.env.PORT || 8443; 7 | // const credentials = { 8 | // key: fs.readFileSync("server.pem"), 9 | // cert: fs.readFileSync("server.pem"), 10 | // }; 11 | 12 | function forceHTTPS(req, res, next) { 13 | const isSecure = 14 | req.secure || 15 | // If behind a proxy, check for the X-Forwarded-Proto header. 16 | req.headers["x-forwarded-proto"] === "https"; 17 | 18 | if (isSecure) { 19 | next(); 20 | return; 21 | } 22 | 23 | if (req.method === "GET" || req.method === "HEAD") { 24 | res.redirect(301, "https://" + req.headers.host + req.originalUrl); 25 | } else { 26 | res.status(403).send("Server requires HTTPS."); 27 | } 28 | } 29 | 30 | const app = express(); 31 | app.use(forceHTTPS); 32 | app.use(express.static("public")); 33 | 34 | const httpServer = http.createServer(app); 35 | const wss = new WebSocket.Server({ server: httpServer }); 36 | const peersSocks = new Map(); 37 | const peersIds = new Map(); 38 | let idCount = 0; 39 | 40 | wss.on("connection", (ws, req) => { 41 | let remoteAddress = 42 | req.headers["x-forwarded-for"] || req.connection.remoteAddress; 43 | console.log("Connection of " + remoteAddress); 44 | ws.on("message", (jsonMsg) => { 45 | let msg = JSON.parse(jsonMsg); 46 | if (msg == "ping") { 47 | console.log("ping from", remoteAddress); 48 | ws.send(JSON.stringify("pong")); 49 | } else if (msg.msgType == "join") { 50 | console.log("join", idCount); 51 | // Greet each pair of peers on both sides. 52 | for (let [id, sock] of peersSocks) { 53 | sendJsonMsg(ws, "greet", id, { polite: true }); 54 | sendJsonMsg(sock, "greet", idCount, { polite: false }); 55 | } 56 | peersSocks.set(idCount, ws); 57 | peersIds.set(ws, idCount); 58 | idCount += 1; 59 | } else if (msg.msgType == "leave") { 60 | leave(ws); 61 | } else if (msg.msgType == "sessionDescription") { 62 | relay(ws, msg); 63 | } else if (msg.msgType == "iceCandidate") { 64 | relay(ws, msg); 65 | } 66 | }); 67 | ws.on("close", () => { 68 | console.log("WebSocket closing"); 69 | leave(ws); 70 | }); 71 | }); 72 | 73 | function leave(ws) { 74 | originId = peersIds.get(ws); 75 | console.log("leave", originId); 76 | if (originId == undefined) return; 77 | peersIds.delete(ws); 78 | peersSocks.delete(originId); 79 | for (let [id, sock] of peersSocks) { 80 | sendJsonMsg(sock, "left", originId); 81 | } 82 | } 83 | 84 | function relay(ws, msg) { 85 | // Relay message to target peer. 86 | const target = peersSocks.get(msg.remotePeerId); 87 | if (target == undefined) return; 88 | const originId = peersIds.get(ws); 89 | if (originId == undefined) return; 90 | console.log("relay", msg.msgType, "from", originId, "to", msg.remotePeerId); 91 | sendJsonMsg(target, msg.msgType, originId, { data: msg.data }); 92 | } 93 | 94 | function sendJsonMsg(ws, msgType, remotePeerId, extra = {}) { 95 | const msg = Object.assign({ msgType, remotePeerId }, extra); 96 | ws.send(JSON.stringify(msg)); 97 | } 98 | 99 | console.log("Listening at localhost:" + PORT); 100 | httpServer.listen(PORT); 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /public/WebRTCClient.js: -------------------------------------------------------------------------------- 1 | // let { localStream, join, leave, setMic, setCam } = await WebRTCClient({ 2 | // audio: true, 3 | // video: { 4 | // facingMode: "user", 5 | // frameRate: 15, 6 | // width: 320, 7 | // height: 240, 8 | // }, 9 | // signalingSocketAddress: "wss://" + window.location.host, 10 | // iceServers: [{ urls: "stun:stun.l.google.com:19302" }], 11 | // onRemoteConnected: (id) => { ... }, 12 | // onRemoteDisconnected: (id) => { ... }, 13 | // onUpdatedStream: ({ id, stream }) => { ... }, 14 | // onError: (string) => { ... }, 15 | // onLog: (string) => { ... }, 16 | // }); 17 | // 18 | // All arguments are optional. 19 | // By default, the callback functions just log to the console. 20 | // 21 | async function WebRTCClient(config) { 22 | // DEFAULTS ######################################################## 23 | 24 | // Default configuration for local audio stream. 25 | let audioConfig = config.audio == undefined ? true : config.audio; 26 | 27 | // Default configuration for local video stream. 28 | let videoConfig = 29 | config.video == undefined 30 | ? { 31 | facingMode: "user", 32 | frameRate: 15, 33 | width: 320, 34 | height: 240, 35 | } 36 | : config.video; 37 | 38 | // Default address for the signaling WebSocket. 39 | let signalingSocketAddress = 40 | config.signalingSocketAddress == undefined 41 | ? "wss://" + window.location.host 42 | : config.signalingSocketAddress; 43 | 44 | // Default ICE configuration. 45 | let iceServers = 46 | config.iceServers == undefined 47 | ? [{ urls: "stun:stun.l.google.com:19302" }] 48 | : config.iceServers; 49 | 50 | // Default callback on remote connection. 51 | let onRemoteConnected = 52 | config.onRemoteConnected == undefined 53 | ? (id) => { 54 | console.log("Remote connected", id); 55 | } 56 | : config.onRemoteConnected; 57 | 58 | // Default callback on remote disconnection. 59 | let onRemoteDisconnected = 60 | config.onRemoteDisconnected == undefined 61 | ? (id) => { 62 | console.log("Remote disconnected", id); 63 | } 64 | : config.onRemoteDisconnected; 65 | 66 | // Default callback on a stream update. 67 | let onUpdatedStream = 68 | config.onUpdatedStream == undefined 69 | ? ({ id, stream }) => { 70 | console.log("Updated stream of", id); 71 | } 72 | : config.onUpdatedStream; 73 | 74 | // Default callback on error. 75 | let onError = 76 | config.onError == undefined ? (str) => console.error(str) : config.onError; 77 | 78 | // Default callback on log. 79 | let onLog = 80 | config.onLog == undefined ? (str) => console.log(str) : config.onLog; 81 | 82 | // INIT ############################################################ 83 | 84 | // Init audio and video streams, merge them in "localStream". 85 | // let audioStream = await navigator.mediaDevices.getUserMedia({ 86 | // audio: audioConfig, 87 | // }); 88 | // let videoStream = await navigator.mediaDevices.getUserMedia({ 89 | // video: videoConfig, 90 | // }); 91 | // let localStream = new MediaStream( 92 | // videoStream.getVideoTracks().concat(audioStream.getAudioTracks()) 93 | // ); 94 | let localStream = await navigator.mediaDevices.getUserMedia({ 95 | audio: audioConfig, 96 | video: videoConfig, 97 | }); 98 | 99 | // Hashmap containing every peer connection. 100 | let pcs = new Map(); 101 | 102 | // Initialize the signaling socket. 103 | let signalingSocket; 104 | try { 105 | signalingSocket = await SignalingSocket({ 106 | socketAddress: signalingSocketAddress, 107 | 108 | // Callback for each remote peer connection. 109 | onRemotePeerConnected: (chan, polite) => { 110 | try { 111 | // Inform caller on defined callback for onRemoteConnected. 112 | onRemoteConnected(chan.remotePeerId); 113 | 114 | // Start peer connection. 115 | const pc = PeerConnection({ 116 | rtcConfig: { iceServers }, 117 | signalingChannel: chan, 118 | polite, 119 | onRemoteTrack: (streams) => { 120 | // Inform caller when a stream is updated. 121 | onUpdatedStream({ id: chan.remotePeerId, stream: streams[0] }); 122 | }, 123 | onError, 124 | onLog, 125 | }); 126 | pcs.set(chan.remotePeerId, pc); 127 | 128 | // Send our local stream to the peer connection. 129 | pc.startNegotiation(localStream); 130 | } catch (err) { 131 | console.error(err); 132 | onError("124\n" + err.toString()); 133 | } 134 | }, 135 | 136 | // Callback for each remote peer disconnection. 137 | onRemotePeerDisconnected: (remotePeerId) => { 138 | try { 139 | const pc = pcs.get(remotePeerId); 140 | if (pc == undefined) return; 141 | pc.close(); 142 | pcs.delete(remotePeerId); 143 | 144 | // Inform caller that a remote peer disconnected. 145 | onRemoteDisconnected(remotePeerId); 146 | } catch (err) { 147 | console.error(err); 148 | onError("140\n" + err.toString()); 149 | } 150 | }, 151 | onError, 152 | onLog, 153 | }); 154 | } catch (err) { 155 | console.error(err); 156 | onError("146\n" + err.toString()); 157 | } 158 | 159 | // JOIN and LEAVE ################################################## 160 | 161 | // Code executed when joining the call. 162 | async function join() { 163 | try { 164 | const perfectNegotiationOk = await compatiblePerfectNegotiation(); 165 | console.log( 166 | "Browser compatible with perfect negotiation:", 167 | perfectNegotiationOk 168 | ); 169 | signalingSocket.join(); 170 | } catch (err) { 171 | console.error(err); 172 | onError("162\n" + err.toString()); 173 | } 174 | } 175 | 176 | // Cleaning code when leaving the call. 177 | function leave() { 178 | // Warn the other peers that we are leaving. 179 | signalingSocket.leave(); 180 | 181 | // Close all WebRTC peer connections. 182 | for (let pc of pcs.values()) { 183 | pc.close(); 184 | } 185 | pcs.clear(); 186 | } 187 | 188 | // MIC and CAMERA ################################################## 189 | 190 | function setMic(micOn) { 191 | let audioTrack = localStream.getAudioTracks()[0]; 192 | audioTrack.enabled = micOn; 193 | } 194 | 195 | function setCam(camOn) { 196 | let videoTrack = localStream.getVideoTracks()[0]; 197 | videoTrack.enabled = camOn; 198 | } 199 | 200 | // PUBLIC API of WebRTC ############################################ 201 | 202 | return { 203 | localStream, 204 | join, 205 | leave, 206 | setMic, 207 | setCam, 208 | }; 209 | } 210 | 211 | // Class helping with signaling between WebRTC peers. 212 | // 213 | // let { join, leave } = await SignalingSocket({ 214 | // socketAddress, 215 | // onRemotePeerConnected: (channel, polite) => { ... }, 216 | // onRemotePeerDisconnected: (remotePeerId) => { ... }, 217 | // onError: (string) => { ... }, 218 | // onLog: (string) => { ... }, 219 | // }); 220 | async function SignalingSocket({ 221 | socketAddress, 222 | // Callback for each remote peer connection. 223 | onRemotePeerConnected, 224 | // Callback for each remote peer disconnection. 225 | onRemotePeerDisconnected, 226 | onError, 227 | onLog, 228 | }) { 229 | // Create the WebSocket object. 230 | const socket = new WebSocket(socketAddress); 231 | 232 | // If the signaling socket is closed by the browser or server, 233 | // it means that something unexpected occured. 234 | socket.onclose = (event) => { 235 | throw "The signaling socket was closed"; 236 | // TODO add a disconnected event 237 | }; 238 | 239 | // Hashmap holding one signaling channel per peer. 240 | const channels = new Map(); 241 | 242 | // Listen to incoming messages and redirect either to 243 | // the ICE candidate or the description callback. 244 | socket.onmessage = (jsonMsg) => { 245 | try { 246 | const msg = JSON.parse(jsonMsg.data); 247 | if (msg == "pong") { 248 | // The server "pong" answer to our "ping". 249 | console.log("pong"); 250 | } else if (msg.msgType == "greet") { 251 | // A peer just connected with us. 252 | const chan = addChannel(msg.remotePeerId); 253 | onRemotePeerConnected(chan, msg.polite); 254 | } else if (msg.msgType == "left") { 255 | // A peer just disconnected. 256 | channels.delete(msg.remotePeerId); 257 | onRemotePeerDisconnected(msg.remotePeerId); 258 | } else { 259 | const chan = channels.get(msg.remotePeerId); 260 | if (chan == undefined) return; 261 | if (msg.msgType == "sessionDescription") { 262 | chan.onRemoteDescription(msg.data); 263 | } else if (msg.msgType == "iceCandidate") { 264 | chan.onRemoteIceCandidate(msg.data); 265 | } 266 | } 267 | } catch (err) { 268 | console.error(err); 269 | onError("257, msg.msgType: " + msg.msgType + "\n" + err.toString()); 270 | } 271 | }; 272 | 273 | // --------------- Private helper functions of SignalingSocket 274 | 275 | // Add a dedicated channel for a remote peer. 276 | // Return the created channel to the caller. 277 | function addChannel(remotePeerId) { 278 | const chan = { 279 | remotePeerId: remotePeerId, 280 | // Send a session description to the remote peer. 281 | sendDescription: (localDescription) => 282 | sendJsonMsg("sessionDescription", remotePeerId, { 283 | data: localDescription, 284 | }), 285 | // Send an ICE candidate to the remote peer. 286 | sendIceCandidate: (iceCandidate) => 287 | sendJsonMsg("iceCandidate", remotePeerId, { data: iceCandidate }), 288 | // Callbacks to be defined later. 289 | onRemoteDescription: undefined, 290 | onRemoteIceCandidate: undefined, 291 | }; 292 | // Add the dedicated channel to our hashmap containing all channels. 293 | channels.set(remotePeerId, chan); 294 | return chan; 295 | } 296 | 297 | // Helper function to send a JSON message to the signaling socket. 298 | function sendJsonMsg(msgType, remotePeerId, extra = {}) { 299 | try { 300 | const msg = Object.assign({ msgType, remotePeerId }, extra); 301 | socket.send(JSON.stringify(msg)); 302 | } catch (err) { 303 | console.error(err); 304 | onError("292\n" + err.toString()); 305 | } 306 | } 307 | 308 | // Prevent time out with regular ping-pong exchanges. 309 | function ping(ms) { 310 | setTimeout(() => { 311 | if (socket.readyState != 1) return; 312 | socket.send(JSON.stringify("ping")); 313 | ping(ms); 314 | }, ms); 315 | } 316 | 317 | // PUBLIC API of SignalingSocket ################################# 318 | // 319 | // { join, leave } = await SignalingSocket(...); 320 | 321 | return new Promise(function (resolve, reject) { 322 | socket.onopen = () => { 323 | ping(10000); 324 | resolve({ 325 | // Inform the signaling server that we are ready. 326 | join: () => socket.send(JSON.stringify({ msgType: "join" })), 327 | // Inform the signaling server that we are leaving. 328 | leave: () => socket.send(JSON.stringify({ msgType: "leave" })), 329 | }); 330 | }; 331 | socket.onerror = (err) => { 332 | console.error(err); 333 | onError("321\n" + err.toString()); 334 | reject(err); 335 | }; 336 | }); 337 | } 338 | 339 | // Create a peer connection with a dedicated signaling channel. 340 | // 341 | // let { startNegotiation, close } = PeerConnection({ 342 | // rtcConfig, 343 | // signalingChannel, 344 | // polite, 345 | // onRemoteTrack: (streams) => { ... }, 346 | // onError: (string) => { ... }, 347 | // onLog: (string) => { ... }, 348 | // }); 349 | function PeerConnection({ 350 | rtcConfig, 351 | signalingChannel, 352 | polite, 353 | onRemoteTrack, 354 | onError, 355 | onLog, 356 | }) { 357 | // Init the RTCPeerConnection. 358 | let pc = new RTCPeerConnection(rtcConfig); 359 | 360 | // Notify when a track is received. 361 | pc.ontrack = ({ track, streams }) => { 362 | try { 363 | onLog("pc.ontrack with muted = " + track.muted); 364 | // track.onunmute = () => onRemoteTrack(streams); 365 | onRemoteTrack(streams); 366 | } catch (err) { 367 | console.error(err); 368 | onError("352\n" + err.toString()); 369 | } 370 | }; 371 | 372 | // --------------- Private helper functions of PeerConnection 373 | 374 | // SDP and ICE candidate negotiation 375 | function startNegotiation(localStream) { 376 | // if (bothPerfectNegotiation) { 377 | if (false) { 378 | perfectNegotiation(pc, signalingChannel); 379 | setLocalStream(pc, localStream); 380 | } else { 381 | simpleNegotiation(pc, signalingChannel, localStream); 382 | // The impolite peer is the caller. 383 | if (!polite) setLocalStream(pc, localStream); 384 | } 385 | } 386 | 387 | // Add tracks of local stream in the peer connection. 388 | // This will trigger a negotiationneeded event and start negotiations. 389 | function setLocalStream(pc, localStream) { 390 | for (const track of localStream.getTracks()) { 391 | pc.addTrack(track, localStream); 392 | } 393 | } 394 | 395 | // Simple peer-to-peer negotiation. 396 | // https://w3c.github.io/webrtc-pc/#simple-peer-to-peer-example 397 | function simpleNegotiation(pc, signalingChannel, localStream) { 398 | // let the "negotiationneeded" event trigger offer generation 399 | pc.onnegotiationneeded = async () => { 400 | try { 401 | onLog("pc.onnegotiationneeded"); 402 | await pc.setLocalDescription(await pc.createOffer()); 403 | signalingChannel.sendDescription(pc.localDescription); 404 | } catch (err) { 405 | console.error(err); 406 | onError("389\n" + err.toString()); 407 | } 408 | }; 409 | 410 | // Handling remote session description update. 411 | signalingChannel.onRemoteDescription = async (description) => { 412 | onLog( 413 | "onRemoteDescription with description:\n" + 414 | JSON.stringify(description, null, " ") 415 | ); 416 | if (description == null) return; 417 | try { 418 | if (description.type == "offer") { 419 | await pc.setRemoteDescription(description); 420 | setLocalStream(pc, localStream); 421 | await pc.setLocalDescription(await pc.createAnswer()); 422 | signalingChannel.sendDescription(pc.localDescription); 423 | } else if (description.type == "answer") { 424 | await pc.setRemoteDescription(description); 425 | } else { 426 | console.error("Unsupported SDP type. Your code may differ here."); 427 | onError("Unsupported SDP type. Your code may differ here."); 428 | } 429 | } catch (err) { 430 | console.error(err); 431 | onError("409, type: " + description.type + "\n" + err.toString()); 432 | } 433 | }; 434 | 435 | // Send any ICE candidates to the other peer 436 | pc.onicecandidate = ({ candidate }) => { 437 | onLog( 438 | "pc.onicecandidate with candidate:\n" + 439 | JSON.stringify(candidate, null, " ") 440 | ); 441 | try { 442 | signalingChannel.sendIceCandidate(candidate); 443 | } catch (err) { 444 | console.error(err); 445 | onError("419\n" + err.toString()); 446 | } 447 | }; 448 | 449 | // Handling remote ICE candidate update. 450 | signalingChannel.onRemoteIceCandidate = async (iceCandidate) => { 451 | onLog( 452 | "onRemoteIceCandidate with iceCandidate:\n" + 453 | JSON.stringify(iceCandidate, null, " ") 454 | ); 455 | if (iceCandidate == null) return; 456 | // Try a fix inspired by PeerJS library: 457 | // https://github.com/peers/peerjs/blob/72e7c17/lib/negotiator.ts#L308 458 | try { 459 | await pc.addIceCandidate( 460 | new RTCIceCandidate({ 461 | sdpMid: iceCandidate.sdpMid, 462 | sdpMLineIndex: iceCandidate.sdpMLineIndex, 463 | candidate: iceCandidate.candidate, 464 | }) 465 | ); 466 | } catch (err) { 467 | console.error(err); 468 | onError( 469 | "430: " + 470 | err.toString() + 471 | "\n\nWith detailed JSON err:\n" + 472 | JSON.stringify(err, null, " ") + 473 | "\n\nHappening with the following ICE candidate:\n" + 474 | JSON.stringify(iceCandidate, null, " ") + 475 | "\n\nThe complete stack trace below:\n" + 476 | err.stack 477 | ); 478 | } 479 | }; 480 | } 481 | 482 | // Below is the "perfect negotiation" logic. 483 | // https://w3c.github.io/webrtc-pc/#perfect-negotiation-example 484 | // 485 | // Unfortunately this can still cause race conditions 486 | // so we are not going to use it anyway. 487 | // https://stackoverflow.com/questions/61956693/webrtc-perfect-negotiation-issues?noredirect=1#comment109599219_61956693 488 | function perfectNegotiation(pc, signalingChannel) { 489 | // Handling the negotiationneeded event. 490 | let makingOffer = false; 491 | let ignoreOffer = false; 492 | pc.onnegotiationneeded = async () => { 493 | try { 494 | makingOffer = true; 495 | await pc.setLocalDescription(); 496 | signalingChannel.sendDescription(pc.localDescription); 497 | } catch (err) { 498 | throw err; 499 | } finally { 500 | makingOffer = false; 501 | } 502 | }; 503 | 504 | // Handling remote session description update. 505 | signalingChannel.onRemoteDescription = async (description) => { 506 | if (description == null) return; 507 | const offerCollision = 508 | description.type == "offer" && 509 | (makingOffer || pc.signalingState != "stable"); 510 | ignoreOffer = !polite && offerCollision; 511 | if (ignoreOffer) return; 512 | await pc.setRemoteDescription(description); 513 | if (description.type == "offer") { 514 | await pc.setLocalDescription(); 515 | signalingChannel.sendDescription(pc.localDescription); 516 | } 517 | }; 518 | 519 | // ICE candidate negotiation. 520 | 521 | // Send any ICE candidates to the other peer 522 | pc.onicecandidate = ({ candidate }) => 523 | signalingChannel.sendIceCandidate(candidate); 524 | 525 | // Handling remote ICE candidate update. 526 | signalingChannel.onRemoteIceCandidate = async (candidate) => { 527 | if (candidate == null) return; 528 | try { 529 | await pc.addIceCandidate(candidate); 530 | } catch (err) { 531 | if (!ignoreOffer) throw err; 532 | } 533 | }; 534 | } 535 | 536 | // PUBLIC API of PeerConnection ################################## 537 | // 538 | // { startNegotiation, close } = PeerConnection(...); 539 | 540 | return { 541 | // Start the negotiation process. 542 | startNegotiation: startNegotiation, 543 | // Close the connection with the peer. 544 | close: () => { 545 | pc.close(); 546 | pc = null; 547 | signalingChannel = null; // is it needed? 548 | }, 549 | }; 550 | } 551 | 552 | // The four features enabling perfect negotiation are 553 | // detailed in this discussions 554 | // https://groups.google.com/a/chromium.org/forum/#!topic/blink-dev/OqPfCpC5RYU 555 | // They are: 556 | // 1. The restartIce() function 557 | // 2. The ability to call setLocalDescription() with no argument 558 | // 3. The ability to implicit rollback in setRemoteDescription(offer) 559 | // 4. Stopping and stopped transceiviers 560 | // 561 | // As of now (23 may of 2020), the compatibility table for those three features is: 562 | // 563 | // restartIce(): 564 | // Chrome 77, Edge 79, FF 70, Safari NO, Android Chrome 77, Android FF NO 565 | // setLocalDescription() with no argument: 566 | // Chrome 80, Edge 80, FF 75, Safari NO, Android Chrome 80, Android FF NO 567 | // setRemoteDescription() with implicit rollback: 568 | // Chrome NO, Edge NO, FF 75, Safari NO, Android Chrome NO, Android FF NO 569 | // 570 | // Only Desktop FF >= 75 is compatible with perfect negotiation. 571 | // We can detect this with: 572 | async function compatiblePerfectNegotiation() { 573 | try { 574 | // Check that setLocalDescription() with no argument is supported. 575 | // This will rule out the Android version of Firefox. 576 | let pc = new RTCPeerConnection(); 577 | await pc.setLocalDescription(); 578 | 579 | // Now rule out browsers that are not Firefox. 580 | // A better way would be to test for the implicit rollback 581 | // capability of setRemoteDescription() but I don't know how ... 582 | return window.mozInnerScreenX != undefined; 583 | } catch (e) { 584 | return false; 585 | } 586 | } 587 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Browser 4 | import Element exposing (Device, Element) 5 | import Element.Background as Background 6 | import Element.Border as Border 7 | import Element.Events 8 | import Element.Font as Font 9 | import Element.Input as Input 10 | import FeatherIcons as Icon exposing (Icon) 11 | import Html exposing (Html) 12 | import Html.Attributes as HA 13 | import Html.Keyed 14 | import Json.Encode as Encode exposing (Value) 15 | import Layout2D 16 | import Set exposing (Set) 17 | import UI 18 | 19 | 20 | port resize : ({ width : Float, height : Float } -> msg) -> Sub msg 21 | 22 | 23 | port requestFullscreen : () -> Cmd msg 24 | 25 | 26 | port exitFullscreen : () -> Cmd msg 27 | 28 | 29 | 30 | -- WebRTC ports 31 | 32 | 33 | port readyForLocalStream : String -> Cmd msg 34 | 35 | 36 | port updatedStream : ({ id : Int, stream : Value } -> msg) -> Sub msg 37 | 38 | 39 | port videoReadyForStream : { id : Int, stream : Value } -> Cmd msg 40 | 41 | 42 | port remoteDisconnected : (Int -> msg) -> Sub msg 43 | 44 | 45 | port joinCall : () -> Cmd msg 46 | 47 | 48 | port leaveCall : () -> Cmd msg 49 | 50 | 51 | port mute : Bool -> Cmd msg 52 | 53 | 54 | port hide : Bool -> Cmd msg 55 | 56 | 57 | port error : (String -> msg) -> Sub msg 58 | 59 | 60 | port log : (String -> msg) -> Sub msg 61 | 62 | 63 | port copyToClipboard : String -> Cmd msg 64 | 65 | 66 | 67 | -- Main 68 | 69 | 70 | main : Program Flags Model Msg 71 | main = 72 | Browser.element 73 | { init = init 74 | , view = view 75 | , update = update 76 | , subscriptions = subscriptions 77 | } 78 | 79 | 80 | type alias Flags = 81 | { width : Float 82 | , height : Float 83 | } 84 | 85 | 86 | type alias Model = 87 | { width : Float 88 | , height : Float 89 | , mic : Bool 90 | , cam : Bool 91 | , joined : Bool 92 | , device : Element.Device 93 | , remotePeers : Set Int 94 | , errors : List String 95 | , logs : List String 96 | , showErrorsOrLogs : ShowErrorsOrLogs 97 | } 98 | 99 | 100 | type ShowErrorsOrLogs 101 | = ShowNone 102 | | ShowErrors 103 | | ShowLogs 104 | 105 | 106 | type Msg 107 | = Resize { width : Float, height : Float } 108 | | SetMic Bool 109 | | SetCam Bool 110 | | SetJoined Bool 111 | -- WebRTC messages 112 | | UpdatedStream { id : Int, stream : Value } 113 | | RemoteDisconnected Int 114 | | Error String 115 | | Log String 116 | | ToggleShowErrors 117 | | ToggleShowLogs 118 | | CopyButtonClicked 119 | 120 | 121 | init : Flags -> ( Model, Cmd Msg ) 122 | init { width, height } = 123 | ( { width = width 124 | , height = height 125 | , mic = True 126 | , cam = True 127 | , joined = False 128 | , device = 129 | Element.classifyDevice 130 | { width = round width, height = round height } 131 | , remotePeers = Set.empty 132 | , errors = [] 133 | , logs = [] 134 | , showErrorsOrLogs = ShowNone 135 | } 136 | , readyForLocalStream "localVideo" 137 | ) 138 | 139 | 140 | update : Msg -> Model -> ( Model, Cmd Msg ) 141 | update msg model = 142 | case msg of 143 | Resize { width, height } -> 144 | ( { model 145 | | width = width 146 | , height = height 147 | , device = 148 | Element.classifyDevice 149 | { width = round width, height = round height } 150 | } 151 | , Cmd.none 152 | ) 153 | 154 | SetMic mic -> 155 | ( { model | mic = mic } 156 | , mute mic 157 | ) 158 | 159 | SetCam cam -> 160 | ( { model | cam = cam } 161 | , hide cam 162 | ) 163 | 164 | SetJoined joined -> 165 | ( { model | joined = joined, remotePeers = Set.empty } 166 | , if joined then 167 | Cmd.batch [ requestFullscreen (), joinCall () ] 168 | 169 | else 170 | Cmd.batch [ exitFullscreen (), leaveCall () ] 171 | ) 172 | 173 | -- WebRTC messages 174 | UpdatedStream { id, stream } -> 175 | ( { model 176 | | remotePeers = Set.insert id model.remotePeers 177 | , logs = ("UpdatedStream " ++ String.fromInt id) :: model.logs 178 | } 179 | , videoReadyForStream { id = id, stream = stream } 180 | -- , Cmd.none 181 | ) 182 | 183 | RemoteDisconnected id -> 184 | ( { model | remotePeers = Set.remove id model.remotePeers } 185 | , Cmd.none 186 | ) 187 | 188 | -- JavaScript error 189 | Error err -> 190 | ( { model | errors = err :: model.errors } 191 | , Cmd.none 192 | ) 193 | 194 | -- JavaScript log 195 | Log logMsg -> 196 | ( { model | logs = logMsg :: model.logs } 197 | , Cmd.none 198 | ) 199 | 200 | ToggleShowErrors -> 201 | let 202 | showErrorsOrLogs = 203 | case model.showErrorsOrLogs of 204 | ShowErrors -> 205 | ShowNone 206 | 207 | _ -> 208 | ShowErrors 209 | in 210 | ( { model | showErrorsOrLogs = showErrorsOrLogs } 211 | , Cmd.none 212 | ) 213 | 214 | ToggleShowLogs -> 215 | let 216 | showErrorsOrLogs = 217 | case model.showErrorsOrLogs of 218 | ShowLogs -> 219 | ShowNone 220 | 221 | _ -> 222 | ShowLogs 223 | in 224 | ( { model | showErrorsOrLogs = showErrorsOrLogs } 225 | , Cmd.none 226 | ) 227 | 228 | CopyButtonClicked -> 229 | ( model 230 | , case model.showErrorsOrLogs of 231 | ShowNone -> 232 | Cmd.none 233 | 234 | ShowErrors -> 235 | copyToClipboard (String.join "\n\n" <| List.reverse model.errors) 236 | 237 | ShowLogs -> 238 | copyToClipboard (String.join "\n\n" <| List.reverse model.logs) 239 | ) 240 | 241 | 242 | subscriptions : Model -> Sub Msg 243 | subscriptions _ = 244 | Sub.batch 245 | [ resize Resize 246 | 247 | -- WebRTC incomming ports 248 | , updatedStream UpdatedStream 249 | , remoteDisconnected RemoteDisconnected 250 | 251 | -- JavaScript errors and logs 252 | , error Error 253 | , log Log 254 | ] 255 | 256 | 257 | 258 | -- View 259 | 260 | 261 | view : Model -> Html Msg 262 | view model = 263 | Element.layout 264 | [ Background.color UI.darkGrey 265 | , Font.color UI.lightGrey 266 | , Element.width Element.fill 267 | , Element.height Element.fill 268 | , Element.clip 269 | ] 270 | (layout model) 271 | 272 | 273 | layout : Model -> Element Msg 274 | layout model = 275 | let 276 | availableHeight = 277 | model.height 278 | - UI.controlButtonSize model.device.class 279 | - (2 * toFloat UI.spacing) 280 | in 281 | Element.column 282 | [ Element.width Element.fill 283 | , Element.height Element.fill 284 | , Element.inFront (displayErrorsOrLogs model.width model.height availableHeight model.showErrorsOrLogs model.errors model.logs) 285 | ] 286 | [ Element.row [ Element.padding UI.spacing, Element.width Element.fill ] 287 | [ showLogsButton model.device model.showErrorsOrLogs model.logs 288 | , filler 289 | , micControl model.device model.mic 290 | , filler 291 | , camControl model.device model.cam 292 | , Element.text <| String.fromInt <| Set.size model.remotePeers 293 | , filler 294 | , showErrorsButton model.device model.showErrorsOrLogs model.errors 295 | ] 296 | , Element.html <| 297 | videoStreams model.width availableHeight model.joined model.remotePeers 298 | ] 299 | 300 | 301 | displayErrorsOrLogs : Float -> Float -> Float -> ShowErrorsOrLogs -> List String -> List String -> Element Msg 302 | displayErrorsOrLogs totalWidth totalHeight availableHeight showErrorsOrLogs errors logs = 303 | case showErrorsOrLogs of 304 | ShowNone -> 305 | Element.none 306 | 307 | ShowErrors -> 308 | showLogs totalWidth totalHeight availableHeight errors 309 | 310 | ShowLogs -> 311 | showLogs totalWidth totalHeight availableHeight logs 312 | 313 | 314 | showLogs : Float -> Float -> Float -> List String -> Element Msg 315 | showLogs totalWidth totalHeight availableHeight logs = 316 | Element.column 317 | [ Element.clip 318 | , Element.scrollbars 319 | , Element.centerX 320 | , Element.width (Element.maximum (floor totalWidth) Element.shrink) 321 | , Element.moveDown (totalHeight - availableHeight) 322 | , Element.height (Element.maximum (floor availableHeight) Element.shrink) 323 | , Element.padding 20 324 | , Element.spacing 40 325 | , Background.color UI.darkRed 326 | , Element.htmlAttribute (HA.style "z-index" "1000") 327 | , Font.size 8 328 | , Element.inFront copyButton 329 | ] 330 | (List.map preformatted <| List.reverse logs) 331 | 332 | 333 | preformatted : String -> Element msg 334 | preformatted str = 335 | Element.html (Html.pre [] [ Html.text str ]) 336 | 337 | 338 | showLogsButton : Device -> ShowErrorsOrLogs -> List String -> Element Msg 339 | showLogsButton device showErrorsOrLogs logs = 340 | if List.isEmpty logs then 341 | Element.none 342 | 343 | else if showErrorsOrLogs == ShowLogs then 344 | Icon.x 345 | |> Icon.withSize (UI.controlButtonSize device.class) 346 | |> Icon.toHtml [] 347 | |> Element.html 348 | |> Element.el [ Element.Events.onClick ToggleShowLogs, Element.pointer ] 349 | 350 | else 351 | Icon.menu 352 | |> Icon.withSize (UI.controlButtonSize device.class) 353 | |> Icon.toHtml [] 354 | |> Element.html 355 | |> Element.el [ Element.Events.onClick ToggleShowLogs, Element.pointer ] 356 | 357 | 358 | showErrorsButton : Device -> ShowErrorsOrLogs -> List String -> Element Msg 359 | showErrorsButton device showErrorsOrLogs errors = 360 | if List.isEmpty errors then 361 | Element.none 362 | 363 | else if showErrorsOrLogs == ShowErrors then 364 | Icon.x 365 | |> Icon.withSize (UI.controlButtonSize device.class) 366 | |> Icon.toHtml [] 367 | |> Element.html 368 | |> Element.el [ Element.Events.onClick ToggleShowErrors, Element.pointer ] 369 | 370 | else 371 | Icon.alertTriangle 372 | |> Icon.withSize (UI.controlButtonSize device.class) 373 | |> Icon.toHtml [] 374 | |> Element.html 375 | |> Element.el [ Element.Events.onClick ToggleShowErrors, Element.pointer ] 376 | 377 | 378 | micControl : Device -> Bool -> Element Msg 379 | micControl device micOn = 380 | Element.row [ Element.spacing UI.spacing ] 381 | [ Icon.micOff 382 | |> Icon.withSize (UI.controlButtonSize device.class) 383 | |> Icon.toHtml [] 384 | |> Element.html 385 | |> Element.el [] 386 | , toggle SetMic micOn (UI.controlButtonSize device.class) 387 | , Icon.mic 388 | |> Icon.withSize (UI.controlButtonSize device.class) 389 | |> Icon.toHtml [] 390 | |> Element.html 391 | |> Element.el [] 392 | ] 393 | 394 | 395 | camControl : Device -> Bool -> Element Msg 396 | camControl device camOn = 397 | Element.row [ Element.spacing UI.spacing ] 398 | [ Icon.videoOff 399 | |> Icon.withSize (UI.controlButtonSize device.class) 400 | |> Icon.toHtml [] 401 | |> Element.html 402 | |> Element.el [] 403 | , toggle SetCam camOn (UI.controlButtonSize device.class) 404 | , Icon.video 405 | |> Icon.withSize (UI.controlButtonSize device.class) 406 | |> Icon.toHtml [] 407 | |> Element.html 408 | |> Element.el [] 409 | ] 410 | 411 | 412 | filler : Element msg 413 | filler = 414 | Element.el [ Element.width Element.fill ] Element.none 415 | 416 | 417 | 418 | -- Toggle 419 | 420 | 421 | toggle : (Bool -> Msg) -> Bool -> Float -> Element Msg 422 | toggle msg checked height = 423 | Input.checkbox [] <| 424 | { onChange = msg 425 | , label = Input.labelHidden "Activer/Désactiver" 426 | , checked = checked 427 | , icon = 428 | toggleCheckboxWidget 429 | { offColor = UI.lightGrey 430 | , onColor = UI.green 431 | , sliderColor = UI.white 432 | , toggleWidth = 2 * round height 433 | , toggleHeight = round height 434 | } 435 | } 436 | 437 | 438 | toggleCheckboxWidget : { offColor : Element.Color, onColor : Element.Color, sliderColor : Element.Color, toggleWidth : Int, toggleHeight : Int } -> Bool -> Element msg 439 | toggleCheckboxWidget { offColor, onColor, sliderColor, toggleWidth, toggleHeight } checked = 440 | let 441 | pad = 442 | 3 443 | 444 | sliderSize = 445 | toggleHeight - 2 * pad 446 | 447 | translation = 448 | (toggleWidth - sliderSize - pad) 449 | |> String.fromInt 450 | in 451 | Element.el 452 | [ Background.color <| 453 | if checked then 454 | onColor 455 | 456 | else 457 | offColor 458 | , Element.width <| Element.px <| toggleWidth 459 | , Element.height <| Element.px <| toggleHeight 460 | , Border.rounded (toggleHeight // 2) 461 | , Element.inFront <| 462 | Element.el [ Element.height Element.fill ] <| 463 | Element.el 464 | [ Background.color sliderColor 465 | , Border.rounded <| sliderSize // 2 466 | , Element.width <| Element.px <| sliderSize 467 | , Element.height <| Element.px <| sliderSize 468 | , Element.centerY 469 | , Element.moveRight pad 470 | , Element.htmlAttribute <| 471 | HA.style "transition" ".4s" 472 | , Element.htmlAttribute <| 473 | if checked then 474 | HA.style "transform" <| "translateX(" ++ translation ++ "px)" 475 | 476 | else 477 | HA.class "" 478 | ] 479 | (Element.text "") 480 | ] 481 | (Element.text "") 482 | 483 | 484 | 485 | -- Video element 486 | 487 | 488 | videoStreams : Float -> Float -> Bool -> Set Int -> Html Msg 489 | videoStreams width height joined remotePeers = 490 | if not joined then 491 | -- Dedicated layout when we are not connected yet 492 | Html.Keyed.node "div" 493 | [ HA.style "display" "flex" 494 | , HA.style "height" (String.fromFloat height ++ "px") 495 | , HA.style "width" "100%" 496 | ] 497 | [ ( "localVideo", video "" "localVideo" ) 498 | , ( "joinButton", joinButton ) 499 | ] 500 | 501 | else if Set.size remotePeers <= 1 then 502 | -- Dedicated layout for 1-1 conversation 503 | let 504 | thumbHeight = 505 | max (toFloat UI.minVideoHeight) (height / 4) 506 | |> String.fromFloat 507 | in 508 | Html.Keyed.node "div" 509 | [ HA.style "width" "100%" 510 | , HA.style "height" (String.fromFloat height ++ "px") 511 | , HA.style "position" "relative" 512 | ] 513 | (if Set.isEmpty remotePeers then 514 | [ ( "localVideo", thumbVideo thumbHeight "" "localVideo" ) 515 | , ( "leaveButton", leaveButton 0 ) 516 | ] 517 | 518 | else 519 | let 520 | remotePeerId = 521 | List.head (Set.toList remotePeers) 522 | |> Maybe.withDefault -1 523 | |> String.fromInt 524 | in 525 | [ ( "localVideo", thumbVideo thumbHeight "" "localVideo" ) 526 | , ( remotePeerId, remoteVideo width height "" remotePeerId ) 527 | , ( "leaveButton", leaveButton height ) 528 | ] 529 | ) 530 | 531 | else 532 | -- We use a grid layout if more than 1 peer 533 | let 534 | ( ( nbCols, nbRows ), ( cellWidth, cellHeight ) ) = 535 | Layout2D.fixedGrid width height (3 / 2) (Set.size remotePeers + 1) 536 | 537 | remoteVideos = 538 | Set.toList remotePeers 539 | |> List.map 540 | (\id -> 541 | ( String.fromInt id 542 | , gridVideoItem False "" (String.fromInt id) 543 | ) 544 | ) 545 | 546 | localVideo = 547 | ( "localVideo", gridVideoItem True "" "localVideo" ) 548 | 549 | allVideos = 550 | remoteVideos ++ [ localVideo ] 551 | in 552 | videosGrid height cellWidth cellHeight nbCols nbRows allVideos 553 | 554 | 555 | videosGrid : Float -> Float -> Float -> Int -> Int -> List ( String, Html Msg ) -> Html Msg 556 | videosGrid height cellWidthNoSpace cellHeightNoSpace cols rows videos = 557 | let 558 | cellWidth = 559 | cellWidthNoSpace - toFloat (cols - 1) / toFloat cols * toFloat UI.spacing 560 | 561 | cellHeight = 562 | cellHeightNoSpace - toFloat (rows - 1) / toFloat rows * toFloat UI.spacing 563 | 564 | gridWidth = 565 | List.repeat cols (String.fromFloat cellWidth ++ "px") 566 | |> String.join " " 567 | 568 | gridHeight = 569 | List.repeat rows (String.fromFloat cellHeight ++ "px") 570 | |> String.join " " 571 | in 572 | Html.Keyed.node "div" 573 | [ HA.style "width" "100%" 574 | , HA.style "height" (String.fromFloat height ++ "px") 575 | , HA.style "position" "relative" 576 | , HA.style "display" "grid" 577 | , HA.style "grid-template-columns" gridWidth 578 | , HA.style "grid-template-rows" gridHeight 579 | , HA.style "justify-content" "space-evenly" 580 | , HA.style "align-content" "start" 581 | , HA.style "column-gap" (String.fromInt UI.spacing ++ "px") 582 | , HA.style "row-gap" (String.fromInt UI.spacing ++ "px") 583 | ] 584 | (videos ++ [ ( "leaveButton", leaveButton 0 ) ]) 585 | 586 | 587 | gridVideoItem : Bool -> String -> String -> Html msg 588 | gridVideoItem muted src id = 589 | Html.video 590 | [ HA.id id 591 | , HA.autoplay True 592 | , HA.property "muted" (Encode.bool muted) 593 | , HA.attribute "playsinline" "playsinline" 594 | 595 | -- prevent focus outline 596 | , HA.style "outline" "none" 597 | 598 | -- grow and center video 599 | , HA.style "justify-self" "stretch" 600 | , HA.style "align-self" "stretch" 601 | ] 602 | [ Html.source [ HA.src src, HA.type_ "video/mp4" ] [] ] 603 | 604 | 605 | remoteVideo : Float -> Float -> String -> String -> Html msg 606 | remoteVideo width height src id = 607 | Html.video 608 | [ HA.id id 609 | , HA.autoplay True 610 | , HA.attribute "playsinline" "playsinline" 611 | , HA.poster "spinner.png" 612 | 613 | -- prevent focus outline 614 | , HA.style "outline" "none" 615 | 616 | -- grow and center video 617 | , HA.style "max-height" (String.fromFloat height ++ "px") 618 | , HA.style "height" (String.fromFloat height ++ "px") 619 | , HA.style "max-width" (String.fromFloat width ++ "px") 620 | , HA.style "width" (String.fromFloat width ++ "px") 621 | , HA.style "position" "relative" 622 | , HA.style "left" "50%" 623 | , HA.style "bottom" "50%" 624 | , HA.style "transform" "translate(-50%, 50%)" 625 | , HA.style "z-index" "-1" 626 | ] 627 | [ Html.source [ HA.src src, HA.type_ "video/mp4" ] [] ] 628 | 629 | 630 | thumbVideo : String -> String -> String -> Html msg 631 | thumbVideo height src id = 632 | Html.video 633 | [ HA.id id 634 | , HA.autoplay True 635 | , HA.property "muted" (Encode.bool True) 636 | , HA.attribute "playsinline" "playsinline" 637 | 638 | -- prevent focus outline 639 | , HA.style "outline" "none" 640 | 641 | -- grow and center video 642 | , HA.style "position" "absolute" 643 | , HA.style "bottom" "0" 644 | , HA.style "right" "0" 645 | , HA.style "max-width" "100%" 646 | , HA.style "max-height" (height ++ "px") 647 | , HA.style "height" (height ++ "px") 648 | , HA.style "margin" (String.fromInt UI.spacing ++ "px") 649 | ] 650 | [ Html.source [ HA.src src, HA.type_ "video/mp4" ] [] ] 651 | 652 | 653 | video : String -> String -> Html msg 654 | video src id = 655 | Html.video 656 | [ HA.id id 657 | , HA.autoplay True 658 | , HA.property "muted" (Encode.bool True) 659 | , HA.attribute "playsinline" "playsinline" 660 | 661 | -- prevent focus outline 662 | , HA.style "outline" "none" 663 | 664 | -- grow and center video 665 | , HA.style "flex" "1 1 auto" 666 | , HA.style "max-height" "100%" 667 | , HA.style "max-width" "100%" 668 | ] 669 | [ Html.source [ HA.src src, HA.type_ "video/mp4" ] [] ] 670 | 671 | 672 | joinButton : Html Msg 673 | joinButton = 674 | Element.layoutWith 675 | { options = [ Element.noStaticStyleSheet ] } 676 | [ Element.htmlAttribute <| HA.style "position" "absolute" 677 | , Element.htmlAttribute <| HA.style "z-index" "1" 678 | ] 679 | (Input.button 680 | [ Element.centerX 681 | , Element.centerY 682 | , Element.htmlAttribute <| HA.style "outline" "none" 683 | ] 684 | { onPress = Just (SetJoined True) 685 | , label = roundButton UI.green UI.joinButtonSize Icon.phone 686 | } 687 | ) 688 | 689 | 690 | leaveButton : Float -> Html Msg 691 | leaveButton height = 692 | Element.layoutWith 693 | { options = [ Element.noStaticStyleSheet ] } 694 | [ Element.width Element.fill 695 | , Element.height Element.fill 696 | , Element.htmlAttribute <| HA.style "position" "absolute" 697 | , Element.htmlAttribute <| 698 | HA.style "transform" ("translateY(-" ++ String.fromFloat height ++ "px)") 699 | ] 700 | (Input.button 701 | [ Element.centerX 702 | , Element.alignBottom 703 | , Element.padding <| 3 * UI.spacing 704 | , Element.htmlAttribute <| HA.style "outline" "none" 705 | ] 706 | { onPress = Just (SetJoined False) 707 | , label = roundButton UI.red UI.leaveButtonSize Icon.phoneOff 708 | } 709 | ) 710 | 711 | 712 | copyButton : Element Msg 713 | copyButton = 714 | Input.button 715 | [ Element.alignTop 716 | , Element.alignRight 717 | , Element.padding UI.spacing 718 | , Element.htmlAttribute <| HA.style "outline" "none" 719 | ] 720 | { onPress = Just CopyButtonClicked 721 | , label = roundButton UI.darkGrey UI.copyButtonSize Icon.copy 722 | } 723 | 724 | 725 | roundButton : Element.Color -> Int -> Icon -> Element msg 726 | roundButton color size icon = 727 | Element.el 728 | [ Background.color color 729 | , Element.htmlAttribute <| HA.style "outline" "none" 730 | , Element.width <| Element.px size 731 | , Element.height <| Element.px size 732 | , Border.rounded <| size // 2 733 | , Border.shadow 734 | { offset = ( 0, 0 ) 735 | , size = 0 736 | , blur = UI.joinButtonBlur 737 | , color = UI.black 738 | } 739 | , Font.color UI.white 740 | ] 741 | (Icon.withSize (toFloat size / 2) icon 742 | |> Icon.toHtml [] 743 | |> Element.html 744 | |> Element.el [ Element.centerX, Element.centerY ] 745 | ) 746 | --------------------------------------------------------------------------------