ScreencastR - Simple Screen Sharing App Using SignalR Streaming

In this article, we will see how to create a simple screen- sharing app using signalR streaming with IAsyncEnumerable API

Introduction

 
In this article, we will see how to create a simple screen-sharing app using signalR streaming. SignalR supports both server-to-client and client-to-server streaming. In my previous article, I have done server to client streaming with ChannelReader and ChannelWriter for streaming support. This may look very complex to implement asynchronous streaming just like writing the asynchronous method without async and await keyword. IAsyncEnumerable is the latest addition to .Net Core 3.0 and C# 8 feature for asynchronous streaming. It is now super easy to implement asynchronous streaming with a few lines of clean code. In this example, we will use the client to server streaming to stream the desktop content to all the connected remote client viewers using signalR stream with the support of IAsyncEnumerable API. 
 
Disclaimer
 
The sample code for this article is just an experimental project for testing signalR streaming with IAsyncEnumerable. In Real-world scenarios, You may consider using peer to peer connection using WebRTC or other socket libraries for building effective screen sharing tool.
 

Architecture 

 

How it works

 

ScreencastR Agent

 
 

Steps

 
ScreencastR agent is an Electron-based desktop application. Electron is a framework for creating native applications with web technologies like JavaScript, HTML, and CSS. It allows you to create desktop applications with pure JavaScript by providing a runtime with rich native (operating system) APIs. In our example, I have used desktopCapturer API to capture the desktop content. If you are new to electron, you can follow this official doc to create your first electron application.
 
A simple electron application will have the following files which are similar to the node.js application.
  1. your-app/  
  2. ├── package.json  
  3. ├── main.js  
  4. └── index.html  
The starting point is the package.json which will have entry point javascript (main.js) and main.js will create a basic electron shell with default menu option and load the main HTML page. (index.html). In this package.json, I have added the dependency of latest SignalR client.
 
package.json
  1. {    
  2.   "name""ScreencastRAgent",    
  3.   "version""1.0.0",    
  4.   "description""ScreencastR Agent",    
  5.   "main""main.js",    
  6.   "scripts": {    
  7.     "start""electron ."    
  8.   },    
  9.   "repository""https://github.com/electron/electron-quick-start",    
  10.   "keywords": [    
  11.     "Electron",    
  12.     "quick",    
  13.     "start",    
  14.     "tutorial",    
  15.     "demo"    
  16.   ],    
  17.   "author""Jeeva Subburaj",    
  18.   "license""CC0-1.0",    
  19.   "devDependencies": {    
  20.     "electron""^6.0.0"    
  21.   },    
  22.   "dependencies": {    
  23.     "@microsoft/signalr""^3.0.0-preview8.19405.7"    
  24.   }    
  25. }   
When we run the npm build, it will bring all the dependencies under node_modules folder including signalR client. Copy the signalr.js file from node_modules\@microsoft\signalr\dist\browserfolder into the root folder.
 
index.html
  1. <!DOCTYPE html>    
  2. <html>    
  3.   <head>    
  4.     <meta charset="UTF-8">    
  5.     <title>ScreencastR Agent</title>    
  6.   </head>    
  7.   <body>    
  8.       <h1>ScreencastR Agent</h1>    
  9.       <div>    
  10.       <h4>Agent Name</h1>    
  11.       <input type="text" id="agentName"/>    
  12.     </div>    
  13.       <input id="startCast" type="button" value="Start Casting">    
  14.       <input id="stopCast" type="button" value="Stop Casting">       
  15.       <canvas id='screenCanvas'></canvas>          
  16.   </body>    
  17.   <script>    
  18.       require('./renderer.js')    
  19.       require('./signalr.js')    
  20.     </script>    
  21. </html>    
In the index.html page, we have simple layout to get the name of agent and start and stop casting button. 

