In this tutorial, you'll learn how to create an SFU connection to stream video and audio in a conference.
Prerequisites
This tutorial requires the Handle Media app that you created earlier.
Create an Upstream Connection
To send audio and video data, create two streams: one for audio data and one for video data. These streams are represented by the AudioStream and VideoStream classes. To create a uni-directional upstream, we create an instance of each of these classes, and provide only the LocalMedia instance to make it a send-only stream.
To establish an SFU upstream connection:
Invoke the CreateSfuUpstreamConnection method from the Channel instance, and specify the send-only audio and video streams. This returns an SfuUpstreamConnection instance that's set up to send but doesn't receive any data.
Invoke the Open method of the SfuUpstreamConnection instance to establish an upstream connection. This returns a promise that verifies if the upstream connection is established successfully.
Paste the following code into the HelloWorldLogic class.
private SfuUpstreamConnection _UpstreamConnection;
// Creating a SFU upstream connection
private SfuUpstreamConnection OpenSfuUpstreamConnection(LocalMedia localMedia)
{
AudioStream audioStream = (localMedia.AudioTrack != null) ? new AudioStream(localMedia) : null;
VideoStream videoStream = (localMedia.VideoTrack != null) ? new VideoStream(localMedia) : null;
SfuUpstreamConnection connection = Channel.CreateSfuUpstreamConnection(audioStream, videoStream);
connection.OnStateChange += (conn) =>
{
Log.Info(string.Format("Upstream connection {0} is currently in a {1} state.", conn.Id, conn.State.ToString()));
if (conn.State == ConnectionState.Closing || conn.State == ConnectionState.Failing)
{
if (conn.RemoteClosed)
{
Log.Info(string.Format("Upstream connection {0} was closed,", conn.Id));
}
}
else if (conn.State == ConnectionState.Failed)
{
OpenSfuUpstreamConnection(localMedia);
}
};
connection.Open();
return connection;
}
private SfuUpstreamConnection upstreamConnection;
private SfuUpstreamConnection openSfuUpstreamConnection(LocalMedia localMedia) {
// Create audio and video streams from local media.
AudioStream audioStream = (localMedia.getAudioTrack() != null) ? new AudioStream(localMedia) : null;
VideoStream videoStream = (localMedia.getVideoTrack() != null) ? new VideoStream(localMedia) : null;
// Create a SFU upstream connection with local audio and video.
SfuUpstreamConnection connection = channel.createSfuUpstreamConnection(audioStream, videoStream);
connection.addOnStateChange((ManagedConnection conn) -> {
Log.info(String.format("Upstream connection %s is in a %s state.", conn.getId(), conn.getState().toString()));
if (conn.getState() == ConnectionState.Closing || conn.getState() == ConnectionState.Failing) {
if (conn.getRemoteClosed()) {
Log.info(String.format("Media server has closed the upstream connection %s.", conn.getId()));
}
} else if (connection.getState() == ConnectionState.Failed) {
// Reconnect if the connection failed.
openSfuUpstreamConnection(localMedia);
}
});
connection.open();
return connection;
}
// SFU connections
var _upstreamConnection: FMLiveSwitchSfuUpstreamConnection?
func OpenSfuUpstreamConnection(localMedia: LocalMedia) -> FMLiveSwitchSfuUpstreamConnection {
var connection: FMLiveSwitchSfuUpstreamConnection?
let audioStream: FMLiveSwitchAudioStream = ((localMedia.audioTrack() != nil) ? FMLiveSwitchAudioStream.init(localMedia: localMedia) : nil)!
let videoStream: FMLiveSwitchVideoStream = ((localMedia.videoTrack() != nil) ? FMLiveSwitchVideoStream.init(localMedia: localMedia) : nil)!
connection = _channel?.createSfuUpstreamConnection(with: audioStream, videoStream: videoStream)
connection?.addOnStateChange({[weak self] (obj: Any!) in
let conn = obj as! FMLiveSwitchSfuUpstreamConnection
let state = conn.state()
FMLiveSwitchLog.info(withMessage: "Upstream connection \(String(describing: conn.id()!)) is currently in a \(String(describing: FMLiveSwitchConnectionStateWrapper(value: state).description()!)).")
if (state == FMLiveSwitchConnectionState.closing || state == FMLiveSwitchConnectionState.failing) {
if (conn.remoteClosed()) {
FMLiveSwitchLog.info(withMessage: "Upstream connection \(String(describing: conn.id()!)) is closed by media server.")
}
}
// Reconnect the stream if the connection has failed
else if (state == FMLiveSwitchConnectionState.failed) {
self?.OpenSfuUpstreamConnection(localMedia: localMedia)
}
})
connection?.open()
return connection!
}
private upstreamConnection: fm.liveswitch.SfuUpstreamConnection;
private openSfuUpstreamConnection(localMedia: fm.liveswitch.LocalMedia): fm.liveswitch.SfuUpstreamConnection {
// Create audio and video streams from local media.
const audioStream = new fm.liveswitch.AudioStream(localMedia);
const videoStream = new fm.liveswitch.VideoStream(localMedia);
// Create a SFU upstream connection with local audio and video.
const connection: fm.liveswitch.SfuUpstreamConnection = this.channel.createSfuUpstreamConnection(audioStream, videoStream);
connection.addOnStateChange(conn => {
fm.liveswitch.Log.debug(`Upstream connection is ${new fm.liveswitch.ConnectionStateWrapper(conn.getState()).toString()}.`);
if (conn.getState() === fm.liveswitch.ConnectionState.Closing || conn.getState() === fm.liveswitch.ConnectionState.Failing) {
if (conn.getRemoteClosed()) {
fm.liveswitch.Log.info(`Upstream connection ${conn.getId()} was closed`);
}
} else if (conn.getState() === fm.liveswitch.ConnectionState.Failed) {
this.openSfuUpstreamConnection(localMedia);
}
});
connection.open();
return connection;
}
Create a Downstream Connection
To receive audio and video data, create an AudioStream and a VideoStream, and provide only the RemoteMedia instance to make it as receive-only streams.
To establish an SFU downstream connection:
Invoke the CreateSfuDownstreamConnection method from the Channel instance. This returns an SfuDownstreamConnection instance that only receives data.
Add the RemoteMedia instance to the layout manager by invoking the AddRemoteView method of the LayoutManager instance.
Invoke the Open method of the SfuDownstreamConnection instance to establish a downstream connection.
When a user leaves a session, we need to properly tear down the session by removing the remote view associated with them. To do so, add an OnStateChange event handler to each SfuDownstreamConnection instance. In the handler, inspect the state of the SfuDownstreamConnection instance. If the state is Closing or Failing, remove the associated remote view by invoking the RemoveRemoteView instance of the layout manager.
Paste the following code into the HelloWorldLogic class.
private Dictionary<string, SfuDownstreamConnection> _DownStreamConnections = new Dictionary<string, SfuDownstreamConnection>();
// Creating a SFU downstream connection
private SfuDownstreamConnection OpenSfuDownStreamConnection(ConnectionInfo remoteConnectionInfo)
{
RemoteMedia remoteMedia = null;
_Dispatcher.Invoke(() =>
{
remoteMedia = new RemoteMedia(false, false, _AecContext);
if (remoteMedia.View != null)
{
remoteMedia.View.Name = "RemoteView_" + remoteMedia.Id;
}
});
_LayoutManager.AddRemoteView(remoteMedia.Id, remoteMedia.View);
AudioStream audioStream = (remoteConnectionInfo.HasAudio) ? new AudioStream(remoteMedia) : null;
VideoStream videoStream = (remoteConnectionInfo.HasVideo) ? new VideoStream(remoteMedia) : null;
SfuDownstreamConnection connection = Channel.CreateSfuDownstreamConnection(remoteConnectionInfo, audioStream, videoStream);
_DownStreamConnections.Add(remoteMedia.Id, connection);
connection.OnStateChange += (conn) =>
{
Log.Info(string.Format("Downstream connection {0} is currently in a {1} state.", conn.Id, conn.State.ToString()));
if (conn.State == ConnectionState.Closing || conn.State == ConnectionState.Failing)
{
_LayoutManager.RemoveRemoteView(remoteMedia.Id);
_Dispatcher.Invoke(() =>
{
remoteMedia.Destroy();
});
_DownStreamConnections.Remove(remoteMedia.Id);
}
};
connection.Open();
return connection;
}
private final HashMap<String, SfuDownstreamConnection> downstreamConnections = new HashMap<>();
private SfuDownstreamConnection openSfuDownstreamConnection(final ConnectionInfo remoteConnectionInfo) {
// Create remote media.
final RemoteMedia remoteMedia = new RemoteMedia(context, false, false, aecContext);
// Adding remote view to UI.
handler.post(() -> layoutManager.addRemoteView(remoteMedia.getId(), remoteMedia.getView()));
// Create audio and video streams from remote media.
AudioStream audioStream = (remoteConnectionInfo.getHasAudio()) ? new AudioStream(remoteMedia) : null;
VideoStream videoStream = (remoteConnectionInfo.getHasVideo()) ? new VideoStream(remoteMedia) : null;
// Create a SFU downstream connection with remote audio and video and data streams.
SfuDownstreamConnection connection = channel.createSfuDownstreamConnection(remoteConnectionInfo, audioStream, videoStream);
// Store the downstream connection.
downstreamConnections.put(remoteMedia.getId(), connection);
connection.addOnStateChange((ManagedConnection conn) -> {
Log.info(String.format("Downstream connection %s is currently in a %s state.", conn.getId(), conn.getState().toString()));
if (conn.getState() == ConnectionState.Closing || conn.getState() == ConnectionState.Failing) {
if (conn.getRemoteClosed()) {
Log.info(String.format("Media server has closed the downstream connection %s.", conn.getId()));
}
// Removing remote view from UI.
handler.post(() -> {
layoutManager.removeRemoteView(remoteMedia.getId());
remoteMedia.destroy();
});
downstreamConnections.remove(remoteMedia.getId());
} else if (conn.getState() == ConnectionState.Failed) {
// Reconnect if the connection failed.
openSfuDownstreamConnection(remoteConnectionInfo);
}
});
connection.open();
return connection;
}
var _downStreamConnections: Dictionary = [String: FMLiveSwitchSfuDownstreamConnection]()
// Add and remove remote view
func addRemoteView(remoteMedia: RemoteMedia) -> Void {
DispatchQueue.main.async {
self._layoutManager?.addRemoteView(withId: remoteMedia.id(), view: remoteMedia.view())
}
}
func removeRemoteView(remoteMedia: RemoteMedia) -> Void {
DispatchQueue.main.async {
self._layoutManager?.removeRemoteView(withId: remoteMedia.id())
}
}
// Create the downstream connection
func OpenSfuDownstreamConnection(remoteConnectionInfo: FMLiveSwitchConnectionInfo) -> FMLiveSwitchSfuDownstreamConnection {
let remoteMedia: RemoteMedia = RemoteMedia.init(disableAudio: false, disableVideo: false, aecContext: nil)
var connection: FMLiveSwitchSfuDownstreamConnection?
self.addRemoteView(remoteMedia: remoteMedia)
let audioStream: FMLiveSwitchAudioStream = ((self._localMedia!.audioTrack() != nil) ? FMLiveSwitchAudioStream.init(remoteMedia: remoteMedia) : nil)!
let videoStream: FMLiveSwitchVideoStream = ((self._localMedia!.videoTrack() != nil) ? FMLiveSwitchVideoStream.init(remoteMedia: remoteMedia) : nil)!
connection = (_channel?.createSfuDownstreamConnection(withRemoteConnectionInfo: remoteConnectionInfo, audioStream: audioStream, videoStream: videoStream))!
_downStreamConnections[remoteMedia.id()] = connection
connection?.addOnStateChange({ [weak self] (obj: Any!) in
let conn = obj as! FMLiveSwitchSfuDownstreamConnection
let state = conn.state()
FMLiveSwitchLog.info(withMessage: "Downstream connection \(String(describing: conn.id()!)) is currently in a \(String(describing: FMLiveSwitchConnectionStateWrapper(value: state).description()!)).")
if (state == FMLiveSwitchConnectionState.closing || state == FMLiveSwitchConnectionState.failing) {
if (conn.remoteClosed()) {
FMLiveSwitchLog.info(withMessage: "Downstream connection \(String(describing: conn.id()!)) is closed by media server.")
}
if (self?._layoutManager != nil) {
self?.removeRemoteView(remoteMedia: remoteMedia)
}
// Taking down the audio session from the remote media for our local media
do {
if #available(iOS 10.0, *) {
try AVAudioSession.sharedInstance().setCategory(.record, mode: .default)
} else {
AVAudioSession.sharedInstance().perform(NSSelectorFromString("setCategory:error:"), with: AVAudioSession.Category.record)
}
} catch {
FMLiveSwitchLog.error(withMessage: "Could not set audio session category for local media.")
}
remoteMedia.destroy()
do {
if #available(iOS 10.0, *) {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, (AVAudioSession.CategoryOptions.defaultToSpeaker)])
} else {
AVAudioSession.sharedInstance().perform(NSSelectorFromString("setCategory:withOptions:error:"),with: AVAudioSession.Category.playAndRecord, with: [.allowBluetooth, AVAudioSession.CategoryOptions.defaultToSpeaker])
}
} catch {
FMLiveSwitchLog.error(withMessage: "Could not set audio session category for local media.")
}
} else if (state == FMLiveSwitchConnectionState.failed) {
self?.OpenSfuDownstreamConnection(remoteConnectionInfo: remoteConnectionInfo)
}
})
connection?.open()
return connection!
}
private downstreamConnections: { [key: string]: fm.liveswitch.SfuDownstreamConnection } = {};
private openSfuDownstreamConnection(remoteConnectionInfo: fm.liveswitch.ConnectionInfo): fm.liveswitch.SfuDownstreamConnection {
// Create remote media.
const remoteMedia = new fm.liveswitch.RemoteMedia();
const audioStream = new fm.liveswitch.AudioStream(remoteMedia);
const videoStream = new fm.liveswitch.VideoStream(remoteMedia);
// Add remote media to the layout.
this.layoutManager.addRemoteMedia(remoteMedia);
// Create a SFU downstream connection with remote audio and video.
const connection: fm.liveswitch.SfuDownstreamConnection = this.channel.createSfuDownstreamConnection(remoteConnectionInfo, audioStream, videoStream);
// Store the downstream connection.
this.downstreamConnections[connection.getId()] = connection;
connection.addOnStateChange(conn => {
fm.liveswitch.Log.debug(`Downstream connection is ${new fm.liveswitch.ConnectionStateWrapper(conn.getState()).toString()}.`);
// Remove the remote media from the layout and destroy it if the remote is closed.
if (conn.getRemoteClosed()) {
delete this.downstreamConnections[connection.getId()];
this.layoutManager.removeRemoteMedia(remoteMedia);
remoteMedia.destroy();
}
});
connection.open();
return connection;
}
Handle Stream Connection
In an SFU session, we must create a new downstream connection every time that a client joins the channel. To do so, add an event handler for the OnRemoteUpstreamConnectionOpen event. This event is raised whenever a remote client opens an upstream connection on this channel. We add the event handler to the OnClientRegistered class.
Update the OnClientRegistered function in the HelloWorldLogic class with the following:
// Procedure to run once client is registered
private void OnClientRegistered(Channel[] channels)
{
Channel = channels[0];
DisplayMessage($"Client {Client.Id} has successfully connected to channel {Channel.Id}, Hello World!");
Channel.OnRemoteUpstreamConnectionOpen += (remoteConnectionInfo) =>
{
Log.Info("An upstream connection opened.");
OpenSfuDownStreamConnection(remoteConnectionInfo);
};
_UpstreamConnection = OpenSfuUpstreamConnection(LocalMedia);
foreach (var remoteConnectionInfo in Channel.RemoteUpstreamConnectionInfos)
{
OpenSfuDownStreamConnection(remoteConnectionInfo);
}
}
// Register the client with token.
private void onClientRegistered(Channel[] channels) {
// Store our channel reference.
channel = channels[0];
// Open a new SFU downstream connection when a new remote upstream connection is opened.
channel.addOnRemoteUpstreamConnectionOpen(connectionInfo -> {
Log.info("A remote upstream connection has opened.");
openSfuDownstreamConnection(connectionInfo);
});
// Open a new SFU upstream connection.
upstreamConnection = openSfuUpstreamConnection(localMedia);
// Check for existing remote upstream connections and open a downstream connection for
// each of them.
for (ConnectionInfo connectionInfo : channel.getRemoteUpstreamConnectionInfos()) {
openSfuDownstreamConnection(connectionInfo);
}
}
func onClientRegistered(obj: Any!) -> Void {
let channels = obj as! [FMLiveSwitchChannel]
self._channel = channels[0]
self._channel?.addOnRemoteUpstreamConnectionOpen({[weak self] (obj: Any!) in
let remoteConnectionInfo = obj as! FMLiveSwitchConnectionInfo
self?.OpenSfuDownstreamConnection(remoteConnectionInfo: remoteConnectionInfo)
})
self._upstreamConnection = self.OpenSfuUpstreamConnection(localMedia: self._localMedia!)
for remoteConnectionInfo in (self._channel?.remoteUpstreamConnectionInfos())! {
self.OpenSfuDownstreamConnection(remoteConnectionInfo: remoteConnectionInfo as! FMLiveSwitchConnectionInfo)
}
}
private onClientRegistered(channels: fm.liveswitch.Channel[]): void {
this.channel = channels[0];
this.displayMessage(`Client ${this.client.getId()} has successfully connected to channel ${this.channel.getId()}, Hello World!`);
this.channel.addOnRemoteUpstreamConnectionOpen(remoteConnectionInfo => {
fm.liveswitch.Log.info("An upstream connection opened.");
this.openSfuDownstreamConnection(remoteConnectionInfo);
});
this.upstreamConnection = this.openSfuUpstreamConnection(this.localMedia);
for (let remoteConnectionInfo of this.channel.getRemoteUpstreamConnectionInfos()) {
this.openSfuDownstreamConnection(remoteConnectionInfo);
}
}
Run Your App
Note
LiveSwitch recommends that you use a phone and not an emulator to run the mobile apps. It's possible to run the mobile apps on an emulator, but the camera doesn't work.
Run your app in your project IDE and click Join. You should see video streaming on your app window.
For an SFU connection, the Media Server only sends back other participant's views. If you run your app on two devices, you should see the local view is displayed on the corner of the window and the remote view is displayed on the main window.