Calls between two people
A call rings on every page the other person has open, and runs straight between the two browsers once answered:
- Direct: the two browsers connect to each other, which is the best picture and sound, at no cost.
- Relayed: when a network allows no direct connection, the media goes through WordPlus Cloud's TURN servers, over UDP or over TLS on port 443.
- A room: when neither works, the call moves to a room on WordPlus Cloud's media servers, on its own and without hanging up.
The call screen does all of this with two HTML elements. This page is for building your own interface.
Who may call whom
Your plugin owns the calls whose names start with its prefix, and decides each time someone places or answers one:
add_action( 'plugins_loaded', function () {
wordplus_realtime()->calls( 'support-', function ( $user_id, $call, $with ) {
if ( null === $with ) {
return false; // a group call's room: see group-calls.md
}
$agent = my_support_agent_id();
if ( $user_id === $agent ) {
return 'support-' . $with === $call; // the agent answers a customer's call
}
return $with === $agent && 'support-' . $user_id === $call; // a customer calls the agent, on their own call
} );
} );
$user_idplaces or answers the call: the signed-in user, or a visitor's id (visitors).$callis the call's name, such assupport-42, which your plugin chooses. Names follow the channels' rules: up to 200 ofA-Z a-z 0-9 _ - = @ , . ;.$withis the other person: the one being called when$user_idplaces the call, the caller when$user_idanswers. It isnullfor a group call.- The answer is
false,true, or an array whoseinfois what the other side sees of$user_id. By default that's their display name and picture:array( 'name' => …, 'avatar' => … ). - Both sides ask. The caller's page asks when placing the call, and the callee's when answering, so your callback decides for both.
- The longest prefix wins, as for channels.
The site needs a plan that includes calls; without it a call ends at once, and a page's line is refused with not_included.
The browser's side
Load the calls script on the pages that place or answer calls:
wp_enqueue_script( 'my-support', plugins_url( 'support.js', __FILE__ ), array( wordplus_realtime()->calls_script() ), '1.0.0', true );
// The page's calls, shared with the call screen and every other plugin on the page. Null without the site's settings.
const page = window.wordplusRealtimeCalls && wordplusRealtimeCalls.page();
if ( page ) {
const { calls } = page;
// …
}
page() makes the page's channels and calls once, from window.wordplusRealtimeConfig. Use it rather than wordplusRealtimeCalls.calls( rt, config ), so a page holds one line and a call rings once.
Placing a call
let call;
try {
call = await calls.start( 'support-42', { to: 34, type: 'video' } ); // type: 'video' or 'audio'
} catch ( e ) {
console.warn( e.code ); // forbidden: your callback said no; no_user; auth_failed …
}
call.on( 'state', ( state ) => showState( state ) ); // ringing, connecting, connected, reconnecting, ended
call.on( 'remote', ( remote ) => {
if ( remote.camera ) remote.camera.attach( document.querySelector( '#their-video' ) );
if ( remote.microphone ) remote.microphone.attach( document.querySelector( '#their-audio' ) );
} );
call.on( 'ended', ( reason ) => showEnded( reason ) );
const media = await navigator.mediaDevices.getUserMedia( { audio: true, video: true } );
call.setTrack( 'microphone', media.getAudioTracks()[ 0 ] );
call.setTrack( 'camera', media.getVideoTracks()[ 0 ] );
Starting the call makes it ring on every page of user 34 that holds their line. It rings for 45 seconds, then ends with no_answer.
Ringing and answering
Every page that loads the calls script for a signed-in user holds that user's line, so their calls ring there:
calls.on( 'incoming', ( incoming ) => {
// incoming.call: the call's name; incoming.from: the caller's id; incoming.info: what your callback gave for them;
// incoming.type: 'video' or 'audio'.
showRing( incoming );
incoming.on( 'over', ( reason ) => hideRing() ); // answered_elsewhere, rejected, busy, cancelled, no_answer
} );
// The person answers:
const call = await incoming.accept(); // the call, connecting; rejects with `forbidden` when your callback says no
// Or turns it down:
await incoming.decline(); // the caller hears `rejected`; decline( 'busy' ) and they hear `busy`
- A call rings on every page of the person at once, on every device. It stops everywhere as soon as they answer or decline on one, with
over. - A page opened while a call rings rings too.
calls.incoming()lists the calls ringing now.- A page that shouldn't ring passes
ring: falsein its settings, or callscalls.stopRinging().
The call
| Member | |
|---|---|
id | this attempt at the call, made by the caller's page |
name, type, role | the call's name, video or audio, and caller or callee |
peer | { user, info }: the other person |
state | ringing (the caller, until answered), connecting, connected, reconnecting, ended |
path | direct, relay or room: what carries it now |
remote | the other side's tracks, camera, microphone, screen and screenAudio, each with attach( element ) and detach(), media (what they have switched on) and quality (excellent, good, poor, lost) |
endReason | why it ended |
setTrack( source, track ) | what this page sends from camera, microphone, screen_share or screen_share_audio; null stops sending it |
end() | hangs up, or calls it off while it still rings (cancelled) |
Events: state, path, remote, reacquire (the other side stopped hearing this page's microphone or camera: open the device again), autoplay_blocked (the browser needs a click before it plays the sound), and ended.
Mute by sending null for the microphone and the track again to unmute. Turn the camera off the same way, and stop the track so the camera's light goes off. Share a screen with getDisplayMedia(), sending its video as screen_share and its sound as screen_share_audio. Each side's sources reach the other as they are, so a screen share arrives as remote.screen beside the camera.
Why a call ended
| Reason | |
|---|---|
hangup | a side hung up |
cancelled | the caller hung up before an answer |
rejected, busy | the callee declined, or said they were busy |
no_answer | nobody answered within 45 seconds |
page_closed | a side closed its page |
peer_lost | a side's connection didn't come back within 45 seconds |
room_lost | the room it moved to gave up |
not_included, forbidden, bad_token, expired | the server refused to start it: the plan doesn't include calls, or the token wasn't one it takes (errors) |
no_call, call_ended | answered after the caller had given up |
signaling_unavailable | the page's connection was down when it started |
What WordPlus Cloud sees
- Your site decides every call: the server takes only tokens your site signed, for the people and the call it named, with a key derived from its secret.
- The two browsers' negotiation is sealed with a key your site derives from its own WordPress salt, which WordPlus Cloud never sees. The servers relay and store it encrypted, so they can't read the call's addresses or codecs.
- The media is encrypted end to end by WebRTC on a direct or relayed path. In a room, the media server forwards it between the two.
Visitors
A visitor calls, and is called, once your plugin gives them an id: a negative number, which can't be a WordPress user's.
add_filter( 'wordplus_realtime_visitor_id', function ( $id ) {
$guest = my_plugin_guest_id(); // your own, kept in a cookie
return $guest ? -1 * $guest : $id;
} );
How it works
- Lines: each page holds its user's line on the shared connection, with a token the site signs (
POST /wp-json/wordplus/v1/realtime/callswith{ line: true }). - Placing: the caller's page asks the site for its token (
{ call, id, role: 'caller', to, type }). Its first join makes the call and rings the callee's lines. - Answering: the callee's page asks for its own (
{ call, id, role: 'callee', from, type }) and joins. The server lets in only the person the caller rang, for the call it rang about. - The engine is the one-to-one call engine Better Messages runs: it picks the best video codec both sides handle in hardware, restarts ICE on a network change, tries TURN twice before the room, and keeps the call through a reconnect of up to 45 seconds.