├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── index.js ├── player.js └── publisher.js ├── lib └── live_ex_webrtc │ ├── core_components.ex │ ├── player.ex │ └── publisher.ex ├── mix.exs ├── mix.lock ├── package.json └── test ├── live_ex_webrtc_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:phoenix], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_ex_webrtc-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Elixir WebRTC Developers 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiveExWebRTC 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/live_ex_webrtc.svg)](https://hex.pm/packages/live_ex_webrtc) 4 | [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/live_ex_webrtc) 5 | 6 | Phoenix Live Components for [Elixir WebRTC](https://github.com/elixir-webrtc/ex_webrtc). 7 | 8 | ## Installation 9 | 10 | In your `mix.exs`: 11 | 12 | ```elixir 13 | def deps do 14 | [ 15 | {:live_ex_webrtc, "~> 0.8.0"} 16 | ] 17 | end 18 | ``` 19 | 20 | In your `tailwind.config.js` 21 | 22 | ```js 23 | module.exports = { 24 | content: [ 25 | "../deps/live_ex_webrtc/**/*.*ex" // ADD THIS LINE 26 | ] 27 | } 28 | ``` 29 | 30 | ## Usage 31 | 32 | `LiveExWebRTC` comes with two `Phoenix.LiveView`s: 33 | * `LiveExWebRTC.Publisher` - sends audio and video via WebRTC from a web browser to a Phoenix app (browser publishes) 34 | * `LiveExWebRTC.Player` - sends audio and video via WebRTC from a Phoenix app to a web browser and plays it in the HTMLVideoElement (browser subscribes) 35 | 36 | See module docs and [live_broadcaster](https://github.com/elixir-webrtc/live_broadcaster) for more. 37 | 38 | ## Local development 39 | 40 | For local development: 41 | * include `live_ex_webrtc` in your `mix.exs` via `path` 42 | * modify `NODE_PATH` env variable in your esbuild configuration, which is located in `config.exs` - this will allow for importing javascript hooks from `live_ex_webrtc`. 43 | 44 | For example: 45 | 46 | ```elixir 47 | config :esbuild, 48 | # ... 49 | default: [ 50 | # ... 51 | env: %{ 52 | "NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:/path/to/parent/dir/of/live_ex_webrtc" 53 | } 54 | ] 55 | ``` 56 | 57 | * modify `content` in `tailwind.config.js` - this will compile tailwind classes used in live components. 58 | 59 | For example: 60 | 61 | ```js 62 | module.exports = { 63 | content: [ 64 | // ... 65 | "../deps/**/*.ex" 66 | ] 67 | } 68 | ``` 69 | 70 | > #### Important {: .info} 71 | > Separate paths with `:` on MacOS/Linux and with `;` on Windows. 72 | 73 | > #### Important {: .info} 74 | > Specify path to live_ex_webrtc's parent directory. 75 | -------------------------------------------------------------------------------- /assets/index.js: -------------------------------------------------------------------------------- 1 | import { createPublisherHook } from "./publisher.js"; 2 | import { createPlayerHook } from "./player.js"; 3 | 4 | export { createPublisherHook, createPlayerHook }; 5 | -------------------------------------------------------------------------------- /assets/player.js: -------------------------------------------------------------------------------- 1 | export function createPlayerHook(iceServers = []) { 2 | return { 3 | async mounted() { 4 | const view = this; 5 | 6 | view.handleEvent( 7 | `connect-${view.el.id}`, 8 | async () => await view.connect(view) 9 | ); 10 | 11 | const eventName = "answer" + "-" + view.el.id; 12 | view.handleEvent(eventName, async (answer) => { 13 | if (view.pc) { 14 | await view.pc.setRemoteDescription(answer); 15 | } 16 | }); 17 | 18 | view.videoQuality = document.getElementById("lexp-video-quality"); 19 | view.videoQuality.onchange = () => { 20 | view.pushEventTo(view.el, "layer", view.videoQuality.value); 21 | }; 22 | }, 23 | 24 | async connect(view) { 25 | view.el.srcObject = undefined; 26 | view.pc = new RTCPeerConnection({ iceServers: iceServers }); 27 | view.el.play(); 28 | 29 | view.pc.onicecandidate = (ev) => { 30 | view.pushEventTo(view.el, "ice", JSON.stringify(ev.candidate)); 31 | }; 32 | 33 | view.pc.ontrack = (ev) => { 34 | if (!view.el.srcObject) { 35 | view.el.srcObject = ev.streams[0]; 36 | } 37 | }; 38 | view.pc.addTransceiver("audio", { direction: "recvonly" }); 39 | view.pc.addTransceiver("video", { direction: "recvonly" }); 40 | 41 | const offer = await view.pc.createOffer(); 42 | await view.pc.setLocalDescription(offer); 43 | 44 | view.pushEventTo(view.el, "offer", offer); 45 | }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /assets/publisher.js: -------------------------------------------------------------------------------- 1 | export function createPublisherHook(iceServers = []) { 2 | return { 3 | async mounted() { 4 | const view = this; 5 | 6 | view.handleEvent("start-streaming", () => view.startStreaming(view)); 7 | view.handleEvent("stop-streaming", () => view.stopStreaming(view)); 8 | 9 | view.audioDevices = document.getElementById("lex-audio-devices"); 10 | view.videoDevices = document.getElementById("lex-video-devices"); 11 | 12 | view.echoCancellation = document.getElementById("lex-echo-cancellation"); 13 | view.autoGainControl = document.getElementById("lex-auto-gain-control"); 14 | view.noiseSuppression = document.getElementById("lex-noise-suppression"); 15 | 16 | view.width = document.getElementById("lex-width"); 17 | view.height = document.getElementById("lex-height"); 18 | view.fps = document.getElementById("lex-fps"); 19 | view.bitrate = document.getElementById("lex-bitrate"); 20 | 21 | view.recordStream = document.getElementById("lex-record-stream"); 22 | 23 | view.previewPlayer = document.getElementById("lex-preview-player"); 24 | 25 | view.audioBitrate = document.getElementById("lex-audio-bitrate"); 26 | view.videoBitrate = document.getElementById("lex-video-bitrate"); 27 | view.packetLoss = document.getElementById("lex-packet-loss"); 28 | view.statusStarted = document.getElementById("lex-status-started"); 29 | view.statusStopped = document.getElementById("lex-status-stopped"); 30 | view.time = document.getElementById("lex-time"); 31 | 32 | view.button = document.getElementById("lex-button"); 33 | view.applyButton = document.getElementById("lex-apply-button"); 34 | 35 | view.simulcast = document.getElementById("lex-simulcast"); 36 | 37 | view.audioDevices.onchange = function () { 38 | view.setupStream(view); 39 | }; 40 | 41 | view.videoDevices.onchange = function () { 42 | view.setupStream(view); 43 | }; 44 | 45 | view.applyButton.onclick = function () { 46 | view.setupStream(view); 47 | }; 48 | 49 | // handle remote events 50 | view.handleEvent(`answer-${view.el.id}`, async (answer) => { 51 | if (view.pc) { 52 | await view.pc.setRemoteDescription(answer); 53 | } else { 54 | console.warn("Received SDP cnswer but there is no PC. Ignoring."); 55 | } 56 | }); 57 | 58 | view.handleEvent(`ice-${view.el.id}`, async (cand) => { 59 | if (view.pc) { 60 | await view.pc.addIceCandidate(JSON.parse(cand)); 61 | } else { 62 | console.warn("Received ICE candidate but there is no PC. Ignoring."); 63 | } 64 | }); 65 | 66 | try { 67 | await view.findDevices(view); 68 | try { 69 | await view.setupStream(view); 70 | view.button.disabled = false; 71 | view.applyButton.disabled = false; 72 | } catch (error) { 73 | console.error("Couldn't setup stream, reason:", error.stack); 74 | } 75 | } catch (error) { 76 | console.error( 77 | "Couldn't find audio and/or video devices, reason: ", 78 | error.stack 79 | ); 80 | } 81 | }, 82 | 83 | disableControls(view) { 84 | view.audioDevices.disabled = true; 85 | view.videoDevices.disabled = true; 86 | view.echoCancellation.disabled = true; 87 | view.autoGainControl.disabled = true; 88 | view.noiseSuppression.disabled = true; 89 | view.width.disabled = true; 90 | view.height.disabled = true; 91 | view.fps.disabled = true; 92 | view.bitrate.disabled = true; 93 | view.simulcast.disabled = true; 94 | view.applyButton.disabled = true; 95 | // Button present only when Recorder is used 96 | if (view.recordStream) view.recordStream.disabled = true; 97 | }, 98 | 99 | enableControls(view) { 100 | view.audioDevices.disabled = false; 101 | view.videoDevices.disabled = false; 102 | view.echoCancellation.disabled = false; 103 | view.autoGainControl.disabled = false; 104 | view.noiseSuppression.disabled = false; 105 | view.width.disabled = false; 106 | view.height.disabled = false; 107 | view.fps.disabled = false; 108 | view.bitrate.disabled = false; 109 | view.simulcast.disabled = false; 110 | view.applyButton.disabled = false; 111 | // See above 112 | if (view.recordStream) view.recordStream.disabled = false; 113 | }, 114 | 115 | async findDevices(view) { 116 | // ask for permissions 117 | view.localStream = await navigator.mediaDevices.getUserMedia({ 118 | video: true, 119 | audio: true, 120 | }); 121 | 122 | console.log(`Obtained stream with id: ${view.localStream.id}`); 123 | 124 | // enumerate devices 125 | const devices = await navigator.mediaDevices.enumerateDevices(); 126 | devices.forEach((device) => { 127 | if (device.kind === "videoinput") { 128 | view.videoDevices.options[view.videoDevices.options.length] = 129 | new Option(device.label, device.deviceId); 130 | } else if (device.kind === "audioinput") { 131 | view.audioDevices.options[view.audioDevices.options.length] = 132 | new Option(device.label, device.deviceId); 133 | } 134 | }); 135 | 136 | // for some reasons, firefox loses labels after closing the stream 137 | // so we close it after filling audio/video devices selects 138 | view.closeStream(view); 139 | }, 140 | 141 | closeStream(view) { 142 | if (view.localStream != undefined) { 143 | console.log(`Closing stream with id: ${view.localStream.id}`); 144 | view.localStream.getTracks().forEach((track) => track.stop()); 145 | view.localStream = undefined; 146 | } 147 | }, 148 | 149 | async setupStream(view) { 150 | if (view.localStream != undefined) { 151 | view.closeStream(view); 152 | } 153 | 154 | const videoDevice = view.videoDevices.value; 155 | const audioDevice = view.audioDevices.value; 156 | 157 | console.log( 158 | `Setting up stream: audioDevice: ${audioDevice}, videoDevice: ${videoDevice}` 159 | ); 160 | 161 | view.localStream = await navigator.mediaDevices.getUserMedia({ 162 | video: { 163 | deviceId: { exact: videoDevice }, 164 | width: view.width.value, 165 | height: view.height.value, 166 | frameRate: view.fps.value, 167 | }, 168 | audio: { 169 | deviceId: { exact: audioDevice }, 170 | echoCancellation: view.echoCancellation.checked, 171 | autoGainControl: view.autoGainControl.checked, 172 | noiseSuppression: view.noiseSuppression.checked, 173 | }, 174 | }); 175 | 176 | console.log(`Obtained stream with id: ${view.localStream.id}`); 177 | 178 | view.previewPlayer.srcObject = view.localStream; 179 | }, 180 | 181 | async startStreaming(view) { 182 | view.disableControls(view); 183 | 184 | view.pc = new RTCPeerConnection({ iceServers: iceServers }); 185 | 186 | // handle local events 187 | view.pc.onconnectionstatechange = () => { 188 | if (view.pc.connectionState === "connected") { 189 | view.startTime = new Date(); 190 | view.statusStopped.style.display = "none"; 191 | view.statusStarted.style.display = "block"; 192 | 193 | view.statsIntervalId = setInterval(async function () { 194 | if (!view.pc) { 195 | clearInterval(view.statsIntervalId); 196 | view.statsIntervalId = undefined; 197 | return; 198 | } 199 | 200 | view.time.innerText = view.toHHMMSS(new Date() - view.startTime); 201 | 202 | const stats = await view.pc.getStats(null); 203 | view.processStats(view, stats); 204 | }, 1000); 205 | } else if (view.pc.connectionState === "failed") { 206 | view.pushEvent("stop-streaming", { reason: "failed" }); 207 | view.stopStreaming(view); 208 | } 209 | }; 210 | 211 | view.pc.onicecandidate = (ev) => { 212 | view.pushEventTo(view.el, "ice", JSON.stringify(ev.candidate)); 213 | }; 214 | 215 | view.pc.addTrack(view.localStream.getAudioTracks()[0], view.localStream); 216 | 217 | if (view.simulcast.checked === true) { 218 | view.addSimulcastVideo(view); 219 | } else { 220 | view.addNormalVideo(view); 221 | } 222 | 223 | const offer = await view.pc.createOffer(); 224 | await view.pc.setLocalDescription(offer); 225 | 226 | view.pushEventTo(view.el, "offer", offer); 227 | }, 228 | 229 | processStats(view, stats) { 230 | let videoBytesSent = 0; 231 | let videoPacketsSent = 0; 232 | let videoNack = 0; 233 | let audioBytesSent = 0; 234 | let audioPacketsSent = 0; 235 | let audioNack = 0; 236 | 237 | let statsTimestamp; 238 | stats.forEach((report) => { 239 | if (!statsTimestamp) statsTimestamp = report.timestamp; 240 | 241 | if (report.type === "outbound-rtp" && report.kind === "video") { 242 | videoBytesSent += report.bytesSent; 243 | videoPacketsSent += report.packetsSent; 244 | videoNack += report.nackCount; 245 | } else if (report.type === "outbound-rtp" && report.kind === "audio") { 246 | audioBytesSent += report.bytesSent; 247 | audioPacketsSent += report.packetsSent; 248 | audioNack += report.nackCount; 249 | } 250 | }); 251 | 252 | const timeDiff = (statsTimestamp - view.lastStatsTimestamp) / 1000; 253 | 254 | let bitrate; 255 | 256 | if (!view.lastVideoBytesSent) { 257 | bitrate = (videoBytesSent * 8) / 1000; 258 | } else { 259 | if (timeDiff == 0) { 260 | // this should never happen as we are getting stats every second 261 | bitrate = 0; 262 | } else { 263 | bitrate = ((videoBytesSent - view.lastVideoBytesSent) * 8) / timeDiff; 264 | } 265 | } 266 | 267 | view.videoBitrate.innerText = (bitrate / 1000).toFixed(); 268 | 269 | if (!view.lastAudioBytesSent) { 270 | bitrate = (audioBytesSent * 8) / 1000; 271 | } else { 272 | if (timeDiff == 0) { 273 | // this should never happen as we are getting stats every second 274 | bitrate = 0; 275 | } else { 276 | bitrate = ((audioBytesSent - view.lastAudioBytesSent) * 8) / timeDiff; 277 | } 278 | } 279 | 280 | view.audioBitrate.innerText = (bitrate / 1000).toFixed(); 281 | 282 | // calculate packet loss 283 | if (!view.lastAudioPacketsSent || !view.lastVideoPacketsSent) { 284 | view.packetLoss.innerText = 0; 285 | } else { 286 | const packetsSent = 287 | videoPacketsSent + 288 | audioPacketsSent - 289 | view.lastAudioPacketsSent - 290 | view.lastVideoPacketsSent; 291 | 292 | const nack = 293 | videoNack + audioNack - view.lastVideoNack - view.lastAudioNack; 294 | 295 | if (packetsSent == 0 || timeDiff == 0) { 296 | view.packetLoss.innerText = 0; 297 | } else { 298 | view.packetLoss.innerText = ( 299 | ((nack / packetsSent) * 100) / 300 | timeDiff 301 | ).toFixed(2); 302 | } 303 | } 304 | 305 | view.lastVideoBytesSent = videoBytesSent; 306 | view.lastVideoPacketsSent = videoPacketsSent; 307 | view.lastVideoNack = videoNack; 308 | view.lastAudioBytesSent = audioBytesSent; 309 | view.lastAudioPacketsSent = audioPacketsSent; 310 | view.lastAudioNack = audioNack; 311 | view.lastStatsTimestamp = statsTimestamp; 312 | }, 313 | 314 | addSimulcastVideo(view) { 315 | const videoTrack = view.localStream.getVideoTracks()[0]; 316 | const settings = videoTrack.getSettings(); 317 | const maxTotalBitrate = view.bitrate.value * 1024; 318 | 319 | // This is based on: 320 | // https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/video/config/simulcast.cc;l=79?q=simulcast.cc 321 | let sendEncodings; 322 | if (settings.width >= 960 && settings.height >= 540) { 323 | // we do a very simple calculation: maxTotalBitrate = x + 1/4x + 1/16x 324 | // x - bitrate for base resolution 325 | // 1/4x- bitrate for resolution scaled down by 2 - we decrese total number of pixels by 4 (width/2*height/2) 326 | // 1/16x- bitrate for resolution scaled down by 4 - we decrese total number of pixels by 16 (width/4*height/4) 327 | const maxHBitrate = Math.floor((16 * maxTotalBitrate) / 21); 328 | const maxMBitrate = Math.floor(maxHBitrate / 4); 329 | const maxLBitrate = Math.floor(maxHBitrate / 16); 330 | sendEncodings = [ 331 | { rid: "h", maxBitrate: maxHBitrate }, 332 | { rid: "m", scaleResolutionDownBy: 2, maxBitrate: maxMBitrate }, 333 | { rid: "l", scaleResolutionDownBy: 4, maxBitrate: maxLBitrate }, 334 | ]; 335 | } else if (settings.width >= 480 && settings.height >= 270) { 336 | // maxTotalBitate = x + 1/4x 337 | const maxHBitrate = Math.floor((4 * maxTotalBitrate) / 5); 338 | const maxMBitrate = Math.floor(maxHBitrate / 4); 339 | sendEncodings = [ 340 | { rid: "h", maxBitrate: maxHBitrate }, 341 | { rid: "m", scaleResolutionDownBy: 2, maxBitrate: maxMBitrate }, 342 | ]; 343 | } else { 344 | sendEncodings = [{ rid: "h", maxBitrate: maxTotalBitrate }]; 345 | } 346 | 347 | view.pc.addTransceiver(view.localStream.getVideoTracks()[0], { 348 | streams: [view.localStream], 349 | sendEncodings: sendEncodings, 350 | }); 351 | }, 352 | 353 | addNormalVideo(view) { 354 | view.pc.addTrack(view.localStream.getVideoTracks()[0], view.localStream); 355 | 356 | // set max bitrate 357 | view.pc 358 | .getSenders() 359 | .filter((sender) => sender.track.kind === "video") 360 | .forEach(async (sender) => { 361 | const params = sender.getParameters(); 362 | params.encodings[0].maxBitrate = view.bitrate.value * 1024; 363 | await sender.setParameters(params); 364 | }); 365 | }, 366 | 367 | stopStreaming(view) { 368 | if (view.pc) { 369 | view.pc.close(); 370 | view.pc = undefined; 371 | } 372 | 373 | view.resetStats(view); 374 | 375 | view.enableControls(view); 376 | }, 377 | 378 | resetStats(view) { 379 | view.startTime = undefined; 380 | view.lastAudioReport = undefined; 381 | view.lastVideoReport = undefined; 382 | view.lastVideoBytesSent = 0; 383 | view.lastVideoPacketsSent = 0; 384 | view.lastVideoNack = 0; 385 | view.lastAudioBytesSent = 0; 386 | view.lastAudioPacketsSent = 0; 387 | view.lastAudioNack = 0; 388 | view.audioBitrate.innerText = 0; 389 | view.videoBitrate.innerText = 0; 390 | view.packetLoss.innerText = 0; 391 | view.time.innerText = "00:00:00"; 392 | view.statusStopped.style.display = "block"; 393 | view.statusStarted.style.display = "none"; 394 | }, 395 | 396 | toHHMMSS(milliseconds) { 397 | // Calculate hours 398 | let hours = Math.floor(milliseconds / (1000 * 60 * 60)); 399 | // Calculate minutes, subtracting the hours part 400 | let minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); 401 | // Calculate seconds, subtracting the hours and minutes parts 402 | let seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); 403 | 404 | // Formatting each unit to always have at least two digits 405 | hours = hours < 10 ? "0" + hours : hours; 406 | minutes = minutes < 10 ? "0" + minutes : minutes; 407 | seconds = seconds < 10 ? "0" + seconds : seconds; 408 | 409 | return hours + ":" + minutes + ":" + seconds; 410 | }, 411 | }; 412 | } 413 | -------------------------------------------------------------------------------- /lib/live_ex_webrtc/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveExWebRTC.CoreComponents do 2 | # This code is copied from a newly generated Phoenix project. 3 | 4 | @moduledoc """ 5 | Provides core UI components. 6 | 7 | At first glance, this module may seem daunting, but its goal is to provide 8 | core building blocks for your application, such as modals, tables, and 9 | forms. The components consist mostly of markup and are well-documented 10 | with doc strings and declarative assigns. You may customize and style 11 | them in any way you want, based on your application growth and needs. 12 | 13 | The default components use Tailwind CSS, a utility-first CSS framework. 14 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn 15 | how to customize them or feel free to swap in another framework altogether. 16 | 17 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. 18 | """ 19 | use Phoenix.Component 20 | 21 | alias Phoenix.LiveView.JS 22 | 23 | @doc """ 24 | Renders a modal. 25 | 26 | ## Examples 27 | 28 | <.modal id="confirm-modal"> 29 | This is a modal. 30 | 31 | 32 | JS commands may be passed to the `:on_cancel` to configure 33 | the closing/cancel event, for example: 34 | 35 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> 36 | This is another modal. 37 | 38 | 39 | """ 40 | attr :id, :string, required: true 41 | attr :show, :boolean, default: false 42 | attr :on_cancel, JS, default: %JS{} 43 | slot :inner_block, required: true 44 | 45 | def modal(assigns) do 46 | ~H""" 47 | 331 | """ 332 | end 333 | 334 | def input(%{type: "select"} = assigns) do 335 | ~H""" 336 |
337 | <.label for={@id}>{@label} 338 | 348 | <.error :for={msg <- @errors}>{msg} 349 |
350 | """ 351 | end 352 | 353 | def input(%{type: "textarea"} = assigns) do 354 | ~H""" 355 |
356 | <.label for={@id}>{@label} 357 | 368 | <.error :for={msg <- @errors}>{msg} 369 |
370 | """ 371 | end 372 | 373 | # All other inputs text, datetime-local, url, password, etc. are handled here... 374 | def input(assigns) do 375 | ~H""" 376 |
377 | <.label for={@id}>{@label} 378 | 391 | <.error :for={msg <- @errors}>{msg} 392 |
393 | """ 394 | end 395 | 396 | @doc """ 397 | Renders a label. 398 | """ 399 | attr :for, :string, default: nil 400 | slot :inner_block, required: true 401 | 402 | def label(assigns) do 403 | ~H""" 404 | 407 | """ 408 | end 409 | 410 | @doc """ 411 | Generates a generic error message. 412 | """ 413 | slot :inner_block, required: true 414 | 415 | def error(assigns) do 416 | ~H""" 417 |

418 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 419 | {render_slot(@inner_block)} 420 |

421 | """ 422 | end 423 | 424 | @doc """ 425 | Renders a header with title. 426 | """ 427 | attr :class, :string, default: nil 428 | 429 | slot :inner_block, required: true 430 | slot :subtitle 431 | slot :actions 432 | 433 | def header(assigns) do 434 | ~H""" 435 |
436 |
437 |

438 | {render_slot(@inner_block)} 439 |

440 |

441 | {render_slot(@subtitle)} 442 |

443 |
444 |
{render_slot(@actions)}
445 |
446 | """ 447 | end 448 | 449 | @doc ~S""" 450 | Renders a table with generic styling. 451 | 452 | ## Examples 453 | 454 | <.table id="users" rows={@users}> 455 | <:col :let={user} label="id"><%= user.id %> 456 | <:col :let={user} label="username"><%= user.username %> 457 | 458 | """ 459 | attr :id, :string, required: true 460 | attr :rows, :list, required: true 461 | attr :row_id, :any, default: nil, doc: "the function for generating the row id" 462 | attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 463 | 464 | attr :row_item, :any, 465 | default: &Function.identity/1, 466 | doc: "the function for mapping each row before calling the :col and :action slots" 467 | 468 | slot :col, required: true do 469 | attr :label, :string 470 | end 471 | 472 | slot :action, doc: "the slot for showing user actions in the last table column" 473 | 474 | def table(assigns) do 475 | assigns = 476 | with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 477 | assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 478 | end 479 | 480 | ~H""" 481 |
482 | 483 | 484 | 485 | 486 | 489 | 490 | 491 | 496 | 497 | 509 | 520 | 521 | 522 |
{col[:label]} 487 | Actions 488 |
502 |
503 | 504 | 505 | {render_slot(col, @row_item.(row))} 506 | 507 |
508 |
510 |
511 | 512 | 516 | {render_slot(action, @row_item.(row))} 517 | 518 |
519 |
523 |
524 | """ 525 | end 526 | 527 | @doc """ 528 | Renders a data list. 529 | 530 | ## Examples 531 | 532 | <.list> 533 | <:item title="Title"><%= @post.title %> 534 | <:item title="Views"><%= @post.views %> 535 | 536 | """ 537 | slot :item, required: true do 538 | attr :title, :string, required: true 539 | end 540 | 541 | def list(assigns) do 542 | ~H""" 543 |
544 |
545 |
546 |
{item.title}
547 |
{render_slot(item)}
548 |
549 |
550 |
551 | """ 552 | end 553 | 554 | @doc """ 555 | Renders a back navigation link. 556 | 557 | ## Examples 558 | 559 | <.back navigate={~p"/posts"}>Back to posts 560 | """ 561 | attr :navigate, :any, required: true 562 | slot :inner_block, required: true 563 | 564 | def back(assigns) do 565 | ~H""" 566 |
567 | <.link 568 | navigate={@navigate} 569 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 570 | > 571 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> 572 | {render_slot(@inner_block)} 573 | 574 |
575 | """ 576 | end 577 | 578 | @doc """ 579 | Renders a [Heroicon](https://heroicons.com). 580 | 581 | Heroicons come in three styles – outline, solid, and mini. 582 | By default, the outline style is used, but solid and mini may 583 | be applied by using the `-solid` and `-mini` suffix. 584 | 585 | You can customize the size and colors of the icons by setting 586 | width, height, and background color classes. 587 | 588 | Icons are extracted from your `assets/vendor/heroicons` directory and bundled 589 | within your compiled app.css by the plugin in your `assets/tailwind.config.js`. 590 | 591 | ## Examples 592 | 593 | <.icon name="hero-x-mark-solid" /> 594 | <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> 595 | """ 596 | attr :name, :string, required: true 597 | attr :class, :string, default: nil 598 | 599 | def icon(%{name: "hero-" <> _} = assigns) do 600 | ~H""" 601 | 602 | """ 603 | end 604 | 605 | ## JS Commands 606 | 607 | def show(js \\ %JS{}, selector) do 608 | JS.show(js, 609 | to: selector, 610 | transition: 611 | {"transition-all transform ease-out duration-300", 612 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 613 | "opacity-100 translate-y-0 sm:scale-100"} 614 | ) 615 | end 616 | 617 | def hide(js \\ %JS{}, selector) do 618 | JS.hide(js, 619 | to: selector, 620 | time: 200, 621 | transition: 622 | {"transition-all transform ease-in duration-200", 623 | "opacity-100 translate-y-0 sm:scale-100", 624 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 625 | ) 626 | end 627 | 628 | def show_modal(js \\ %JS{}, id) when is_binary(id) do 629 | js 630 | |> JS.show(to: "##{id}") 631 | |> JS.show( 632 | to: "##{id}-bg", 633 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} 634 | ) 635 | |> show("##{id}-container") 636 | |> JS.add_class("overflow-hidden", to: "body") 637 | |> JS.focus_first(to: "##{id}-content") 638 | end 639 | 640 | def hide_modal(js \\ %JS{}, id) do 641 | js 642 | |> JS.hide( 643 | to: "##{id}-bg", 644 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} 645 | ) 646 | |> hide("##{id}-container") 647 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 648 | |> JS.remove_class("overflow-hidden", to: "body") 649 | |> JS.pop_focus() 650 | end 651 | 652 | @doc """ 653 | Translates an error message using gettext. 654 | """ 655 | def translate_error({msg, opts}) do 656 | # You can make use of gettext to translate error messages by 657 | # uncommenting and adjusting the following code: 658 | 659 | # if count = opts[:count] do 660 | # Gettext.dngettext(LiveBroadcasterWeb.Gettext, "errors", msg, msg, count, opts) 661 | # else 662 | # Gettext.dgettext(LiveBroadcasterWeb.Gettext, "errors", msg, opts) 663 | # end 664 | 665 | Enum.reduce(opts, msg, fn {key, value}, acc -> 666 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 667 | end) 668 | end 669 | 670 | @doc """ 671 | Translates the errors for a field from a keyword list of errors. 672 | """ 673 | def translate_errors(errors, field) when is_list(errors) do 674 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 675 | end 676 | end 677 | -------------------------------------------------------------------------------- /lib/live_ex_webrtc/player.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveExWebRTC.Player do 2 | @moduledoc ~S''' 3 | Component for sending and playing audio and video via WebRTC from a Phoenix app to a browser (browser subscribes). 4 | 5 | It: 6 | * renders a single HTMLVideoElement 7 | * creates WebRTC PeerConnection both on the server and client side 8 | * connects those two peer connections negotiating a single audio and a single video track 9 | * attaches audio and video on the client side to the HTMLVideoElement 10 | * subscribes to the configured PubSub where it expects audio and video packets and sends them to the client side. 11 | 12 | When `LiveExWebRTC.Publisher` is used, audio an video packets are delivered automatically, 13 | assuming both components are configured with the same PubSub. 14 | 15 | If `LiveExWebRTC.Publisher` is not used, you need to send track information and packets, 16 | and receive keyframe requests manually using specific PubSub topics. 17 | See `LiveExWebRTC.Publisher` module doc for more. 18 | 19 | ## JavaScript Hook 20 | 21 | Player live view requires JavaScript hook to be registered under `Player` name. 22 | The hook can be created using `createPlayerHook` function. 23 | For example: 24 | 25 | ```javascript 26 | import { createPlayerHook } from "live_ex_webrtc"; 27 | let Hooks = {}; 28 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }]; 29 | Hooks.Player = createPlayerHook(iceServers); 30 | let liveSocket = new LiveSocket("/live", Socket, { 31 | // ... 32 | hooks: Hooks 33 | }); 34 | ``` 35 | 36 | ## Simulcast 37 | 38 | Simulcast requires video codecs to be H264 (packetization mode 1) and/or VP8. 39 | See `LiveExWebRTC.Publisher` module doc for more. 40 | 41 | ## Examples 42 | 43 | ```elixir 44 | defmodule LiveTwitchWeb.StreamViewerLive do 45 | use LiveTwitchWeb, :live_view 46 | 47 | alias LiveExWebRTC.Player 48 | 49 | @impl true 50 | def render(assigns) do 51 | ~H""" 52 | 53 | """ 54 | end 55 | 56 | @impl true 57 | def mount(_params, _session, socket) do 58 | socket = Player.attach(socket, id: "player", publisher_id: "publisher", pubsub: LiveTwitch.PubSub) 59 | {:ok, socket} 60 | end 61 | end 62 | ``` 63 | ''' 64 | use Phoenix.LiveView 65 | 66 | require Logger 67 | 68 | alias ExWebRTC.RTPCodecParameters 69 | alias ExWebRTC.RTP.{H264, VP8} 70 | alias LiveExWebRTC.Player 71 | 72 | @type on_connected() :: (publisher_id :: String.t() -> any()) 73 | 74 | @type on_packet() :: 75 | (publisher_id :: String.t(), 76 | packet_type :: :audio | :video, 77 | packet :: ExRTP.Packet.t(), 78 | socket :: Phoenix.LiveView.Socket.t() -> 79 | packet :: ExRTP.Packet.t()) 80 | 81 | @type t() :: struct() 82 | 83 | @check_lock_timeout_ms 3000 84 | @max_lock_timeout_ms 3000 85 | 86 | defstruct id: nil, 87 | publisher_id: nil, 88 | publisher_audio_track: nil, 89 | publisher_video_track: nil, 90 | pubsub: nil, 91 | pc: nil, 92 | audio_track_id: nil, 93 | video_track_id: nil, 94 | on_packet: nil, 95 | on_connected: nil, 96 | ice_servers: nil, 97 | ice_ip_filter: nil, 98 | ice_port_range: nil, 99 | audio_codecs: nil, 100 | video_codecs: nil, 101 | pc_genserver_opts: nil, 102 | munger: nil, 103 | layer: nil, 104 | target_layer: nil, 105 | video_layers: [], 106 | # codec that will be used for video sending 107 | video_send_codec: nil, 108 | last_seen: nil, 109 | locked: false, 110 | lock_timer: nil 111 | 112 | alias ExWebRTC.{ICECandidate, MediaStreamTrack, PeerConnection, RTP.Munger, SessionDescription} 113 | alias ExRTCP.Packet.PayloadFeedback.PLI 114 | alias Phoenix.PubSub 115 | 116 | attr(:socket, Phoenix.LiveView.Socket, required: true, doc: "Parent live view socket") 117 | 118 | attr(:player, __MODULE__, 119 | required: true, 120 | doc: """ 121 | Player struct. It is used to pass player id and publisher id to the newly created live view via live view session. 122 | This data is then used to do a handshake between parent live view and child live view during which child live view receives 123 | the whole Player struct. 124 | """ 125 | ) 126 | 127 | attr(:class, :string, default: nil, doc: "CSS/Tailwind classes for styling container") 128 | 129 | attr(:video_class, :string, 130 | default: nil, 131 | doc: "CSS/Tailwind classes for styling HTMLVideoElement" 132 | ) 133 | 134 | @doc """ 135 | Helper function for rendering Player live view. 136 | """ 137 | def live_render(assigns) do 138 | ~H""" 139 | {live_render(@socket, __MODULE__, 140 | id: "#{@player.id}-lv", 141 | session: %{ 142 | "publisher_id" => @player.publisher_id, 143 | "class" => @class, 144 | "video_class" => @video_class 145 | } 146 | )} 147 | """ 148 | end 149 | 150 | @doc """ 151 | Attaches required hooks and creates `t:t/0` struct. 152 | 153 | Created struct is saved in socket's assigns and has to be passed to `LiveExWebRTC.Player.live_render/1`. 154 | 155 | Options: 156 | * `id` [**required**] - player id. This is typically your user id (if there is users database). 157 | It is used to identify live view and generated HTML video player. 158 | * `publisher_id` [**required**] - publisher id that this player is going to subscribe to. 159 | * `pubsub` [**required**] - a pubsub that player live view will use for receiving audio and video packets. See module doc for more info. 160 | * `on_connected` - callback called when the underlying peer connection changes its state to the `:connected`. See `t:on_connected/0`. 161 | * `on_packet` - callback called for each audio and video RTP packet. Can be used to modify the packet before sending via WebRTC to the other side. See `t:on_packet/0`. 162 | * `ice_servers` - a list of `t:ExWebRTC.PeerConnection.Configuration.ice_server/0`, 163 | * `ice_ip_filter` - `t:ExICE.ICEAgent.ip_filter/0`, 164 | * `ice_port_range` - `t:Enumerable.t(non_neg_integer())/1`, 165 | * `audio_codecs` - a list of `t:ExWebRTC.RTPCodecParameters.t/0`, 166 | * `video_codecs` - a list of `t:ExWebRTC.RTPCodecParameters.t/0`, 167 | * `pc_genserver_opts` - `t:GenServer.options/0` for the underlying `ExWebRTC.PeerConnection` process. 168 | * `class` - a list of CSS/Tailwind classes that will be applied to the HTMLVideoPlayer. Defaults to "". 169 | """ 170 | @spec attach(Phoenix.LiveView.Socket.t(), Keyword.t()) :: Phoenix.LiveView.Socket.t() 171 | def attach(socket, opts) do 172 | opts = 173 | Keyword.validate!(opts, [ 174 | :id, 175 | :publisher_id, 176 | :pc_genserver_opts, 177 | :pubsub, 178 | :on_connected, 179 | :on_packet, 180 | :ice_servers, 181 | :ice_ip_filter, 182 | :ice_port_range, 183 | :audio_codecs, 184 | :video_codecs 185 | ]) 186 | 187 | player = %Player{ 188 | id: Keyword.fetch!(opts, :id), 189 | publisher_id: Keyword.fetch!(opts, :publisher_id), 190 | pubsub: Keyword.fetch!(opts, :pubsub), 191 | on_packet: Keyword.get(opts, :on_packet), 192 | on_connected: Keyword.get(opts, :on_connected), 193 | ice_servers: Keyword.get(opts, :ice_servers, [%{urls: "stun:stun.l.google.com:19302"}]), 194 | ice_ip_filter: Keyword.get(opts, :ice_ip_filter), 195 | ice_port_range: Keyword.get(opts, :ice_port_range), 196 | audio_codecs: Keyword.get(opts, :audio_codecs), 197 | video_codecs: Keyword.get(opts, :video_codecs), 198 | pc_genserver_opts: Keyword.get(opts, :pc_genserver_opts, []) 199 | } 200 | 201 | socket 202 | |> assign(player: player) 203 | |> attach_hook(:handshake, :handle_info, &handshake/2) 204 | end 205 | 206 | defp handshake({__MODULE__, {:connected, ref, child_pid, _meta}}, socket) do 207 | # child live view is connected, send it player struct 208 | send(child_pid, {ref, socket.assigns.player}) 209 | {:halt, socket} 210 | end 211 | 212 | defp handshake(_msg, socket) do 213 | {:cont, socket} 214 | end 215 | 216 | ## CALLBACKS 217 | 218 | @impl true 219 | def render(%{player: nil} = assigns) do 220 | ~H""" 221 | """ 222 | end 223 | 224 | @impl true 225 | def render(assigns) do 226 | ~H""" 227 |
228 |
229 | 237 | 238 |
242 |
246 |
247 | 250 | 259 |
260 | 266 |
267 |
268 | 269 | 275 |
276 |
277 | """ 278 | end 279 | 280 | @impl true 281 | def mount( 282 | _params, 283 | %{"publisher_id" => pub_id, "class" => class, "video_class" => video_class}, 284 | socket 285 | ) do 286 | socket = assign(socket, class: class, player: nil, video_class: video_class) 287 | 288 | if connected?(socket) do 289 | ref = make_ref() 290 | send(socket.parent_pid, {__MODULE__, {:connected, ref, self(), %{publisher_id: pub_id}}}) 291 | 292 | socket = 293 | receive do 294 | {^ref, %Player{publisher_id: ^pub_id} = player} -> 295 | PubSub.subscribe(player.pubsub, "streams:info:#{player.publisher_id}") 296 | assign(socket, player: player, display_settings: "hidden") 297 | after 298 | 5000 -> exit(:timeout) 299 | end 300 | 301 | {:ok, socket} 302 | else 303 | {:ok, socket} 304 | end 305 | end 306 | 307 | @impl true 308 | def handle_info( 309 | {:ex_webrtc, pc, {:connection_state_change, :connected}}, 310 | %{assigns: %{player: %{pc: pc}}} = socket 311 | ) do 312 | %{player: player} = socket.assigns 313 | 314 | # subscribe only if we managed to negotiate tracks 315 | if player.audio_track_id != nil do 316 | PubSub.subscribe( 317 | player.pubsub, 318 | "streams:audio:#{player.publisher_id}:#{player.publisher_audio_track.id}" 319 | ) 320 | end 321 | 322 | if player.video_track_id != nil do 323 | PubSub.subscribe( 324 | player.pubsub, 325 | "streams:video:#{player.publisher_id}:#{player.publisher_video_track.id}:#{player.layer}" 326 | ) 327 | 328 | broadcast_keyframe_req(socket) 329 | end 330 | 331 | if player.on_connected, do: player.on_connected.(player.publisher_id) 332 | 333 | {:noreply, socket} 334 | end 335 | 336 | @impl true 337 | def handle_info( 338 | {:ex_webrtc, pc, {:connection_state_change, :failed}}, 339 | %{assigns: %{player: %{pc: pc}}} 340 | ) do 341 | exit(:pc_failed) 342 | end 343 | 344 | def handle_info({:ex_webrtc, _pc, {:rtcp, packets}}, socket) do 345 | # Browser, we are sending to, requested a keyframe. 346 | # Forward this request to the publisher. 347 | if Enum.any?(packets, fn {_, packet} -> match?(%PLI{}, packet) end) do 348 | broadcast_keyframe_req(socket) 349 | end 350 | 351 | {:noreply, socket} 352 | end 353 | 354 | def handle_info({:ex_webrtc, _pid, _}, socket) do 355 | {:noreply, socket} 356 | end 357 | 358 | @impl true 359 | def handle_info({:live_ex_webrtc, :info, publisher_audio_track, publisher_video_track}, socket) do 360 | %{player: player} = socket.assigns 361 | 362 | case player do 363 | %Player{ 364 | publisher_audio_track: ^publisher_audio_track, 365 | publisher_video_track: ^publisher_video_track 366 | } -> 367 | # tracks are the same, update last_seen and do nothing 368 | player = %Player{player | last_seen: System.monotonic_time(:millisecond)} 369 | socket = assign(socket, player: player) 370 | {:noreply, socket} 371 | 372 | %Player{locked: true} -> 373 | # Different tracks but we are still receiving updates from old publisher. Ignore. 374 | {:noreply, socket} 375 | 376 | %Player{ 377 | publisher_audio_track: old_publisher_audio_track, 378 | publisher_video_track: old_publisher_video_track, 379 | video_layers: old_layers, 380 | locked: false 381 | } -> 382 | if player.pc, do: PeerConnection.close(player.pc) 383 | 384 | if player.lock_timer do 385 | Process.cancel_timer(player.lock_timer) 386 | 387 | # flush mailbox 388 | receive do 389 | :check_lock -> :ok 390 | after 391 | 0 -> :ok 392 | end 393 | end 394 | 395 | video_layers = (publisher_video_track && publisher_video_track.rids) || ["h"] 396 | 397 | video_layers = 398 | Enum.map(video_layers, fn 399 | "h" -> {"h", "high"} 400 | "m" -> {"m", "medium"} 401 | "l" -> {"l", "low"} 402 | end) 403 | 404 | player = %Player{ 405 | player 406 | | publisher_audio_track: publisher_audio_track, 407 | publisher_video_track: publisher_video_track, 408 | pc: nil, 409 | layer: "h", 410 | target_layer: "h", 411 | video_layers: video_layers, 412 | munger: nil, 413 | last_seen: System.monotonic_time(:millisecond), 414 | locked: true, 415 | lock_timer: Process.send_after(self(), :check_lock, @check_lock_timeout_ms) 416 | } 417 | 418 | socket = assign(socket, :player, player) 419 | 420 | if old_publisher_audio_track != nil or old_publisher_video_track != nil do 421 | PubSub.unsubscribe( 422 | player.pubsub, 423 | "streams:audio:#{player.publisher_id}:#{old_publisher_audio_track.id}" 424 | ) 425 | 426 | Enum.each(old_layers, fn {id, _layer} -> 427 | PubSub.unsubscribe( 428 | player.pubsub, 429 | "streams:video:#{player.publisher_id}:#{old_publisher_video_track.id}:#{id}" 430 | ) 431 | end) 432 | end 433 | 434 | if publisher_audio_track != nil or publisher_video_track != nil do 435 | socket = push_event(socket, "connect-#{player.id}", %{}) 436 | {:noreply, socket} 437 | else 438 | {:noreply, socket} 439 | end 440 | end 441 | end 442 | 443 | @impl true 444 | def handle_info({:live_ex_webrtc, :bye, publisher_audio_track, publisher_video_track}, socket) do 445 | %{player: player} = socket.assigns 446 | 447 | case player do 448 | %Player{ 449 | publisher_audio_track: ^publisher_audio_track, 450 | publisher_video_track: ^publisher_video_track 451 | } -> 452 | player = %Player{player | locked: false} 453 | socket = assign(socket, player: player) 454 | {:noreply, socket} 455 | 456 | _ -> 457 | {:noreply, socket} 458 | end 459 | end 460 | 461 | @impl true 462 | def handle_info({:live_ex_webrtc, :audio, packet}, socket) do 463 | %{player: player} = socket.assigns 464 | 465 | packet = 466 | if player.on_packet, 467 | do: player.on_packet.(player.publisher_id, :audio, packet, socket), 468 | else: packet 469 | 470 | PeerConnection.send_rtp(player.pc, player.audio_track_id, packet) 471 | {:noreply, socket} 472 | end 473 | 474 | @impl true 475 | def handle_info({:live_ex_webrtc, :video, rid, packet}, socket) do 476 | %{player: player} = socket.assigns 477 | 478 | packet = 479 | if player.on_packet, 480 | do: player.on_packet.(player.publisher_id, :video, packet), 481 | else: packet 482 | 483 | cond do 484 | rid == player.layer -> 485 | {packet, munger} = Munger.munge(player.munger, packet) 486 | player = %Player{player | munger: munger} 487 | socket = assign(socket, player: player) 488 | :ok = PeerConnection.send_rtp(player.pc, player.video_track_id, packet) 489 | {:noreply, socket} 490 | 491 | rid == player.target_layer -> 492 | if keyframe?(player.video_send_codec, packet) == true do 493 | munger = Munger.update(player.munger) 494 | {packet, munger} = Munger.munge(munger, packet) 495 | 496 | PeerConnection.send_rtp(player.pc, player.video_track_id, packet) 497 | 498 | PubSub.unsubscribe( 499 | socket.assigns.player.pubsub, 500 | "streams:video:#{player.publisher_id}:#{player.publisher_video_track.id}:#{player.layer}" 501 | ) 502 | 503 | flush_layer(player.layer) 504 | 505 | player = %Player{player | munger: munger, layer: rid} 506 | socket = assign(socket, player: player) 507 | {:noreply, socket} 508 | else 509 | {:noreply, socket} 510 | end 511 | 512 | true -> 513 | Logger.warning("Unexpected packet. Ignoring.") 514 | {:noreply, socket} 515 | end 516 | end 517 | 518 | @impl true 519 | def handle_info(:check_lock, %{assigns: %{player: %Player{locked: true} = player}} = socket) do 520 | now = System.monotonic_time(:millisecond) 521 | 522 | if now - socket.assigns.player.last_seen > @max_lock_timeout_ms do 523 | # unlock i.e. allow for track update 524 | player = %Player{player | lock_timer: nil, locked: false} 525 | socket = assign(socket, :player, player) 526 | {:noreply, socket} 527 | else 528 | timer = Process.send_after(self(), :check_lock, @check_lock_timeout_ms) 529 | player = %Player{player | lock_timer: timer} 530 | socket = assign(socket, :player, player) 531 | {:noreply, socket} 532 | end 533 | end 534 | 535 | @impl true 536 | def handle_info(:check_lock, socket) do 537 | player = %Player{socket.assigns.player | lock_timer: nil} 538 | socket = assign(socket, :player, player) 539 | {:noreply, socket} 540 | end 541 | 542 | @impl true 543 | def handle_event("toggle-settings", _params, socket) do 544 | socket = 545 | case socket.assigns do 546 | %{display_settings: "hidden"} -> 547 | assign(socket, :display_settings, "flex") 548 | 549 | %{display_settings: "flex"} -> 550 | assign(socket, :display_settings, "hidden") 551 | end 552 | 553 | {:noreply, socket} 554 | end 555 | 556 | @impl true 557 | def handle_event("offer", unsigned_params, socket) do 558 | %{player: player} = socket.assigns 559 | 560 | offer = SessionDescription.from_json(unsigned_params) 561 | {:ok, pc} = spawn_peer_connection(socket) 562 | 563 | :ok = PeerConnection.set_remote_description(pc, offer) 564 | 565 | stream_id = MediaStreamTrack.generate_stream_id() 566 | audio_track = MediaStreamTrack.new(:audio, [stream_id]) 567 | video_track = MediaStreamTrack.new(:video, [stream_id]) 568 | {:ok, audio_sender} = PeerConnection.add_track(pc, audio_track) 569 | {:ok, video_sender} = PeerConnection.add_track(pc, video_track) 570 | {:ok, answer} = PeerConnection.create_answer(pc) 571 | :ok = PeerConnection.set_local_description(pc, answer) 572 | :ok = gather_candidates(pc) 573 | answer = PeerConnection.get_local_description(pc) 574 | 575 | transceivers = PeerConnection.get_transceivers(pc) 576 | video_tr = Enum.find(transceivers, fn tr -> tr.sender.id == video_sender.id end) 577 | audio_tr = Enum.find(transceivers, fn tr -> tr.sender.id == audio_sender.id end) 578 | 579 | # check if tracks were negotiated successfully 580 | video_negotiated? = video_tr && video_tr.current_direction not in [:recvonly, :inactive] 581 | audio_negotiated? = audio_tr && audio_tr.current_direction not in [:recvonly, :inactive] 582 | 583 | new_player = %Player{ 584 | player 585 | | pc: pc, 586 | audio_track_id: audio_negotiated? && audio_track.id, 587 | video_track_id: video_negotiated? && video_track.id, 588 | munger: video_negotiated? && Munger.new(List.first(video_tr.codecs)), 589 | video_send_codec: video_negotiated? && List.first(video_tr.codecs) 590 | } 591 | 592 | {:noreply, 593 | socket 594 | |> assign(player: new_player) 595 | |> push_event("answer-#{player.id}", SessionDescription.to_json(answer))} 596 | end 597 | 598 | @impl true 599 | def handle_event("ice", "null", socket) do 600 | %{player: player} = socket.assigns 601 | 602 | case player do 603 | %Player{pc: nil} -> 604 | {:noreply, socket} 605 | 606 | %Player{pc: pc} -> 607 | :ok = PeerConnection.add_ice_candidate(pc, %ICECandidate{candidate: ""}) 608 | {:noreply, socket} 609 | end 610 | end 611 | 612 | @impl true 613 | def handle_event("ice", unsigned_params, socket) do 614 | %{player: player} = socket.assigns 615 | 616 | case player do 617 | %Player{pc: nil} -> 618 | {:noreply, socket} 619 | 620 | %Player{pc: pc} -> 621 | cand = 622 | unsigned_params 623 | |> Jason.decode!() 624 | |> ExWebRTC.ICECandidate.from_json() 625 | 626 | :ok = PeerConnection.add_ice_candidate(pc, cand) 627 | 628 | {:noreply, socket} 629 | end 630 | end 631 | 632 | @impl true 633 | def handle_event("layer", layer, socket) when layer in ["l", "m", "h"] do 634 | %{player: player} = socket.assigns 635 | 636 | if player.layer == layer do 637 | {:noreply, socket} 638 | else 639 | # this shouldn't be needed but just to make sure we won't duplicate subscription 640 | PubSub.unsubscribe( 641 | player.pubsub, 642 | "streams:video:#{player.publisher_id}:#{player.publisher_video_track.id}:#{layer}" 643 | ) 644 | 645 | PubSub.subscribe( 646 | player.pubsub, 647 | "streams:video:#{player.publisher_id}:#{player.publisher_video_track.id}:#{layer}" 648 | ) 649 | 650 | player = %Player{player | target_layer: layer} 651 | 652 | socket = assign(socket, player: player) 653 | broadcast_keyframe_req(socket) 654 | {:noreply, socket} 655 | end 656 | end 657 | 658 | defp spawn_peer_connection(socket) do 659 | %{player: player} = socket.assigns 660 | 661 | pc_opts = 662 | [ 663 | ice_servers: player.ice_servers, 664 | ice_ip_filter: player.ice_ip_filter, 665 | ice_port_range: player.ice_port_range, 666 | audio_codecs: player.audio_codecs, 667 | video_codecs: player.video_codecs 668 | ] 669 | |> Enum.reject(fn {_k, v} -> v == nil end) 670 | 671 | PeerConnection.start_link(pc_opts, player.pc_genserver_opts) 672 | end 673 | 674 | defp gather_candidates(pc) do 675 | # we either wait for all of the candidates 676 | # or whatever we were able to gather in one second 677 | receive do 678 | {:ex_webrtc, ^pc, {:ice_gathering_state_change, :complete}} -> :ok 679 | after 680 | 1000 -> :ok 681 | end 682 | end 683 | 684 | defp broadcast_keyframe_req(socket) do 685 | %{player: player} = socket.assigns 686 | 687 | layer = player.target_layer || player.layer 688 | 689 | PubSub.broadcast( 690 | player.pubsub, 691 | "publishers:#{player.publisher_id}", 692 | {:live_ex_webrtc, :keyframe_req, layer} 693 | ) 694 | end 695 | 696 | defp keyframe?(%RTPCodecParameters{mime_type: "video/H264"}, packet), do: H264.keyframe?(packet) 697 | defp keyframe?(%RTPCodecParameters{mime_type: "video/VP8"}, packet), do: VP8.keyframe?(packet) 698 | 699 | defp flush_layer(layer) do 700 | receive do 701 | {:live_ex_webrtc, :video, ^layer, _packet} -> flush_layer(layer) 702 | after 703 | 0 -> :ok 704 | end 705 | end 706 | end 707 | -------------------------------------------------------------------------------- /lib/live_ex_webrtc/publisher.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveExWebRTC.Publisher do 2 | @moduledoc ~S''' 3 | Component for sending audio and video via WebRTC from a browser to a Phoenix app (browser publishes). 4 | 5 | It: 6 | * renders: 7 | * audio and video device selects 8 | * audio and video stream configs 9 | * stream recording toggle (with recordings enabled) 10 | * stream preview 11 | * transmission stats 12 | * on clicking "Start Streaming", creates WebRTC PeerConnection both on the client and server side 13 | * connects those two peer connections negotiatiing a single audio and video track 14 | * sends audio and video from selected devices to the live view process 15 | * publishes received audio and video packets to the configured PubSub 16 | * can optionally use the [ExWebRTC Recorder](https://github.com/elixir-webrtc/ex_webrtc_recorder) to record the stream 17 | 18 | When `LiveExWebRTC.Player` is used, audio and video packets are delivered automatically, 19 | assuming both components are configured with the same PubSub. 20 | 21 | If `LiveExWebRTC.Player` is not used, you should use following topics and messages: 22 | * `streams:audio:#{publisher_id}:#{audio_track_id}` - for receiving audio packets 23 | * `streams:video:#{publisher_id}:#{video_track_id}:#{layer}` - for receiving video packets. 24 | The message is in form of `{:live_ex_webrtc, :video, "l" | "m" | "h", ExRTP.Packet.t()}` or 25 | `{:live_ex_webrtc, :audio, ExRTP.Packet.t()}`. Packets for non-simulcast video tracks are always 26 | sent with "h" identifier. 27 | * `streams:info:#{publisher.id}"` - for receiving information about publisher tracks and their layers. 28 | The message is in form of: `{:live_ex_webrtc, :info | :bye, audio_track :: ExWebRTC.MediaStreamTrack.t(), video_track :: ExWebRTC.MediaStreamTrack.t()}`. 29 | * `publishers:#{publisher_id}` for sending keyframe request. 30 | The message must be in form of `{:live_ex_webrtc, :keyframe_req, "l" | "m" | "h"}` 31 | E.g. 32 | ```elixir 33 | PubSub.broadcast(LiveTwitch.PubSub, "publishers:my_publisher", {:live_ex_webrtc, :keyframe_req, "h"}) 34 | ``` 35 | 36 | ## JavaScript Hook 37 | 38 | Publisher live view requires JavaScript hook to be registered under `Publisher` name. 39 | The hook can be created using `createPublisherHook` function. 40 | For example: 41 | 42 | ```javascript 43 | import { createPublisherHook } from "live_ex_webrtc"; 44 | let Hooks = {}; 45 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }]; 46 | Hooks.Publisher = createPublisherHook(iceServers); 47 | let liveSocket = new LiveSocket("/live", Socket, { 48 | // ... 49 | hooks: Hooks 50 | }); 51 | ``` 52 | 53 | ## Simulcast 54 | 55 | Simulcast requires video codecs to be H264 (packetization mode 1) and/or VP8. E.g. 56 | 57 | ```elixir 58 | video_codecs = [ 59 | %RTPCodecParameters{ 60 | payload_type: 98, 61 | mime_type: "video/H264", 62 | clock_rate: 90_000, 63 | sdp_fmtp_line: %FMTP{ 64 | pt: 98, 65 | level_asymmetry_allowed: true, 66 | packetization_mode: 1, 67 | profile_level_id: 0x42E01F 68 | } 69 | }, 70 | %RTPCodecParameters{ 71 | payload_type: 96, 72 | mime_type: "video/VP8", 73 | clock_rate: 90_000 74 | } 75 | ] 76 | ``` 77 | 78 | You can also use the shorthands for default H264 and VP8 codec parameters: 79 | ```elixir 80 | video_codecs = [:h264, :vp8] 81 | ``` 82 | 83 | ## Examples 84 | 85 | ```elixir 86 | defmodule LiveTwitchWeb.StreamerLive do 87 | use LiveTwitchWeb, :live_view 88 | 89 | alias LiveExWebRTC.Publisher 90 | 91 | @impl true 92 | def render(assigns) do 93 | ~H""" 94 | 95 | """ 96 | end 97 | 98 | @impl true 99 | def mount(_params, _session, socket) do 100 | socket = Publisher.attach(socket, id: "publisher", pubsub: LiveTwitch.PubSub) 101 | {:ok, socket} 102 | end 103 | end 104 | ``` 105 | ''' 106 | use Phoenix.LiveView 107 | 108 | require Logger 109 | 110 | import LiveExWebRTC.CoreComponents 111 | 112 | alias ExWebRTC.RTPCodecParameters 113 | alias LiveExWebRTC.Publisher 114 | alias ExWebRTC.{ICECandidate, PeerConnection, Recorder, SessionDescription} 115 | alias Phoenix.PubSub 116 | 117 | @typedoc """ 118 | Called when WebRTC has connected. 119 | """ 120 | @type on_connected :: (publisher_id :: String.t() -> any()) 121 | 122 | @typedoc """ 123 | Called when WebRTC has disconnected. 124 | """ 125 | @type on_disconnected :: (publisher_id :: String.t() -> any()) 126 | 127 | @typedoc """ 128 | Called when recorder finishes stream recording. 129 | 130 | For exact meaning of the second argument, refer to `t:ExWebRTC.Recorder.end_tracks_ok_result/0`. 131 | """ 132 | @type on_recording_finished :: (publisher_id :: String.t(), Recorder.end_tracks_ok_result() -> 133 | any()) 134 | 135 | @typedoc """ 136 | Called when recorder sends a message to the Publisher. 137 | 138 | For exact meaning of the second argument, refer to `t:ExWebRTC.Recorder.message/0`. 139 | """ 140 | @type on_recorder_message :: (publisher_id :: String.t(), Recorder.message() -> any()) 141 | 142 | @type on_packet :: 143 | (publisher_id :: String.t(), 144 | packet_type :: :audio | :video, 145 | layer :: nil | String.t(), 146 | packet :: ExRTP.Packet.t(), 147 | socket :: Phoenix.LiveView.Socket.t() -> 148 | packet :: ExRTP.Packet.t()) 149 | 150 | @type t :: struct() 151 | 152 | defstruct id: nil, 153 | pc: nil, 154 | pc_mref: nil, 155 | streaming?: false, 156 | simulcast_supported?: nil, 157 | # record checkbox status 158 | record?: false, 159 | # whether recorings are allowed or not 160 | recordings?: true, 161 | # recorder instance 162 | recorder: nil, 163 | recorder_opts: [], 164 | audio_track: nil, 165 | video_track: nil, 166 | on_packet: nil, 167 | on_connected: nil, 168 | on_disconnected: nil, 169 | on_recording_finished: nil, 170 | on_recorder_message: nil, 171 | pubsub: nil, 172 | ice_servers: nil, 173 | ice_ip_filter: nil, 174 | ice_port_range: nil, 175 | audio_codecs: nil, 176 | video_codecs: nil, 177 | pc_genserver_opts: nil, 178 | info_timer: nil 179 | 180 | attr(:socket, Phoenix.LiveView.Socket, required: true, doc: "Parent live view socket") 181 | 182 | attr(:publisher, __MODULE__, 183 | required: true, 184 | doc: """ 185 | Publisher struct. It is used to pass publisher id to the newly created live view via live view session. 186 | This data is then used to do a handshake between parent live view and child live view during which child live 187 | view receives the whole Publisher struct. 188 | """ 189 | ) 190 | 191 | @doc """ 192 | Helper function for rendering Publisher live view. 193 | """ 194 | def live_render(assigns) do 195 | ~H""" 196 | {live_render(@socket, __MODULE__, 197 | id: "#{@publisher.id}-lv", 198 | session: %{"publisher_id" => @publisher.id} 199 | )} 200 | """ 201 | end 202 | 203 | @doc """ 204 | Attaches required hooks and creates `t:t/0` struct. 205 | 206 | Created struct is saved in socket's assigns and has to be passed to `LiveExWebRTC.Publisher.live_render/1`. 207 | 208 | Options: 209 | * `id` [**required**] - publisher id. This is typically your user id (if there is users database). 210 | It is used to identify live view and generated HTML elements. 211 | * `pubsub` [**required**] - a pubsub that publisher live view will use for broadcasting audio and video packets received from a browser. See module doc for more info. 212 | * `recordings?` - whether to allow for recordings or not. Defaults to true. 213 | See module doc and `t:on_disconnected/0` for more info. 214 | * `recorder_opts` - a list of options that will be passed to the recorder. In particular, they can contain S3 config where recordings will be uploaded. See `t:ExWebRTC.Recorder.option/0` for more. 215 | * `on_connected` - callback called when the underlying peer connection changes its state to the `:connected`. See `t:on_connected/0`. 216 | * `on_disconnected` - callback called when the underlying peer connection process terminates. See `t:on_disconnected/0`. 217 | * `on_recording_finished` - callback called when the stream recording has finised. See `t:on_recording_finished/0`. 218 | * `on_packet` - callback called for each audio and video RTP packet. Can be used to modify the packet before publishing it on a pubsub. See `t:on_packet/0`. 219 | * `ice_servers` - a list of `t:ExWebRTC.PeerConnection.Configuration.ice_server/0`, 220 | * `ice_ip_filter` - `t:ExICE.ICEAgent.ip_filter/0`, 221 | * `ice_port_range` - `t:Enumerable.t(non_neg_integer())/1`, 222 | * `audio_codecs` - a list of `t:ExWebRTC.RTPCodecParameters.t/0`, 223 | * `video_codecs` - a list of `t:ExWebRTC.RTPCodecParameters.t/0`, 224 | * `pc_genserver_opts` - `t:GenServer.options/0` for the underlying `ExWebRTC.PeerConnection` process. 225 | """ 226 | @spec attach(Phoenix.LiveView.Socket.t(), Keyword.t()) :: Phoenix.LiveView.Socket.t() 227 | def attach(socket, opts) do 228 | opts = 229 | Keyword.validate!(opts, [ 230 | :id, 231 | :name, 232 | :pubsub, 233 | :recordings?, 234 | :recorder_opts, 235 | :on_packet, 236 | :on_connected, 237 | :on_disconnected, 238 | :on_recording_finished, 239 | :on_recorder_message, 240 | :ice_servers, 241 | :ice_ip_filter, 242 | :ice_port_range, 243 | :audio_codecs, 244 | :video_codecs, 245 | :pc_genserver_opts 246 | ]) 247 | 248 | publisher = %Publisher{ 249 | id: Keyword.fetch!(opts, :id), 250 | pubsub: Keyword.fetch!(opts, :pubsub), 251 | recordings?: Keyword.get(opts, :recordings?, true), 252 | recorder_opts: Keyword.get(opts, :recorder_opts, []), 253 | on_packet: Keyword.get(opts, :on_packet), 254 | on_connected: Keyword.get(opts, :on_connected), 255 | on_disconnected: Keyword.get(opts, :on_disconnected), 256 | on_recording_finished: Keyword.get(opts, :on_recording_finished), 257 | on_recorder_message: Keyword.get(opts, :on_recorder_message), 258 | ice_servers: Keyword.get(opts, :ice_servers, [%{urls: "stun:stun.l.google.com:19302"}]), 259 | ice_ip_filter: Keyword.get(opts, :ice_ip_filter), 260 | ice_port_range: Keyword.get(opts, :ice_port_range), 261 | audio_codecs: Keyword.get(opts, :audio_codecs), 262 | video_codecs: Keyword.get(opts, :video_codecs), 263 | pc_genserver_opts: Keyword.get(opts, :pc_genserver_opts, []) 264 | } 265 | 266 | # Check the "Record stream?" checkbox by default if recordings are allowed 267 | record? = publisher.recordings? == true 268 | 269 | socket 270 | |> assign(publisher: %Publisher{publisher | record?: record?}) 271 | |> attach_hook(:handshake, :handle_info, &handshake/2) 272 | end 273 | 274 | defp handshake({__MODULE__, {:connected, ref, pid, _meta}}, socket) do 275 | send(pid, {ref, socket.assigns.publisher}) 276 | {:halt, socket} 277 | end 278 | 279 | defp handshake(_msg, socket) do 280 | {:cont, socket} 281 | end 282 | 283 | ## CALLBACKS 284 | 285 | @impl true 286 | def render(%{publisher: nil} = assigns) do 287 | ~H""" 288 | """ 289 | end 290 | 291 | @impl true 292 | def render(assigns) do 293 | ~H""" 294 |
299 |
300 |
301 |
302 | 310 |
311 |
312 |
317 | 320 | 325 |
326 |
331 | 334 | 339 |
340 |
341 |
342 |
348 |
349 |

Statistics

350 |
351 |
352 |
353 |
354 | 355 |

0

356 |
357 |
358 | 359 |

0

360 |
361 |
362 | 363 | 0 364 |
365 |
366 | 367 | 00:00:00 368 |
369 |
370 |
371 |
372 | <.icon name="hero-signal-slash" class="w-6 h-6 text-red-500" /> 373 |
374 | 377 |
378 |
379 |
380 |
381 |
382 |
383 | 389 | 397 | 405 |
406 | 418 | 421 |
422 |
423 |
424 | <.modal id="settings-modal"> 425 |
426 |
427 |
Audio Settings
428 |
429 |
430 | 431 | 432 |
433 |
434 | 435 | 436 |
437 |
438 | 439 | 440 |
441 |
442 |
443 |
444 |
Video Settings
445 |
446 |
447 | 448 | 454 |
455 |
456 | 457 | 463 |
464 |
465 | 466 | 472 |
473 |
474 |
475 |
476 | 483 |
484 |
485 | 486 | 492 |
493 | <%= if @publisher.simulcast_supported? do %> 494 |
495 | 496 | 497 |
498 | <% else %> 499 |
500 |
501 | 502 | 503 |
504 |

505 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 506 | Simulcast requires server to be configured with H264 and/or VP8 codec 507 |

508 |
509 | <% end %> 510 |
511 | 512 |
513 | """ 514 | end 515 | 516 | @impl true 517 | def mount(_params, %{"publisher_id" => pub_id}, socket) do 518 | socket = assign(socket, publisher: nil) 519 | 520 | if connected?(socket) do 521 | ref = make_ref() 522 | send(socket.parent_pid, {__MODULE__, {:connected, ref, self(), %{publisher_id: pub_id}}}) 523 | 524 | socket = 525 | receive do 526 | {^ref, %Publisher{id: ^pub_id} = publisher} -> 527 | codecs = publisher.video_codecs || PeerConnection.Configuration.default_video_codecs() 528 | publisher = %Publisher{publisher | simulcast_supported?: simulcast_supported?(codecs)} 529 | assign(socket, publisher: publisher) 530 | after 531 | 5000 -> exit(:timeout) 532 | end 533 | 534 | {:ok, socket} 535 | else 536 | {:ok, socket} 537 | end 538 | end 539 | 540 | @impl true 541 | def handle_info( 542 | {:live_ex_webrtc, :keyframe_req, layer}, 543 | %{assigns: %{publisher: %{pc: pc}}} = socket 544 | ) 545 | when pc != nil do 546 | %{publisher: publisher} = socket.assigns 547 | 548 | # Non-simulcast tracks are always sent with "h" identifier 549 | # Hence, when we receive a keyframe request for "h", we must 550 | # check whether it's simulcast track or not. 551 | layer = 552 | if layer == "h" and publisher.video_track.rids == nil do 553 | nil 554 | else 555 | layer 556 | end 557 | 558 | :ok = PeerConnection.send_pli(pc, publisher.video_track.id, layer) 559 | 560 | {:noreply, socket} 561 | end 562 | 563 | @impl true 564 | def handle_info({:ex_webrtc, _pc, {:rtp, track_id, rid, packet}}, socket) do 565 | %{publisher: publisher} = socket.assigns 566 | 567 | if publisher.record?, do: Recorder.record(publisher.recorder, track_id, rid, packet) 568 | 569 | {kind, rid} = 570 | case publisher do 571 | %Publisher{video_track: %{id: ^track_id}} -> {:video, rid || "h"} 572 | %Publisher{audio_track: %{id: ^track_id}} -> {:audio, nil} 573 | end 574 | 575 | packet = 576 | if publisher.on_packet, 577 | do: publisher.on_packet.(publisher.id, kind, rid, packet, socket), 578 | else: packet 579 | 580 | {layer, msg} = 581 | case kind do 582 | :audio -> {"", {:live_ex_webrtc, kind, packet}} 583 | # for non simulcast tracks, push everything with "h" identifier 584 | :video -> {":#{rid}", {:live_ex_webrtc, kind, rid, packet}} 585 | end 586 | 587 | PubSub.broadcast(publisher.pubsub, "streams:#{kind}:#{publisher.id}:#{track_id}#{layer}", msg) 588 | 589 | {:noreply, socket} 590 | end 591 | 592 | @impl true 593 | def handle_info({:ex_webrtc, _pid, {:connection_state_change, :connected}}, socket) do 594 | %{publisher: pub} = socket.assigns 595 | 596 | info_timer = Process.send_after(self(), :streams_info, 1000) 597 | pub = %Publisher{pub | info_timer: info_timer} 598 | socket = assign(socket, :publisher, pub) 599 | 600 | if pub.record? do 601 | [ 602 | %{kind: :audio, receiver: %{track: audio_track}}, 603 | %{kind: :video, receiver: %{track: video_track}} 604 | ] = PeerConnection.get_transceivers(pub.pc) 605 | 606 | Recorder.add_tracks(pub.recorder, [audio_track, video_track]) 607 | end 608 | 609 | if pub.on_connected, do: pub.on_connected.(pub.id) 610 | 611 | {:noreply, socket} 612 | end 613 | 614 | @impl true 615 | def handle_info({:ex_webrtc, _pid, {:connection_state_change, :failed}}, socket) do 616 | {:noreply, bye(socket)} 617 | end 618 | 619 | @impl true 620 | def handle_info({:ex_webrtc, _, _}, socket) do 621 | {:noreply, socket} 622 | end 623 | 624 | @impl true 625 | def handle_info(:streams_info, socket) do 626 | %{publisher: publisher} = socket.assigns 627 | 628 | if publisher.audio_track != nil or publisher.video_track != nil do 629 | PubSub.broadcast( 630 | publisher.pubsub, 631 | "streams:info:#{publisher.id}", 632 | {:live_ex_webrtc, :info, publisher.audio_track, publisher.video_track} 633 | ) 634 | 635 | info_timer = Process.send_after(self(), :streams_info, 1_000) 636 | publisher = %Publisher{publisher | info_timer: info_timer} 637 | socket = assign(socket, :publisher, publisher) 638 | {:noreply, socket} 639 | else 640 | {:noreply, socket} 641 | end 642 | end 643 | 644 | @impl true 645 | def handle_info( 646 | {:DOWN, _ref, :process, pc, _reason}, 647 | %{assigns: %{publisher: %{pc: pc}}} = socket 648 | ) do 649 | {:noreply, bye(socket)} 650 | end 651 | 652 | @impl true 653 | def handle_info( 654 | {:ex_webrtc_recorder, rec, _} = msg, 655 | %{assigns: %{publisher: %{recorder: rec} = pub}} = socket 656 | ) do 657 | if pub.on_recorder_message, do: pub.on_recorder_message.(pub.id, msg) 658 | 659 | {:noreply, socket} 660 | end 661 | 662 | @impl true 663 | def handle_info(_msg, socket) do 664 | {:noreply, socket} 665 | end 666 | 667 | @impl true 668 | def handle_event("start-streaming", _, socket) do 669 | publisher = socket.assigns.publisher 670 | 671 | recorder = 672 | if publisher.record? == true and publisher.recorder == nil do 673 | {:ok, recorder} = Recorder.start_link(socket.assigns.publisher.recorder_opts) 674 | recorder 675 | else 676 | publisher.recorder 677 | end 678 | 679 | publisher = %Publisher{socket.assigns.publisher | streaming?: true, recorder: recorder} 680 | 681 | {:noreply, 682 | socket 683 | |> assign(publisher: publisher) 684 | |> push_event("start-streaming", %{})} 685 | end 686 | 687 | @impl true 688 | def handle_event("stop-streaming", _, socket) do 689 | {:noreply, bye(socket)} 690 | end 691 | 692 | @impl true 693 | def handle_event("record-stream-change", params, socket) do 694 | record? = params["value"] == "on" 695 | {:noreply, assign(socket, publisher: %Publisher{socket.assigns.publisher | record?: record?})} 696 | end 697 | 698 | @impl true 699 | def handle_event("offer", unsigned_params, socket) do 700 | %{publisher: publisher} = socket.assigns 701 | offer = SessionDescription.from_json(unsigned_params) 702 | {:ok, pc} = spawn_peer_connection(socket) 703 | pc_mref = Process.monitor(pc) 704 | 705 | :ok = PeerConnection.set_remote_description(pc, offer) 706 | 707 | [ 708 | %{kind: :audio, receiver: %{track: audio_track}}, 709 | %{kind: :video, receiver: %{track: video_track}} 710 | ] = PeerConnection.get_transceivers(pc) 711 | 712 | {:ok, answer} = PeerConnection.create_answer(pc) 713 | :ok = PeerConnection.set_local_description(pc, answer) 714 | :ok = gather_candidates(pc) 715 | answer = PeerConnection.get_local_description(pc) 716 | 717 | # subscribe now that we are initialized 718 | PubSub.subscribe(publisher.pubsub, "publishers:#{publisher.id}") 719 | 720 | new_publisher = %Publisher{ 721 | publisher 722 | | pc: pc, 723 | pc_mref: pc_mref, 724 | audio_track: audio_track, 725 | video_track: video_track 726 | } 727 | 728 | {:noreply, 729 | socket 730 | |> assign(publisher: new_publisher) 731 | |> push_event("answer-#{publisher.id}", SessionDescription.to_json(answer))} 732 | end 733 | 734 | @impl true 735 | def handle_event("ice", "null", socket) do 736 | %{publisher: publisher} = socket.assigns 737 | 738 | case publisher do 739 | %Publisher{pc: nil} -> 740 | {:noreply, socket} 741 | 742 | %Publisher{pc: pc} -> 743 | :ok = PeerConnection.add_ice_candidate(pc, %ICECandidate{candidate: ""}) 744 | {:noreply, socket} 745 | end 746 | end 747 | 748 | @impl true 749 | def handle_event("ice", unsigned_params, socket) do 750 | %{publisher: publisher} = socket.assigns 751 | 752 | case publisher do 753 | %Publisher{pc: nil} -> 754 | {:noreply, socket} 755 | 756 | %Publisher{pc: pc} -> 757 | cand = 758 | unsigned_params 759 | |> Jason.decode!() 760 | |> ExWebRTC.ICECandidate.from_json() 761 | 762 | :ok = PeerConnection.add_ice_candidate(pc, cand) 763 | 764 | {:noreply, socket} 765 | end 766 | end 767 | 768 | @impl true 769 | def terminate(_reason, socket), do: bye(socket) 770 | 771 | defp spawn_peer_connection(socket) do 772 | %{publisher: publisher} = socket.assigns 773 | 774 | pc_opts = 775 | [ 776 | ice_servers: publisher.ice_servers, 777 | ice_ip_filter: publisher.ice_ip_filter, 778 | ice_port_range: publisher.ice_port_range, 779 | audio_codecs: publisher.audio_codecs, 780 | video_codecs: publisher.video_codecs 781 | ] 782 | |> Enum.reject(fn {_k, v} -> v == nil end) 783 | 784 | PeerConnection.start(pc_opts, publisher.pc_genserver_opts) 785 | end 786 | 787 | defp gather_candidates(pc) do 788 | # we either wait for all of the candidates 789 | # or whatever we were able to gather in one second 790 | receive do 791 | {:ex_webrtc, ^pc, {:ice_gathering_state_change, :complete}} -> :ok 792 | after 793 | 1000 -> :ok 794 | end 795 | end 796 | 797 | defp simulcast_supported?(codecs) do 798 | Enum.all?(codecs, fn 799 | default_codec when default_codec in [:h264, :vp8] -> 800 | true 801 | 802 | %RTPCodecParameters{mime_type: "video/VP8"} -> 803 | true 804 | 805 | %RTPCodecParameters{mime_type: "video/H264", sdp_fmtp_line: fmtp} when fmtp != nil -> 806 | fmtp.level_asymmetry_allowed == true and fmtp.packetization_mode == 1 and 807 | fmtp.profile_level_id == 0x42E01F 808 | 809 | _ -> 810 | false 811 | end) 812 | end 813 | 814 | defp bye(socket) do 815 | %{publisher: publisher} = socket.assigns 816 | 817 | if publisher.audio_track != nil or publisher.video_track != nil do 818 | PubSub.broadcast( 819 | publisher.pubsub, 820 | "streams:info:#{publisher.id}", 821 | {:live_ex_webrtc, :bye, publisher.audio_track, publisher.video_track} 822 | ) 823 | end 824 | 825 | if publisher.info_timer != nil, do: Process.cancel_timer(publisher.info_timer) 826 | 827 | receive do 828 | :streams_info -> :ok 829 | after 830 | 0 -> :ok 831 | end 832 | 833 | try do 834 | Process.demonitor(publisher.pc_mref) 835 | PeerConnection.close(publisher.pc) 836 | catch 837 | _, _ -> :ok 838 | end 839 | 840 | if publisher.record? and publisher.audio_track != nil and publisher.video_track != nil do 841 | recorder_result = 842 | Recorder.end_tracks(publisher.recorder, [ 843 | publisher.audio_track.id, 844 | publisher.video_track.id 845 | ]) 846 | 847 | if publisher.on_recording_finished, 848 | do: publisher.on_recording_finished.(publisher.id, recorder_result) 849 | end 850 | 851 | if publisher.on_disconnected, do: publisher.on_disconnected.(publisher.id) 852 | 853 | publisher = %Publisher{ 854 | publisher 855 | | info_timer: nil, 856 | audio_track: nil, 857 | video_track: nil, 858 | streaming?: false, 859 | pc: nil, 860 | pc_mref: nil 861 | } 862 | 863 | socket 864 | |> assign(:publisher, publisher) 865 | |> push_event("stop-streaming", %{}) 866 | end 867 | end 868 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveExWebRTC.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.8.0" 5 | @source_url "https://github.com/elixir-webrtc/live_ex_webrtc" 6 | 7 | def project do 8 | [ 9 | app: :live_ex_webrtc, 10 | version: @version, 11 | elixir: "~> 1.15", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | description: "Phoenix Live Components for Elixir WebRTC", 15 | package: package(), 16 | deps: deps(), 17 | 18 | # docs 19 | docs: docs(), 20 | source_url: @source_url 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_env), do: ["lib"] 32 | 33 | def package do 34 | [ 35 | licenses: ["Apache-2.0"], 36 | links: %{"GitHub" => @source_url}, 37 | files: ~w(mix.exs lib assets package.json README.md LICENSE) 38 | ] 39 | end 40 | 41 | defp deps do 42 | [ 43 | {:phoenix_live_view, "~> 1.0"}, 44 | {:jason, "~> 1.0"}, 45 | {:ex_webrtc, "~> 0.13.0"}, 46 | {:ex_webrtc_recorder, "~> 0.2.0"}, 47 | 48 | # Dev deps 49 | {:ex_doc, "~> 0.31", only: :dev, runtime: false} 50 | ] 51 | end 52 | 53 | defp docs do 54 | [ 55 | main: "readme", 56 | extras: ["README.md"], 57 | source_ref: "v#{@version}", 58 | formatters: ["html"], 59 | nest_modules_by_prefix: [LiveExWebRTC] 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, 3 | "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, 4 | "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"}, 5 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 6 | "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 8 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 9 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 10 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 11 | "ex_dtls": {:hex, :ex_dtls, "0.17.0", "dbe1d494583a307c26148cb5ea5d7c14e65daa8ec96cc73002cc3313ce4b9a81", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "3eaa7221ec08fa9e4bc9430e426cbd5eb4feb8d8f450b203cf39b2114a94d713"}, 12 | "ex_ice": {:hex, :ex_ice, "0.12.0", "b52ec3ff878d5fb632ef9facc7657dfdf59e2ff9f23e634b0918e6ce1a05af48", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "a86024a5fbf9431082784be4bb3606d3cde9218fb325a9f208ccd6e0abfd0d73"}, 13 | "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"}, 14 | "ex_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"}, 15 | "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, 16 | "ex_sdp": {:hex, :ex_sdp, "1.1.1", "1a7b049491e5ec02dad9251c53d960835dc5631321ae978ec331831f3e4f6d5f", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "1b13a72ac9c5c695b8824dbdffc671be8cbb4c0d1ccb4ff76a04a6826759f233"}, 17 | "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, 18 | "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, 19 | "ex_webrtc": {:hex, :ex_webrtc, "0.13.0", "17e9c4954cb19ae67db51eaeea4dec35f5aab399fdaf9d340f86b85750b0e7ff", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.17.0", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.12.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.2", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.0", [hex: :ex_sdp, repo: "hexpm", optional: false]}], "hexpm", "714a918afd476b1e9b6b6b25cd6498345bf2b94aa7ed683506b8428144bca7e0"}, 20 | "ex_webrtc_recorder": {:hex, :ex_webrtc_recorder, "0.2.0", "3fd162d387dabcefcdb6bacbd588eb46ac48dc7430231aaa90674799367728f2", [:mix], [{:ex_aws, "~> 2.5", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.5", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:ex_webrtc, "~> 0.13.0", [hex: :ex_webrtc, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "b5f0623c1faac6e8af39a885450f04b2f3fedadbb3793d929b2c06e80683c230"}, 21 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 22 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 23 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 24 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 27 | "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, 28 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 29 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 30 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 31 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 32 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 33 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 34 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 35 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.9", "4dc5e535832733df68df22f9de168b11c0c74bca65b27b088a10ac36dfb75d04", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1dccb04ec8544340e01608e108f32724458d0ac4b07e551406b3b920c40ba2e5"}, 36 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 37 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 38 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 39 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 40 | "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, 41 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 42 | "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, 43 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 44 | "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"}, 45 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 46 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 47 | "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_ex_webrtc", 3 | "version": "0.1.0", 4 | "description": "Phoenix Live Components for Elixir WebRTC", 5 | "main": "./assets/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/elixir-webrtc/live_ex_webrtc.git" 9 | }, 10 | "license": "Apache-2.0", 11 | "homepage": "https://github.com/elixir-webrtc/live_ex_webrtc" 12 | } 13 | -------------------------------------------------------------------------------- /test/live_ex_webrtc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveExWebrtcTest do 2 | use ExUnit.Case 3 | 4 | test "greets the world" do 5 | assert LiveExWebrtc.hello() == :world 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------