Skip to main content
Markdown

Calls

Calls between two people and group calls, in WordPlusRealtimeCalls, on the same relay and media servers as browsers: a phone and a browser call each other, and either may add someone. The Better Messages call screen, in WordPlusRealtimeUI, draws them with no screen code of yours. Both products are optional.

  • Calls between two people ring on every page and app the callee has open, and run directly between the two, through TURN when a network needs it, or in a room on the WordPlus Cloud media servers when nothing else works.
  • Group calls run on the WordPlus Cloud media servers, with no limit on people in a call. Up to 32 people on video at once for each 1,000 connections take no credits, a person on audio counting half, and each one above takes a credit a minute from your account.
  • Encrypted end to end, with keys only your server gives out (what WordPlus Cloud sees).
  • Calls ring while the app is open. Ringing a closed app needs push, which this SDK leaves for later.
  • User ids are integers in this version, as in the relay. The SDK takes them as strings ("34").

Who may call​

Your server declares calls by pattern, as it declares rooms (calls in depth):

realtime.calls( 'support:{a}:{b}', ( user, withUser, { a, b } ) => [ a, b ].map( Number ).includes( user ) && [ a, b ].map( Number ).includes( withUser ) );
realtime.groupCalls( 'meeting:{id}', ( user ) => isStaff( user ), { encrypt: true } );

The app asks your calls route ({endpoint}/calls) with its headers, as the browser does.

The call screen​

import WordPlusRealtimeCalls
import WordPlusRealtimeUI

CallScreen.shared.attach(rt.calls) // rings for every call coming in, and shows it once answered
CallScreen.shared.onAnswered = { answered in
switch answered {
case let .call(call): follow(call) // the call a ring became
case let .group(group): follow(group) // or the group call it rang into
}
}

let call = try await rt.calls.start("support:34:42", to: "34", type: .video)
CallScreen.shared.show(call, name: "Ann", avatar: "https://shop.example.com/ann.jpg")

let meeting = try await rt.calls.group("meeting:daily", type: .video)
CallScreen.shared.show(meeting, title: "Daily meeting")
  • It is the Better Messages screen itself, the same files as its app: the ring with Answer and Decline, the call between two with the picture of the other side and your own in the corner, the grid of a group call, and the controls (microphone, camera, turning the camera round, the speaker, picture in picture, hanging up).
  • It sits in a window of its own above the app. Its chevron puts it away and the call goes on: show a bar of your own meanwhile, and CallScreen.shared.restore() brings it back. hide() puts it away from your code.
  • A call ringing during another is answered busy.
  • A call that becomes a group call stays on the screen, as the group call.
  • Its words and colours: configure(.init(strings: [...], tokens: [...], tokensDark: [...], darkMode: false)), the strings by the Better Messages keys (answer, reject, end_call, calling, connecting …), the colours by its design tokens ("--bm-color-accent": "#21759b"). The English is the default for any string left out.
  • CallScreen.shared.log hands you the lines the screen writes, for your own log.

Calls of your own​

The same calls, for screens you draw:

rt.calls.onIncoming { incoming in
// incoming.from, incoming.info ["name", "avatar"], incoming.type, incoming.group (a ring into a group call)
incoming.onOver { reason in hideRing() } // answered elsewhere, the caller gave up …
Task { @MainActor in
switch try await incoming.accept() { // or try await incoming.decline(busy: true)
case let .call(call): show(call)
case let .group(group): show(group)
}
}
}

let call = try await rt.calls.start("support:34:42", to: "34", type: .video)
call.onState { state in label.text = state.rawValue } // ringing, connecting, connected, reconnecting, ended
call.onRemote { remoteView.show(call.remoteCamera) } // what the other side sends changed
call.onEnded { reason in close(reason) } // hangup, declined, busy, cancelled, group …

let remoteView = CallVideoView() // a UIView
let ownView = CallVideoView()
ownView.mirrored = true
ownView.show(call.localCamera)

call.microphone = false // mute
call.camera = false // stop sending the camera
call.flipCamera() // the front one to the back, and back
call.end() // hangs up, or calls it off while it rings
  • The microphone and, for a video call, the camera open as the call begins, so iOS asks the user then (shipping).
  • call.path says what carries it: .direct, .relay (TURN) or .room. call.peer and call.peerInfo who the other side is.
  • rt.calls.stopRinging() lets go of this app line: its user calls no longer ring here.

Adding people​

Once a call is up, either side may ring someone else into it. Both sides go on in a group call of their own, without hanging up:

call.onGroup { group in follow(group) }                // both sides, and the call then ends as `group`
let invite = try await call.add("43") // rings user 43, throws its code
  • Your rule decides, as for any call: the user calling the person about the call name, then the person taking a call from them.
  • The person rung in gets an IncomingCall whose group is set, and accept() answers .group.
  • group.add(_:) rings more people in, from anyone in it. A group call your server names (rt.calls.group) takes nobody that way: not_a_call.

Group calls​

let group = try await rt.calls.group("meeting:daily", type: .video)
group.microphone = true // nothing is sent into a group call until asked
group.camera = true
group.onPeople { people = group.people } // [GroupPerson]: user, info, camera, microphone, speaking
group.onState { state in label.text = state.rawValue }
group.onEnded { reason in close(reason) }
group.leave()
  • group.encrypted says whether its media is encrypted end to end: always for the group call that comes from a call, and for one your server names when its rule says encrypt.
  • In this version a group call video is drawn by the call screen. Drawing it in screens of your own comes later. Audio, people and every control work without it.

Why a call ended​

call.endReason, and the reason onEnded gets: the same codes as the browser (why a call ended), plus connection_failed when no path connected. A call that could not start throws a CallError with the code: forbidden, no_user, not_included, auth_failed (errors).