Watch content together with SharePlay

Learn how to build a SharePlay experience with Bitmovin Player iOS SDK.

Introduction to SharePlay

With SharePlay you can enjoy shows, movies, and music in sync with friends and family while being on a FaceTime call together. During a SharePlay session, playback is kept in sync across multiple devices and each participant is allowed to control playback.

SharePlay is supported starting with Bitmovin Player iOS SDK 3.31.0 on iOS / tvOS 15.0+. Earlier iOS / tvOS versions do not contain the GroupActivities framework that is required for SharePlay.

Enable SharePlay with the Bitmovin Player iOS SDK

To enable the SharePlay experience in your own app using Bitmovin Player iOS SDK, follow the steps below. Make sure to use Bitmovin Player iOS SDK 3.31.0 or later. This guide is based on the SharePlay sample application that is published in our samples repository. The sample application and this guide are kept simple and straightforward for demonstration purposes. It can be adapted and extended to fit individual use cases and application needs.

Before getting started, make sure the "Group Activities" capability is added in the project settings under "Signing & Capabilities".

Create an Activity

To define a shareable group watching experience, the sample contains the MediaWatchingActivity class that adopts the GroupActivity protocol. The activity stores the asset to share with the group and provides supporting metadata that the system displays when a user shares an activity. GroupActivity extends Codable, so any data that an activity stores must also conform to Codable.

class MediaWatchingActivity: GroupActivity {
    // The movie to watch.
    let asset: Asset
    let identifier: String

    init(asset: Asset) {
        self.asset = asset
        self.identifier = UUID().uuidString
    }

    // Metadata that the system displays to participants.
    var metadata: GroupActivityMetadata {
        var metadata = GroupActivityMetadata()
        metadata.type = .watchTogether
        metadata.title = asset.title
        metadata.supportsContinuationOnTV = true

        return metadata
    }
}

struct Asset: Codable {
    let url: URL
    let posterUrl: URL
    let title: String
    let customSharePlayIdentifier: String?
}

GroupSession Management

The CoordinationManager takes care of sharing an activity with the group if one of the assets were selected for playback. In this process, a GroupSession instance is created by the system and provided to every participant. This GroupSession is a central part in the group watching experience. Later, it also needs to be passed to the Bitmovin Player instance in order to allow coordinated playback.

When a user selects a movie, in CoordinationManager.prepareToPlay(asset:) we first check if there is already a GroupSession available. If that's the case, we check if the selected asset is maybe already the current activity of the group. In that case, we do not need to update groupSession.activity and just have to make sure that the selected asset is loaded into the local player.

If the selected asset is different from the current activity of the GroupSession, we simply set a new activity for every participant in the group: groupSession.activity = MediaWatchingActivity(asset: asset).

if let groupSession = groupSession, groupSession.state == .joined {
    if asset.customSharePlayIdentifier == groupSession.activity.asset.customSharePlayIdentifier,
       asset.url == groupSession.activity.asset.url {
        // Do not change the current activity of the group in case the 
        // same asset is selected again. In this case, we just set it 
        // locally for us, so that the PlaybackViewController is presented.
        self.asset = asset
    } else {
        // Create a new activity based on the selected asset so that every
        // participant in the group can react to that change.
        groupSession.activity = MediaWatchingActivity(asset: asset)
    }
    return
}

When there is no GroupSession available yet and the local user selects an asset to play back, we need to determine whether it needs to play the movie for the local user only, or share it with the group. It makes this determination by calling the activity’s asynchronous prepareForActivation() method, which enables the system to present an interface for the user to select their preferred action.

Task {
    // Create a new activity for the selected movie.
    let activity = MediaWatchingActivity(asset: asset)

    // Await the result of the preparation call.
    switch await activity.prepareForActivation() {
    case .activationDisabled:
        // Playback coordination isn't active, or the user prefers to play
        // the movie apart from the group. Enqueue the movie for local 
        // playback only.
        self.asset = activity.asset
    case .activationPreferred:
        // The user prefers to share this activity with the group.
        // The app enqueues the movie for playback when the activity starts.
        do {
            _ = try await activity.activate()
        } catch {
            print("Unable to activate the activity: \(error)")
        }
    case .cancelled:
        // The user cancels the operation. So we do nothing.
        break
    default:
        break
    }
}

