Sayed Asad
Sayed Asad

Reputation: 31

Live Streaming in ASP.NET Core 8 MVC using SignalR

I am creating a one-on-one live streaming functionality.

  1. First I generate the link using Guid.
  2. Open the link in two different tabs and connection is being established in Initiator and Receiver both the tabs.
  3. There are two video tags one is for localVideo and another is for remoteVideo which should display other user's video.
  4. But I can only see the localVideo

Program.cs file:

using VideoStreamingApp.Hubs;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddSignalR(); // Adding SignalR

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

// **Map SignalR Hub**
app.MapHub<StreamingHub>("/streamingHub");

app.Run();

Controller:

public class AdminController : Controller
{
    private static Dictionary<string, DateTime> _streamLinks = new Dictionary<string, DateTime>();

    public IActionResult GenerateLink()
    {
        string streamId = Guid.NewGuid().ToString();
        DateTime expiryTime = DateTime.UtcNow.AddMinutes(1); // Set expiry time to 1 minutes

        // Store the link with expiration time
        _streamLinks[streamId] = expiryTime;

        string streamLink = $"{Request.Scheme}://{Request.Host}/Stream/Join?streamId={streamId}";
        ViewBag.StreamLink = streamLink;
        ViewBag.ExpiryTime = expiryTime.ToString("yyyy-MM-dd HH:mm:ss UTC");

        return View("GenerateLink");
    }

    public static bool IsLinkValid(string streamId)
    {
        return _streamLinks.ContainsKey(streamId) && _streamLinks[streamId] > DateTime.UtcNow;
    }
}

public class StreamController : Controller
{
    public IActionResult Join(string streamId)
    {
        if (!AdminController.IsLinkValid(streamId))
        {
            return View("ExpiredLink"); // Show expiration message
        }

        ViewBag.StreamId = streamId;
        return View();
    }
}

Inside Hubs folder > StreamingHub:

public class StreamingHub : Hub
{
    private static ConcurrentDictionary<string, string> _initiators = new ConcurrentDictionary<string, string>();

    public async Task JoinStream(string streamId)
    {
        bool isInitiator = !_initiators.ContainsKey(streamId);

        if (isInitiator)
        {
            _initiators[streamId] = Context.ConnectionId;
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, streamId);
        await Clients.Caller.SendAsync("SetInitiator", isInitiator);
    }

    public async Task SendOffer(string streamId, string offer)
    {
        await Clients.OthersInGroup(streamId).SendAsync("ReceiveOffer", offer);
    }

    public async Task SendAnswer(string streamId, string answer)
    {
        await Clients.OthersInGroup(streamId).SendAsync("ReceiveAnswer", answer);
    }

    public async Task SendMuteStatus(string streamId, bool isMuted)
    {
        await Clients.OthersInGroup(streamId).SendAsync("ReceiveMuteStatus", Context.ConnectionId, isMuted);
    }

    public async Task SendVideoStatus(string streamId, bool isVideoOn)
    {
        await Clients.OthersInGroup(streamId).SendAsync("ReceiveVideoStatus", Context.ConnectionId, isVideoOn);
    }
}

Join.cshtml of StreamController:

<h6>Live Video Stream</h6>

<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

<button id="muteBtn">Mute</button>
<button id="videoToggleBtn">Turn Off Video</button>

<script src="https://cdnjs.cloudflare.com/ajax/libs/simple-peer/9.11.1/simplepeer.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.2/signalr.min.js"></script>

