├── .gitignore ├── wwwroot ├── favicon.ico ├── css │ ├── open-iconic │ │ ├── font │ │ │ ├── fonts │ │ │ │ ├── open-iconic.eot │ │ │ │ ├── open-iconic.otf │ │ │ │ ├── open-iconic.ttf │ │ │ │ ├── open-iconic.woff │ │ │ │ └── open-iconic.svg │ │ │ └── css │ │ │ │ └── open-iconic-bootstrap.min.css │ │ ├── ICON-LICENSE │ │ ├── README.md │ │ └── FONT-LICENSE │ └── site.css └── js │ └── webcam.js ├── appsettings.json ├── appsettings.Development.json ├── WebCamOptions.cs ├── BlazorCam.csproj ├── Pages ├── Counter.razor ├── Index.razor ├── Error.cshtml.cs ├── _Host.cshtml └── Error.cshtml ├── App.razor ├── _Imports.razor ├── Shared ├── MainLayout.razor ├── SurveyPrompt.razor ├── NavMenu.razor ├── NavMenu.razor.css └── MainLayout.razor.css ├── Program.cs ├── Properties └── launchSettings.json ├── Services └── WebRtcservice.cs ├── blazorcam.sln ├── Startup.cs ├── history.md └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vscode/ -------------------------------------------------------------------------------- /wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykay76/blazorcam/HEAD/wwwroot/favicon.ico -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/fonts/open-iconic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykay76/blazorcam/HEAD/wwwroot/css/open-iconic/font/fonts/open-iconic.eot -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/fonts/open-iconic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykay76/blazorcam/HEAD/wwwroot/css/open-iconic/font/fonts/open-iconic.otf -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/fonts/open-iconic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykay76/blazorcam/HEAD/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/fonts/open-iconic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykay76/blazorcam/HEAD/wwwroot/css/open-iconic/font/fonts/open-iconic.woff -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "DetailedErrors": true, 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /WebCamOptions.cs: -------------------------------------------------------------------------------- 1 | namespace BlazorCam 2 | { 3 | public class WebCamOptions 4 | { 5 | public int Width { get; set; } = 320; 6 | public string VideoID { get; set; } 7 | public string CanvasID { get; set; } 8 | public string Filter { get; set; } = null; 9 | } 10 | } -------------------------------------------------------------------------------- /BlazorCam.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Pages/Counter.razor: -------------------------------------------------------------------------------- 1 | @page "/counter" 2 | 3 |

Counter

4 | 5 |

Current count: @currentCount