Renderer.js

  1. const { desktopCapturer } = require('electron')  
  2. const signalR = require('@microsoft/signalr')  
  3.   
  4. let connection;  
  5. let subject;  
  6. let screenCastTimer;  
  7. let isStreaming = false;  
  8. const framepersecond = 10;  
  9. const screenWidth = 1280;  
  10. const screenHeight = 800;  
  11.   
  12.   
  13. async function initializeSignalR() {  
  14.     connection = new signalR.HubConnectionBuilder()  
  15.         .withUrl("https://localhost:5001/ScreenCastHub")  
  16.         .configureLogging(signalR.LogLevel.Information)  
  17.         .build();  
  18.   
  19.     connection.on("NewViewer"function () {  
  20.         if (isStreaming === false)  
  21.             startStreamCast()  
  22.     });  
  23.   
  24.     connection.on("NoViewer"function () {  
  25.         if (isStreaming === true)  
  26.             stopStreamCast()  
  27.     });  
  28.   
  29.     await connection.start().then(function () {  
  30.         console.log("connected");  
  31.     });  
  32.   
  33.     return connection;  
  34. }  
  35.   
  36. initializeSignalR();  
  37.   
  38. function CaptureScreen() {  
  39.     return new Promise(function (resolve, reject) {  
  40.         desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { width: screenWidth, height: screenHeight } },  
  41.             (error, sources) => {  
  42.                 if (error) console.error(error);  
  43.                 for (const source of sources) {  
  44.                     if (source.name === 'Entire screen') {  
  45.                         resolve(source.thumbnail.toDataURL())  
  46.                     }  
  47.                 }  
  48.             })  
  49.     })  
  50. }  
  51.   
  52. const agentName = document.getElementById('agentName');  
  53. const startCastBtn = document.getElementById('startCast');  
  54. const stopCastBtn = document.getElementById('stopCast');  
  55. stopCastBtn.setAttribute("disabled""disabled");  
  56.   
  57. startCastBtn.onclick = function () {  
  58.     startCastBtn.setAttribute("disabled""disabled");  
  59.     stopCastBtn.removeAttribute("disabled");  
  60.     connection.send("AddScreenCastAgent", agentName.value);  
  61. };  
  62.   
  63. function startStreamCast() {  
  64.     isStreaming = true;  
  65.     subject = new signalR.Subject();  
  66.     connection.send("StreamCastData", subject, agentName.value);  
  67.     screenCastTimer = setInterval(function () {  
  68.         try {  
  69.             CaptureScreen().then(function (data) {  
  70.                 subject.next(data);  
  71.             });  
  72.   
  73.         } catch (e) {  
  74.             console.log(e);  
  75.         }  
  76.     }, Math.round(1000 / framepersecond));  
  77. }  
  78.   
  79. function stopStreamCast() {  
  80.   
  81.     if (isStreaming === true) {  
  82.         clearInterval(screenCastTimer);  
  83.         subject.complete();  
  84.         isStreaming = false;  
  85.     }  
  86. }  
  87.   
  88. stopCastBtn.onclick = function () {  
  89.     stopCastBtn.setAttribute("disabled""disabled");  
  90.     startCastBtn.removeAttribute("disabled");  
  91.     stopStreamCast();  
  92.     connection.send("RemoveScreenCastAgent", agentName.value);  
  93. };  
In the renderer.js javascript, initializeSignalR method would initialize the signalR connection when the application gets loaded and listens to NewViewer and NoViewer hub methods. The NewViewer method gets called whenever the new remote viewer joining to view the stream. The agent will not stream the content until atleast one viewer exists. When NoViewer method is called, it will stop the stream.
 
CaptureScreen method will use the desktopCapturer API to get the list of available screen and window sources and filter to get the “Entire screen“ source only. After the source is identified, screen thumbnail data can be generated from the source based on the thumbnail size that is defined. CaptureScreen method is based on the promised API and will return the image data in a string as part of the resolve method. We will call the CaptureScreen method in the timer (setInterval method) based on the frame per second defined and the output will be streamed via signalR subject class.
 

ScreenCastR Remote Viewer 

 
ScreencastR - Simple Screen Sharing App Using SignalR Streaming
 
ScreenCastR Remote Viewer is a server-side blazor app with signalR hub hosted in it. This app also has the interface for signalR client to receive the streaming data from the hub. Whenever the New Agent joined, it will show the details of the agent in the dashboard page with the name of agent and View and Stop Cast button. When the user clicks on the View Cast button, it will start receiving the streaming from the hub and render the output on the screen. In the above video, the left side is the agent streaming data to the signalR hub and the right side is the viewer rendering the streaming data from the signalR hub.
 