When a MovieWatchingActivity is activated, the system creates a group session. CoordinationManager accesses the session by calling the sessions() method, which returns available sessions as an asynchronous sequence. When the sample receives a new session, it sets it as the active group session, and then joins it, which makes the app eligible to participate in group watching. Then, it subscribes to the session’s activity publisher and, when it receives a new value, it finally enqueues the activity’s movie for playback.

// Await new sessions to watch movies together.
for await groupSession in MediaWatchingActivity.sessions() {
    // Set the app's active group session.
    self.groupSession = groupSession
    cancellables.removeAll()

    // Observe changes to the session state.
    groupSession.$state.sink { [weak self] state in
        if case .invalidated = state {
            self?.groupSession = nil
            self?.cancellables.removeAll()
        }
    }
    .store(in: &cancellables)

    // Join the session to participate in playback coordination.
    groupSession.join()
    // Observe when the local user or a remote participant starts an activity.
    groupSession.$activity
        .removeDuplicates {
            $0.identifier == $1.identifier
        }
        .sink { [weak self] activity in
            // Set the movie to enqueue it in the player.
            self?.asset = activity.asset
        }
        .store(in: &cancellables)
}

Coordinated Playback

In AssetsTableViewController we listen to changes to the selected asset of CoordinationManger. As soon as we receive a new asset, the PlaybackViewController is presented.

CoordinationManager.shared.$asset
    .receive(on: RunLoop.main)
    .sink { [weak self] asset in
        guard let self = self else { return }

        self.navigationController?.popToRootViewController(animated: true)

        guard let asset = asset else { return }

        self.presentPlaybackViewController(for: asset)
    }
    .store(in: &cancellables)

The PlaybackViewController, when setting up the Bitmovin Player instance, checks if there is an active GroupSession available and coordinates the player with the session. This step is important as otherwise only local non-synchronized playback would happen. The player instance would not know about any GroupSession's.

func setupPlayer() -> Player {
    let player = PlayerFactory.createPlayer()
    player.add(listener: self)

    // Check if there is a group session to coordinate playback with.
    if let groupSession = viewModel.groupSession {
        // Coordinate playback with the active session.
        player.sharePlay.coordinate(with: groupSession)
    }

    return player
}

When the player instance is prepared, we can create a SourceConfig based on the selected asset and load it into the player.

player = setupPlayer()
setupPlayerView(with: player)

guard let sourceConfig = SourceConfig.create(from: viewModel.asset) else {
    print("Could not create asset")
    return
}

player.load(sourceConfig: sourceConfig)

Every participant of the GroupSession now has a player presented with the selected asset loaded into it. When one of the participants hits the play button, synchronized playback starts for the whole group.

Suspensions

Suspensions can be used to prevent local interruptions of one participant from impacting other participants in the same GroupSession. For example, if the local participant wants to answer an incoming phone call, local playback needs to be paused without pausing the whole group.
Suspensions disconnect the local participant from the group temporarily.

class PlaybackViewController: UIViewController {
    private var currentSuspension: SharePlaySuspension?

    func startSuspension() {
        currentSuspension = player.sharePlay.beginSuspension(
            for: .userActionRequired
        )
    }

    func endSusepension() {
        guard let suspension = currentSuspension else { return }
        player.sharePlay.endSuspension(suspension)
    }
}

For more details on the APIs Bitmovin Player offers regarding suspensions, see chapters Bitmovin Player SharePlay APIs and Suspension Handling.

SharePlay Related Events

There are a couple of SharePlay related events that Bitmovin Player offers. They are available in the PlayerListener protocol alongside all previously existing player events. For more information on those events, have a look at the Events section.

extension PlaybackViewController: PlayerListener {
    func onSharePlayStarted(_ event: SharePlayStartedEvent, player: Player) {
        print("Player started to participate in group session")
    }

