When adding live video to your applications on Stream, we recommend checking out our newly released Video API!
Using Stream Video, developers can build live video calling and conferencing, voice calling, audio rooms, and livestreaming from a single unified API, complete with our fully customizable UI Kits across all major frontend platforms.
To learn more, check out our Video homepage or dive directly into the code using one of our SDKs.
Welcome to the sixth TwitterClone tutorial. If you followed the fifth tutorial, we hope you now know how easily you can integrate the Stream Chat iOS/SwiftUI SDK into your app to build immersive chat messaging experiences. In this tutorial, you will learn about conversations with spaces. In particular, you will discover how to implement audio rooms so that users can hang out with one another, meet new people, and talk about anything one could imagine.
Find the project demo and download the source code from GitHub.
Introduction to Drop-in Audio Chat
Have you ever joined Twitter Spaces or Club House's instant audio conversations? If yes, then you have experienced drop-in audio, or in other words, an audio room. Drop-in audio-supported applications allow users to connect instantly to live voice conversations. It supports two-way and group audio conversations allowing users to express themselves in many ways. If you are thinking of having a drop-in audio feature in your app, one possible way is to use the 100ms SDK.
What is 100ms?
100ms provides SDKs to build live video and audio experiences.
Getting 100ms Account
To integrate 100ms with your app, you need to set up a 100ms account. Head to their website and follow the steps to create a free account. The screenshot below shows an example 100ms dashboard account.
Install the 100ms SDK
We add 100ms’ dependencies as a Swift Packages to our Tuist dependencies.swift
file.
var swiftPackageManagerDependencies = SwiftPackageManagerDependencies(
[
…
.remote(url: "https://github.com/100mslive/100ms-ios-sdk.git",requirement: .upToNextMinor(from: "0.6.2")),
],
)
We then add the dependency on the Tuist project, in project.swift
, to a target.
let spacesTarget =
Project.makeFrameworkTargets(name: spacesName,
platform: .iOS,
dependencies: [
.external(name: "StreamChatSwiftUI"),
.external(name: "StreamChat"),
.target(name: uiName),
.target(name: authName),
.target(name: chatName),
.external(name: "HMSSDK")
])
We now fetch the dependency by running
tuist fetch
And make sure to generate the Xcode project again by running:
tuist generate
From our codebase, you can find and explore how we implemented RevenueCat in the following Swift files in the folders Profile -> Sources from the Project Navigator.
Integrate the 100ms SDK
For our Spaces emulation, we did something interesting to create an audio space as a channel on our chat product. We achieved the desired Spaces behavior by adding features specific to the audio Spaces to these channels. We created the audio feature using the 100ms product. When a Space starts, a call begins with 100ms.
To explore the full implementation of the 100ms SDK in our code base, open the Xcode Project Navigator and find the folders Spaces -> Sources -> VieModel. Find the complete integration code in the Swift file SpacesViewModel.swift
.
To get access to the 100ms SDK, you should initialize it. We initialized the SDK by importing import HMSSDK
and adding the stored property var hmsSDK = HMSSDK.build()
in SpacesViewModel.swift
. Next, we implemented the necessary properties and functions for handling and publishing updates about the Space and its participants, such as joining and leaving the audio room.
SpacesViewModel.swift
//
// SpacesViewModel.swift
// Spaces
//
// Created by Stefan Blos on 14.02.23.
// Copyright © 2023 Stream.io Inc. All rights reserved.
import HMSSDK
import Chat
import StreamChat
import StreamChatSwiftUI
public class SpacesViewModel: ObservableObject {
@Injected(\.chatClient) var chatClient
@Published var ownTrack: HMSAudioTrack?
@Published var otherTracks: Set<HMSAudioTrack> = []
@Published var isAudioMuted = false
@Published var isInSpace = false
var hmsSDK = HMSSDK.build()
@Published var spaces: [Space] = []
@Published var selectedSpace: Space?
var eventsController: EventsController?
init() {
let query = ChannelListQuery(
filter: .equal(.type, to: .livestream)
)
let controller = chatClient.channelListController(query: query)
controller.synchronize { error in
if let error = error {
print("Error querying channels: \(error.localizedDescription)")
}
self.spaces = Array(controller.channels)
.filter({ channel in
channel.type == .livestream
})
.filter({ channel in
channel.extraData.keys.contains("spaceChannel")
})
.map { Space.from($0) }
}
}
var isHost: Bool {
guard let userId = chatClient.currentUserId, let hostId = selectedSpace?.hostId else {
return false
}
return userId == hostId
}
func spaceTapped(space: Space) {
watchChannel(id: space.id)
selectedSpace = space
}
@MainActor
func joinSpace(id: String) async {
do {
let channelId = try ChannelId(cid: "livestream:\(id)")
// add user to the channel members
let controller = chatClient.channelController(for: channelId)
if let currentUserId = chatClient.currentUserId {
controller.addMembers(userIds: [currentUserId])
}
if let callId = selectedSpace?.callId {
await joinCall(with: callId, in: channelId)
isInSpace = true
} else {
// handle error
}
} catch {
print(error.localizedDescription)
isInSpace = false
}
}
func leaveSpace(id: String) {
// TODO: stop observing channel updates
if let channelId = try? ChannelId(cid: "livestream:\(id)") {
let controller = chatClient.channelController(for: channelId)
if let currentUserId = chatClient.currentUserId {
controller.removeMembers(userIds: [currentUserId])
}
}
// leaveCall(with: id)
isInSpace = false
}
@MainActor
func startSpace(id: String) async {
do {
let channelId = try ChannelId(cid: "livestream:\(id)")
let callId = await startCall(with: id, in: channelId)
updateChannel(with: channelId, to: .running, callId: callId)
isInSpace = true
} catch {
print(error.localizedDescription)
isInSpace = false
}
}
func endSpace(with id: String) {
if let channelId = try? ChannelId(cid: "livestream:\(id)") {
// TODO temporary
updateChannel(with: channelId, to: .planned)
}
// TODO: stop observing channel updates
// TODO: should we lock the room?
// TODO: deactivate to focus on channel updates for now
// endCall()
}
func toggleAudioMute() {
isAudioMuted.toggle()
hmsSDK.localPeer?.localAudioTrack()?.setMute(isAudioMuted)
}
// TODO: make this return a Result<> with different error types for the errors and display that to users
func createChannelForSpace(title: String, description: String, happeningNow: Bool, date: Date) {
// create new channel
guard let userId = chatClient.currentUserId else {
print("ERROR: chat client doesn't have a userId")
return
}
guard let channelController = try? chatClient.channelController(
createChannelWithId: ChannelId(type: .livestream, id: UUID().uuidString),
name: title,
members: [userId],
isCurrentUserMember: true,
messageOrdering: .bottomToTop,
// Potantially invite other users who could be part of it
invites: [],
extraData: [
"spaceChannel": .bool(true),
"description": .string(description),
"spaceState": .string(happeningNow ? SpaceState.running.rawValue : SpaceState.planned.rawValue),
"startTime": .string(date.ISO8601Format()),
"speakerIdList": .array([.string(String(userId))])
]
) else {
print("Channel creation failed")
return
}
// TODO: listen to errors and act accordingly
channelController.synchronize { error in
if let error {
print("Synchronize error: \(error.localizedDescription)")
}
}
}
}
Start, Join, Leave, and End the Audio Conversation
In addition to the above, we implemented the required methods to start, join, leave, and end the audio room in the Swift file SpacesViewModel+Call.swift
.
//
// SpacesViewModel+Call.swift
// TwitterClone
//
// Created by Stefan Blos on 17.02.23.
// Copyright © 2023 Stream.io Inc. All rights reserved.
import Foundation
import StreamChat
import HMSSDK
extension SpacesViewModel {
func startCall(with id: String, in channelId: ChannelId) async -> String? {
guard let call = try? await chatClient.createCall(with: id, in: channelId) else {
// TODO: proper error handling
print("Couldn't start call with id '\(id)' in channel '\(channelId.id)'.")
return nil
}
let token = call.token
// The fact that we join audio-only is handled in the 100ms dashboard
let config = HMSConfig(userName: chatClient.currentUserController().currentUser?.name ?? "Unknown", authToken: token)
hmsSDK.join(config: config, delegate: self)
return call.call.hms?.roomId
}
func joinCall(with id: String, in channelId: ChannelId) async {
guard let call = try? await chatClient.createCall(with: id, in: channelId) else {
// TODO: proper error handling
print("Couldn't join call with id '\(id)' in channel '\(channelId.id)'.")
return
}
let token = call.token
// TODO: how to join audio only
let config = HMSConfig(userName: chatClient.currentUserController().currentUser?.name ?? "Unknown", authToken: token)
hmsSDK.join(config: config, delegate: self)
}
func leaveCall(with id: String) {
hmsSDK.leave { [weak self] _, error in
if let error {
print(error.localizedDescription)
self?.isInSpace = false
}
self?.ownTrack = nil
self?.otherTracks = []
self?.isInSpace = false
}
}
func endCall() {
// Do we need to lock the room?
hmsSDK.endRoom(lock: false, reason: "Host ended the room") { [weak self] _, error in
if let error {
print("Error ending the space: \(error.localizedDescription)")
}
self?.isInSpace = false
}
}
}
Observe and Update the UI During an Active Conversation
During active audio conversations, the 100ms SDK provides convenient ways to update the UI while conversations are happening. The updates include joining and leaving the audio room mentioned previously. To observe these ongoing updates and respond accordingly, we implemented HMSUpdateListener
protocol in the 100ms SDK HMSSDK
. Open SpacesViewModel+HMSUpdateListener.swift
to explore the complete implementation.
//
// SpacesViewModel+HMSUpdateListener.swift
// Spaces
//
// Created by Stefan Blos on 14.02.23.
// Copyright © 2023 Stream.io Inc. All rights reserved.
//
import HMSSDK
extension SpacesViewModel: HMSUpdateListener {
public func on(join room: HMSRoom) {
print("[HMSUpdate] on join room: \(room.roomID ?? "unknown")")
// Do something here
}
public func on(room: HMSRoom, update: HMSRoomUpdate) {
print("[HMSUpdate] on room: \(room.roomID ?? "unknown"), update: \(update.description)")
}
public func on(peer: HMSPeer, update: HMSPeerUpdate) {
// Do something here
print("[HMSUpdate] on peer: \(peer.name), update: \(update.description)")
}
public func on(track: HMSTrack, update: HMSTrackUpdate, for peer: HMSPeer) {
print("[HMSUpdate] on track: \(track.trackId), update: \(update.description), peer: \(peer.name)")
switch update {
case .trackAdded:
if let audioTrack = track as? HMSAudioTrack {
if peer.isLocal {
ownTrack = audioTrack
} else {
otherTracks.insert(audioTrack)
}
}
case .trackRemoved:
if let audioTrack = track as? HMSAudioTrack {
if peer.isLocal {
ownTrack = nil
} else {
otherTracks.remove(audioTrack)
}
}
default:
break
}
}
public func on(error: Error) {
// Do something here
print("[HMSUpdate] on error: \(error.localizedDescription)")
}
public func on(message: HMSMessage) {
print("[HMSUpdate] on message: \(message.message)")
}
public func on(updated speakers: [HMSSpeaker]) {
// Do something here
print("[HMSUpdate] on updated speakers: \(speakers.description)")
}
public func onReconnecting() {
// Do something here
print("[HMSUpdate] on reconnecting")
}
public func onReconnected() {
// Do something here
print("[HMSUpdate] on reconnected")
}
}
Creating the Spaces UI in our App
For the home view of our audio space, we wanted the layout and the visual appearance to mimic the actual Twitter Sppaces app. Find SpacesTimelineView.swift
from the Spaces folder in the Project Navigator to explore the composition of the Spaces home view.
//
// SpacesTimelineView.swift
// Spaces
//
// Created by Amos Gyamfi on 7.2.2023.
// Copyright © 2023 Stream.io Inc. All rights reserved.
//
import SwiftUI
import TwitterCloneUI
public struct SpacesTimelineView: View {
@StateObject var spacesViewModel = SpacesViewModel()
@Environment(\.colorScheme) var colorScheme
@State private var searchSpaces = "Search spaces"
@State private var isShowingSpacesWelcome = false
public init() {}
public var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Real spaces")
.font(.title3)
.bold()
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(spacesViewModel.spaces) { space in
VStack {
SpaceCard(viewModel: spacesViewModel, space: space)
}
}
}
}
}
.padding(.horizontal)
VStack(alignment: .leading) {
Text("Happening Now")
.font(.title3)
.bold()
Text("Spaces going on right now")
.font(.caption)
.foregroundColor(.secondary)
SpacesLiveView(spacesViewModel: spacesViewModel)
}
.padding(.horizontal)
VStack(alignment: .leading) {
Text("Get these in your calendar")
.font(.title3)
.bold()
Text("People you follow will be tuning in")
.font(.caption)
.foregroundColor(.secondary)
Button {
isShowingSpacesWelcome.toggle()
} label: {
SpacesScheduledView()
}
.buttonStyle(.plain)
.sheet(isPresented: $isShowingSpacesWelcome) {
SpacesWelcomeView()
.presentationDetents([.fraction(0.6)])
}
}
.padding(.horizontal)
.padding(.top)
VStack(alignment: .leading) {
Text("Trending")
.font(.title3)
.bold()
SpacesLiveView(spacesViewModel: spacesViewModel)
}
.padding()
SpacesScheduledView()
.padding()
}
.searchable(text: $searchSpaces)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ProfileImage(imageUrl: "https://picsum.photos/id/550/200/200", action: {})
.scaleEffect(0.7)
}
ToolbarItem(placement: .principal) {
Text("Spaces")
.font(.title3)
.bold()
}
}
HStack {
Spacer()
SpacesAddNewButton(spacesViewModel: spacesViewModel)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 16))
}
}
}
}
}
Conclusion
Congratulations!. You have completed our tutorial about integrating the 100ms SDK with the TwitterClone app to provide an audio room experience.
Check our project demo and download the source code from GitHub. Are you looking for ways to integrate chat messaging, activity feeds, real-time audio, and video with your app? Contact our team for help.
Join us for part seven of Build Your Own Twitter where we integrate Twitter Blue & In-App Subscriptions using Revenue Cat!