Skip to main content
Markdown

Calls between two people

A call rings on every page the other person has open, and runs straight between the two browsers once answered:

  1. Direct: the two browsers connect to each other, which is the best picture and sound, at no cost.
  2. 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.
  3. 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_id places or answers the call: the signed-in user, or a visitor's id (visitors).
  • $call is the call's name, such as support-42, which your plugin chooses. Names follow the channels' rules: up to 200 of A-Z a-z 0-9 _ - = @ , . ;.
  • $with is the other person: the one being called when $user_id places the call, the caller when $user_id answers. It is null for a group call.
  • The answer is false, true, or an array whose info is 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: false in its settings, or calls calls.stopRinging().

The call​

Member
idthis attempt at the call, made by the caller's page
name, type, rolethe call's name, video or audio, and caller or callee
peer{ user, info }: the other person
stateringing (the caller, until answered), connecting, connected, reconnecting, ended
pathdirect, relay or room: what carries it now
remotethe 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)
endReasonwhy 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
hangupa side hung up
cancelledthe caller hung up before an answer
rejected, busythe callee declined, or said they were busy
no_answernobody answered within 45 seconds
page_closeda side closed its page
peer_losta side's connection didn't come back within 45 seconds
room_lostthe room it moved to gave up
not_included, forbidden, bad_token, expiredthe server refused to start it: the plan doesn't include calls, or the token wasn't one it takes (errors)
no_call, call_endedanswered after the caller had given up
signaling_unavailablethe 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/calls with { 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.