    func onSharePlayEnded(_ event: SharePlayEndedEvent, player: Player) {
        print("Player stopped to participate in group session")
    }

    func onSharePlaySuspensionStarted(_ event: SharePlaySuspensionStartedEvent, player: Player) {
        print("Suspension started")
    }

    func onSharePlaySuspensionEnded(_ event: SharePlaySuspensionEndedEvent, player: Player) {
        print("Suspension ended")
    }
}

Starting a Shared Experience

Given that there is now a SharePlay enabled app ready to use, the following steps are necessary to start or join a shared experience. They are again based on our SharePlay sample app, but at the same time apply to every SharePlay enabled app in general.

iOS / iPadOS Devices

You will need at least two devices that have the SharePlay sample app installed.

  1. Join the same FaceTime call with each device that should be part of the shared experience. For simplicity, this guide will assume two devices, A and B.
  2. On one of the devices, let's assume A, open the SharePlay enabled app. The system will notify us that we are eligible to use SharePlay by showing the hint "Choose Content to Use SharePlay" (screenshot 1).
  3. Tap one of the assets from the list and a pop-up appears, asking if we want to initiate a SharePlay GroupSession or if the content should be played only locally. Choose "SharePlay" (screenshot 2) and the video player should appear, ready to start playback (screenshot 3).
  4. On device B, the user is asked to join the SharePlay GroupSession that was just started on device A. Tap "Join SharePlay" to join the shared experience (screenshot 4).
  5. Now, playback can be initiated on any device by tapping the play button. Playback starts perfectly in sync on both devices (screenshot 5). Also, when the playback time is changed on one of the devices by seeking to a certain position, both devices jump to the new playback time and continue playback in sync.
Screenshot 1:
Choose Content
Screenshot 2:
Start SharePlay
Screenshot 3:
SharePlay Started
Screenshot 4:
Join SharePlay
Screenshot 5:
Playback Started
Device A Choose ContentDevice A Start Shareplaydevice A Started ShareplayDevice B Join ShareplayDevice A Started Playback

tvOS Devices

To use SharePlay from your Apple TV, follow the steps from this guide: Use SharePlay to watch movies and TV shows together on your Apple TV.

Bitmovin Player SharePlay APIs

sharePlay Player API Namespace

For Bitmovin Player, all SharePlay related API calls reside in their own player API namespace named sharePlay. It is available on iOS and tvOS 15.0 and above and can only be used from Swift. When the sharePlay namespace is accessed from Obj-C, it does not contain any APIs. This is due to the limitation of Apple's Swift-only GroupActivities framework.

Joining a GroupSession

func coordinate<T>(with groupSession: GroupSession<T>) where T: GroupActivity

To let a player instance join a group session, the GroupSession object simply needs to be passed to coordinate(with groupSession:). The player will then react to state changes to the group session and also start receiving and sending playback commands through it.

Obtaining a GroupSession instance is not concern of the Bitmovin Player SDK and needs to be done and handled in the app that is integrating the player. GroupSession is part of Apple's GroupActivities framework.

Leaving a GroupSession

There is no specific API on the Bitmovin Player for this. The app that is integrating the player is responsible for managing the GroupSession object's life-cycle. To let a player leave a group session, GroupSession.leave() or GroupSession.end() can be used. The player instance will react to state changes of the group session instance accordingly.

Beginning a Suspension

func beginSuspension(
    for suspensionReason: SharePlaySuspension.Reason
) -> SharePlaySuspension

A suspension is used to tell the player that it cannot, or should not, participate in coordinated group playback temporarily. Once a suspension is started, the player will not respond to playback commands coming from the group, and it will also not coordinate any commands with the group.

To resume synchronized group playback, end an active suspension by calling one of the endSuspension(_:) methods available on the sharePlay API namespace.

It is possible to start multiple suspensions for the same participant. player.sharePlay.suspensionReasons always returns the full list of all active suspensions. Only if all active suspensions are ended, the participant joins synchronized group playback again.