Steps

 
ScreenCastHub
  1. public class ScreenCastHub : Hub  
  2.     {  
  3.         private readonly ScreenCastManager screenCastManager;  
  4.         private const string AGENT_GROUP_PREFIX = "AGENT_";  
  5.   
  6.         public ScreenCastHub(ScreenCastManager screenCastManager)  
  7.         {  
  8.             this.screenCastManager = screenCastManager;  
  9.         }  
  10.   
  11.         public async Task AddScreenCastAgent(string agentName)  
  12.         {  
  13.             await Clients.Others.SendAsync("NewScreenCastAgent", agentName);  
  14.             await Groups.AddToGroupAsync(Context.ConnectionId, AGENT_GROUP_PREFIX + agentName);  
  15.         }  
  16.   
  17.         public async Task RemoveScreenCastAgent(string agentName)  
  18.         {  
  19.             await Clients.Others.SendAsync("RemoveScreenCastAgent", agentName);  
  20.             await Groups.RemoveFromGroupAsync(Context.ConnectionId, AGENT_GROUP_PREFIX + agentName);  
  21.             screenCastManager.RemoveViewerByAgent(agentName);  
  22.         }  
  23.   
  24.         public async Task AddScreenCastViewer(string agentName)  
  25.         {  
  26.             await Groups.AddToGroupAsync(Context.ConnectionId, agentName);  
  27.             screenCastManager.AddViewer(Context.ConnectionId, agentName);  
  28.             await Clients.Groups(AGENT_GROUP_PREFIX + agentName).SendAsync("NewViewer");  
  29.         }  
  30.   
  31.         public async Task RemoveScreenCastViewer(string agentName)  
  32.         {  
  33.             await Groups.RemoveFromGroupAsync(Context.ConnectionId, agentName);  
  34.             screenCastManager.RemoveViewer(Context.ConnectionId);  
  35.             if(!screenCastManager.IsViewerExists(agentName))  
  36.                 await Clients.Groups(AGENT_GROUP_PREFIX + agentName).SendAsync("NoViewer");  
  37.         }  
  38.   
  39.         public async Task StreamCastData(IAsyncEnumerable<string> stream, string agentName)  
  40.         {  
  41.             await foreach (var item in stream)  
  42.             {  
  43.                 await Clients.Group(agentName).SendAsync("OnStreamCastDataReceived", item);  
  44.             }  
  45.         }  
  46.     }  
ScreenCastHub class is the streaming hub with all methods to communicate between agent and remote viewer.
 
StreamCastData is the main streaming method which will take IAsyncEnumerable Items and stream the chunk of data that it receives to all the connected remote viewers.
 
AddScreenCastAgent method will send the notification to all the connected remote viewers whenever the new agent joins the hub.
 
RemoveScreenCastAgent method will send the notification to all the connected remote viewer whenever the agent disconnects from the hub.
 
AddScreenCastViewer method will send the notification to the agent if the new viewer joined to view the screencast.
 
RemoveScreenCastViewer method will send the notification to the agent if all the viewers are disconnected from viewing the screencast.
 
ScreenCastManager
  1. public class ScreenCastManager  
  2.     {  
  3.         private List<Viewer> viewers = new List<Viewer>();  
  4.   
  5.         public void AddViewer(string connectionId, string agentName)  
  6.         {  
  7.             viewers.Add(new Viewer(connectionId, agentName));  
  8.         }  
  9.   
  10.         public void RemoveViewer(string connectionId)  
  11.         {  
  12.             viewers.Remove(viewers.First(i => i.ConnectionId == connectionId));  
  13.         }  
  14.   
  15.         public void RemoveViewerByAgent(string agentName)  
  16.         {  
  17.             viewers.RemoveAll(i => i.AgentName == agentName);  
  18.         }  
  19.   
  20.         public bool IsViewerExists(string agentName)  
  21.         {  
  22.             return viewers.Any(i => i.AgentName == agentName);  
  23.         }  
  24.   
  25.     }  
  26.   
  27.     internal class Viewer  
  28.     {  
  29.         public string ConnectionId { getset; }  
  30.         public string AgentName { getset; }  
  31.   
  32.         public Viewer(string connectionId, string agentName)  
  33.         {  
  34.             ConnectionId = connectionId;  
  35.             AgentName = agentName;  
  36.         }  
  37.     }  
This class will hold the number of viewers connected per agent. This class is injected to hub via dependency injection in singleton scope.
 
services.AddSingleton<ScreenCastManager>();
 
Startup.cs
 
In startup.cs, increase the default message size from 32KB to a bigger range based on the quality of stream output. Otherwise, the hub will fail to transmit the data. 
  1. public void ConfigureServices(IServiceCollection services)  
  2.         {  
  3.             services.AddRazorPages();  
  4.             services.AddServerSideBlazor();  
  5.             services.AddSignalR().AddHubOptions<ScreenCastHub>(options => { options.MaximumReceiveMessageSize = 102400000; });  
  6.             services.AddSingleton<ScreenCastManager>();  
  7.         }  
