Daily coverage of WWDC21.
A Swift by Sundell spin-off.

Using SharePlay to create a custom shared experience over FaceTime

Published at 21:10 GMT, 10 Jun 2021
Written by: Gui Rambo

One of my favorite announcements from WWDC21 is the new SharePlay feature, which enables users to share what they’re doing during FaceTime calls with one or more people. The most common use case for such a feature is to consume media, such as movies or music, with friends — but Apple’s SharePlay API goes way beyond that, enabling developers to offer completely custom, shared in-app experiences.

In this article, I’ll show you how I built the example app that you can see in the video above, which lets users read WWDC By Sundell & Friends together. When the app is launched, it displays a list of articles from the website. After SharePlay is initiated, any article that’s selected will then be opened automatically for all users that are participating in the call. It’s a very simple demo, but it demonstrates just how easily a custom experience can be created on top of SharePlay.

Initiating a group activity

The first step in creating a custom SharePlay experience is defining an activity, which consists of implementing the GroupActivity protocol. Here’s an example of the activity that I have created for this demo app:

struct ReadWWDCBySundellAndFriends: GroupActivity {
    var metadata: GroupActivityMetadata {
        var meta = GroupActivityMetadata()
        
        meta.title = NSLocalizedString("Read WWDC by Sundell & Friends", comment: "")
        meta.type = .generic
        
        return meta
    }
}

The group activity’s metadata property describes what the activity is about. Setting its type to .generic is crucial in this example, since we’re going to transfer completely custom data through the SharePlay protocol, not just standard media sync commands for audio or video playback.

Now that we have an activity defined, we have to activate it in order for it to become available to everyone in the current FaceTime call. That can be done very easily — just call the activate() method on your activity:

ReadWWDCBySundellAndFriends().activate()

This should always be done as the result of some form of user action, such as tapping a button. In my example, I’m calling this when the user taps the little blue button at the bottom of the list.

As you might imagine, syncing activities like this between multiple participants of a FaceTime call is (ironically) a highly asynchronous task. Everything that happens in a group activity will happen within a session, and we can be notified when a new session has become available by calling our activity’s sessions() method using the new await keyword:

for await session in ReadWWDCBySundellAndFriends.sessions() {
    configureGroupSession(session)
}

To await the sessions in the context of a SwiftUI view, you can use the new task modifier, which starts the enclosed asynchronous tasks when the view appears, and automatically cancels them when the view goes away.

Receiving messages

When we receive a session, we then have to configure it, join it, and also create a GroupSessionMessenger, which we’ll be using in order to send and receive messages during the FaceTime call:

var tasks = Set<Task.Handle<(), Never>>()

var messenger: GroupSessionMessenger?

var groupSession: GroupSession<ReadWWDCBySundellAndFriends>?

func configureGroupSession(_ session: GroupSession<ReadWWDCBySundellAndFriends>) {
    groupSession = session
    
    session.join()
    
    messenger = GroupSessionMessenger(session: session)

    configureMessenger()
}

Here, the session is being stored, the join() method is being called in order to activate the session in the current user’s context, and then a messenger is being created. The messenger is what’s going to enable us to send and receive our custom message payloads. A message payload can be anything that conforms to the Codable protocol, but it must be as small as possible, in order to preserve bandwidth and to ensure that messages can be delivered in a timely fashion.

In my example, the message is very simple — it just has an id for the message itself, and an articleID property for the article that’s currently selected:

struct ChooseArticleMessage: Codable {
    let id: UUID
    let articleID: String?
}

To determine what should happen when a message of a given type is received, we can await the messenger, which has a method called messages(of:) that receives the type of message that we’d like to handle. To make it easier to bridge the synchronous and asynchronous worlds, as well as being able to easily cancel tasks at a later time, we can use detach:

var tasks = Set<Task.Handle<(), Never>>()

func configureMessenger() {
    let articleTask = detach { [weak self] in
        guard let messenger = await self?.messenger else { return }
        
        for await (message, _) in messenger.messages(of: ChooseArticleMessage.self) {
            await self?.handle(message)
        }
    }
    
    tasks.insert(articleTask)
}

Here, we’re configuring a task to encapsulate the process of awaiting the ChooseArticleMessage values that we might receive from other participants in the group activity, then we’re storing that task in the tasks set, to ensure that it sticks around in order to handle incoming messages, as well as to be able to cancel it later if needed.

The implementation of the handle method will depend on each app’s individual features, but in my example, I’m simply storing the articleID from any received message in an @Published property that’s defined within my class:

@Published var selectedArticleID: String?

func handle(_ message: ChooseArticleMessage) {
    selectedArticleID = message.articleID
}

Sending messages

Sending messages is quite easy as well, the messenger has a send() method that enables us to do just that:

async {
    do {
        try await messenger?.send(ChooseArticleMessage(id: UUID(), articleID: selectedArticleID))
    } catch {
        print("Send failed: \(error)")
    }
}

It’s important to be careful not to send too many messages in quick succession, since that may cause the app’s messages to be rate limited by the FaceTime service.

Conclusion

So that’s how you can create a completely custom, shared experience powered by SharePlay. It’s a lot easier than I thought it would be when I first saw it during the keynote, and I have quite a few ideas of how I could use this new API within my apps.

There are of course more features that you’d have to implement in order to make the experience better for your users. For instance, there are APIs that enable you to know whether the user is currently able to start a group activity, so that you can hide the button from your user interface unless the user is in a FaceTime call. To learn more about this subject, check out session 10187.

Have fun!

Written by: Gui Rambo
RaycastRaycast

Take the macOS Spotlight experience to the next level: Create Jira issues, manage GitHub pull requests and control other tools with a few keystrokes. Automate every-day tasks with scripts and boost your developer productivity by downloading Raycast for free.