The suspensionReason indicates the reason for the suspension that is shared with other participants. Apple provides predefined suspension reasons, which can be used for common use cases (AVCoordinatedPlaybackSuspensionReason). However, also custom user-defined suspension reasons can be used.

Ending a Suspension

/// Ends the suspension.
func endSuspension(_ suspension: SharePlaySuspension)

/// Ends the suspension and proposes a new time that everyone should seek to.
func endSuspension(
    _ suspension: SharePlaySuspension,
    proposingNewTime newTime: TimeInterval
)

Use the above methods to end an active suspension and join group playback again. When ending a suspension, a new group playback time can be proposed optionally.

After ending a suspension, the player will receive the current group playback state and applies it locally so that the unsuspended participant is in-sync again. If a new playback time was proposed when ending a suspension, the rest of the group will be brought in sync accordingly.

Retrieving SharePlay State

/// Describes whether the player is currently in group playback.
/// - Returns: `true` when the `Player` is within a Group Session.
var isInGroupSession: Bool { get }

/// Describes whether the player is currently suspended and not able to participate in group playback.
/// - Returns: `true` when the participant does not react to any changes in group playback.
var isSuspended: Bool { get }

/// Describes why the player is currently not able to participate in group playback.
var suspensionReasons: [SharePlaySuspension.Reason] { get }

If the player is coordinated with a valid session, isInGroupSession returns true. A valid session is a GroupSession that is either in state .waiting or .joined. An invalid session is a GroupSession that is in state .invalidated. In this case, isInGroupSession returns false.

If the player is coordinated with a valid session (isInGroupSession returns true) and the player is currently suspended, the list of suspension reasons contains at least one entry. In this case, isSuspended returns true.

suspensionReasons always contains the current list of reasons why the player is suspended. If it is empty, the player is not suspended, and isSuspended returns false.

Configuration for Source Identifier

All participants in a group session need to have the same source loaded into their local player instance in order to allow synchronized playback. By default, sourceConfig.url.absoluteString is used as the asset identifier that is communicated to the group session. To allow use cases where participants in the same group session use different URLs for the same media content (for instance, there might be an access token or user ID encoded into the source URL for some participants) SourceOptions.sharePlayIdentifier can be used. If SourceOptions.sharePlayIdentifier is set, it is used instead of sourceConfig.url.absoluteString to identify an asset within a group session.

Events

  • SharePlayStartedEvent is emitted when SharePlayApi.isInGroupSession changes from false to true .
  • SharePlayEndedEvent is emitted when SharePlayApi.isInGroupSession changes from true to false.
  • SharePlaySuspensionStartedEvent is emitted when a suspension started. In this case the player transitions into SharePlayApi.isSuspended is true, if it was not already set to true by a previous suspension that has not yet ended.
  • SharePlaySuspensionEndedEvent is emitted when a specific suspension ended. After seeing this event, SharePlayApi.isSuspended can still return true in the case there is still another suspension that is ongoing. When all ongoing suspensions have ended (i.e. suspensionReasons is empty), SharePlayApi.isSuspended will return false.

Advanced SharePlay topics

The following chapters provide a technical deep dive into certain SharePlay topics for those who are interested.

Stall Recovery

During a SharePlay session, it is possible that one of the participants experiences bad network conditions, which prevents the participant from staying in sync with the group due to a playback stall. In general, there are two ways to deal with this:

  1. Group playback is paused until the stalling participant has recovered from the stall. Then, the whole group resumes playback in sync.
  2. Group playback is not interrupted and the stalling participant is suspended. While being suspended, the device tries to catch-up to the group playback time again. Once the device has caught-up to the group playback time, the recovered participant is unsuspended and re-joins group playback.

The first approach has the advantage that the stalling participant does not miss out on any watched content. However, the rest of the group has to wait for the stalling participant to be ready again, which is not ideal if it happens too often. This approach is probably only suitable for scenarios where a small group watches VOD content together from home, where the network is expected to be stable and network stalls only happen very rarely.