Screen CastRemote Viewer Razor Component
  1. @using Microsoft.AspNetCore.SignalR.Client  
  2.   
  3. <div class="card border-primary mb-3" style="max-width: 20rem;">  
  4.     @if (agents.Count > 0)  
  5.     {  
  6.         @foreach (var agent in agents)  
  7.         {  
  8.             <div class="card-body">  
  9.                 <div>  
  10.                     <h3 class="badge-primary">  
  11.                         @agent  
  12.                     </h3>  
  13.                     <div style="padding-top:10px">  
  14.                         <button id="ViewCast" disabled="@(IsViewingCastOf(agent))" class="btn btn-success btn-sm" @onclick="@(() => OnViewCastClicked(agent))">  
  15.                             View cast  
  16.                         </button>  
  17.   
  18.                         <button id="StopViewCast" disabled="@(!IsViewingCastOf(agent))" class="btn btn-warning btn-sm" @onclick="@(() => OnStopViewCastClicked(agent))">  
  19.                             Stop cast  
  20.                         </button>  
  21.                     </div>  
  22.                 </div>  
  23.             </div>  
  24.         }  
  25.     }  
  26.     else  
  27.     {  
  28.         <div class="card-body">  
  29.             <h3 class="card-header badge-warning">No Screencast Agents casting the screen now!</h3>  
  30.         </div>  
  31.     }  
  32. </div>  
  33. <div class="border">  
  34.     <img id='screenImage' src="@imageSource" />  
  35. </div>  
  36. @code{  
  37.   
  38.     private List<string> agents = new List<string>();  
  39.   
  40.     HubConnection connection;  
  41.     string imageSource = null;  
  42.     string CurrentViewCastAgent = null;  
  43.   
  44.     protected async override Task OnInitializedAsync()  
  45.     {  
  46.         connection = new HubConnectionBuilder()  
  47.         .WithUrl("https://localhost:5001/ScreenCastHub")  
  48.         .Build();  
  49.   
  50.         connection.On<string>("NewScreenCastAgent", NewScreenCastAgent);  
  51.         connection.On<string>("RemoveScreenCastAgent", RemoveScreenCastAgent);  
  52.         connection.On<string>("OnStreamCastDataReceived", OnStreamCastDataReceived);  
  53.   
  54.         await connection.StartAsync();  
  55.     }  
  56.   
  57.     bool IsViewingCastOf(string agentName)  
  58.     {  
  59.         return agentName == CurrentViewCastAgent;  
  60.     }  
  61.   
  62.     void NewScreenCastAgent(string agentName)  
  63.     {  
  64.         agents.Add(agentName);  
  65.         StateHasChanged();  
  66.     }  
  67.   
  68.     void RemoveScreenCastAgent(string agentName)  
  69.     {  
  70.         agents.Remove(agentName);  
  71.         imageSource = null;  
  72.         CurrentViewCastAgent = null;  
  73.         StateHasChanged();  
  74.     }  
  75.   
  76.     void OnStreamCastDataReceived(string streamData)  
  77.     {  
  78.         imageSource = streamData;  
  79.         StateHasChanged();  
  80.     }  
  81.   
  82.     private async Task OnViewCastClicked(string agentName)  
  83.     {  
  84.         CurrentViewCastAgent = agentName;  
  85.         await connection.InvokeAsync("AddScreenCastViewer", agentName);  
  86.     }  
  87.   
  88.     private async Task OnStopViewCastClicked(string agentName)  
  89.     {  
  90.         CurrentViewCastAgent = null;  
  91.         await connection.InvokeAsync("RemoveScreenCastViewer", agentName);  
  92.         imageSource = null;  
  93.         StateHasChanged();  
  94.     }  
  95.   
  96. }  
In this component, as part of OnInitializedAsync method, initialize the signalR client connection with the hub and subscribe to the streaming method. When the stream data arrives from the hub, it updates the image source DOM element and renders the screen with the changes.
 
Demo (Youtube)
 
 

Summary

 
IAsyncEnumerable is a very nice feature added to .Net Core 3.0 and C# 8 for asynchronous streaming with cleaner and more readable code. With this new feature and SignalR streaming, we can do many cool projects like a Real Time App health monitor dashboard, Real Time multiplayer games, etc… I have uploaded the entire source code for this article in the GitHub repository.
 
Happy Coding!!!