<script>
    const hubConnection = new signalR.HubConnectionBuilder()
        .withUrl("/streamingHub")
        .configureLogging(signalR.LogLevel.Information)
        .build();

    let peer;
    let isInitiator = false;
    const streamId = "@ViewBag.StreamId";

    hubConnection.start().then(async () => {
        console.log(`βœ… Connected to SignalR. with StreamId: ${streamId}`);

        try {
            console.log("πŸ”Ή Sending JoinStream request...");
            await hubConnection.invoke("JoinStream", streamId);
            console.log("βœ… Successfully invoked JoinStream.");
        } catch (error) {
            console.error("❌ Error invoking JoinStream:", error);
        }
    });

    hubConnection.on("SetInitiator", (role) => {
        console.log("πŸ”Ή Received SetInitiator event:", role);
        isInitiator = role;
        console.log(`πŸ”Ή Role assigned: ${isInitiator ? "Initiator" : "Receiver"}`);
        startStreaming();
    });


            async function startStreaming() {
        console.log("πŸ”Ή Requesting user media...");

        try {
            const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
            console.log("βœ… Local video stream received:", stream);
            document.getElementById("localVideo").srcObject = stream;

            // Close existing peer connection before creating a new one
            if (peer) {
                console.log("πŸ”Ή Closing existing peer connection...");
                peer.destroy();
                peer = null;
            }

            peer = new SimplePeer({
                initiator: isInitiator,
                trickle: false,
                stream: stream // Directly pass the stream
            });

                // 2️⃣ When peer connection generates an ANSWER, send it back
    peer.on("signal", (data) => {
        console.log("πŸ”Ή [Receiver] Generating ANSWER:", JSON.stringify(data));

        hubConnection.invoke("SendAnswer", streamId, JSON.stringify(data))
            .then(() => console.log("βœ… [Receiver] Answer sent successfully."))
            .catch((err) => console.error("❌ [Receiver] Error sending Answer:", err));
    });


            peer.on("stream", (remoteStream) => {
                console.log("βœ… Remote stream received:", remoteStream);
                const remoteVideoElement = document.getElementById("remoteVideo");

                if (remoteVideoElement) {
                    remoteVideoElement.srcObject = remoteStream;
                    console.log("πŸŽ₯ Remote video assigned successfully.");
                } else {
                    console.error("❌ remoteVideo element not found.");
                }
            });


            peer.on("error", (err) => {
                console.error("❌ WebRTC Error:", err);
            });

            peer.on("connect", () => {
                console.log("βœ… Peer connection established.");
            });

        } catch (error) {
            console.error("❌ Error accessing media devices:", error);
        }
    }


           // 1️⃣ Receiver listens for OFFER
    hubConnection.on("ReceiveOffer", (offer) => {
        console.log("πŸ”Ή [Receiver] Received OFFER:", offer);

        if (!peer) {
            console.log("πŸ”Ή [Receiver] Initializing peer connection...");
            startStreaming();
        }

        if (peer) {
            console.log("πŸ”Ή [Receiver] Applying OFFER...");
            peer.signal(JSON.parse(offer));
        } else {
            console.error("❌ [Receiver] Peer object is NULL while processing offer.");
        }
    });


        // 3️⃣ Initiator listens for the ANSWER
    hubConnection.on("ReceiveAnswer", (answer) => {
        console.log("πŸ”Ή [Initiator] Received ANSWER:", answer);

        if (peer) {
            console.log("πŸ”Ή [Initiator] Applying ANSWER...");
            peer.signal(JSON.parse(answer));
        } else {
            console.error("❌ [Initiator] Peer object is NULL while processing answer.");
        }
    });

</script>

Console logs:

First Tab:
Connected to SignalR. with StreamId: 2acc13b3-9c48-423f-aa3e-1e497494f886
Sending JoinStream request...
Received SetInitiator event: true
Role assigned: Initiator
Requesting user media...
Successfully invoked JoinStream.
[Receiver] Generating ANSWER:
Local video stream received: 
[Receiver] Answer sent successfully.

Second Tab:
Connected to SignalR. with StreamId: 2acc13b3-9c48-423f-aa3e-1e497494f886
Sending JoinStream request...
Received SetInitiator event: false
Role assigned: Receiver
Requesting user media...
Successfully invoked JoinStream.
Local video stream received: 

Upvotes: 2

Views: 78

Answers (1)

Jason Pan
Jason Pan

Reputation: 22029

After carefully reviewing the documentation of the three-party simple-peer you use, I found that the problem is that every time you create a SimplePeer object, the role always is initiator. You need to modify the logic yourself. I just manually set isInitiator=false (debugging in vs2022) when establishing the signalr connection to bypass this problem.

In addition, Clients.OthersInGroup does not seem to be effective, please check the official documentation. I use Clients.All.SendAsync instead here, just for testing.

Test Result

enter image description here

Logs