6 | 7 | 8 | 9 | @code { 10 | private int currentCount = 0; 11 | 12 | private void IncrementCount() 13 | { 14 | currentCount++; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Sorry, there's nothing at this address.

8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using Microsoft.AspNetCore.Authorization 3 | @using Microsoft.AspNetCore.Components.Authorization 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using Microsoft.AspNetCore.Components.Web.Virtualization 8 | @using Microsoft.JSInterop 9 | @using BlazorCam 10 | @using BlazorCam.Shared 11 | -------------------------------------------------------------------------------- /Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 | 7 | 8 |
9 |
10 | About 11 |
12 | 13 |
14 | @Body 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /Shared/SurveyPrompt.razor: -------------------------------------------------------------------------------- 1 | 11 | 12 | @code { 13 | // Demonstrates how a parent component can supply parameters 14 | [Parameter] 15 | public string Title { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace BlazorCam 11 | { 12 | public class Program 13 | { 14 | public static void Main(string[] args) 15 | { 16 | CreateHostBuilder(args).Build().Run(); 17 | } 18 | 19 | public static IHostBuilder CreateHostBuilder(string[] args) => 20 | Host.CreateDefaultBuilder(args) 21 | .ConfigureWebHostDefaults(webBuilder => 22 | { 23 | webBuilder.UseStartup(); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:48000", 7 | "sslPort": 44382 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "BlazorCam": { 19 | "commandName": "Project", 20 | "dotnetRunMessages": "true", 21 | "launchBrowser": true, 22 | "applicationUrl": "https://localhost:6001;http://localhost:6000", 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Services/WebRtcservice.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.SignalR; 6 | 7 | namespace BlazorCam.Services 8 | { 9 | public class WebRtcService : Hub 10 | { 11 | public override Task OnConnectedAsync() 12 | { 13 | Console.WriteLine($"{Context.ConnectionId} connected"); 14 | return base.OnConnectedAsync(); 15 | } 16 | 17 | public override async Task OnDisconnectedAsync(Exception e) 18 | { 19 | Console.WriteLine($"Disconnected {e?.Message} {Context.ConnectionId}"); 20 | await base.OnDisconnectedAsync(e); 21 | } 22 | 23 | public async Task Send(string message) 24 | { 25 | Console.WriteLine(message); 26 | await Clients.Others.SendAsync("Receive", message); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @inject IJSRuntime JSRuntime 3 | 4 |

Fun Blazor Web Camera Application

5 |
6 | 7 |
8 |
9 | 13 | 17 |
18 | @code{ 19 | WebCamOptions options = new WebCamOptions() 20 | { 21 | CanvasID = "canvas", 22 | VideoID = "video" 23 | }; 24 | 25 | protected override void OnInitialized() 26 | { 27 | options.Width = 480; 28 | } 29 | 30 | public async Task Start() 31 | { 32 | await JSRuntime.InvokeVoidAsync("WebCamFunctions.start", options); 33 | } 34 | } -------------------------------------------------------------------------------- /Pages/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.AspNetCore.Mvc.RazorPages; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace BlazorCam.Pages 11 | { 12 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 13 | [IgnoreAntiforgeryToken] 14 | public class ErrorModel : PageModel 15 | { 16 | public string RequestId { get; set; } 17 | 18 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 19 | 20 | private readonly ILogger _logger; 21 | 22 | public ErrorModel(ILogger logger) 23 | { 24 | _logger = logger; 25 | } 26 | 27 | public void OnGet() 28 | { 29 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /wwwroot/css/site.css: -------------------------------------------------------------------------------- 1 | @import url('open-iconic/font/css/open-iconic-bootstrap.min.css'); 2 | 3 | html, body { 4 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 5 | } 6 | 7 | a, .btn-link { 8 | color: #0366d6; 9 | } 10 | 11 | .btn-primary { 12 | color: #fff; 13 | background-color: #1b6ec2; 14 | border-color: #1861ac; 15 | } 16 | 17 | .content { 18 | padding-top: 1.1rem; 19 | } 20 | 21 | .valid.modified:not([type=checkbox]) { 22 | outline: 1px solid #26b050; 23 | } 24 | 25 | .invalid { 26 | outline: 1px solid red; 27 | } 28 | 29 | .validation-message { 30 | color: red; 31 | } 32 | 33 | #blazor-error-ui { 34 | background: lightyellow; 35 | bottom: 0; 36 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 37 | display: none; 38 | left: 0; 39 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 40 | position: fixed; 41 | width: 100%; 42 | z-index: 1000; 43 | } 44 | 45 | #blazor-error-ui .dismiss { 46 | cursor: pointer; 47 | position: absolute; 48 | right: 0.75rem; 49 | top: 0.5rem; 50 | } 51 | -------------------------------------------------------------------------------- /Shared/NavMenu.razor: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 21 |
22 | 23 | @code { 24 | private bool collapseNavMenu = true; 25 | 26 | private string NavMenuCssClass => collapseNavMenu ? "collapse" : null; 27 | 28 | private void ToggleNavMenu() 29 | { 30 | collapseNavMenu = !collapseNavMenu; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /blazorcam.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.5.2.0 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorCam", "BlazorCam.csproj", "{E972E23A-A6F8-5AF0-84DD-E62F3A9AAC3A}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {E972E23A-A6F8-5AF0-84DD-E62F3A9AAC3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {E972E23A-A6F8-5AF0-84DD-E62F3A9AAC3A}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {E972E23A-A6F8-5AF0-84DD-E62F3A9AAC3A}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {E972E23A-A6F8-5AF0-84DD-E62F3A9AAC3A}.Release|Any CPU.Build.0 = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(SolutionProperties) = preSolution 19 | HideSolutionNode = FALSE 20 | EndGlobalSection 21 | GlobalSection(ExtensibilityGlobals) = postSolution 22 | SolutionGuid = {70D178E9-05C6-4106-AB96-C8D0C4C42DA6} 23 | EndGlobalSection 24 | EndGlobal 25 | -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/ICON-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Shared/NavMenu.razor.css: -------------------------------------------------------------------------------- 1 | .navbar-toggler { 2 | background-color: rgba(255, 255, 255, 0.1); 3 | } 4 | 5 | .top-row { 6 | height: 3.5rem; 7 | background-color: rgba(0,0,0,0.4); 8 | } 9 | 10 | .navbar-brand { 11 | font-size: 1.1rem; 12 | } 13 | 14 | .oi { 15 | width: 2rem; 16 | font-size: 1.1rem; 17 | vertical-align: text-top; 18 | top: -2px; 19 | } 20 | 21 | .nav-item { 22 | font-size: 0.9rem; 23 | padding-bottom: 0.5rem; 24 | } 25 | 26 | .nav-item:first-of-type { 27 | padding-top: 1rem; 28 | } 29 | 30 | .nav-item:last-of-type { 31 | padding-bottom: 1rem; 32 | } 33 | 34 | .nav-item ::deep a { 35 | color: #d7d7d7; 36 | border-radius: 4px; 37 | height: 3rem; 38 | display: flex; 39 | align-items: center; 40 | line-height: 3rem; 41 | } 42 | 43 | .nav-item ::deep a.active { 44 | background-color: rgba(255,255,255,0.25); 45 | color: white; 46 | } 47 | 48 | .nav-item ::deep a:hover { 49 | background-color: rgba(255,255,255,0.1); 50 | color: white; 51 | } 52 | 53 | @media (min-width: 641px) { 54 | .navbar-toggler { 55 | display: none; 56 | } 57 | 58 | .collapse { 59 | /* Never collapse the sidebar for wide screens */ 60 | display: block; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Pages/_Host.cshtml: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @namespace BlazorCam.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | @{ 5 | Layout = null; 6 | } 7 | 8 | 9 | 10 | 11 | 12 | 13 | BlazorCam 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | An error has occurred. This application may no longer respond until reloaded. 25 | 26 | 27 | An unhandled exception has occurred. See browser dev tools for details. 28 | 29 | Reload 30 | 🗙 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | } 28 | 29 | .top-row a:first-child { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | } 33 | 34 | @media (max-width: 640.98px) { 35 | .top-row:not(.auth) { 36 | display: none; 37 | } 38 | 39 | .top-row.auth { 40 | justify-content: space-between; 41 | } 42 | 43 | .top-row a, .top-row .btn-link { 44 | margin-left: 0; 45 | } 46 | } 47 | 48 | @media (min-width: 641px) { 49 | .page { 50 | flex-direction: row; 51 | } 52 | 53 | .sidebar { 54 | width: 250px; 55 | height: 100vh; 56 | position: sticky; 57 | top: 0; 58 | } 59 | 60 | .top-row { 61 | position: sticky; 62 | top: 0; 63 | z-index: 1; 64 | } 65 | 66 | .main > div { 67 | padding-left: 2rem !important; 68 | padding-right: 1.5rem !important; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Pages/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model BlazorCam.Pages.ErrorModel 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Error 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Error.

19 |

An error occurred while processing your request.

20 | 21 | @if (Model.ShowRequestId) 22 | { 23 |

24 | Request ID: @Model.RequestId 25 |

26 | } 27 | 28 |

Development Mode

29 |

30 | Swapping to the Development environment displays detailed information about the error that occurred. 31 |

32 |

33 | The Development environment shouldn't be enabled for deployed applications. 34 | It can result in displaying sensitive information from exceptions to end users. 35 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 36 | and restarting the app. 37 |

38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Components; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.HttpsPolicy; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using BlazorCam.Services; 13 | 14 | namespace BlazorCam 15 | { 16 | public class Startup 17 | { 18 | public Startup(IConfiguration configuration) 19 | { 20 | Configuration = configuration; 21 | } 22 | 23 | public IConfiguration Configuration { get; } 24 | 25 | // This method gets called by the runtime. Use this method to add services to the container. 26 | // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 27 | public void ConfigureServices(IServiceCollection services) 28 | { 29 | services.AddSignalR(); 30 | services.AddRazorPages(); 31 | services.AddServerSideBlazor(); 32 | } 33 | 34 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 35 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 36 | { 37 | if (env.IsDevelopment()) 38 | { 39 | app.UseDeveloperExceptionPage(); 40 | } 41 | else 42 | { 43 | app.UseExceptionHandler("/Error"); 44 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 45 | app.UseHsts(); 46 | } 47 | 48 | app.UseHttpsRedirection(); 49 | app.UseStaticFiles(); 50 | 51 | app.UseRouting(); 52 | 53 | app.UseEndpoints(endpoints => 54 | { 55 | endpoints.MapBlazorHub(); 56 | endpoints.MapHub("/webrtc"); 57 | endpoints.MapFallbackToPage("/_Host"); 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | A bit of a log of how I got here... 2 | --- 3 | 4 | I wanted to expand on the basic JS tutorial for web cam usage and incorporate Blazor and a WebRTC server so that I can start to build video chat functionality into sites. 5 | 6 | This code is based on an article by Roman Simuta: 7 | 8 | https://romansimuta.com/posts/using-a-web-camera-with-fun-filters-in-your-asp-net-core-blazor-webassembly-application/ 9 | 10 | With a few differences: 11 | - the Javascript is referenced in `/Pages/_Host.cshtml` there is no `/wwwroot/index.html` 12 | 13 | It worked first time, great! However it only shows the camera output locally, it doesn't incorporate WebRTC for collaboration. If all you need is to capture camera output on a page then this is a perfect starting point. 14 | --- 15 | 16 | To add sharing of video and audio I used Microsoft.MixedReality.WebRTC. 17 | 18 | https://microsoft.github.io/MixedReality-WebRTC/manual/gettingstarted.html 19 | 20 | I think this may also handle the video and audio so I don't need to use JSInterop any more. 21 | 22 | But it just kept failing to initialise all the time, probably because I have virtual camera and audio devices. So I went back to the JS option to build on Romans article. 23 | 24 | Also in the events raised when a remote stream is added I couldn't find a way to get the stream. In JS it seems the stream is just passed as an event parameter. 25 | --- 26 | 27 | This article explains the process or connecting to a SignalR hub to share streams. Basically the streams are encapsulated into opaque messages that we have to get from peer to peer. This results in a very simple hub: 28 | 29 | https://www.skylinetechnologies.com/Blog/Skyline-Blog/February-2013/Peer-to-Peer-Media-Streaming-with-WebRTC-and-Signa 30 | 31 | I copied some code from one of my own projects to set up SignalR: 32 | 33 | https://github.com/aykay76/gameserver 34 | 35 | But i'm back to needing a Javascript SignalR client, which I got from here: 36 | 37 | https://docs.microsoft.com/en-us/aspnet/core/signalr/javascript-client?view=aspnetcore-5.0 38 | 39 | I downloaded adapter.js which improves compatibility, following the instructions here: 40 | 41 | https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/adapter.js 42 | --- 43 | 44 | The code from the original example is 8 years old and some elements used have been deprecated. Such as using promises instead of success functions on the RTC client. 45 | 46 | So, i'm back to the drawing board with this: 47 | 48 | https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling#Handling_the_invitation 49 | 50 | Now that I fully understand the process there were some things missing from the examples I have been following. 51 | 52 | This repository now contains a working video sharing service with SignalR acting as the signalling server. 53 | 54 | To turn it into a fully working video calling service I need to: 55 | - gracefully handle start/stop of calls 56 | - introduce UI to call and answer/reject 57 | - handle more than 2 endpoints 58 | - have options to turn on/off audio/video 59 | - enumerate devices and allow user to choose camera/mic 60 | - a nicer UI :) 61 | 62 | Other things to do: 63 | - rename this project 64 | - introduce chat and drawing? 65 | 66 | --- 67 | Other articles of use: 68 | 69 | https://www.html5rocks.com/en/tutorials/webrtc/infrastructure/ -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/README.md: -------------------------------------------------------------------------------- 1 | [Open Iconic v1.1.1](http://useiconic.com/open) 2 | =========== 3 | 4 | ### Open Iconic is the open source sibling of [Iconic](http://useiconic.com). It is a hyper-legible collection of 223 icons with a tiny footprint—ready to use with Bootstrap and Foundation. [View the collection](http://useiconic.com/open#icons) 5 | 6 | 7 | 8 | ## What's in Open Iconic? 9 | 10 | * 223 icons designed to be legible down to 8 pixels 11 | * Super-light SVG files - 61.8 for the entire set 12 | * SVG sprite—the modern replacement for icon fonts 13 | * Webfont (EOT, OTF, SVG, TTF, WOFF), PNG and WebP formats 14 | * Webfont stylesheets (including versions for Bootstrap and Foundation) in CSS, LESS, SCSS and Stylus formats 15 | * PNG and WebP raster images in 8px, 16px, 24px, 32px, 48px and 64px. 16 | 17 | 18 | ## Getting Started 19 | 20 | #### For code samples and everything else you need to get started with Open Iconic, check out our [Icons](http://useiconic.com/open#icons) and [Reference](http://useiconic.com/open#reference) sections. 21 | 22 | ### General Usage 23 | 24 | #### Using Open Iconic's SVGs 25 | 26 | We like SVGs and we think they're the way to display icons on the web. Since Open Iconic are just basic SVGs, we suggest you display them like you would any other image (don't forget the `alt` attribute). 27 | 28 | ``` 29 | icon name 30 | ``` 31 | 32 | #### Using Open Iconic's SVG Sprite 33 | 34 | Open Iconic also comes in a SVG sprite which allows you to display all the icons in the set with a single request. It's like an icon font, without being a hack. 35 | 36 | Adding an icon from an SVG sprite is a little different than what you're used to, but it's still a piece of cake. *Tip: To make your icons easily style able, we suggest adding a general class to the* `` *tag and a unique class name for each different icon in the* `` *tag.* 37 | 38 | ``` 39 | 40 | 41 | 42 | ``` 43 | 44 | Sizing icons only needs basic CSS. All the icons are in a square format, so just set the `` tag with equal width and height dimensions. 45 | 46 | ``` 47 | .icon { 48 | width: 16px; 49 | height: 16px; 50 | } 51 | ``` 52 | 53 | Coloring icons is even easier. All you need to do is set the `fill` rule on the `` tag. 54 | 55 | ``` 56 | .icon-account-login { 57 | fill: #f00; 58 | } 59 | ``` 60 | 61 | To learn more about SVG Sprites, read [Chris Coyier's guide](http://css-tricks.com/svg-sprites-use-better-icon-fonts/). 62 | 63 | #### Using Open Iconic's Icon Font... 64 | 65 | 66 | ##### …with Bootstrap 67 | 68 | You can find our Bootstrap stylesheets in `font/css/open-iconic-bootstrap.{css, less, scss, styl}` 69 | 70 | 71 | ``` 72 | 73 | ``` 74 | 75 | 76 | ``` 77 | 78 | ``` 79 | 80 | ##### …with Foundation 81 | 82 | You can find our Foundation stylesheets in `font/css/open-iconic-foundation.{css, less, scss, styl}` 83 | 84 | ``` 85 | 86 | ``` 87 | 88 | 89 | ``` 90 | 91 | ``` 92 | 93 | ##### …on its own 94 | 95 | You can find our default stylesheets in `font/css/open-iconic.{css, less, scss, styl}` 96 | 97 | ``` 98 | 99 | ``` 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | 106 | ## License 107 | 108 | ### Icons 109 | 110 | All code (including SVG markup) is under the [MIT License](http://opensource.org/licenses/MIT). 111 | 112 | ### Fonts 113 | 114 | All fonts are under the [SIL Licensed](http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web). 115 | -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/FONT-LICENSE: -------------------------------------------------------------------------------- 1 | SIL OPEN FONT LICENSE Version 1.1 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | PREAMBLE 6 | The goals of the Open Font License (OFL) are to stimulate worldwide 7 | development of collaborative font projects, to support the font creation 8 | efforts of academic and linguistic communities, and to provide a free and 9 | open framework in which fonts may be shared and improved in partnership 10 | with others. 11 | 12 | The OFL allows the licensed fonts to be used, studied, modified and 13 | redistributed freely as long as they are not sold by themselves. The 14 | fonts, including any derivative works, can be bundled, embedded, 15 | redistributed and/or sold with any software provided that any reserved 16 | names are not used by derivative works. The fonts and derivatives, 17 | however, cannot be released under any other type of license. The 18 | requirement for fonts to remain under this license does not apply 19 | to any document created using the fonts or their derivatives. 20 | 21 | DEFINITIONS 22 | "Font Software" refers to the set of files released by the Copyright 23 | Holder(s) under this license and clearly marked as such. This may 24 | include source files, build scripts and documentation. 25 | 26 | "Reserved Font Name" refers to any names specified as such after the 27 | copyright statement(s). 28 | 29 | "Original Version" refers to the collection of Font Software components as 30 | distributed by the Copyright Holder(s). 31 | 32 | "Modified Version" refers to any derivative made by adding to, deleting, 33 | or substituting -- in part or in whole -- any of the components of the 34 | Original Version, by changing formats or by porting the Font Software to a 35 | new environment. 36 | 37 | "Author" refers to any designer, engineer, programmer, technical 38 | writer or other person who contributed to the Font Software. 39 | 40 | PERMISSION & CONDITIONS 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 43 | redistribute, and sell modified and unmodified copies of the Font 44 | Software, subject to the following conditions: 45 | 46 | 1) Neither the Font Software nor any of its individual components, 47 | in Original or Modified Versions, may be sold by itself. 48 | 49 | 2) Original or Modified Versions of the Font Software may be bundled, 50 | redistributed and/or sold with any software, provided that each copy 51 | contains the above copyright notice and this license. These can be 52 | included either as stand-alone text files, human-readable headers or 53 | in the appropriate machine-readable metadata fields within text or 54 | binary files as long as those fields can be easily viewed by the user. 55 | 56 | 3) No Modified Version of the Font Software may use the Reserved Font 57 | Name(s) unless explicit written permission is granted by the corresponding 58 | Copyright Holder. This restriction only applies to the primary font name as 59 | presented to the users. 60 | 61 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 62 | Software shall not be used to promote, endorse or advertise any 63 | Modified Version, except to acknowledge the contribution(s) of the 64 | Copyright Holder(s) and the Author(s) or with their explicit written 65 | permission. 66 | 67 | 5) The Font Software, modified or unmodified, in part or in whole, 68 | must be distributed entirely under this license, and must not be 69 | distributed under any other license. The requirement for fonts to 70 | remain under this license does not apply to any document created 71 | using the Font Software. 72 | 73 | TERMINATION 74 | This license becomes null and void if any of the above conditions are 75 | not met. 76 | 77 | DISCLAIMER 78 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 81 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 82 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 83 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 84 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 85 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 86 | OTHER DEALINGS IN THE FONT SOFTWARE. 87 | -------------------------------------------------------------------------------- /wwwroot/js/webcam.js: -------------------------------------------------------------------------------- 1 | let rtcConnection = null 2 | let myVideoStream = null 3 | let video = null; 4 | let otherVideo = null 5 | let context = null; 6 | let streaming = false; 7 | 8 | let width = 100; // We will scale the photo width to this. 9 | let height = 0; // This will be computed based on the input stream 10 | 11 | var mediaConstraints = { 12 | audio: true, // We want an audio track 13 | video: true // ...and we want a video track 14 | }; 15 | 16 | /// SignalR setup /// 17 | const srConnection = new signalR.HubConnectionBuilder() 18 | .withUrl("/webrtc") 19 | .configureLogging(signalR.LogLevel.Information) 20 | .build(); 21 | 22 | // automatically reconnect on close 23 | srConnection.onclose(start); 24 | 25 | // define (re)start function 26 | async function start() { 27 | try { 28 | await srConnection.start(); 29 | console.log("SignalR Connected."); 30 | } catch (err) { 31 | console.log(err); 32 | setTimeout(start, 5000); 33 | } 34 | }; 35 | 36 | // connect to SignalR 37 | start(); 38 | 39 | window.WebCamFunctions = { 40 | start: (options) => { onStart(options); } 41 | }; 42 | 43 | function onStart(options) { 44 | video = document.getElementById(options.videoID); 45 | width = options.width; 46 | 47 | createPeerConnection() 48 | 49 | // navigator.mediaDevices.getDisplayMedia(mediaConstraints) 50 | navigator.mediaDevices.getUserMedia(mediaConstraints) 51 | .then(function (stream) { 52 | video.srcObject = stream 53 | myVideoStream = stream 54 | 55 | rtcConnection.addStream(myVideoStream) 56 | }) 57 | .catch(function (err) { 58 | console.log("An error occurred: " + err); 59 | }); 60 | 61 | video.addEventListener('canplay', function () { 62 | if (!streaming) { 63 | height = video.videoHeight / (video.videoWidth / width); 64 | 65 | if (isNaN(height)) { 66 | height = width / (4 / 3); 67 | } 68 | 69 | video.setAttribute('width', width); 70 | video.setAttribute('height', height); 71 | streaming = true; 72 | } 73 | }, false); 74 | } 75 | 76 | srConnection.on("Receive", data => { 77 | var message = JSON.parse(data) 78 | 79 | if (message.sdp) { 80 | if (message.sdp.type == 'offer') { 81 | createPeerConnection() 82 | rtcConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)) 83 | .then(function () { 84 | // return navigator.mediaDevices.getDisplayMedia(mediaConstraints); 85 | return navigator.mediaDevices.getUserMedia(mediaConstraints); 86 | }) 87 | .then(function(stream) { 88 | myVideoStream = stream 89 | video = document.getElementById("video") 90 | video.srcObject = stream 91 | 92 | // Add our stream to the connection to be shared 93 | rtcConnection.addStream(myVideoStream); 94 | }) 95 | .then(function() { 96 | return rtcConnection.createAnswer() 97 | }) 98 | .then(function (answer) { 99 | return rtcConnection.setLocalDescription(answer); 100 | }) 101 | .then(function() { 102 | srConnection.invoke("Send", JSON.stringify({ 'sdp': rtcConnection.localDescription })) 103 | }) 104 | } 105 | else if (message.sdp.type == 'answer') { 106 | rtcConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)) 107 | } 108 | } else if (message.candidate) { 109 | rtcConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); 110 | } 111 | }); 112 | 113 | // code from MDN examples 114 | function createPeerConnection() 115 | { 116 | rtcConnection = new RTCPeerConnection(null) 117 | 118 | rtcConnection.onicecandidate = function(event) { 119 | if (event.candidate) { 120 | // send to peers over SignalR 121 | srConnection.invoke("Send", JSON.stringify({ "candidate": event.candidate })); 122 | } 123 | } 124 | 125 | rtcConnection.onaddstream = function(event) { 126 | otherVideo = document.getElementById('remote'); 127 | 128 | // Attach the stream to the Video element via adapter.js 129 | otherVideo.srcObject = event.stream 130 | otherVideo.play() 131 | } 132 | 133 | rtcConnection.onnegotiationneeded = function() 134 | { 135 | rtcConnection.createOffer() 136 | .then(function(offer) { 137 | return rtcConnection.setLocalDescription(offer) 138 | }) 139 | .then(function() { 140 | srConnection.invoke("Send", JSON.stringify({"sdp": rtcConnection.localDescription})) 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Icons;src:url(../fonts/open-iconic.eot);src:url(../fonts/open-iconic.eot?#iconic-sm) format('embedded-opentype'),url(../fonts/open-iconic.woff) format('woff'),url(../fonts/open-iconic.ttf) format('truetype'),url(../fonts/open-iconic.otf) format('opentype'),url(../fonts/open-iconic.svg#iconic-sm) format('svg');font-weight:400;font-style:normal}.oi{position:relative;top:1px;display:inline-block;speak:none;font-family:Icons;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.oi:empty:before{width:1em;text-align:center;box-sizing:content-box}.oi.oi-align-center:before{text-align:center}.oi.oi-align-left:before{text-align:left}.oi.oi-align-right:before{text-align:right}.oi.oi-flip-horizontal:before{-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.oi.oi-flip-vertical:before{-webkit-transform:scale(1,-1);-ms-transform:scale(-1,1);transform:scale(1,-1)}.oi.oi-flip-horizontal-vertical:before{-webkit-transform:scale(-1,-1);-ms-transform:scale(-1,1);transform:scale(-1,-1)}.oi-account-login:before{content:'\e000'}.oi-account-logout:before{content:'\e001'}.oi-action-redo:before{content:'\e002'}.oi-action-undo:before{content:'\e003'}.oi-align-center:before{content:'\e004'}.oi-align-left:before{content:'\e005'}.oi-align-right:before{content:'\e006'}.oi-aperture:before{content:'\e007'}.oi-arrow-bottom:before{content:'\e008'}.oi-arrow-circle-bottom:before{content:'\e009'}.oi-arrow-circle-left:before{content:'\e00a'}.oi-arrow-circle-right:before{content:'\e00b'}.oi-arrow-circle-top:before{content:'\e00c'}.oi-arrow-left:before{content:'\e00d'}.oi-arrow-right:before{content:'\e00e'}.oi-arrow-thick-bottom:before{content:'\e00f'}.oi-arrow-thick-left:before{content:'\e010'}.oi-arrow-thick-right:before{content:'\e011'}.oi-arrow-thick-top:before{content:'\e012'}.oi-arrow-top:before{content:'\e013'}.oi-audio-spectrum:before{content:'\e014'}.oi-audio:before{content:'\e015'}.oi-badge:before{content:'\e016'}.oi-ban:before{content:'\e017'}.oi-bar-chart:before{content:'\e018'}.oi-basket:before{content:'\e019'}.oi-battery-empty:before{content:'\e01a'}.oi-battery-full:before{content:'\e01b'}.oi-beaker:before{content:'\e01c'}.oi-bell:before{content:'\e01d'}.oi-bluetooth:before{content:'\e01e'}.oi-bold:before{content:'\e01f'}.oi-bolt:before{content:'\e020'}.oi-book:before{content:'\e021'}.oi-bookmark:before{content:'\e022'}.oi-box:before{content:'\e023'}.oi-briefcase:before{content:'\e024'}.oi-british-pound:before{content:'\e025'}.oi-browser:before{content:'\e026'}.oi-brush:before{content:'\e027'}.oi-bug:before{content:'\e028'}.oi-bullhorn:before{content:'\e029'}.oi-calculator:before{content:'\e02a'}.oi-calendar:before{content:'\e02b'}.oi-camera-slr:before{content:'\e02c'}.oi-caret-bottom:before{content:'\e02d'}.oi-caret-left:before{content:'\e02e'}.oi-caret-right:before{content:'\e02f'}.oi-caret-top:before{content:'\e030'}.oi-cart:before{content:'\e031'}.oi-chat:before{content:'\e032'}.oi-check:before{content:'\e033'}.oi-chevron-bottom:before{content:'\e034'}.oi-chevron-left:before{content:'\e035'}.oi-chevron-right:before{content:'\e036'}.oi-chevron-top:before{content:'\e037'}.oi-circle-check:before{content:'\e038'}.oi-circle-x:before{content:'\e039'}.oi-clipboard:before{content:'\e03a'}.oi-clock:before{content:'\e03b'}.oi-cloud-download:before{content:'\e03c'}.oi-cloud-upload:before{content:'\e03d'}.oi-cloud:before{content:'\e03e'}.oi-cloudy:before{content:'\e03f'}.oi-code:before{content:'\e040'}.oi-cog:before{content:'\e041'}.oi-collapse-down:before{content:'\e042'}.oi-collapse-left:before{content:'\e043'}.oi-collapse-right:before{content:'\e044'}.oi-collapse-up:before{content:'\e045'}.oi-command:before{content:'\e046'}.oi-comment-square:before{content:'\e047'}.oi-compass:before{content:'\e048'}.oi-contrast:before{content:'\e049'}.oi-copywriting:before{content:'\e04a'}.oi-credit-card:before{content:'\e04b'}.oi-crop:before{content:'\e04c'}.oi-dashboard:before{content:'\e04d'}.oi-data-transfer-download:before{content:'\e04e'}.oi-data-transfer-upload:before{content:'\e04f'}.oi-delete:before{content:'\e050'}.oi-dial:before{content:'\e051'}.oi-document:before{content:'\e052'}.oi-dollar:before{content:'\e053'}.oi-double-quote-sans-left:before{content:'\e054'}.oi-double-quote-sans-right:before{content:'\e055'}.oi-double-quote-serif-left:before{content:'\e056'}.oi-double-quote-serif-right:before{content:'\e057'}.oi-droplet:before{content:'\e058'}.oi-eject:before{content:'\e059'}.oi-elevator:before{content:'\e05a'}.oi-ellipses:before{content:'\e05b'}.oi-envelope-closed:before{content:'\e05c'}.oi-envelope-open:before{content:'\e05d'}.oi-euro:before{content:'\e05e'}.oi-excerpt:before{content:'\e05f'}.oi-expand-down:before{content:'\e060'}.oi-expand-left:before{content:'\e061'}.oi-expand-right:before{content:'\e062'}.oi-expand-up:before{content:'\e063'}.oi-external-link:before{content:'\e064'}.oi-eye:before{content:'\e065'}.oi-eyedropper:before{content:'\e066'}.oi-file:before{content:'\e067'}.oi-fire:before{content:'\e068'}.oi-flag:before{content:'\e069'}.oi-flash:before{content:'\e06a'}.oi-folder:before{content:'\e06b'}.oi-fork:before{content:'\e06c'}.oi-fullscreen-enter:before{content:'\e06d'}.oi-fullscreen-exit:before{content:'\e06e'}.oi-globe:before{content:'\e06f'}.oi-graph:before{content:'\e070'}.oi-grid-four-up:before{content:'\e071'}.oi-grid-three-up:before{content:'\e072'}.oi-grid-two-up:before{content:'\e073'}.oi-hard-drive:before{content:'\e074'}.oi-header:before{content:'\e075'}.oi-headphones:before{content:'\e076'}.oi-heart:before{content:'\e077'}.oi-home:before{content:'\e078'}.oi-image:before{content:'\e079'}.oi-inbox:before{content:'\e07a'}.oi-infinity:before{content:'\e07b'}.oi-info:before{content:'\e07c'}.oi-italic:before{content:'\e07d'}.oi-justify-center:before{content:'\e07e'}.oi-justify-left:before{content:'\e07f'}.oi-justify-right:before{content:'\e080'}.oi-key:before{content:'\e081'}.oi-laptop:before{content:'\e082'}.oi-layers:before{content:'\e083'}.oi-lightbulb:before{content:'\e084'}.oi-link-broken:before{content:'\e085'}.oi-link-intact:before{content:'\e086'}.oi-list-rich:before{content:'\e087'}.oi-list:before{content:'\e088'}.oi-location:before{content:'\e089'}.oi-lock-locked:before{content:'\e08a'}.oi-lock-unlocked:before{content:'\e08b'}.oi-loop-circular:before{content:'\e08c'}.oi-loop-square:before{content:'\e08d'}.oi-loop:before{content:'\e08e'}.oi-magnifying-glass:before{content:'\e08f'}.oi-map-marker:before{content:'\e090'}.oi-map:before{content:'\e091'}.oi-media-pause:before{content:'\e092'}.oi-media-play:before{content:'\e093'}.oi-media-record:before{content:'\e094'}.oi-media-skip-backward:before{content:'\e095'}.oi-media-skip-forward:before{content:'\e096'}.oi-media-step-backward:before{content:'\e097'}.oi-media-step-forward:before{content:'\e098'}.oi-media-stop:before{content:'\e099'}.oi-medical-cross:before{content:'\e09a'}.oi-menu:before{content:'\e09b'}.oi-microphone:before{content:'\e09c'}.oi-minus:before{content:'\e09d'}.oi-monitor:before{content:'\e09e'}.oi-moon:before{content:'\e09f'}.oi-move:before{content:'\e0a0'}.oi-musical-note:before{content:'\e0a1'}.oi-paperclip:before{content:'\e0a2'}.oi-pencil:before{content:'\e0a3'}.oi-people:before{content:'\e0a4'}.oi-person:before{content:'\e0a5'}.oi-phone:before{content:'\e0a6'}.oi-pie-chart:before{content:'\e0a7'}.oi-pin:before{content:'\e0a8'}.oi-play-circle:before{content:'\e0a9'}.oi-plus:before{content:'\e0aa'}.oi-power-standby:before{content:'\e0ab'}.oi-print:before{content:'\e0ac'}.oi-project:before{content:'\e0ad'}.oi-pulse:before{content:'\e0ae'}.oi-puzzle-piece:before{content:'\e0af'}.oi-question-mark:before{content:'\e0b0'}.oi-rain:before{content:'\e0b1'}.oi-random:before{content:'\e0b2'}.oi-reload:before{content:'\e0b3'}.oi-resize-both:before{content:'\e0b4'}.oi-resize-height:before{content:'\e0b5'}.oi-resize-width:before{content:'\e0b6'}.oi-rss-alt:before{content:'\e0b7'}.oi-rss:before{content:'\e0b8'}.oi-script:before{content:'\e0b9'}.oi-share-boxed:before{content:'\e0ba'}.oi-share:before{content:'\e0bb'}.oi-shield:before{content:'\e0bc'}.oi-signal:before{content:'\e0bd'}.oi-signpost:before{content:'\e0be'}.oi-sort-ascending:before{content:'\e0bf'}.oi-sort-descending:before{content:'\e0c0'}.oi-spreadsheet:before{content:'\e0c1'}.oi-star:before{content:'\e0c2'}.oi-sun:before{content:'\e0c3'}.oi-tablet:before{content:'\e0c4'}.oi-tag:before{content:'\e0c5'}.oi-tags:before{content:'\e0c6'}.oi-target:before{content:'\e0c7'}.oi-task:before{content:'\e0c8'}.oi-terminal:before{content:'\e0c9'}.oi-text:before{content:'\e0ca'}.oi-thumb-down:before{content:'\e0cb'}.oi-thumb-up:before{content:'\e0cc'}.oi-timer:before{content:'\e0cd'}.oi-transfer:before{content:'\e0ce'}.oi-trash:before{content:'\e0cf'}.oi-underline:before{content:'\e0d0'}.oi-vertical-align-bottom:before{content:'\e0d1'}.oi-vertical-align-center:before{content:'\e0d2'}.oi-vertical-align-top:before{content:'\e0d3'}.oi-video:before{content:'\e0d4'}.oi-volume-high:before{content:'\e0d5'}.oi-volume-low:before{content:'\e0d6'}.oi-volume-off:before{content:'\e0d7'}.oi-warning:before{content:'\e0d8'}.oi-wifi:before{content:'\e0d9'}.oi-wrench:before{content:'\e0da'}.oi-x:before{content:'\e0db'}.oi-yen:before{content:'\e0dc'}.oi-zoom-in:before{content:'\e0dd'}.oi-zoom-out:before{content:'\e0de'} -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WebRTC with Blazor WASM 2 | 3 | I created this project as a reference for WebRTC and a base implementation of it in case I need it for future projects. Before HTML5 the only options were to use Flash or Silverlight to be able to share multimedia content on the web and that was prohibitive for me as a hobby developer. WebRTC isn't new, but it's newer than my last attempts to do similar things. 4 | 5 | I started with a BlazorServer project because i'm a big fan of C# and want to use that as much as possible, instead of having to switch between C# on the server side and Javascript on the front end. However, I have found that some functionality still isn't yet supported in WASM so Blazor Server seems to be the best approach for me. 6 | 7 | Create a blazor server by creating an empty directory and running: 8 | 9 | `dotnet new blazorserver` 10 | 11 | This creates a template application with a simple navigation system and some example pages. These can be deleted if not required, to keep the project clean. 12 | 13 | Having read a little about WebRTC I knew that a signalling server is required. This correlates events or instructions between the clients of a WebRTC session. The ideal choice for this, IMHO, is SignalR as it fits with the ASP.NET ecosystem and is simple to implement (I have another example of using it in a game server [here](https://github.com/aykay76/gameserver)). 14 | 15 | To incorporate SignalR run: 16 | 17 | `dotnet add package Microsoft.AspNetCore.SignalR.Client --version 5.0.1` (replacing the version with the latest, or a specific version you require) 18 | 19 | Configuring SignalR in this project is quite simple. To save bouncing around lots of tutorials I will provide a link here and the basic steps I followed to make it work: 20 | 21 | ## SignalR 22 | 23 | The first step is to tell your ASP.NET application to use SignalR. In the `ConfigureServices` method of your `Startup.cs` file add the following: 24 | 25 | ```cs 26 | public void ConfigureServices(IServiceCollection services) 27 | { 28 | services.AddSignalR(); // add signalr 29 | services.AddRazorPages(); 30 | services.AddServerSideBlazor(); 31 | } 32 | ``` 33 | 34 | Then in the `Configure` method add the following to the UseEndpoints block of code: 35 | 36 | ```cs 37 | app.UseEndpoints(endpoints => 38 | { 39 | endpoints.MapBlazorHub(); 40 | endpoints.MapHub("/webrtc"); // map an endpoint to a SignalR service 41 | endpoints.MapFallbackToPage("/_Host"); 42 | }); 43 | ``` 44 | 45 | Then you need to add a SignalR hub that will respond to calls to the `/webrtc` endpoint. For this I create a folder called `Services` and create a class that is passed into the `MapHub` call. 46 | 47 | It's important that this class is a subclass of Hub, which is defined in the `Microsoft.AspNetCore.SignalR` namespace: 48 | 49 | ```cs 50 | public class WebRtcService : Hub 51 | { 52 | public async Task Send(string message) 53 | { 54 | Console.WriteLine(message); 55 | await Clients.Others.SendAsync("Receive", message); 56 | } 57 | } 58 | ``` 59 | 60 | This class is very simple, it contains one method which I will explain later. The `Send` method basically receives messages a client wishes to send. When a client sends a message SignalR will then tell all other clients to receive it. 61 | 62 | The last step is to enable SignalR on the client side. This requires downloading a Javascript SignalR client and referencing it in the host HTML file. 63 | 64 | You can reference it directly from CDN using the following HTML: 65 | 66 | ```html 67 | 68 | ``` 69 | (I downloaded it and referenced it locally, personal preference 😊, if you want to do this download the file to the `/wwwroot` directory of your project) 70 | 71 | The reference in `/Pages/_Host.cshtml` can be added below the reference to `_framework/blazor.server.js`: 72 | 73 | ```html 74 | 75 | 76 | ``` 77 | This completes adding SignalR to your project, it is now ready for use by your WebRTC client. 78 | 79 | ## WebRTC 80 | 81 | Now let's get down to what this project is about; sharing video, audio and desktop content with WebRTC. The first step is to lay out some HTML components in the `Index.razor` page that will contain the video, and a button to initiate a call. 82 | At this point I should say that this is a contrived project assuming two clients connected to the same page and one of the clients initiating a call. There is no authentication, no user lists to choose who to call, that's your homework to extend this if you wish 👍. 83 | 84 | So in `/Pages/Index.razor` add the following: 85 | 86 | ``` 87 |
88 | 89 |
90 |
91 | 95 | 99 |
100 | @code{ 101 | WebCamOptions options = new WebCamOptions() 102 | { 103 | CanvasID = "canvas", 104 | VideoID = "video" 105 | }; 106 | 107 | protected override void OnInitialized() 108 | { 109 | options.Width = 480; 110 | } 111 | 112 | public async Task Start() 113 | { 114 | await JSRuntime.InvokeVoidAsync("WebCamFunctions.start", options); 115 | } 116 | } 117 | ``` 118 | 119 | The options code isn't necessary, it's a hangover from a [tutorial](https://romansimuta.com/posts/using-a-web-camera-with-fun-filters-in-your-asp-net-core-blazor-webassembly-application/) I started looking at which showed how to capture webcam from Blazor. I'm lazy and didn't remove it 😜 120 | 121 | You'll see that clicking the start button uses the JS interop runtime to call a Javascript function. This could of course be replaced with a simple button that calls Javascript directly. 122 | 123 | Add another Javascript file to your project and put it in the `/wwwroot` folder, referencing it from your HTML as with SignalR above. I called mine `webcam.js`. 124 | 125 | The first thing I do when the page loads is to ensure i'm connected (and stay connected) to SignalR on the server: 126 | 127 | ```js 128 | const srConnection = new signalR.HubConnectionBuilder() 129 | .withUrl("/webrtc") 130 | .configureLogging(signalR.LogLevel.Information) 131 | .build(); 132 | 133 | // automatically reconnect on close 134 | srConnection.onclose(start); 135 | 136 | // define (re)start function 137 | async function start() { 138 | try { 139 | await srConnection.start(); 140 | console.log("SignalR Connected."); 141 | } catch (err) { 142 | console.log(err); 143 | setTimeout(start, 5000); 144 | } 145 | }; 146 | 147 | // connect to SignalR 148 | start(); 149 | ``` 150 | The above code connects the SignalR client to the `/webrtc` path defined in `Startup.cs`. If the connection closes it will be re-established, and the initial connection is started. 151 | 152 | Next, I wire up the WASM->JS interop code: 153 | ```js 154 | window.WebCamFunctions = { 155 | start: (options) => { onStart(options); } 156 | }; 157 | 158 | function onStart(options) { 159 | video = document.getElementById(options.videoID); 160 | width = options.width; 161 | 162 | createPeerConnection() 163 | 164 | navigator.mediaDevices.getUserMedia(mediaConstraints) 165 | .then(function (stream) { 166 | video.srcObject = stream 167 | myVideoStream = stream 168 | 169 | rtcConnection.addStream(myVideoStream) 170 | }) 171 | .catch(function (err) { 172 | console.log("An error occurred: " + err); 173 | }); 174 | 175 | video.addEventListener('canplay', function () { 176 | if (!streaming) { 177 | height = video.videoHeight / (video.videoWidth / width); 178 | 179 | if (isNaN(height)) { 180 | height = width / (4 / 3); 181 | } 182 | 183 | video.setAttribute('width', width); 184 | video.setAttribute('height', height); 185 | streaming = true; 186 | } 187 | }, false); 188 | } 189 | ``` 190 | The `onStart` function will create a WebRTC peer connection which will then interact with the signalling server to handle the RTC traffic. It then gets the user media stream and adds it to the connection. At the same time it populates one of the video elements in HTML so that the caller has a preview of their video. 191 | 192 | I don't think the `canplay` event is actually used, other than to set the size of the video based in the options passed from WASM. It could probably be removed. 193 | 194 | This starts the ICE process which will negotiate the correct candidate for media streaming. The MDN article [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling#signaling_transaction_flow) can explain it much better than I can. I know I said I would put everything in one place here but it's background, not essential to get this working. 195 | 196 | The code to create a peer connection will handle the flow of the negotiation, offers and answers that are used to establish the call: 197 | 198 | ```js 199 | function createPeerConnection() 200 | { 201 | rtcConnection = new RTCPeerConnection(null) 202 | 203 | rtcConnection.onicecandidate = function(event) { 204 | if (event.candidate) { 205 | // send to peers over SignalR 206 | srConnection.invoke("Send", JSON.stringify({ "candidate": event.candidate })); 207 | } 208 | } 209 | 210 | rtcConnection.onaddstream = function(event) { 211 | otherVideo = document.getElementById('remote'); 212 | 213 | // Attach the stream to the Video element via adapter.js 214 | otherVideo.srcObject = event.stream 215 | otherVideo.play() 216 | } 217 | 218 | rtcConnection.onnegotiationneeded = function() 219 | { 220 | rtcConnection.createOffer() 221 | .then(function(offer) { 222 | return rtcConnection.setLocalDescription(offer) 223 | }) 224 | .then(function() { 225 | srConnection.invoke("Send", JSON.stringify({"sdp": rtcConnection.localDescription})) 226 | }) 227 | } 228 | } 229 | ``` 230 | 231 | The `onicecandidate` event handler will send the candidate to SignalR, which will ensure it sends it to all connected clients. All clients will store all candidates so that a suitable channel can be used for communication between any clients. 232 | 233 | The `onaddstream` event needs work. In this contrived example it will simply set the other video element in HTML to show the incoming stream. In multi-party chat you would want multiple (dynamic) video elements and to manage which element corresponds to which client. 234 | 235 | The `onnegotiationneeded` event will create an offer. This is essentially offering the video stream to clients. This is 'calling another user' and we can see how they choose to accept the call or not. 236 | 237 | It's worth noting at this point the specific line: 238 | 239 | ```js 240 | srConnection.invoke("Send", JSON.stringify({"sdp": rtcConnection.localDescription})) 241 | ``` 242 | 243 | This demonstrates two things: 244 | 245 | 1. The invocation of the Send method in our SignalR service class from the client. The first parameter matches the name of the method, each following parameter is an argument to that method. 246 | 2. In this case we have one which is a JSON format string representing an object that contains the video stream description. This could be augmented with additional information about the sending user, the intended recipient etc. 247 | 248 | So, how does the callee know when a call has been made? Remember the SignalR server will call `Receive` on each connected client? We add that to the SignalR client in Javascript: 249 | 250 | ```js 251 | srConnection.on("Receive", data => { 252 | var message = JSON.parse(data) 253 | 254 | if (message.sdp) { 255 | if (message.sdp.type == 'offer') { 256 | createPeerConnection() 257 | rtcConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)) 258 | .then(function () { 259 | return navigator.mediaDevices.getUserMedia(mediaConstraints); 260 | }) 261 | .then(function(stream) { 262 | myVideoStream = stream 263 | video = document.getElementById("video") 264 | video.srcObject = stream 265 | 266 | // Add our stream to the connection to be shared 267 | rtcConnection.addStream(myVideoStream); 268 | }) 269 | .then(function() { 270 | return rtcConnection.createAnswer() 271 | }) 272 | .then(function (answer) { 273 | return rtcConnection.setLocalDescription(answer); 274 | }) 275 | .then(function() { 276 | srConnection.invoke("Send", JSON.stringify({ 'sdp': rtcConnection.localDescription })) 277 | }) 278 | } 279 | else if (message.sdp.type == 'answer') { 280 | rtcConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)) 281 | } 282 | } else if (message.candidate) { 283 | rtcConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); 284 | } 285 | }); 286 | ``` 287 | 288 | When the SignalR client receives a message from the server, and the SDP message type is `offer` then it creates its own RTC peer connection and configures it with the received stream details. (This example assumes automatic answering of the call, you would want to incorporate some form of UI to answer or reject the call) 289 | 290 | The client then creates an answer, sets the local description (for the callee video stream) and sends it to the signalling server. 291 | 292 | You can also see in the above code the caller will receive the `answer` SDP type and set the remote description to the callee video stream description. 293 | 294 | This completes the negotiation and the clients will then communicate with each other - no more code required from you, other than to handle reconnects etc. 295 | 296 | ** It's important to note that this will only work in a scenario where each client can communicate directly with each other. If you want to use this over the internet where clients are behind NAT devices (not in DMZ) then you will need to connect via a TURN/STUN server ** 297 | 298 | One last thing is to add the WebRTC adapter code to your project, which improves compatibility with certain browsers, and adds lots of other functionality that I haven't yet fully explored. 299 | 300 | ## Additional notes 301 | 302 | - To use this over a network you must use HTTPS for your web hosting. The only exception to this is localhost, for local testing you can use HTTP. 303 | - You can replace `getUserMedia` with `getDisplayMedia` to share screen instead of video. 304 | - You can choose a specific device to share by first calling `navigator.mediaDevices.enumerateDevices()` and handling user selection. 305 | - You can specify whether to share audio/video by adjusting the `mediaConstraints` object fields declared at the top of `webcam.js` 306 | 307 | The full tutorial on how to do this can be found [here on the Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling#Handling_the_invitation) 308 | 309 | I was amazed at how simple this was to set up. I hope it serves as a useful starter for you also. Please feel free to fork this repository, I would be interested to hear any enhancements I haven't thought of. 310 | 311 | 312 | # 2025 Update 313 | 314 | In recent years I have switched to a Macbook for day-to-day development, and noticed that port 5000 is taken by the Control Centre. You may also see a port clash, you can identify what's running by executing: 315 | 316 | `lsof -i:5000` 317 | 318 | And you may see something like: 319 | 320 | ``` 321 | COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME 322 | ControlCe 3848 vanilla 10u IPv4 0x3a19d881e570c7bd 0t0 TCP *:commplex-main (LISTEN) 323 | ControlCe 3848 vanilla 11u IPv6 0x1e7df997af5684a0 0t0 TCP *:commplex-main (LISTEN) 324 | ``` 325 | 326 | So I changed the port numbers in `launchSettings.json` and all is good - note that the hostname must match your computer's name on the network also. 327 | 328 | I have also updated this for .net 9.0 as 5.0 is out of support. 329 | 330 | I haven't done a lot with this project since I started it, it was really just to learn something new. But it's getting some attention, thanks for that! :) -------------------------------------------------------------------------------- /wwwroot/css/open-iconic/font/fonts/open-iconic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by FontForge 20120731 at Tue Jul 1 20:39:22 2014 9 | By P.J. Onori 10 | Created by P.J. Onori with FontForge 2.0 (http://fontforge.sf.net) 11 | 12 | 13 | 14 | 27 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 45 | 47 | 49 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 74 | 76 | 79 | 81 | 84 | 86 | 88 | 91 | 93 | 95 | 98 | 100 | 102 | 104 | 106 | 109 | 112 | 115 | 117 | 121 | 123 | 125 | 127 | 130 | 132 | 134 | 136 | 138 | 141 | 143 | 145 | 147 | 149 | 151 | 153 | 155 | 157 | 159 | 162 | 165 | 167 | 169 | 172 | 174 | 177 | 179 | 181 | 183 | 185 | 189 | 191 | 194 | 196 | 198 | 200 | 202 | 205 | 207 | 209 | 211 | 213 | 215 | 218 | 220 | 222 | 224 | 226 | 228 | 230 | 232 | 234 | 236 | 238 | 241 | 243 | 245 | 247 | 249 | 251 | 253 | 256 | 259 | 261 | 263 | 265 | 267 | 269 | 272 | 274 | 276 | 280 | 282 | 285 | 287 | 289 | 292 | 295 | 298 | 300 | 302 | 304 | 306 | 309 | 312 | 314 | 316 | 318 | 320 | 322 | 324 | 326 | 330 | 334 | 338 | 340 | 343 | 345 | 347 | 349 | 351 | 353 | 355 | 358 | 360 | 363 | 365 | 367 | 369 | 371 | 373 | 375 | 377 | 379 | 381 | 383 | 386 | 388 | 390 | 392 | 394 | 396 | 399 | 401 | 404 | 406 | 408 | 410 | 412 | 414 | 416 | 419 | 421 | 423 | 425 | 428 | 431 | 435 | 438 | 440 | 442 | 444 | 446 | 448 | 451 | 453 | 455 | 457 | 460 | 462 | 464 | 466 | 468 | 471 | 473 | 477 | 479 | 481 | 483 | 486 | 488 | 490 | 492 | 494 | 496 | 499 | 501 | 504 | 506 | 509 | 512 | 515 | 517 | 520 | 522 | 524 | 526 | 529 | 532 | 534 | 536 | 539 | 542 | 543 | 544 | --------------------------------------------------------------------------------