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.
- 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.
- 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).
- 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). - 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). - 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 |
---|---|---|---|---|
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 whenSharePlayApi.isInGroupSession
changes fromfalse
totrue
.SharePlayEndedEvent
is emitted whenSharePlayApi.isInGroupSession
changes fromtrue
tofalse
.SharePlaySuspensionStartedEvent
is emitted when a suspension started. In this case the player transitions intoSharePlayApi.isSuspended
istrue
, if it was not already set totrue
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 returntrue
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 returnfalse
.
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:
- Group playback is paused until the stalling participant has recovered from the stall. Then, the whole group resumes playback in sync.
- 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
Updated 9 months ago