├── 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 |
--------------------------------------------------------------------------------