Media
WebRTC Signalling
Call lifecycle and SDP/ICE exchange
Symple provides a complete WebRTC call signalling flow built on top of regular messaging. Call control messages use type: 'message' with a subtype prefix of call:.
Call subtypes
| Subtype | Direction | Purpose |
|---|---|---|
call:init | Caller → Callee | Initiate a call |
call:accept | Callee → Caller | Accept the call |
call:reject | Callee → Caller | Reject (with optional reason) |
call:offer | Caller → Callee | SDP offer |
call:answer | Callee → Caller | SDP answer |
call:candidate | Both | ICE candidate (trickle) |
call:hangup | Either | End the call |
Call lifecycle
Caller side
call(peerId)→ state = RINGING, sendcall:init- Receive
call:accept→ state = CONNECTING, create WebRTCPlayer as initiator - Player acquires local media, creates SDP offer → send
call:offer - Receive
call:answerwith remote SDP - Exchange ICE candidates via
call:candidate - State = ACTIVE when media flows
hangup()or receivecall:hangup→ state = ENDED
Callee side
- Receive
call:init→ state = INCOMING, emitincomingevent - User calls
accept()→ state = CONNECTING, sendcall:accept, create WebRTCPlayer as non-initiator - Receive
call:offerwith remote SDP - Player creates SDP answer → send
call:answer - Exchange ICE candidates
- State = ACTIVE when media flows
ICE candidate buffering
Candidates that arrive before the remote SDP description is set are buffered in _pendingCandidates. When the remote description is set, buffered candidates are flushed to the peer connection. This handles the race condition that most WebRTC implementations get wrong.
Call states
import { CallState } from 'symple-player'
CallState.IDLE // No active call
CallState.RINGING // Outgoing call, waiting for accept
CallState.INCOMING // Incoming call, waiting for user action
CallState.CONNECTING // Accepted, WebRTC negotiation in progress
CallState.ACTIVE // Media flowing
CallState.ENDED // Call ended (resets to IDLE)Message format
{
"type": "message",
"subtype": "call:offer",
"from": "alice|session-abc",
"to": "bob|session-xyz",
"data": {
"type": "offer",
"sdp": "v=0\r\no=- ..."
}
}ICE candidate:
{
"type": "message",
"subtype": "call:candidate",
"from": "alice|session-abc",
"to": "bob|session-xyz",
"data": {
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
}Separation of concerns
The symple server doesn't know anything about WebRTC. It routes call:* messages like any other message. The call lifecycle is entirely client-side. This means:
- Signalling goes through the server
- Media flows peer-to-peer (or through a TURN relay)
- The server never touches media data
