Real-Time Video Chat with Rust and Flutter
Build a video calling app with a Rust actor-model backend and Flutter WebRTC.
Introduction
30 Second Summary
Every time you hop on a video call, a complex dance happens behind the scenes. Your browser negotiates media formats, punches through firewalls, and streams video directly to the other person's device.
In this project, you will build a real-time chat application with 1-to-1 video and audio calling. You'll create a Rust signaling server using the actor model and a Flutter desktop client that establishes peer-to-peer media connections through WebRTC.
What You'll Build
A full-stack video calling app with a Rust Axum WebSocket signaling server (using the actor model for concurrent room management) and a Flutter desktop client handling peer-to-peer media streaming.
By the end of this project, you'll have:
- A Rust Axum WebSocket signaling server with actor-based room management using tokio mpsc channels.
- A Flutter desktop app with video/audio calling and text chat powered by WebRTC peer connections.
- A typed JSON signaling protocol shared between client and server for SDP and ICE exchange.
- Secret Mission: Add screen sharing capability to the video call.
Are there any prerequisites?
This project assumes intermediate Rust knowledge (async/await, ownership, enums) and basic Flutter/Dart familiarity. You should also be comfortable with WebSocket concepts.
Scaffold Both Projects
A real-time video calling app has two sides: a signaling server that coordinates connections, and a client app that handles the actual media.
In this step, you will create both projects from scratch. The Rust project becomes your signaling server, and the Flutter project becomes the calling app.
In this step, get ready to:
- Create and configure the Rust signaling server project with all dependencies.
- Create the Flutter app and add the WebRTC dependency.
- Set up the file and directory structure for both projects.
Create the Rust signaling server project
The signaling server is the coordination layer that helps two peers find each other and exchange the information needed to establish a direct connection. You will build it in Rust using the Axum web framework.
- Press Ctrl+` to open the integrated terminal in VS Code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Rust project and move into it by running these commands:
cargo new signaling-server
cd signaling-server
- Confirm the project was created by running:
ls src
You should see main.rs listed in the output.
Seeing 'command not found' for cargo?
Make sure Rust is installed and cargo is available in your PATH. Run rustc --version to verify your Rust installation.
help me fix cargo not being found in my terminal
Now you need to add the project's dependencies. The signaling server relies on several crates for WebSocket handling, async runtime, serialization, and more.
- Open Cargo.toml in VS Code (it is in the root of your signaling-server folder).
- Replace the entire contents of the file with the following:
[package]
name = "signaling-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tower-http = { version = "0.6", features = ["cors"] }
What are these dependencies?
- axum with the ws feature provides the web framework and WebSocket support.
- tokio is the async runtime that powers the actor model with channels and spawned tasks.
- serde and serde_json handle JSON serialization for the signaling protocol messages.
- uuid generates unique IDs for rooms and peers.
- tower-http with the cors feature lets the Flutter client connect to the server without cross-origin issues.
- Save Cargo.toml.
- Verify the dependencies resolve by running:
cargo check
You should see a series of "Compiling" lines followed by "Finished" with no errors. The first run downloads all the crates, so it may take a minute.
Seeing dependency resolution errors?
Double check that every line in your Cargo.toml matches the code above exactly. Pay attention to the curly braces and quoted strings. A missing quote or bracket will cause a parse error.
help me fix my Cargo.toml dependency errors
✔️ Awesome, I've got everything!
Great. Double check you have saved Cargo.toml before moving on.
ⓧ I'd like to double check the full code
[package]
name = "signaling-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tower-http = { version = "0.6", features = ["cors"] }
Create the Flutter app and add dependencies
The Flutter app is the client that handles the user interface, camera access, and peer-to-peer media connections. It uses the flutter_webrtc package to manage WebRTC peer connections.
- Navigate back to your Desktop by running:
cd ~/Desktop
- Create the Flutter project and move into it by running these commands:
flutter create chat_call_app
cd chat_call_app
Flutter generates a complete project structure including the lib/ folder where your Dart code lives, and a default pubspec.yaml for managing dependencies.
Flutter create failed?
Run flutter doctor to check your Flutter installation. Make sure the Flutter SDK is in your PATH and that the Linux desktop toolchain is available.
help me fix flutter create failing on Linux
Now you need to replace the default pubspec.yaml with your project's dependencies.
- Open pubspec.yaml in VS Code (it is in the root of your chat_call_app folder).
- Replace the entire contents of the file with the following:
name: chat_call_app
description: Real-time chat with video calling
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
flutter_webrtc: ^1.4.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
What does this configure?
The key addition is flutter_webrtc: ^1.4.1. This package provides platform-specific WebRTC bindings for Flutter, handling camera access, peer connections, and media streaming on Linux desktop.
The publish_to: 'none' line prevents accidental publishing to pub.dev since this is a private project.
- Save pubspec.yaml.
- Download the dependency by running:
flutter pub get
You should see output ending with something like "Got dependencies" confirming the packages resolved successfully.
Dependencies failed to resolve?
Check that your pubspec.yaml indentation uses exactly 2 spaces (not tabs). YAML is whitespace-sensitive. Make sure flutter_webrtc is indented under dependencies: at the same level as the flutter: entry.
help me fix flutter pub get failing
✔️ Awesome, I've got everything!
Great. Double check you have saved pubspec.yaml before moving on.
ⓧ I'd like to double check the full code
name: chat_call_app
description: Real-time chat with video calling
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
flutter_webrtc: ^1.4.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Set up the module file structure
Both projects need a clean directory structure before you start writing code. The Rust project uses a module system where each file represents a distinct concern. The Flutter project organizes code into services, screens, and models.
- Create the Flutter directories first (you are already in chat_call_app) by running these commands:
mkdir -p lib/services lib/screens lib/models
- Confirm the directories exist by running:
ls lib
You should see main.dart, models, screens, and services in the output.
- Now navigate to the Rust project and create the actor module structure by running:
cd ~/Desktop/signaling-server
mkdir -p src/actors
touch src/actors/mod.rs src/actors/room_manager.rs src/actors/room.rs src/messages.rs src/ws_handler.rs
What are these files for?
- src/actors/mod.rs declares the actors module and exposes its submodules.
- src/actors/room_manager.rs will hold the RoomManager actor that creates and tracks rooms.
- src/actors/room.rs will hold the Room actor that manages peers and relays messages.
- src/messages.rs defines the typed signaling protocol shared between client and server.
- src/ws_handler.rs handles WebSocket connections and delegates to the actors.
- Confirm the files exist by running:
ls src/actors src/messages.rs src/ws_handler.rs
You should see mod.rs, room.rs, and room_manager.rs listed under src/actors, plus the two standalone files confirmed.
Missing files or directories?
Make sure you are in the signaling-server directory when running the mkdir and touch commands. Run pwd to check your current directory.
help me figure out why my Rust module files were not created
Both projects are scaffolded and ready for code. Next up, you will define the signaling protocol and build the actor infrastructure that powers the server's concurrency model.
Define Protocol and Build Actor Infrastructure
Your project skeleton is ready. Now you need to define how the client and server talk to each other, and build the backend infrastructure that manages rooms and peers.
Real-time systems separate signaling (coordination messages like "join this room" or "here's my video offer") from media (the actual video/audio data). In this step, you'll define the signaling protocol as typed Rust enums and build the actor infrastructure that keeps each room isolated and concurrent.
In this step, get ready to:
- Define typed signaling message enums for client-to-server and server-to-client communication.
- Build the RoomManager actor that creates and looks up rooms via request-response channels.
- Build the Room actor that manages peer connections and relays signaling messages.
Define the signaling protocol
The signaling protocol is a contract between the Flutter client and the Rust server. Every message exchanged over the WebSocket connection maps to a variant of one of two enums: ClientMessage for messages the client sends, and ServerMessage for messages the server sends back.
- Open src/messages.rs in the signaling-server project and add the ClientMessage enum by pasting this code:
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
CreateRoom,
JoinRoom { room_id: String },
Offer { sdp: String },
Answer { sdp: String },
IceCandidate {
candidate: String,
sdp_mid: Option<String>,
sdp_m_line_index: Option<u32>,
},
ChatMessage { content: String },
LeaveRoom,
}
What does this code do?
Each variant represents a different action the client can request.
- The #[serde(tag = "type", rename_all = "snake_case")] attribute tells serde to serialize each variant as a JSON object with a "type" field. So JoinRoom { room_id } becomes {"type": "join_room", "room_id": "abc123"}.
- The IceCandidate variant carries the data needed for WebRTC NAT traversal. Each candidate is a potential network path between peers.
- Offer and Answer carry SDP (Session Description Protocol) strings that describe media capabilities.
- Below the ClientMessage enum, add the ServerMessage enum:
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
RoomCreated { room_id: String },
RoomJoined { room_id: String, peer_id: String },
PeerJoined { peer_id: String },
PeerLeft { peer_id: String },
Offer { from: String, sdp: String },
Answer { from: String, sdp: String },
IceCandidate {
from: String,
candidate: String,
sdp_mid: Option<String>,
sdp_m_line_index: Option<u32>,
},
ChatMessage { from: String, content: String },
Error { message: String },
}
What does this code do?
ServerMessage mirrors the client's messages but adds context the client needs.
- Each relayed message (Offer, Answer, IceCandidate, ChatMessage) includes a from field so the recipient knows who sent it.
- ServerMessage derives Clone because the Room actor needs to serialize it once and send copies to multiple peers.
- The Error variant lets the server report problems (like joining a room that doesn't exist) back to the client.
- Save src/messages.rs.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
CreateRoom,
JoinRoom { room_id: String },
Offer { sdp: String },
Answer { sdp: String },
IceCandidate {
candidate: String,
sdp_mid: Option<String>,
sdp_m_line_index: Option<u32>,
},
ChatMessage { content: String },
LeaveRoom,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
RoomCreated { room_id: String },
RoomJoined { room_id: String, peer_id: String },
PeerJoined { peer_id: String },
PeerLeft { peer_id: String },
Offer { from: String, sdp: String },
Answer { from: String, sdp: String },
IceCandidate {
from: String,
candidate: String,
sdp_mid: Option<String>,
sdp_m_line_index: Option<u32>,
},
ChatMessage { from: String, content: String },
Error { message: String },
}
Why tagged enums instead of a "command" string field?
Serde's tagged enum representation gives you compile-time exhaustiveness checking. Every match on a ClientMessage forces you to handle all variants. If you add a new message type later, the compiler tells you everywhere you forgot to handle it.
Contrast this with parsing a raw JSON string field manually, where a typo or missing case would only surface at runtime.
Build the RoomManager actor
The actor model gives each piece of state its own isolated task that communicates only through message channels. The RoomManager is the top-level actor. It owns the registry of all active rooms and responds to "create a room" or "find a room" requests.
- First, set up the module exports. Open src/actors/mod.rs and add these two lines:
pub mod room;
pub mod room_manager;
- Save src/actors/mod.rs.
- Open src/actors/room_manager.rs and add the message types and handle struct:
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
use uuid::Uuid;
use super::room::{RoomActor, RoomHandle};
pub enum RoomManagerMsg {
CreateRoom {
reply: oneshot::Sender<(String, RoomHandle)>,
},
GetRoom {
room_id: String,
reply: oneshot::Sender<Option<RoomHandle>>,
},
RemoveRoom {
room_id: String,
},
}
#[derive(Clone)]
pub struct RoomManagerHandle {
tx: mpsc::Sender<RoomManagerMsg>,
}
What does this code do?
- RoomManagerMsg defines the three operations the RoomManager supports. CreateRoom and GetRoom include a oneshot::Sender so the caller can wait for a response.
- RoomManagerHandle wraps a channel sender. External code interacts with the actor only through this handle. It derives Clone so multiple WebSocket connections can share it.
- This is the "handle pattern": the actor's internal state is hidden behind a channel. The handle provides typed async methods that send messages and await replies.
- Below the struct definition, add the RoomManagerHandle implementation:
impl RoomManagerHandle {
pub fn new() -> Self {
let (tx, rx) = mpsc::channel(64);
tokio::spawn(run_room_manager(rx));
Self { tx }
}
pub async fn create_room(&self) -> (String, RoomHandle) {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.tx
.send(RoomManagerMsg::CreateRoom { reply: reply_tx })
.await;
reply_rx.await.unwrap()
}
pub async fn get_room(&self, room_id: String) -> Option<RoomHandle> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.tx
.send(RoomManagerMsg::GetRoom {
room_id,
reply: reply_tx,
})
.await;
reply_rx.await.unwrap()
}
}
What does this code do?
- new() creates a bounded channel (capacity 64) and immediately spawns the actor task. The handle is ready to use the instant it's created.
- create_room() and get_room() both follow the request-response pattern: create a oneshot channel, send the request with the reply half, then await the response.
- The bounded channel (64) provides backpressure. If 64 messages queue up, senders will await rather than consuming unbounded memory.
- Below the impl block, add the run_room_manager function that is the actor's event loop:
async fn run_room_manager(mut rx: mpsc::Receiver<RoomManagerMsg>) {
let mut rooms: HashMap<String, RoomHandle> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
RoomManagerMsg::CreateRoom { reply } => {
let room_id = Uuid::new_v4().to_string()[..8].to_string();
let room_handle = RoomActor::spawn(room_id.clone());
rooms.insert(room_id.clone(), room_handle.clone());
tracing::info!("Room created: {}", room_id);
let _ = reply.send((room_id, room_handle));
}
RoomManagerMsg::GetRoom { room_id, reply } => {
let handle = rooms.get(&room_id).cloned();
let _ = reply.send(handle);
}
RoomManagerMsg::RemoveRoom { room_id } => {
rooms.remove(&room_id);
tracing::info!("Room removed: {}", room_id);
}
}
}
}
What does this code do?
- This function IS the actor. It owns the rooms HashMap exclusively. No locks needed because only this task ever reads or writes it.
- On CreateRoom, it generates a short UUID (first 8 characters), spawns a new Room actor, stores the handle, and replies with both the ID and handle.
- On GetRoom, it looks up and clones the handle (cheap since it's just an mpsc::Sender wrapper). Callers get direct access to the Room actor without routing through the RoomManager for every signaling message.
- Save src/actors/room_manager.rs.
Seeing a red underline on RoomActor or RoomHandle?
Your editor may show errors because src/actors/room.rs is still empty. Those types don't exist yet. The errors will resolve once you fill in room.rs in the next substep.
help me understand why my Rust editor shows errors for types defined in other files
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
use uuid::Uuid;
use super::room::{RoomActor, RoomHandle};
pub enum RoomManagerMsg {
CreateRoom {
reply: oneshot::Sender<(String, RoomHandle)>,
},
GetRoom {
room_id: String,
reply: oneshot::Sender<Option<RoomHandle>>,
},
RemoveRoom {
room_id: String,
},
}
#[derive(Clone)]
pub struct RoomManagerHandle {
tx: mpsc::Sender<RoomManagerMsg>,
}
impl RoomManagerHandle {
pub fn new() -> Self {
let (tx, rx) = mpsc::channel(64);
tokio::spawn(run_room_manager(rx));
Self { tx }
}
pub async fn create_room(&self) -> (String, RoomHandle) {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.tx
.send(RoomManagerMsg::CreateRoom { reply: reply_tx })
.await;
reply_rx.await.unwrap()
}
pub async fn get_room(&self, room_id: String) -> Option<RoomHandle> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.tx
.send(RoomManagerMsg::GetRoom {
room_id,
reply: reply_tx,
})
.await;
reply_rx.await.unwrap()
}
}
async fn run_room_manager(mut rx: mpsc::Receiver<RoomManagerMsg>) {
let mut rooms: HashMap<String, RoomHandle> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
RoomManagerMsg::CreateRoom { reply } => {
let room_id = Uuid::new_v4().to_string()[..8].to_string();
let room_handle = RoomActor::spawn(room_id.clone());
rooms.insert(room_id.clone(), room_handle.clone());
tracing::info!("Room created: {}", room_id);
let _ = reply.send((room_id, room_handle));
}
RoomManagerMsg::GetRoom { room_id, reply } => {
let handle = rooms.get(&room_id).cloned();
let _ = reply.send(handle);
}
RoomManagerMsg::RemoveRoom { room_id } => {
rooms.remove(&room_id);
tracing::info!("Room removed: {}", room_id);
}
}
}
}
Build the Room actor
Each room is its own independent actor. It owns a map of connected peers and handles three operations: a peer joining, a peer leaving, and relaying a signaling message to all other peers in the room.
- Open src/actors/room.rs and add the message enum and handle:
use std::collections::HashMap;
use tokio::sync::mpsc;
use crate::messages::ServerMessage;
pub enum RoomMsg {
Join {
peer_id: String,
peer_tx: mpsc::Sender<String>,
},
Leave {
peer_id: String,
},
Relay {
from: String,
message: ServerMessage,
},
}
#[derive(Clone)]
pub struct RoomHandle {
tx: mpsc::Sender<RoomMsg>,
}
impl RoomHandle {
pub async fn join(&self, peer_id: String, peer_tx: mpsc::Sender<String>) {
let _ = self.tx.send(RoomMsg::Join { peer_id, peer_tx }).await;
}
pub async fn leave(&self, peer_id: String) {
let _ = self.tx.send(RoomMsg::Leave { peer_id }).await;
}
pub async fn relay(&self, from: String, message: ServerMessage) {
let _ = self.tx.send(RoomMsg::Relay { from, message }).await;
}
}
What does this code do?
- RoomMsg::Join carries the peer's ID and their personal send channel. The Room stores this channel so it can forward messages to that specific peer later.
- RoomMsg::Relay is fire-and-forget. The WebSocket handler sends a relay and continues immediately without waiting for a response. This keeps signaling fast.
- RoomHandle follows the same handle pattern as the RoomManager. The WebSocket handler stores a clone after joining and talks directly to the Room for all signaling traffic.
- Below the impl RoomHandle block, add the RoomActor struct and its implementation:
pub struct RoomActor {
room_id: String,
peers: HashMap<String, mpsc::Sender<String>>,
}
impl RoomActor {
pub fn spawn(room_id: String) -> RoomHandle {
let (tx, rx) = mpsc::channel(64);
let actor = Self {
room_id,
peers: HashMap::new(),
};
tokio::spawn(actor.run(rx));
RoomHandle { tx }
}
async fn run(mut self, mut rx: mpsc::Receiver<RoomMsg>) {
while let Some(msg) = rx.recv().await {
match msg {
RoomMsg::Join { peer_id, peer_tx } => {
let join_msg = serde_json::to_string(&ServerMessage::PeerJoined {
peer_id: peer_id.clone(),
})
.unwrap();
for (_, tx) in &self.peers {
let _ = tx.send(join_msg.clone()).await;
}
self.peers.insert(peer_id.clone(), peer_tx);
tracing::info!("Peer {} joined room {}", peer_id, self.room_id);
}
RoomMsg::Leave { peer_id } => {
self.peers.remove(&peer_id);
let leave_msg = serde_json::to_string(&ServerMessage::PeerLeft {
peer_id: peer_id.clone(),
})
.unwrap();
for (_, tx) in &self.peers {
let _ = tx.send(leave_msg.clone()).await;
}
tracing::info!("Peer {} left room {}", peer_id, self.room_id);
}
RoomMsg::Relay { from, message } => {
let msg_str = serde_json::to_string(&message).unwrap();
for (id, tx) in &self.peers {
if *id != from {
let _ = tx.send(msg_str.clone()).await;
}
}
}
}
}
tracing::info!("Room actor {} shutting down", self.room_id);
}
}
What does this code do?
- spawn() creates the channel, builds the actor with an empty peer map, and spawns the event loop. It returns the RoomHandle immediately.
- On Join, the actor first notifies all existing peers that someone new arrived, THEN inserts the new peer. This ordering ensures the new peer doesn't get its own join notification.
- On Relay, the actor sends to everyone EXCEPT the sender (if *id != from). This is critical for signaling. You don't want to receive your own offer back.
- When all RoomHandle clones are dropped, the channel closes and the loop exits naturally. The final log line confirms clean shutdown.
- Save src/actors/room.rs.
Your editor should now resolve all the cross-file references. The red underlines in room_manager.rs should disappear.
Still seeing errors in your editor?
Make sure src/actors/mod.rs has both pub mod room; and pub mod room_manager; lines. Without the module declarations, Rust doesn't know these files exist.
Also check that you're using crate::messages::ServerMessage in room.rs and super::room::{RoomActor, RoomHandle} in room_manager.rs. The import paths must match the module hierarchy exactly.
help me fix Rust module import errors in my actor-based project
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files in the src/actors/ directory.
ⓧ I'd like to double check the full code
use std::collections::HashMap;
use tokio::sync::mpsc;
use crate::messages::ServerMessage;
pub enum RoomMsg {
Join {
peer_id: String,
peer_tx: mpsc::Sender<String>,
},
Leave {
peer_id: String,
},
Relay {
from: String,
message: ServerMessage,
},
}
#[derive(Clone)]
pub struct RoomHandle {
tx: mpsc::Sender<RoomMsg>,
}
impl RoomHandle {
pub async fn join(&self, peer_id: String, peer_tx: mpsc::Sender<String>) {
let _ = self.tx.send(RoomMsg::Join { peer_id, peer_tx }).await;
}
pub async fn leave(&self, peer_id: String) {
let _ = self.tx.send(RoomMsg::Leave { peer_id }).await;
}
pub async fn relay(&self, from: String, message: ServerMessage) {
let _ = self.tx.send(RoomMsg::Relay { from, message }).await;
}
}
pub struct RoomActor {
room_id: String,
peers: HashMap<String, mpsc::Sender<String>>,
}
impl RoomActor {
pub fn spawn(room_id: String) -> RoomHandle {
let (tx, rx) = mpsc::channel(64);
let actor = Self {
room_id,
peers: HashMap::new(),
};
tokio::spawn(actor.run(rx));
RoomHandle { tx }
}
async fn run(mut self, mut rx: mpsc::Receiver<RoomMsg>) {
while let Some(msg) = rx.recv().await {
match msg {
RoomMsg::Join { peer_id, peer_tx } => {
let join_msg = serde_json::to_string(&ServerMessage::PeerJoined {
peer_id: peer_id.clone(),
})
.unwrap();
for (_, tx) in &self.peers {
let _ = tx.send(join_msg.clone()).await;
}
self.peers.insert(peer_id.clone(), peer_tx);
tracing::info!("Peer {} joined room {}", peer_id, self.room_id);
}
RoomMsg::Leave { peer_id } => {
self.peers.remove(&peer_id);
let leave_msg = serde_json::to_string(&ServerMessage::PeerLeft {
peer_id: peer_id.clone(),
})
.unwrap();
for (_, tx) in &self.peers {
let _ = tx.send(leave_msg.clone()).await;
}
tracing::info!("Peer {} left room {}", peer_id, self.room_id);
}
RoomMsg::Relay { from, message } => {
let msg_str = serde_json::to_string(&message).unwrap();
for (id, tx) in &self.peers {
if *id != from {
let _ = tx.send(msg_str.clone()).await;
}
}
}
}
}
tracing::info!("Room actor {} shutting down", self.room_id);
}
}
Why actors instead of a shared Mutex?
A shared Arc<Mutex<HashMap>> would work for a simple chat server. But with WebRTC signaling, you have high-frequency message passing (ICE candidates arrive in bursts) and per-room isolation requirements.
Actors give you lock-free concurrency. Each room processes its messages sequentially within its own task. No contention between rooms. If one room has 50 ICE candidates queued, it doesn't block another room's offer/answer exchange.
Your signaling protocol and actor infrastructure are in place. The RoomManager can create and look up rooms, and each Room can manage peers and relay messages. Next up, you'll wire these actors into a WebSocket handler and bring the server online.
Build WebSocket Handler and Server
Your actor infrastructure is ready. The RoomManager and Room actors know how to create rooms, track peers, and relay messages. But right now, nothing connects the outside world to these actors.
In this step, you will build the bridge: a WebSocket handler that accepts client connections, translates incoming JSON into typed messages, and delegates everything to your actors. Then you will wire up the Axum router so the server actually starts and listens for connections.
In this step, get ready to:
- Create the WebSocket handler that connects clients to the actor system.
- Wire up the main entry point with routing and CORS.
- Verify the server compiles and starts successfully.
Create the WebSocket handler
The WebSocket handler is the glue between raw network connections and your typed actor system. Each client connects, gets assigned a unique ID, and then every message they send is deserialized and routed to the appropriate actor.
- Create a new file src/ws_handler.rs inside your signaling-server folder.
- Add the imports and the ws_upgrade handler function by pasting this code:
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::actors::room::RoomHandle;
use crate::actors::room_manager::RoomManagerHandle;
use crate::messages::{ClientMessage, ServerMessage};
// Public handler that upgrades HTTP to WebSocket
pub async fn ws_upgrade(
ws: WebSocketUpgrade,
State(room_manager): State<RoomManagerHandle>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, room_manager))
}
What does this code do?
The WebSocketUpgrade extractor tells Axum this route handles WebSocket connections. When a client connects, Axum performs the HTTP upgrade handshake automatically.
The State(room_manager) extracts your shared RoomManagerHandle from the app state. This gives every new connection access to the actor system without global variables.
You won't see a change yet. The next chunk adds the main socket-handling logic.
- Below the ws_upgrade function in the same file, add the handle_socket function:
async fn handle_socket(socket: WebSocket, room_manager: RoomManagerHandle) {
// Split socket into independent send/receive halves
let (mut ws_sender, mut ws_receiver) = socket.split();
// Assign a unique 8-character peer ID
let peer_id = Uuid::new_v4().to_string()[..8].to_string();
tracing::info!("New connection: {}", peer_id);
// Channel for sending messages back to this client
let (client_tx, mut client_rx) = mpsc::channel::<String>(64);
// Spawn a task that forwards channel messages to the WebSocket
let mut send_task = tokio::spawn(async move {
while let Some(msg) = client_rx.recv().await {
if ws_sender.send(Message::text(msg)).await.is_err() {
break;
}
}
});
let peer_id_clone = peer_id.clone();
let client_tx_clone = client_tx.clone();
// Spawn a task that processes incoming WebSocket messages
let mut recv_task = tokio::spawn(async move {
let peer_id = peer_id_clone;
let client_tx = client_tx_clone;
let mut current_room: Option<RoomHandle> = None;
let mut current_room_id: Option<String> = None;
while let Some(Ok(msg)) = ws_receiver.next().await {
let text = match msg {
Message::Text(t) => t.to_string(),
Message::Close(_) => break,
_ => continue,
};
// Deserialize incoming JSON into a typed ClientMessage
let client_msg: ClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let err_msg = serde_json::to_string(&ServerMessage::Error {
message: format!("Invalid message: {}", e),
})
.unwrap();
let _ = client_tx.send(err_msg).await;
continue;
}
};
// Route each message type to the appropriate actor
match client_msg {
ClientMessage::CreateRoom => {
let (room_id, room_handle) = room_manager.create_room().await;
room_handle.join(peer_id.clone(), client_tx.clone()).await;
current_room = Some(room_handle);
current_room_id = Some(room_id.clone());
let resp = serde_json::to_string(&ServerMessage::RoomCreated {
room_id,
})
.unwrap();
let _ = client_tx.send(resp).await;
}
ClientMessage::JoinRoom { room_id } => {
match room_manager.get_room(room_id.clone()).await {
Some(room_handle) => {
room_handle.join(peer_id.clone(), client_tx.clone()).await;
current_room = Some(room_handle);
current_room_id = Some(room_id.clone());
let resp = serde_json::to_string(&ServerMessage::RoomJoined {
room_id,
peer_id: peer_id.clone(),
})
.unwrap();
let _ = client_tx.send(resp).await;
}
None => {
let resp = serde_json::to_string(&ServerMessage::Error {
message: "Room not found".to_string(),
})
.unwrap();
let _ = client_tx.send(resp).await;
}
}
}
ClientMessage::Offer { sdp } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::Offer {
from: peer_id.clone(),
sdp,
},
)
.await;
}
}
ClientMessage::Answer { sdp } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::Answer {
from: peer_id.clone(),
sdp,
},
)
.await;
}
}
ClientMessage::IceCandidate {
candidate,
sdp_mid,
sdp_m_line_index,
} => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::IceCandidate {
from: peer_id.clone(),
candidate,
sdp_mid,
sdp_m_line_index,
},
)
.await;
}
}
ClientMessage::ChatMessage { content } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::ChatMessage {
from: peer_id.clone(),
content,
},
)
.await;
}
}
ClientMessage::LeaveRoom => {
if let Some(ref room) = current_room {
room.leave(peer_id.clone()).await;
}
current_room = None;
current_room_id = None;
}
}
}
// Clean up if client disconnects without sending LeaveRoom
if let Some(room) = current_room {
room.leave(peer_id.clone()).await;
tracing::info!("Peer {} disconnected, left room", peer_id);
}
});
// If either task ends, abort the other to prevent leaks
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
};
tracing::info!("Connection closed: {}", peer_id);
}
What does this code do?
This function is the core of the signaling server. Each connection goes through this flow:
- The socket is split into independent sender/receiver halves using futures_util. This lets you read and write concurrently without holding a single lock.
- A bounded mpsc channel (capacity 64) acts as the outbox. When actors want to send a message to this client, they push it into this channel. A dedicated send task drains the channel into the WebSocket.
- The receive loop deserializes each incoming JSON string into a ClientMessage and routes it. Room creation goes to the RoomManager. Signaling messages (Offer, Answer, IceCandidate) go directly to the Room actor via the stored RoomHandle.
- The tokio::select! at the end ensures both tasks shut down cleanly. If the client disconnects (recv ends), the send task is aborted. If sending fails (send ends), the receive task is aborted.
- Save src/ws_handler.rs.
You won't see a change yet. The server needs its main.rs entry point before it can run.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/ws_handler.rs before moving on.
ⓧ I'd like to double check the full code
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::actors::room::RoomHandle;
use crate::actors::room_manager::RoomManagerHandle;
use crate::messages::{ClientMessage, ServerMessage};
pub async fn ws_upgrade(
ws: WebSocketUpgrade,
State(room_manager): State<RoomManagerHandle>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, room_manager))
}
async fn handle_socket(socket: WebSocket, room_manager: RoomManagerHandle) {
let (mut ws_sender, mut ws_receiver) = socket.split();
let peer_id = Uuid::new_v4().to_string()[..8].to_string();
tracing::info!("New connection: {}", peer_id);
let (client_tx, mut client_rx) = mpsc::channel::<String>(64);
let mut send_task = tokio::spawn(async move {
while let Some(msg) = client_rx.recv().await {
if ws_sender.send(Message::text(msg)).await.is_err() {
break;
}
}
});
let peer_id_clone = peer_id.clone();
let client_tx_clone = client_tx.clone();
let mut recv_task = tokio::spawn(async move {
let peer_id = peer_id_clone;
let client_tx = client_tx_clone;
let mut current_room: Option<RoomHandle> = None;
let mut current_room_id: Option<String> = None;
while let Some(Ok(msg)) = ws_receiver.next().await {
let text = match msg {
Message::Text(t) => t.to_string(),
Message::Close(_) => break,
_ => continue,
};
let client_msg: ClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let err_msg = serde_json::to_string(&ServerMessage::Error {
message: format!("Invalid message: {}", e),
})
.unwrap();
let _ = client_tx.send(err_msg).await;
continue;
}
};
match client_msg {
ClientMessage::CreateRoom => {
let (room_id, room_handle) = room_manager.create_room().await;
room_handle.join(peer_id.clone(), client_tx.clone()).await;
current_room = Some(room_handle);
current_room_id = Some(room_id.clone());
let resp = serde_json::to_string(&ServerMessage::RoomCreated {
room_id,
})
.unwrap();
let _ = client_tx.send(resp).await;
}
ClientMessage::JoinRoom { room_id } => {
match room_manager.get_room(room_id.clone()).await {
Some(room_handle) => {
room_handle.join(peer_id.clone(), client_tx.clone()).await;
current_room = Some(room_handle);
current_room_id = Some(room_id.clone());
let resp = serde_json::to_string(&ServerMessage::RoomJoined {
room_id,
peer_id: peer_id.clone(),
})
.unwrap();
let _ = client_tx.send(resp).await;
}
None => {
let resp = serde_json::to_string(&ServerMessage::Error {
message: "Room not found".to_string(),
})
.unwrap();
let _ = client_tx.send(resp).await;
}
}
}
ClientMessage::Offer { sdp } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::Offer {
from: peer_id.clone(),
sdp,
},
)
.await;
}
}
ClientMessage::Answer { sdp } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::Answer {
from: peer_id.clone(),
sdp,
},
)
.await;
}
}
ClientMessage::IceCandidate {
candidate,
sdp_mid,
sdp_m_line_index,
} => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::IceCandidate {
from: peer_id.clone(),
candidate,
sdp_mid,
sdp_m_line_index,
},
)
.await;
}
}
ClientMessage::ChatMessage { content } => {
if let Some(ref room) = current_room {
room.relay(
peer_id.clone(),
ServerMessage::ChatMessage {
from: peer_id.clone(),
content,
},
)
.await;
}
}
ClientMessage::LeaveRoom => {
if let Some(ref room) = current_room {
room.leave(peer_id.clone()).await;
}
current_room = None;
current_room_id = None;
}
}
}
if let Some(room) = current_room {
room.leave(peer_id.clone()).await;
tracing::info!("Peer {} disconnected, left room", peer_id);
}
});
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
};
tracing::info!("Connection closed: {}", peer_id);
}
Wire up the main entry point
The main file ties everything together: it declares modules, spawns the RoomManager actor, configures CORS for development, and starts the Axum server on port 8080.
- Replace the contents of src/main.rs with this code:
mod actors;
mod messages;
mod ws_handler;
use axum::{routing::get, Router};
use tower_http::cors::{Any, CorsLayer};
use crate::actors::room_manager::RoomManagerHandle;
#[tokio::main]
async fn main() {
// Initialize structured logging
tracing_subscriber::fmt::init();
// Spawn the RoomManager actor
let room_manager = RoomManagerHandle::new();
// Allow all origins/methods/headers for development
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Build the router with a single WebSocket route
let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade))
.layer(cors)
.with_state(room_manager);
// Bind to all interfaces on port 8080
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
.unwrap();
tracing::info!("Signaling server listening on 0.0.0.0:8080");
axum::serve(listener, app).await.unwrap();
}
What does this code do?
- The three mod declarations tell Rust about the actors, messages, and ws_handler modules. Without these, the compiler cannot find your other files.
- The CorsLayer with Any for all fields is permissive for local development. Your Flutter client will connect from a different port, and CORS prevents the browser from blocking cross-origin WebSocket upgrades.
- The .with_state(room_manager) makes the RoomManagerHandle available to every route handler via the State extractor. Axum clones this handle for each request (which is cheap since RoomManagerHandle is just a channel sender).
- Save src/main.rs.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs before running the server.
ⓧ I'd like to double check the full code
mod actors;
mod messages;
mod ws_handler;
use axum::{routing::get, Router};
use tower_http::cors::{Any, CorsLayer};
use crate::actors::room_manager::RoomManagerHandle;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let room_manager = RoomManagerHandle::new();
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade))
.layer(cors)
.with_state(room_manager);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
.unwrap();
tracing::info!("Signaling server listening on 0.0.0.0:8080");
axum::serve(listener, app).await.unwrap();
}
Verify the server compiles and starts
This is the first time you will compile the full project. Cargo will download and compile all dependencies on the first run, which may take a minute or two.
- Make sure your terminal is inside the signaling-server folder, then start the server by running:
cargo run
- Wait for compilation to finish. You should see a log line containing Signaling server listening on 0.0.0.0:8080 in your terminal output.
What should I see?
After dependencies compile, your terminal prints something like:
INFO signaling_server: Signaling server listening on 0.0.0.0:8080
The exact timestamp and format depends on your terminal. The key thing is you see the listening message with no errors above it.
Seeing a compile error?
- Check that all five source files exist: src/main.rs, src/messages.rs, src/ws_handler.rs, src/actors/mod.rs, src/actors/room.rs, and src/actors/room_manager.rs.
- If you see "unresolved import" errors, make sure src/main.rs has all three mod declarations at the top: mod actors;, mod messages;, and mod ws_handler;.
- If you see "method not found" on split(), confirm that futures-util = "0.3" is in your Cargo.toml dependencies.
Help me debug my signaling server compile error.
- Press Ctrl+C to stop the server when you are done verifying.
Your signaling server is alive and listening for WebSocket connections. Next up, you will build the Flutter client services that connect to this server and manage WebRTC peer connections.
Build Flutter Signaling and WebRTC Services
Your Rust signaling server is running and ready to coordinate clients. Now you need the Flutter side to actually talk to it.
Two service classes will power the Flutter app. The first manages the WebSocket connection to your signaling server, sending and receiving typed JSON messages. The second manages the WebRTC peer connection lifecycle, including camera/microphone access and ICE candidate exchange.
In this step, get ready to:
- Build a signaling service that sends and receives WebSocket messages matching the Rust server protocol.
- Build a WebRTC service that manages peer connections, media streams, and ICE candidates.
- Align the Dart message format with the Rust server's serde-tagged JSON protocol.
Create the signaling service
The signaling service wraps a dart:io WebSocket connection. It provides typed methods for every message the Rust server expects and exposes incoming messages as a broadcast stream that other parts of the app can subscribe to.
- Create the signaling service by opening lib/services/signaling_service.dart inside your chat_call_app project and pasting this code:
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class SignalingService {
WebSocket? _socket;
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
// Expose incoming server messages as a stream
Stream<Map<String, dynamic>> get messages => _messageController.stream;
Future<void> connect(String url) async {
_socket = await WebSocket.connect(url);
_socket!.listen(
(data) {
final message = jsonDecode(data as String) as Map<String, dynamic>;
_messageController.add(message);
},
onError: (error) {
print('WebSocket error: $error');
},
onDone: () {
print('WebSocket connection closed');
},
);
}
void createRoom() {
_send({'type': 'create_room'});
}
void joinRoom(String roomId) {
_send({'type': 'join_room', 'room_id': roomId});
}
void sendOffer(String sdp) {
_send({'type': 'offer', 'sdp': sdp});
}
void sendAnswer(String sdp) {
_send({'type': 'answer', 'sdp': sdp});
}
void sendIceCandidate(String candidate, String? sdpMid, int? sdpMLineIndex) {
_send({
'type': 'ice_candidate',
'candidate': candidate,
'sdp_mid': sdpMid,
'sdp_m_line_index': sdpMLineIndex,
});
}
void sendChatMessage(String content) {
_send({'type': 'chat_message', 'content': content});
}
void leaveRoom() {
_send({'type': 'leave_room'});
}
void _send(Map<String, dynamic> message) {
if (_socket != null && _socket!.readyState == WebSocket.open) {
_socket!.add(jsonEncode(message));
}
}
void dispose() {
_socket?.close();
_messageController.close();
}
}
What does this code do?
The SignalingService class manages the entire WebSocket lifecycle for talking to your Rust server.
- _messageController is a broadcast StreamController so multiple listeners (the UI, the WebRTC service) can all react to incoming messages.
- connect() opens the WebSocket and pipes every incoming JSON string into the stream after decoding it.
- Each send method constructs a Map with a type field that matches the Rust server's serde-tagged enum variant names (e.g. create_room, ice_candidate).
- _send() checks the socket is open before sending, preventing crashes if the connection drops.
Why Maps instead of model classes?
The Rust server uses serde's rename_all = "snake_case" tagged enums. On the Dart side, typed Maps with string keys achieve the same result with zero boilerplate. The type key in each Map matches the enum variant name directly.
For a larger app you might generate Dart classes from a shared schema. For this project, Maps keep things lightweight and easy to debug.
- Save signaling_service.dart.
Seeing import errors for dart:io?
- Make sure you are running a desktop (Linux) Flutter project. The dart:io WebSocket is not available for Flutter web targets.
- Check that your file is saved inside lib/services/ and not at the project root. Dart resolves imports relative to the lib/ directory.
Still stuck? Help me fix dart:io import errors in my Flutter desktop project.
✔️ Awesome, I've got everything!
Great. Double check you have saved signaling_service.dart before moving on.
ⓧ I'd like to double check the full code
Here is the complete signaling_service.dart file for comparison:
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class SignalingService {
WebSocket? _socket;
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messages => _messageController.stream;
Future<void> connect(String url) async {
_socket = await WebSocket.connect(url);
_socket!.listen(
(data) {
final message = jsonDecode(data as String) as Map<String, dynamic>;
_messageController.add(message);
},
onError: (error) {
print('WebSocket error: $error');
},
onDone: () {
print('WebSocket connection closed');
},
);
}
void createRoom() {
_send({'type': 'create_room'});
}
void joinRoom(String roomId) {
_send({'type': 'join_room', 'room_id': roomId});
}
void sendOffer(String sdp) {
_send({'type': 'offer', 'sdp': sdp});
}
void sendAnswer(String sdp) {
_send({'type': 'answer', 'sdp': sdp});
}
void sendIceCandidate(String candidate, String? sdpMid, int? sdpMLineIndex) {
_send({
'type': 'ice_candidate',
'candidate': candidate,
'sdp_mid': sdpMid,
'sdp_m_line_index': sdpMLineIndex,
});
}
void sendChatMessage(String content) {
_send({'type': 'chat_message', 'content': content});
}
void leaveRoom() {
_send({'type': 'leave_room'});
}
void _send(Map<String, dynamic> message) {
if (_socket != null && _socket!.readyState == WebSocket.open) {
_socket!.add(jsonEncode(message));
}
}
void dispose() {
_socket?.close();
_messageController.close();
}
}
Create the WebRTC service
The WebRTC service encapsulates the peer connection, media streams, and ICE candidate exchange. It uses Google's free STUN servers for NAT traversal so peers can discover each other's network addresses.
- Create the WebRTC service by opening lib/services/webrtc_service.dart and pasting this code:
import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart';
class WebRTCService {
RTCPeerConnection? _peerConnection;
MediaStream? _localStream;
MediaStream? _remoteStream;
// Broadcast streams so multiple listeners can react to media events
final _onLocalStream = StreamController<MediaStream>.broadcast();
final _onRemoteStream = StreamController<MediaStream>.broadcast();
final _onIceCandidate = StreamController<RTCIceCandidate>.broadcast();
Stream<MediaStream> get onLocalStream => _onLocalStream.stream;
Stream<MediaStream> get onRemoteStream => _onRemoteStream.stream;
Stream<RTCIceCandidate> get onIceCandidate => _onIceCandidate.stream;
// Google STUN servers for NAT traversal
static const Map<String, dynamic> _iceServers = {
'iceServers': [
{
'urls': [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
},
],
};
// Request camera and microphone access
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': 640,
'height': 480,
},
};
Future<void> initialize() async {
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
_onLocalStream.add(_localStream!);
}
Future<void> createPeerConnection() async {
_peerConnection = await createPeerConnection(_iceServers);
// Attach local tracks so the remote peer receives our media
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
// Forward ICE candidates to the signaling layer
_peerConnection!.onIceCandidate = (candidate) {
_onIceCandidate.add(candidate);
};
// Receive remote media tracks
_peerConnection!.onTrack = (event) {
if (event.streams.isNotEmpty) {
_remoteStream = event.streams[0];
_onRemoteStream.add(_remoteStream!);
}
};
_peerConnection!.onIceConnectionState = (state) {
print('ICE connection state: $state');
};
}
Future<RTCSessionDescription> createOffer() async {
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
return offer;
}
Future<RTCSessionDescription> createAnswer() async {
final answer = await _peerConnection!.createAnswer();
await _peerConnection!.setLocalDescription(answer);
return answer;
}
Future<void> setRemoteDescription(String sdp, String type) async {
final description = RTCSessionDescription(sdp, type);
await _peerConnection!.setRemoteDescription(description);
}
Future<void> addIceCandidate(
String candidate, String? sdpMid, int? sdpMLineIndex) async {
final iceCandidate = RTCIceCandidate(candidate, sdpMid, sdpMLineIndex);
await _peerConnection!.addCandidate(iceCandidate);
}
void toggleAudio(bool enabled) {
_localStream?.getAudioTracks().forEach((track) {
track.enabled = enabled;
});
}
void toggleVideo(bool enabled) {
_localStream?.getVideoTracks().forEach((track) {
track.enabled = enabled;
});
}
Future<void> dispose() async {
_localStream?.getTracks().forEach((track) => track.stop());
await _peerConnection?.close();
_peerConnection = null;
_localStream = null;
_remoteStream = null;
_onLocalStream.close();
_onRemoteStream.close();
_onIceCandidate.close();
}
}
What does this code do?
The WebRTCService manages the entire peer-to-peer media pipeline.
- initialize() requests camera and microphone access via getUserMedia and emits the local stream for the UI to render.
- createPeerConnection() sets up the RTCPeerConnection with STUN servers, attaches local tracks, and registers callbacks for ICE candidates and incoming remote tracks.
- createOffer() and createAnswer() implement the SDP offer/answer handshake. The caller creates an offer, the receiver creates an answer.
- dispose() stops all media tracks, closes the peer connection, and shuts down all StreamControllers to prevent memory leaks.
What are STUN servers?
STUN servers help peers discover their public IP address when they are behind a router (NAT). Without STUN, two computers on different networks cannot find each other for a direct connection.
Google provides free STUN servers at stun:stun1.l.google.com:19302 and stun:stun2.l.google.com:19302. These work well for development and testing.
- Save webrtc_service.dart.
- Verify both services are recognized by your project by running this from the chat_call_app directory:
flutter analyze
You should see no analysis errors for signaling_service.dart or webrtc_service.dart. Warnings about unused imports are fine since no UI code references these services yet.
Seeing errors about flutter_webrtc?
- Confirm flutter_webrtc: ^1.4.1 is listed under dependencies in your pubspec.yaml.
- Make sure you ran flutter pub get in the previous step. If not, run it now from the chat_call_app directory.
- Check that the import path is exactly package:flutter_webrtc/flutter_webrtc.dart. A typo in the package name will cause an unresolved import.
Still stuck? Help me resolve flutter_webrtc import issues in my Flutter Linux desktop project.
✔️ Awesome, I've got everything!
Great. Double check you have saved webrtc_service.dart before moving on.
ⓧ I'd like to double check the full code
Here is the complete webrtc_service.dart file for comparison:
import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart';
class WebRTCService {
RTCPeerConnection? _peerConnection;
MediaStream? _localStream;
MediaStream? _remoteStream;
final _onLocalStream = StreamController<MediaStream>.broadcast();
final _onRemoteStream = StreamController<MediaStream>.broadcast();
final _onIceCandidate = StreamController<RTCIceCandidate>.broadcast();
Stream<MediaStream> get onLocalStream => _onLocalStream.stream;
Stream<MediaStream> get onRemoteStream => _onRemoteStream.stream;
Stream<RTCIceCandidate> get onIceCandidate => _onIceCandidate.stream;
static const Map<String, dynamic> _iceServers = {
'iceServers': [
{
'urls': [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
},
],
};
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': 640,
'height': 480,
},
};
Future<void> initialize() async {
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
_onLocalStream.add(_localStream!);
}
Future<void> createPeerConnection() async {
_peerConnection = await createPeerConnection(_iceServers);
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
_peerConnection!.onIceCandidate = (candidate) {
_onIceCandidate.add(candidate);
};
_peerConnection!.onTrack = (event) {
if (event.streams.isNotEmpty) {
_remoteStream = event.streams[0];
_onRemoteStream.add(_remoteStream!);
}
};
_peerConnection!.onIceConnectionState = (state) {
print('ICE connection state: $state');
};
}
Future<RTCSessionDescription> createOffer() async {
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
return offer;
}
Future<RTCSessionDescription> createAnswer() async {
final answer = await _peerConnection!.createAnswer();
await _peerConnection!.setLocalDescription(answer);
return answer;
}
Future<void> setRemoteDescription(String sdp, String type) async {
final description = RTCSessionDescription(sdp, type);
await _peerConnection!.setRemoteDescription(description);
}
Future<void> addIceCandidate(
String candidate, String? sdpMid, int? sdpMLineIndex) async {
final iceCandidate = RTCIceCandidate(candidate, sdpMid, sdpMLineIndex);
await _peerConnection!.addCandidate(iceCandidate);
}
void toggleAudio(bool enabled) {
_localStream?.getAudioTracks().forEach((track) {
track.enabled = enabled;
});
}
void toggleVideo(bool enabled) {
_localStream?.getVideoTracks().forEach((track) {
track.enabled = enabled;
});
}
Future<void> dispose() async {
_localStream?.getTracks().forEach((track) => track.stop());
await _peerConnection?.close();
_peerConnection = null;
_localStream = null;
_remoteStream = null;
_onLocalStream.close();
_onRemoteStream.close();
_onIceCandidate.close();
}
}
Both services are ready. The signaling service knows how to talk to your Rust server over WebSocket. The WebRTC service knows how to manage peer connections and media. Next up, you will build the Flutter UI that wires these two services together into a working video call screen.
Build Flutter UI and Connect Everything
Your signaling and WebRTC services are ready to handle the hard work of real-time communication. Now you need to give users a way to actually use them.
In this step, you will build the Flutter UI layer that ties everything together. You will create a video call screen with live renderers, a text chat overlay, and call controls. You will also build a home screen where users create or join rooms.
In this step, get ready to:
- Create the call screen with video renderers, chat overlay, and call controls.
- Build the home screen for creating and joining rooms.
- Wire up signaling events to WebRTC actions for a complete peer-to-peer flow.
Create the call screen widget
The call screen is where the action happens. It manages two video renderers (local and remote), handles signaling messages to coordinate the WebRTC connection, and provides a chat overlay for text messaging during a call.
- Create the file lib/screens/call_screen.dart and add the imports, widget class, state fields, and initialization logic by pasting this code:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../services/signaling_service.dart';
import '../services/webrtc_service.dart';
class CallScreen extends StatefulWidget {
final String roomId;
final bool isCreator;
final SignalingService signalingService;
const CallScreen({
super.key,
required this.roomId,
required this.isCreator,
required this.signalingService,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
final _webrtcService = WebRTCService();
final _localRenderer = RTCVideoRenderer();
final _remoteRenderer = RTCVideoRenderer();
final _chatController = TextEditingController();
final _chatMessages = <String>[];
final _subscriptions = <StreamSubscription>[];
bool _audioEnabled = true;
bool _videoEnabled = true;
@override
void initState() {
super.initState();
_initializeCall();
}
// Sets up video renderers, starts media, and subscribes to all streams
Future<void> _initializeCall() async {
await _localRenderer.initialize();
await _remoteRenderer.initialize();
await _webrtcService.initialize();
await _webrtcService.createPeerConnection();
// When local camera stream is ready, attach to the small PiP renderer
_subscriptions.add(
_webrtcService.onLocalStream.listen((stream) {
setState(() {
_localRenderer.srcObject = stream;
});
}),
);
// When remote stream arrives, attach to the full-screen renderer
_subscriptions.add(
_webrtcService.onRemoteStream.listen((stream) {
setState(() {
_remoteRenderer.srcObject = stream;
});
}),
);
// Forward local ICE candidates to the signaling server
_subscriptions.add(
_webrtcService.onIceCandidate.listen((candidate) {
widget.signalingService.sendIceCandidate(
candidate.candidate!,
candidate.sdpMid,
candidate.sdpMLineIndex,
);
}),
);
// Listen for incoming signaling messages from the server
_subscriptions.add(
widget.signalingService.messages.listen(_handleSignalingMessage),
);
}
What does this code do?
The CallScreen widget accepts three parameters: the room ID, whether this peer is the room creator, and a reference to the signaling service that is already connected.
The _initializeCall() method is the heart of the setup. It initializes both video renderers, starts the camera and microphone, creates the peer connection, and then subscribes to three streams. Each stream subscription bridges a gap between the WebRTC service and the signaling service.
- Still in call_screen.dart, add the signaling message handler, action methods, and dispose logic below the _initializeCall() method:
// Routes incoming signaling messages to the correct WebRTC action
void _handleSignalingMessage(Map<String, dynamic> message) async {
switch (message['type']) {
case 'peer_joined':
// Only the room creator sends the initial offer
if (widget.isCreator) {
final offer = await _webrtcService.createOffer();
widget.signalingService.sendOffer(offer.sdp!);
}
break;
case 'offer':
await _webrtcService.setRemoteDescription(
message['sdp'], 'offer');
final answer = await _webrtcService.createAnswer();
widget.signalingService.sendAnswer(answer.sdp!);
break;
case 'answer':
await _webrtcService.setRemoteDescription(
message['sdp'], 'answer');
break;
case 'ice_candidate':
await _webrtcService.addIceCandidate(
message['candidate'],
message['sdp_mid'],
message['sdp_m_line_index'],
);
break;
case 'chat_message':
setState(() {
_chatMessages.add('${message['from']}: ${message['content']}');
});
break;
case 'peer_left':
setState(() {
_remoteRenderer.srcObject = null;
_chatMessages.add('Peer disconnected');
});
break;
}
}
void _sendChat() {
final text = _chatController.text.trim();
if (text.isNotEmpty) {
widget.signalingService.sendChatMessage(text);
setState(() {
_chatMessages.add('You: $text');
});
_chatController.clear();
}
}
void _toggleAudio() {
setState(() {
_audioEnabled = !_audioEnabled;
_webrtcService.toggleAudio(_audioEnabled);
});
}
void _toggleVideo() {
setState(() {
_videoEnabled = !_videoEnabled;
_webrtcService.toggleVideo(_videoEnabled);
});
}
void _endCall() {
widget.signalingService.leaveRoom();
Navigator.of(context).pop();
}
@override
void dispose() {
for (final sub in _subscriptions) {
sub.cancel();
}
_localRenderer.dispose();
_remoteRenderer.dispose();
_webrtcService.dispose();
_chatController.dispose();
super.dispose();
}
What does this code do?
The _handleSignalingMessage() method is the router for all messages arriving from the signaling server. When a peer joins, the room creator initiates the WebRTC handshake by sending an SDP offer. The joiner responds with an SDP answer. ICE candidates are exchanged in parallel to find the best network path.
The dispose() method cancels all stream subscriptions, disposes renderers, and closes the peer connection. This prevents memory leaks when the user navigates away from the call screen.
- Complete call_screen.dart by adding the build() method and the _controlButton() helper below the dispose() method:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Remote video fills the entire screen
Positioned.fill(
child: RTCVideoView(
_remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
// Local video as a small picture-in-picture in the top right
Positioned(
top: 40,
right: 16,
width: 120,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RTCVideoView(
_localRenderer,
mirror: true,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
),
// Room ID badge in the top left
Positioned(
top: 40,
left: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Room: ${widget.roomId}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
// Chat overlay at the bottom with message list and input field
Positioned(
bottom: 100,
left: 16,
right: 16,
height: 200,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _chatMessages.length,
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_chatMessages[index],
style: const TextStyle(
color: Colors.white, fontSize: 13),
),
);
},
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _chatController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Type a message...',
hintStyle: const TextStyle(color: Colors.white54),
filled: true,
fillColor: Colors.black54,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
),
onSubmitted: (_) => _sendChat(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _sendChat,
icon: const Icon(Icons.send, color: Colors.white),
),
],
),
],
),
),
// Call control buttons at the bottom
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_controlButton(
icon: _audioEnabled ? Icons.mic : Icons.mic_off,
color: _audioEnabled ? Colors.white : Colors.red,
onPressed: _toggleAudio,
),
const SizedBox(width: 24),
_controlButton(
icon: Icons.call_end,
color: Colors.red,
backgroundColor: Colors.red,
iconColor: Colors.white,
onPressed: _endCall,
),
const SizedBox(width: 24),
_controlButton(
icon: _videoEnabled ? Icons.videocam : Icons.videocam_off,
color: _videoEnabled ? Colors.white : Colors.red,
onPressed: _toggleVideo,
),
],
),
),
],
),
);
}
// Helper widget for circular call control buttons
Widget _controlButton({
required IconData icon,
required Color color,
Color? backgroundColor,
Color? iconColor,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor ?? Colors.black54,
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, color: iconColor ?? color),
iconSize: 28,
padding: const EdgeInsets.all(12),
),
);
}
}
What does this code do?
The build() method uses a Stack layout to layer elements on top of each other. The remote video fills the entire screen as the background. Your local camera feed sits in a small picture-in-picture box in the top right corner.
The chat overlay sits above the call controls, with a scrollable message list and a text input. The three control buttons (mute, end call, toggle video) are positioned at the bottom center.
- Save lib/screens/call_screen.dart.
Seeing import errors?
Make sure your imports reference the correct relative paths. The ../services/signaling_service.dart and ../services/webrtc_service.dart paths assume call_screen.dart is inside the lib/screens/ directory.
help me fix import errors in my call_screen.dart file
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/screens/call_screen.dart before moving on.
ⓧ I'd like to double check the full code
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../services/signaling_service.dart';
import '../services/webrtc_service.dart';
class CallScreen extends StatefulWidget {
final String roomId;
final bool isCreator;
final SignalingService signalingService;
const CallScreen({
super.key,
required this.roomId,
required this.isCreator,
required this.signalingService,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
final _webrtcService = WebRTCService();
final _localRenderer = RTCVideoRenderer();
final _remoteRenderer = RTCVideoRenderer();
final _chatController = TextEditingController();
final _chatMessages = <String>[];
final _subscriptions = <StreamSubscription>[];
bool _audioEnabled = true;
bool _videoEnabled = true;
@override
void initState() {
super.initState();
_initializeCall();
}
Future<void> _initializeCall() async {
await _localRenderer.initialize();
await _remoteRenderer.initialize();
await _webrtcService.initialize();
await _webrtcService.createPeerConnection();
_subscriptions.add(
_webrtcService.onLocalStream.listen((stream) {
setState(() {
_localRenderer.srcObject = stream;
});
}),
);
_subscriptions.add(
_webrtcService.onRemoteStream.listen((stream) {
setState(() {
_remoteRenderer.srcObject = stream;
});
}),
);
_subscriptions.add(
_webrtcService.onIceCandidate.listen((candidate) {
widget.signalingService.sendIceCandidate(
candidate.candidate!,
candidate.sdpMid,
candidate.sdpMLineIndex,
);
}),
);
_subscriptions.add(
widget.signalingService.messages.listen(_handleSignalingMessage),
);
}
void _handleSignalingMessage(Map<String, dynamic> message) async {
switch (message['type']) {
case 'peer_joined':
if (widget.isCreator) {
final offer = await _webrtcService.createOffer();
widget.signalingService.sendOffer(offer.sdp!);
}
break;
case 'offer':
await _webrtcService.setRemoteDescription(
message['sdp'], 'offer');
final answer = await _webrtcService.createAnswer();
widget.signalingService.sendAnswer(answer.sdp!);
break;
case 'answer':
await _webrtcService.setRemoteDescription(
message['sdp'], 'answer');
break;
case 'ice_candidate':
await _webrtcService.addIceCandidate(
message['candidate'],
message['sdp_mid'],
message['sdp_m_line_index'],
);
break;
case 'chat_message':
setState(() {
_chatMessages.add('${message['from']}: ${message['content']}');
});
break;
case 'peer_left':
setState(() {
_remoteRenderer.srcObject = null;
_chatMessages.add('Peer disconnected');
});
break;
}
}
void _sendChat() {
final text = _chatController.text.trim();
if (text.isNotEmpty) {
widget.signalingService.sendChatMessage(text);
setState(() {
_chatMessages.add('You: $text');
});
_chatController.clear();
}
}
void _toggleAudio() {
setState(() {
_audioEnabled = !_audioEnabled;
_webrtcService.toggleAudio(_audioEnabled);
});
}
void _toggleVideo() {
setState(() {
_videoEnabled = !_videoEnabled;
_webrtcService.toggleVideo(_videoEnabled);
});
}
void _endCall() {
widget.signalingService.leaveRoom();
Navigator.of(context).pop();
}
@override
void dispose() {
for (final sub in _subscriptions) {
sub.cancel();
}
_localRenderer.dispose();
_remoteRenderer.dispose();
_webrtcService.dispose();
_chatController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: RTCVideoView(
_remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
Positioned(
top: 40,
right: 16,
width: 120,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RTCVideoView(
_localRenderer,
mirror: true,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
),
Positioned(
top: 40,
left: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Room: ${widget.roomId}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
Positioned(
bottom: 100,
left: 16,
right: 16,
height: 200,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _chatMessages.length,
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_chatMessages[index],
style: const TextStyle(
color: Colors.white, fontSize: 13),
),
);
},
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _chatController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Type a message...',
hintStyle: const TextStyle(color: Colors.white54),
filled: true,
fillColor: Colors.black54,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
),
onSubmitted: (_) => _sendChat(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _sendChat,
icon: const Icon(Icons.send, color: Colors.white),
),
],
),
],
),
),
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_controlButton(
icon: _audioEnabled ? Icons.mic : Icons.mic_off,
color: _audioEnabled ? Colors.white : Colors.red,
onPressed: _toggleAudio,
),
const SizedBox(width: 24),
_controlButton(
icon: Icons.call_end,
color: Colors.red,
backgroundColor: Colors.red,
iconColor: Colors.white,
onPressed: _endCall,
),
const SizedBox(width: 24),
_controlButton(
icon: _videoEnabled ? Icons.videocam : Icons.videocam_off,
color: _videoEnabled ? Colors.white : Colors.red,
onPressed: _toggleVideo,
),
],
),
),
],
),
);
}
Widget _controlButton({
required IconData icon,
required Color color,
Color? backgroundColor,
Color? iconColor,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor ?? Colors.black54,
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, color: iconColor ?? color),
iconSize: 28,
padding: const EdgeInsets.all(12),
),
);
}
}
Build the home screen
The home screen is the entry point for your app. Users need a way to create a new room (and share the generated room ID) or join an existing room by entering a room ID.
- Open lib/main.dart and replace all of its contents with the app entry point, theme configuration, and the _createRoom() method:
import 'package:flutter/material.dart';
import 'services/signaling_service.dart';
import 'screens/call_screen.dart';
void main() {
runApp(const ChatCallApp());
}
class ChatCallApp extends StatelessWidget {
const ChatCallApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Call App',
theme: ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(
primary: Colors.blue,
secondary: Colors.blueAccent,
),
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _roomIdController = TextEditingController();
final _serverUrlController =
TextEditingController(text: 'ws://127.0.0.1:8080/ws');
String _status = '';
// Creates a new room and navigates to CallScreen when the server responds
Future<void> _createRoom() async {
final signalingService = SignalingService();
try {
await signalingService.connect(_serverUrlController.text);
signalingService.messages.listen((message) {
if (message['type'] == 'room_created') {
final roomId = message['room_id'] as String;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
roomId: roomId,
isCreator: true,
signalingService: signalingService,
),
),
);
}
});
signalingService.createRoom();
setState(() => _status = 'Creating room...');
} catch (e) {
setState(() => _status = 'Connection failed: $e');
}
}
What does this code do?
The ChatCallApp widget configures a dark theme and sets HomeScreen as the landing page. The server URL defaults to ws://127.0.0.1:8080/ws which matches your Rust signaling server.
The _createRoom() method creates a fresh SignalingService, connects to the server, then listens for a room_created response. When it arrives, it navigates to the CallScreen with isCreator: true.
- Still in lib/main.dart, add the _joinRoom() method, dispose(), and the build() method below _createRoom():
// Joins an existing room by ID and navigates to CallScreen on success
Future<void> _joinRoom() async {
final roomId = _roomIdController.text.trim();
if (roomId.isEmpty) {
setState(() => _status = 'Please enter a room ID');
return;
}
final signalingService = SignalingService();
try {
await signalingService.connect(_serverUrlController.text);
signalingService.messages.listen((message) {
if (message['type'] == 'room_joined') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
roomId: roomId,
isCreator: false,
signalingService: signalingService,
),
),
);
} else if (message['type'] == 'error') {
setState(() => _status = 'Error: ${message['message']}');
}
});
signalingService.joinRoom(roomId);
setState(() => _status = 'Joining room...');
} catch (e) {
setState(() => _status = 'Connection failed: $e');
}
}
@override
void dispose() {
_roomIdController.dispose();
_serverUrlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat Call App')),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _createRoom,
icon: const Icon(Icons.add),
label: const Text('Create Room'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
TextField(
controller: _roomIdController,
decoration: const InputDecoration(
labelText: 'Room ID',
hintText: 'Enter room ID to join',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _joinRoom,
icon: const Icon(Icons.login),
label: const Text('Join Room'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 24),
if (_status.isNotEmpty)
Text(
_status,
style: TextStyle(
color: _status.contains('failed') || _status.contains('Error')
? Colors.red
: Colors.green,
),
),
],
),
),
),
);
}
}
What does this code do?
The _joinRoom() method works similarly to _createRoom() but passes isCreator: false to the CallScreen. This is important because only the creator sends the initial SDP offer when a peer joins.
The build method lays out a centered column with a server URL field, a Create Room button, a divider, a Room ID input, and a Join Room button. The status text at the bottom changes color based on whether it contains an error or a success message.
- Save lib/main.dart.
Seeing a compile error about CallScreen?
Make sure the import at the top of main.dart says import 'screens/call_screen.dart'; (no leading ../ since main.dart is in the lib/ root).
help me fix a compile error about CallScreen not being found
✔️ Awesome, I've got everything!
Great. Double check you have saved both lib/main.dart and lib/screens/call_screen.dart.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'services/signaling_service.dart';
import 'screens/call_screen.dart';
void main() {
runApp(const ChatCallApp());
}
class ChatCallApp extends StatelessWidget {
const ChatCallApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Call App',
theme: ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(
primary: Colors.blue,
secondary: Colors.blueAccent,
),
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _roomIdController = TextEditingController();
final _serverUrlController =
TextEditingController(text: 'ws://127.0.0.1:8080/ws');
String _status = '';
Future<void> _createRoom() async {
final signalingService = SignalingService();
try {
await signalingService.connect(_serverUrlController.text);
signalingService.messages.listen((message) {
if (message['type'] == 'room_created') {
final roomId = message['room_id'] as String;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
roomId: roomId,
isCreator: true,
signalingService: signalingService,
),
),
);
}
});
signalingService.createRoom();
setState(() => _status = 'Creating room...');
} catch (e) {
setState(() => _status = 'Connection failed: $e');
}
}
Future<void> _joinRoom() async {
final roomId = _roomIdController.text.trim();
if (roomId.isEmpty) {
setState(() => _status = 'Please enter a room ID');
return;
}
final signalingService = SignalingService();
try {
await signalingService.connect(_serverUrlController.text);
signalingService.messages.listen((message) {
if (message['type'] == 'room_joined') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
roomId: roomId,
isCreator: false,
signalingService: signalingService,
),
),
);
} else if (message['type'] == 'error') {
setState(() => _status = 'Error: ${message['message']}');
}
});
signalingService.joinRoom(roomId);
setState(() => _status = 'Joining room...');
} catch (e) {
setState(() => _status = 'Connection failed: $e');
}
}
@override
void dispose() {
_roomIdController.dispose();
_serverUrlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat Call App')),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _createRoom,
icon: const Icon(Icons.add),
label: const Text('Create Room'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
TextField(
controller: _roomIdController,
decoration: const InputDecoration(
labelText: 'Room ID',
hintText: 'Enter room ID to join',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _joinRoom,
icon: const Icon(Icons.login),
label: const Text('Join Room'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 24),
if (_status.isNotEmpty)
Text(
_status,
style: TextStyle(
color: _status.contains('failed') || _status.contains('Error')
? Colors.red
: Colors.green,
),
),
],
),
),
),
);
}
}
Wire up the event flow
Your code is written, but it is worth understanding how the signaling and WebRTC layers connect to form a complete peer-to-peer call. The flow is a coordinated dance between the two services.
How does the event flow work?
When the room creator clicks Create Room, the signaling server assigns a room ID. When a second peer joins that room, the server sends a peer_joined message to the creator. This triggers the creator to generate an SDP offer and send it through the signaling server.
The joiner receives the offer, sets it as the remote description, generates an SDP answer, and sends it back. Once both sides have each other's SDP, they exchange ICE candidates to find the optimal network path. When ICE negotiation succeeds, the RTCPeerConnection fires onTrack and the remote video stream appears.
The signaling server is only a relay. Once the WebRTC connection is established, video and audio flow directly between peers without touching the server. Chat messages continue to go through the signaling server for simplicity.
Why use isCreator to decide who sends the offer?
In WebRTC, exactly one side must initiate the offer. If both sides send an offer simultaneously, you get a "glare" condition that breaks the connection. By designating the room creator as the offerer, the handshake has a clear direction. The joiner always waits for the offer to arrive before responding with an answer.
Your Flutter app is now fully wired up with a complete UI layer. Next up, you will test the entire system end-to-end by running two app instances and making a real video call.
Test End-to-End
Your signaling server and Flutter client are fully built. Every actor, service, and screen is in place.
Now it is time to prove the system works as a whole. You will run two instances of the Flutter app simultaneously, create and join a room, and verify that video, audio, and text chat all flow correctly through the WebRTC peer connection.
In this step, get ready to:
- Build and start the Rust signaling server and Flutter Linux app.
- Run two Flutter instances to create and join a room.
- Verify video, audio, text chat, and call cleanup work correctly.
Start the signaling server and build the Flutter app
You need both the Rust server running and a compiled Flutter binary. The binary approach lets you launch multiple instances independently without the Flutter debugger locking you to a single window.
- Open a terminal window and navigate to your signaling-server directory.
- Start the signaling server by running:
cargo run
You should see the log message Signaling server listening on 0.0.0.0:8080 in the terminal output.
Server won't start?
If you see a compilation error, check that all files in src/ are saved. A missing semicolon or unmatched brace in any actor file will prevent the build.
If port 8080 is already in use, stop any other process on that port before retrying.
Help me debug my signaling server startup
- Open a second terminal window and navigate to your chat_call_app directory.
- Build the Flutter app for Linux by running:
flutter build linux
Why build instead of flutter run?
The flutter run command attaches a debugger to a single instance. To test a video call you need two clients simultaneously. Building produces a standalone binary you can launch from any terminal without the debugger lock.
- Confirm the build succeeded by checking for the executable:
ls build/linux/x64/release/bundle/chat_call_app
You should see chat_call_app listed as the compiled binary.
Build failed?
Make sure you ran flutter pub get in the chat_call_app directory before building. Missing dependencies are the most common cause of build failures.
Check that your pubspec.yaml has flutter_webrtc: ^1.4.1 listed under dependencies.
Help me fix my Flutter Linux build
Run two instances and connect them
With the server running and the binary built, you can now simulate a real call between two users on the same machine.
- In your second terminal (the one in the chat_call_app directory), launch the first instance by running:
./build/linux/x64/release/bundle/chat_call_app
- Open a third terminal window, navigate to the chat_call_app directory, and launch the second instance by running the same command:
./build/linux/x64/release/bundle/chat_call_app
You should now see two separate app windows, each showing the home screen with the **Server URL** field pre-filled with ws://127.0.0.1:8080/ws.
Why two separate terminals?
Each terminal runs a separate process. Both connect independently to the signaling server via WebSocket. The server assigns each a unique peer ID, and the Room actor manages them as two distinct peers in the same room.
- In the first app window, click Create Room.
- The app navigates to the call screen. Note the 8-character room ID displayed in the top-left corner (e.g. a3f8b2c1).
- In the second app window, type that room ID into the Room ID field.
- Click Join Room.
The second instance navigates to the call screen. Within a few seconds, both windows should display video from the other peer.
What is happening behind the scenes?
- When Instance 2 joins, the Room actor sends a PeerJoined message to Instance 1.
- Instance 1 (the creator) generates an SDP offer and sends it through the signaling server.
- Instance 2 receives the offer, creates an answer, and sends it back.
- Both sides exchange ICE candidates until a direct peer-to-peer connection is established for media.
No video appearing?
Make sure your user account has access to the camera. On Linux, check that your user is in the video group. You can verify with groups $USER in a terminal.
Check the first terminal (running the signaling server) for log messages. You should see connection and room-join logs from both peers. If you only see one connection, the second instance may not be reaching the server.
Help me debug my WebRTC video connection
Verify video, chat, and call cleanup
With both instances connected, you can now confirm each feature works end-to-end.
- Confirm video: both windows show the remote peer's camera feed in the large view and your own camera in the small picture-in-picture rectangle.
- Confirm audio: speak into your microphone. You should hear the audio from the other instance (you may need headphones to avoid feedback).
- Test text chat: type a message in the chat input at the bottom of either window and press Enter.
- Verify the message appears in both windows. Your own message shows as "You: ..." and the remote side shows it with the peer ID prefix.
- Test the mute button: tap the microphone icon on one instance. The icon should toggle to the muted state.
- Test the video toggle: tap the camera icon. Your local video should freeze or go black on the remote side.
- End the call: tap the red end-call button on one instance.
The instance that ended the call should navigate back to the home screen. The other instance should see "Peer disconnected" in the chat overlay.
What does ending the call clean up?
- The leaveRoom() call sends a LeaveRoom message to the signaling server, which removes the peer from the Room actor's peer map.
- The dispose() method on WebRTCService stops all media tracks and closes the peer connection.
- Both RTCVideoRenderer instances are disposed, freeing GPU resources.
- All stream subscriptions are cancelled to prevent memory leaks.
Chat messages not appearing on the other side?
Check the signaling server terminal for relay logs. If you see no relay activity, the peers may not be in the same room. Verify both instances show the same room ID in the top-left corner of the call screen.
Help me debug chat message delivery
You have a fully working real-time video calling app. The actor model keeps room state safe without locks, WebRTC handles the heavy lifting of media streaming peer-to-peer, and the signaling protocol ties everything together cleanly.
Secret mission
Add Screen Sharing
Your video call works great for face-to-face chats. But what if you want to show your screen during a call? In this secret mission, you will add a screen sharing toggle that captures your display and replaces the camera feed in real-time using track replacement.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The signaling-server Rust project folder on your Desktop.
- The chat_call_app Flutter project folder on your Desktop.
- Any running processes (the Rust server or Flutter app instances).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project with the Secret Mission.
- Your project folders stay on your Desktop.
- If the server or app processes are still running, they continue using a small amount of memory.
- You can restart the server anytime with cargo run from the signaling-server folder.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay on disk for next time.
- Switch to any terminal running the Rust server or Flutter app and press Ctrl+C to stop them.
- Your project folders remain on your Desktop. You can pick up where you left off by running cargo run and launching the Flutter app again.
Delete - I don't want to use this again
Remove all project files and start fresh.
- Stop any running processes by pressing Ctrl+C in each terminal window.
- Delete the Rust project folder by running:
rm -rf ~/Desktop/signaling-server
- Delete the Flutter project folder by running:
rm -rf ~/Desktop/chat_call_app
- Confirm the folders are gone by running:
ls ~/Desktop
You should no longer see signaling-server or chat_call_app in the output.
Nice Work!
Nice Work!
You just built a real-time video calling application with a production-grade architecture. From actor-based signaling to peer-to-peer media streaming, this project covers the full stack of real-time communication.
You've learned how to:
- Build a Rust Axum signaling server using the actor model with typed message passing via tokio mpsc and oneshot channels.
- Create a Flutter desktop app with WebRTC peer-to-peer video and audio calling.
- Design a typed JSON signaling protocol for SDP offer/answer exchange and ICE candidate negotiation between peers.
- Secret Mission: Added screen sharing capability with track replacement during live calls.
Ready to quiz yourself?