Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# WebRtcNet Domain Glossary

This file is a glossary of canonical terms used in the WebRtcNet codebase.
It contains **no implementation details** — only term definitions.

---

## Terms

### Host
A peer that initiates a WebRTC session by opening a TCP listener and waiting for
an incoming connection before creating an SDP offer.

### Guest
A peer that joins a WebRTC session by dialling the Host's IP address and port,
then responding to the Host's SDP offer with an SDP answer.

### Signaling
The out-of-band exchange of SDP offers/answers and ICE candidates between two
peers. In this repository the term refers specifically to message exchange; it
does not imply a server. BasicVideoChat uses direct TCP signaling.

### SignalingMessage
A newline-delimited JSON envelope carrying one of: `Offer`, `Answer`,
`Candidate`, or `Bye`.

### BasicVideoChat
The first example application. Demonstrates a peer-to-peer audio/video call over
a direct TCP signaling channel, targeting both .NET 10 and .NET Framework 4.8.

### WpfVideoRenderer
A prototype `VideoRenderer` implementation backed by a WPF `WriteableBitmap`.
Lives in `examples/BasicVideoChat` until a proper `WebRtcNet.Wpf` renderer
assembly is created (see issue #36).
3 changes: 3 additions & 0 deletions WebRtcNet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<Folder Name="/third_party/">
<Project Path="third-party/shared-items/googletest/googletest.vcxitems" Id="357b6b66-ab73-41cd-9271-1b4e5c41dde1" />
</Folder>
<Folder Name="/Examples/">
<Project Path="examples/BasicVideoChat/BasicVideoChat.csproj" Id="ce07d5b9-b81d-4834-a99b-6917ae0b5982" />
</Folder>
<Project Path="WebRtcNet.Api.UnitTests/WebRtcNet.Api.UnitTests.csproj" />
<Project Path="WebRtcNet.Api/WebRtcNet.Api.csproj" />
<Project Path="WebRtcNet/WebRtcNet.csproj" />
Expand Down
7 changes: 7 additions & 0 deletions examples/BasicVideoChat/App.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Application x:Class="BasicVideoChat.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>
7 changes: 7 additions & 0 deletions examples/BasicVideoChat/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.Windows;

namespace BasicVideoChat;

public partial class App : Application
{
}
22 changes: 22 additions & 0 deletions examples/BasicVideoChat/BasicVideoChat.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFrameworks>net10.0-windows;net48</TargetFrameworks>
<UseWPF>true</UseWPF>
<Configurations>Debug;Release</Configurations>
<Platforms>x64;x86;ARM64</Platforms>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<AssemblyTitle>BasicVideoChat</AssemblyTitle>
<AssemblyDescription>A basic WebRTC peer-to-peer video chat example using WebRtcNet</AssemblyDescription>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\WebRtcNet\WebRtcNet.csproj" />
</ItemGroup>

<!-- System.Text.Json is inbox on net10.0; add the NuGet package for net48 only. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="System.Text.Json" Version="9.0.5" />
</ItemGroup>
</Project>
68 changes: 68 additions & 0 deletions examples/BasicVideoChat/MainWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<Window x:Class="BasicVideoChat.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="BasicVideoChat" Height="600" Width="900" MinWidth="640" MinHeight="420">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<!-- Connection controls -->
<WrapPanel Grid.Row="0" Margin="8,6" Orientation="Horizontal">
<RadioButton x:Name="HostRadio" Content="Host" GroupName="Mode"
IsChecked="True" VerticalAlignment="Center" Margin="0,0,8,0"
Checked="HostRadio_Checked"/>
<RadioButton x:Name="GuestRadio" Content="Guest" GroupName="Mode"
VerticalAlignment="Center" Margin="0,0,14,0"
Checked="GuestRadio_Checked"/>
<TextBlock Text="IP:" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBox x:Name="IpBox" Width="130" Text="127.0.0.1" IsEnabled="False"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Port:" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBox x:Name="PortBox" Width="55" Text="7777"
VerticalAlignment="Center" Margin="0,0,14,0"/>
<Button x:Name="ConnectBtn" Content="Connect" Width="80"
Margin="0,0,6,0" Click="ConnectBtn_Click"/>
<Button x:Name="HangUpBtn" Content="Hang Up" Width="80"
Margin="0,0,14,0" IsEnabled="False" Click="HangUpBtn_Click"/>
<TextBlock Text="Status: " VerticalAlignment="Center"/>
<TextBlock x:Name="StatusText" Text="Ready" VerticalAlignment="Center"
FontWeight="SemiBold"/>
</WrapPanel>

<!-- Video panels -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>

<Border Grid.Column="0" Background="#FF1A1A1A" Margin="4,2,2,2">
<Grid>
<Image x:Name="LocalVideo" Stretch="Uniform"/>
<TextBlock Text="Local" Foreground="White" Opacity="0.5"
HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"/>
</Grid>
</Border>

<Border Grid.Column="1" Background="#FF1A1A1A" Margin="2,2,4,2">
<Grid>
<Image x:Name="RemoteVideo" Stretch="Uniform"/>
<TextBlock Text="Remote" Foreground="White" Opacity="0.5"
HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"/>
</Grid>
</Border>
</Grid>

<!-- Media toggle controls -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="8,6"
HorizontalAlignment="Center">
<ToggleButton x:Name="MuteBtn" Content="Mute Audio" Width="100"
Margin="0,0,8,0" IsEnabled="False" Click="MuteBtn_Click"/>
<ToggleButton x:Name="CameraBtn" Content="Camera Off" Width="100"
IsEnabled="False" Click="CameraBtn_Click"/>
</StackPanel>
</Grid>
</Window>
226 changes: 226 additions & 0 deletions examples/BasicVideoChat/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using BasicVideoChat.Signaling;
using WebRtcNet;
using WebRtcNet.Media;

namespace BasicVideoChat;

public partial class MainWindow : Window
{
private const int DefaultPort = 7777;

// Default STUN server for NAT traversal. Works for both LAN and internet connections.
// For LAN-only use you can pass an empty RtcConfiguration().
// Replace with your own STUN/TURN server if needed.
// See: https://webrtc.org/getting-started/turn-server
private static readonly RtcConfiguration DefaultConfiguration = new(
new[] { new RtcIceServer("stun:stun.l.google.com:19302") });

private WebRtcInterop.RtcPeerConnection? _peerConnection;
private TcpSignalingChannel? _signaling;
private MediaStream? _localStream;
private MediaStreamTrack? _audioTrack;
private MediaStreamTrack? _videoTrack;
private WpfVideoRenderer? _localRenderer;
private WpfVideoRenderer? _remoteRenderer;

public MainWindow()
{
InitializeComponent();
}

private bool IsHost => HostRadio.IsChecked == true;

private void HostRadio_Checked(object sender, RoutedEventArgs e) =>
IpBox.IsEnabled = false;

private void GuestRadio_Checked(object sender, RoutedEventArgs e) =>
IpBox.IsEnabled = true;

private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
{
ConnectBtn.IsEnabled = false;
try
{
await StartCallAsync();
HangUpBtn.IsEnabled = true;
MuteBtn.IsEnabled = true;
CameraBtn.IsEnabled = true;
}
catch (Exception ex)
{
SetStatus($"Error: {ex.Message}");
ConnectBtn.IsEnabled = true;
}
}

private async void HangUpBtn_Click(object sender, RoutedEventArgs e) =>
await HangUpAsync();

private void MuteBtn_Click(object sender, RoutedEventArgs e)
{
if (_audioTrack != null)
_audioTrack.Enabled = MuteBtn.IsChecked != true;
}

private void CameraBtn_Click(object sender, RoutedEventArgs e)
{
if (_videoTrack != null)
_videoTrack.Enabled = CameraBtn.IsChecked != true;
}

private async Task StartCallAsync()
{
SetStatus("Acquiring media...");

var mediaDevices = new WebRtcInterop.Media.MediaDevices();
_localStream = await mediaDevices.GetUserMedia(new MediaStreamConstraints(true, true));

_audioTrack = _localStream.GetAudioTracks().FirstOrDefault();
_videoTrack = _localStream.GetVideoTracks().FirstOrDefault();

_localRenderer = new WpfVideoRenderer(LocalVideo);
_remoteRenderer = new WpfVideoRenderer(RemoteVideo);
// TODO: Attach _localRenderer to _videoTrack once VideoRenderer interface is expanded.

// NOTE: Many WebRtcInterop methods currently throw NotImplementedException — this example
// will not run end-to-end until they are implemented. SetLocalDescription and
// AddIceCandidate are the minimum required for a basic call flow.
_peerConnection = new WebRtcInterop.RtcPeerConnection(DefaultConfiguration);
_peerConnection.OnIceCandidate += OnIceCandidate;
_peerConnection.OnTrack += OnTrack;
_peerConnection.OnConnectionStateChange += OnConnectionStateChange;

foreach (var track in _localStream.GetTracks())
_peerConnection.AddTrack(track, _localStream);

_signaling = new TcpSignalingChannel();
_signaling.MessageHandler = OnSignalingMessageAsync;
_signaling.Disconnected += () => Dispatcher.BeginInvoke(async () => await HangUpAsync());

if (IsHost)
{
var port = int.TryParse(PortBox.Text, out var p) ? p : DefaultPort;
SetStatus($"Listening on port {port}...");
await _signaling.ListenAsync(port);
SetStatus("Guest connected. Creating offer...");

// Drive the offer explicitly here rather than relying on OnNegotiationNeeded,
// since we control when the signaling channel is ready.
var offer = await _peerConnection.CreateOffer();
await _peerConnection.SetLocalDescription(offer);
await _signaling.SendAsync(new SignalingMessage { Type = SignalingMessageType.Offer, Sdp = offer.Sdp });
SetStatus("Offer sent. Waiting for answer...");
}
else
{
var host = IpBox.Text.Trim();
var port = int.TryParse(PortBox.Text, out var p) ? p : DefaultPort;
SetStatus($"Connecting to {host}:{port}...");
await _signaling.ConnectAsync(host, port);
SetStatus("Connected. Waiting for offer...");
}
}

// MessageHandler is awaited by TcpSignalingChannel before the next message is dispatched,
// ensuring that SetRemoteDescription always completes before any AddIceCandidate call.
private async Task OnSignalingMessageAsync(SignalingMessage message)
{
try
{
switch (message.Type)
{
case SignalingMessageType.Offer:
await _peerConnection!.SetRemoteDescription(
new RtcSessionDescription(RtcSdpType.Offer, message.Sdp!));
var answer = await _peerConnection.CreateAnswer();
await _peerConnection.SetLocalDescription(answer);
await _signaling!.SendAsync(new SignalingMessage
{
Type = SignalingMessageType.Answer,
Sdp = answer.Sdp
});
SetStatus("Answer sent.");
break;

case SignalingMessageType.Answer:
await _peerConnection!.SetRemoteDescription(
new RtcSessionDescription(RtcSdpType.Answer, message.Sdp!));
SetStatus("Answer received.");
break;

case SignalingMessageType.Candidate:
await _peerConnection!.AddIceCandidate(new RtcIceCandidate(
message.Candidate!, message.SdpMid, message.SdpMLineIndex));
break;

case SignalingMessageType.Bye:
Dispatcher.BeginInvoke(async () => await HangUpAsync());
break;
}
}
catch (Exception ex)
{
SetStatus($"Signaling error: {ex.Message}");
}
}

private void OnIceCandidate(object? sender, RtcIceCandidateEventArgs e)
{
// Capture _signaling before the fire-and-forget to guard against a concurrent HangUp.
var sig = _signaling;
if (sig != null)
_ = sig.SendAsync(new SignalingMessage
{
Type = SignalingMessageType.Candidate,
Candidate = e.Candidate.Candidate,
SdpMid = e.Candidate.SdpMid,
SdpMLineIndex = e.Candidate.SdpMLineIndex
});
}

private void OnTrack(object? sender, RtcTrackEventArgs e)
{
// TODO: Attach e.Track to _remoteRenderer once VideoRenderer interface is expanded.
Dispatcher.Invoke(() => SetStatus($"Remote {e.Track.Kind} track received."));
}

private void OnConnectionStateChange(object? sender, EventArgs e) =>
Dispatcher.Invoke(() => SetStatus($"Connection: {_peerConnection?.ConnectionState}"));

private async Task HangUpAsync()
{
// Best-effort Bye — send before tearing down the channel.
if (_signaling != null)
{
try { await _signaling.SendAsync(new SignalingMessage { Type = SignalingMessageType.Bye }); }
catch { }
_signaling.Dispose();
_signaling = null;
}

_peerConnection?.Close();
_peerConnection = null;

_audioTrack?.Stop();
_videoTrack?.Stop();
_localStream?.Dispose();
_localStream = null;
_audioTrack = null;
_videoTrack = null;

ConnectBtn.IsEnabled = true;
HangUpBtn.IsEnabled = false;
MuteBtn.IsEnabled = false;
MuteBtn.IsChecked = false;
CameraBtn.IsEnabled = false;
CameraBtn.IsChecked = false;
SetStatus("Ready");
}

private void SetStatus(string message) =>
Dispatcher.Invoke(() => StatusText.Text = message);
}
Loading
Loading