[2025-02-24T07:29:12.793Z] Information: Normalizing '/streamingHub' to 'http://localhost:5179/streamingHub'.
signalr.min.js:1 [2025-02-24T07:29:12.864Z] Information: WebSocket connected to ws://localhost:5179/streamingHub?id=3y5EqBOdjMBaO2lsTRk6aA.
signalr.min.js:1 [2025-02-24T07:29:12.865Z] Information: Using HubProtocol 'json'.
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:62 βœ… Connected to SignalR. with StreamId: f2d6b973-f5c9-420f-8d43-40867adea1e9
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:65 πŸ”Ή Sending JoinStream request...
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:75 πŸ”Ή Received SetInitiator event: false
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:77 πŸ”Ή Role assigned: Receiver
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:87 πŸ”Ή Requesting user media...
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:67 βœ… Successfully invoked JoinStream.
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:91 βœ… Local video stream received: MediaStreamΒ {id: '9226ded7-a11d-4f97-bfed-0beca1b4abc6', active: true, onaddtrack: null, onremovetrack: null, onactive: null, …}
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:143 πŸ”Ή Processing pending signals...
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:81 πŸ”Ή Peer connection ready as: Receiver
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:154 πŸ”Ή [Receiver] Received OFFER: {"type":"offer","sdp":"v=0\r\no=- 3232030228750443743 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1 2\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS 26cb8b55-ae56-42fd-b329-705a9df***ize:262144\r\n"}
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:167 πŸ”Ή [Receiver] Applying OFFER to existing peer...
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:130 βœ… Remote stream received: MediaStreamΒ {id: '26cb8b55-ae56-42fd-b329-705a9df9ce5f', active: true, onaddtrack: null, onremovetrack: null, onactive: null, …}
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:107 πŸ”Ή Peer generated signal data: {type: 'answer', sdp: 'v=0\r\no=- 5343498561161681530 2 IN IP4 127.0.0.1\r\ns…:2\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n'}
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:174 πŸ”Ή [Initiator] Received ANSWER: {"type":"answer","sdp":"v=0\r\no=- 5343498561161681530 2 **C:F6:E3:50:79:FA:47:63:76:4F:32:46:46:EF\r\na=setup:active\r\na=mid:2\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"}
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:179  ⚠️ [Initiator] Already in 'stable' state, ignoring duplicate Answer.
(anonymous) @ Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:179
(anonymous) @ signalr.min.js:1
ut @ signalr.min.js:1
I @ signalr.min.js:1
A.connection.onreceive @ signalr.min.js:1
i.onmessage @ signalr.min.js:1
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:120 βœ… [Receiver] Answer sent successfully.
Join?streamId=f2d6b973-f5c9-420f-8d43-40867adea1e9:139 βœ… Peer connection established.

Here is my test code

Join.cshtml

<script src="https://cdnjs.cloudflare.com/ajax/libs/simple-peer/9.11.1/simplepeer.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.2/signalr.min.js"></script>

<h6>Live Video Stream</h6>

<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

<button id="muteBtn">Mute</button>
<button id="videoToggleBtn">Turn Off Video</button>