The second approach offers the better user experience in general. Only the stalling participant is affected by the stall. The downside is that the stalling participant might miss out on content that was watched by the group while being suspended. This approach is suitable for almost every scenario. When watching content at home under good network conditions, stalls are very unlikely and therefore missing out on content is not a problem. In larger groups, especially if one of the participants joins from a mobile network, group playback is not affected by stalls. Especially when watching live content it is important to stay on the live edge and not miss any content, which makes this approach suitable in this case as well.

Bitmovin Player implements the second approach. When a network stall is detected, the stalling participant is suspended. In this case, a SharePlaySuspensionStartedEvent is emitted by the player. The event contains a suspension object of type SharePlaySuspension with reason .stallRecovery. The stall recovery process itself happens automatically inside the player and the integrating app does not need to handle anything on its own. Once stall recovery is successful, a SharePlaySuspensionEndedEvent is emitted by the player and the stalling participant re-joins group playback again.

Suspension Handling

As described in Bitmovin Player SharePlay APIs, the player.sharePlay namespace offers APIs to start a suspension, end a suspension, check whether the local participant is suspended, and get a list of currently active suspensions.

To use suspensions, the AVFoundation framework offers a set of predefined suspension reasons as shown below. All of those reasons could be used in an app to start a suspension.

extension AVCoordinatedPlaybackSuspension.Reason {
    /// The participant's audio session was interrupted.
    public static let audioSessionInterrupted: AVCoordinatedPlaybackSuspension.Reason

    /// The player is buffering data after a stall.
    public static let stallRecovery: AVCoordinatedPlaybackSuspension.Reason

    /// The participant is presented with interstitial content instead of the main player.
    public static let playingInterstitial: AVCoordinatedPlaybackSuspension.Reason

    /// The participant cannot participate in coordinated playback.
    public static let coordinatedPlaybackNotPossible: AVCoordinatedPlaybackSuspension.Reason

    /// The participant's playback object is in a state that requires manual intervention 
    /// by the user to resume playback.
    public static let userActionRequired: AVCoordinatedPlaybackSuspension.Reason

    /// The participant is actively changing current time.
    @available(iOS 15.0, *)
    public static let userIsChangingCurrentTime: AVCoordinatedPlaybackSuspension.Reason
}

Additionally, if an app wants to start a suspension and none of the predefined system suspensions is suitable, a custom suspension reason can be created.

let reason = SharePlaySuspension.Reason("custom")

Scrubbing Suspension

In the chapter Stall Recovery, we describe how the Bitmovin Player uses the .stallRecovery suspension reason to handle playback stalls that one of the participants might experience.

Another important suspension reason is .userIsChangingCurrentTime. Imagine that one of the participants starts scrubbing around, trying to find a certain scene within a movie. During this scrubbing process, the local playback time is changed. However, it is not desired to update the group playback state while scrubbing. Only the final playback position of the scrubbing operation should be coordinated with the group.

To implement such a scenario, the player UI which is in charge of the scrubbing, should suspend the local participant using the .userIsChangingCurrentTime reason and only end the suspension after scrubbing has finished. Please note, that a new playback time should be proposed when ending the suspension. Otherwise, the unsuspended participant would jump back to the group playback position.

let newPlaybackTimeAfterScrubbing = 120.0
player.sharePlay.endSuspension(
    suspension, 
    proposingNewTime: newPlaybackTimeAfterScrubbing
)

Limitations

With Bitmovin Player iOS SDK 3.31.0, we released support for SharePlay for the first time. It provides a stable SharePlay experience, covering everything needed to get started with this exciting new feature. However, with this initial release there are still some technical limitations and missing features which we plan to tackle with future releases:

  • Trick play (slow/fast-forward and rewind) is not supported
  • Synchronized ad playback and ad break management is not supported
  • Casting is not supported
  • Playlists are not supported
  • System UI is not supported
  • AirPlay and Picture in Picture (PiP) are not fully supported. Playback changes done with the AirPlay receiver or PiP mini player are not synchronized with the group. Playback changes done on the AirPlay sender device are working as expected.

Resources