<script>
    const hubConnection = new signalR.HubConnectionBuilder()
        .withUrl("/streamingHub")
        .configureLogging(signalR.LogLevel.Information)
        .build();

    let peer;
    let isInitiator = false;
    const streamId = "@ViewBag.StreamId";  
    let pendingSignals = []; 

    
    hubConnection.start().then(async () => {
        console.log(`βœ… Connected to SignalR. with StreamId: ${streamId}`);

        try {
            console.log("πŸ”Ή Sending JoinStream request...");
            await hubConnection.invoke("JoinStream", streamId);
            console.log("βœ… Successfully invoked JoinStream.");
        } catch (error) {
            console.error("❌ Error invoking JoinStream:", error);
        }
    });

    
    hubConnection.on("SetInitiator", (role) => {
        console.log("πŸ”Ή Received SetInitiator event:", role);
        isInitiator = role;
        console.log(`πŸ”Ή Role assigned: ${isInitiator ? "Initiator" : "Receiver"}`);

        
        startStreaming().then(() => {
            console.log("πŸ”Ή Peer connection ready as:", isInitiator ? "Initiator" : "Receiver");
        });
    });

    
    async function startStreaming() {
        console.log("πŸ”Ή Requesting user media...");

        try {
            const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
            console.log("βœ… Local video stream received:", stream);
            document.getElementById("localVideo").srcObject = stream;

            
            if (peer) {
                peer.destroy();
            }

            peer = new SimplePeer({
                initiator: isInitiator,
                trickle: false,
                stream: stream
            });

           
            peer.on("signal", (data) => {
                console.log("πŸ”Ή Peer generated signal data:", data);

                if (isInitiator) {
                    // if (peer._pc.signalingState === "have-local-offer") {
                    //     console.warn("⚠️ [Initiator] Already have local offer, skipping duplicate send.");
                    //     return;
                    // }
                    hubConnection.invoke("SendOffer", streamId, JSON.stringify(data))
                        .then(() => console.log("βœ… [Initiator] Offer sent successfully."))
                        .catch((err) => console.error("❌ [Initiator] Error sending Offer:", err));
                } else {
                    if (peer._pc.signalingState === "stable") {
                        hubConnection.invoke("SendAnswer", streamId, JSON.stringify(data))
                            .then(() => console.log("βœ… [Receiver] Answer sent successfully."))
                            .catch((err) => console.error("❌ [Receiver] Error sending Answer:", err));
                    } else {
                        console.warn("⚠️ [Receiver] Not in 'stable' state, skipping Answer send.");
                    }
                }
            });

            
            peer.on("stream", (remoteStream) => {
                console.log("βœ… Remote stream received:", remoteStream);
                document.getElementById("remoteVideo").srcObject = remoteStream;
            });

            peer.on("error", (err) => {
                console.error("❌ WebRTC Error:", err);
            });

            peer.on("connect", () => {
                console.log("βœ… Peer connection established.");
            });

            
            console.log("πŸ”Ή Processing pending signals...");
            pendingSignals.forEach((signal) => peer.signal(signal));
            pendingSignals = []; 

        } catch (error) {
            console.error("❌ Error accessing media devices:", error);
        }
    }

 
    hubConnection.on("ReceiveOffer", (offer) => {
        console.log("πŸ”Ή [Receiver] Received OFFER:", offer);
        const parsedOffer = JSON.parse(offer);

        if (!peer) {
            console.log("πŸ”Ή [Receiver] Peer not initialized. Adding to pending signals...");
            pendingSignals.push(parsedOffer);
            startStreaming();
        } else {
            if (peer._pc.signalingState !== "stable") {
                console.warn("⚠️ [Receiver] Peer connection not in stable state, ignoring OFFER.");
                return;
            }

            console.log("πŸ”Ή [Receiver] Applying OFFER to existing peer...");
            peer.signal(parsedOffer);
        }
    });

 
    hubConnection.on("ReceiveAnswer", (answer) => {
        console.log("πŸ”Ή [Initiator] Received ANSWER:", answer);
        const parsedAnswer = JSON.parse(answer);

        if (peer) {
            if (peer._pc.signalingState === "stable") {
                console.warn("⚠️ [Initiator] Already in 'stable' state, ignoring duplicate Answer.");
                return;
            }

            console.log("πŸ”Ή [Initiator] Applying ANSWER...");
            peer.signal(parsedAnswer);
        } else {
            console.error("❌ [Initiator] Peer object is NULL while processing answer.");
        }
    });
</script>

StreamingHub.cs

using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
using System.Threading.Tasks;

namespace VideoStreamingApp.Hubs
{
    public class StreamingHub : Hub
    {
        private static ConcurrentDictionary<string, string> _initiators = new ConcurrentDictionary<string, string>();

        public async Task JoinStream(string streamId)
        {
            bool isInitiator = !_initiators.ContainsKey(streamId);

            if (isInitiator)
            {
                _initiators[streamId] = Context.ConnectionId;
            }

            await Groups.AddToGroupAsync(Context.ConnectionId, streamId);
            await Clients.Caller.SendAsync("SetInitiator", isInitiator);
        }

        public async Task SendOffer(string streamId, string offer)
        {
            await Clients.OthersInGroup(streamId).SendAsync("ReceiveOffer", offer);
            await Clients.All.SendAsync("ReceiveOffer", offer);
        }

        public async Task SendAnswer(string streamId, string answer)
        {
            //await Clients.OthersInGroup(streamId).SendAsync("ReceiveAnswer", answer);
            await Clients.All.SendAsync("ReceiveAnswer", answer);
        }

        public async Task SendMuteStatus(string streamId, bool isMuted)
        {
            //await Clients.OthersInGroup(streamId).SendAsync("ReceiveMuteStatus", Context.ConnectionId, isMuted);
            await Clients.All.SendAsync("ReceiveMuteStatus", Context.ConnectionId, isMuted);
        }

        public async Task SendVideoStatus(string streamId, bool isVideoOn)
        {
            //await Clients.OthersInGroup(streamId).SendAsync("ReceiveVideoStatus", Context.ConnectionId, isVideoOn);
            await Clients.All.SendAsync("ReceiveVideoStatus", Context.ConnectionId, isVideoOn);
        }
    }
}

Upvotes: 0

Related Questions