Build a Music Chat iOS App Using SwiftUI

...

Music has always been an oasis for me while coding and writing. I love chatting for hours on end with my friends, exploring our peculiar music taste. What if we had an app where we could listen to music and discuss with a like-minded community? This tutorial will create a music chat app where you can listen to your dearest music while sharing and chatting about it!

We’ll use the Apple Music API to create our own music player. It helps us to access the user’s library and play songs from Apple Music using MusicKit.

For straightforward implementation of a chat feature, we’ll use Stream Chat SDK. It helps us seamlessly integrate messaging service in your application that you can use for education, e-commerce and more.

The SDK has a free trial, and it's free to use for small companies and hobby projects with a Maker Account.

We’ll learn how to-

Note - You need a paid developer account, and Apple Music installed on your device to follow along this article.

Introduction

The app has a list of popular songs in US. You can play and search for songs. It also includes a chat screen to share your favourite music with your friends.

Please download the starter project and explore the contents under the Initial folder. It consists of helper views, a few extensions and the model for the API data.

So let’s get started!

Setup Apple Music API

The API gives access to the media in the Apple Music Catalog and the user's personal iCloud Music Library.

We use this service to retrieve information about albums, songs, artists, playlists, music videos, Apple Music stations, ratings, charts, recommendations, and the user's most recently played content.

MusicKit Identifier and Private Key
We need an identifier and private key to access the API. We create a JSON Web Token (JWT) to communicate to Apple Music.

Go to the Account section on https://developer.apple.com. Next, select Certificates, Identifiers & Profiles.

In the sidebar, select Identifiers. Next, click the Add button (+), choose MusicKit IDs, and then click Continue. Finally, enter TonesChat in the description and the identifier in a reverse-domain style.

music.com.rudrankriyam.TonesChat

Click Continue, and then Register.

Now, select Keys from the sidebar. We click the Add button (+), enter the Key Name as TonesChat, and enable the MusicKit service.

Click on Configure button. From the dropdown menu, select the Music ID that we configured in the previous step. Click Save. We’ll be taken back to the previous page. Click Continue and then Register to proceed further.

Following all the steps, we can download the key. We also need the Key ID mentioned on this page later.

Name:TonesChat
Key ID: 12AB3C4D56 // <-- Take a note of this Key ID
Services:MusicKit

Click on the Download button to finally retrieve the key. Save it somewhere where we can access it for generating the developer token.

Developer Token
We use the Key ID and private key to create a developer token for authenticating with Apple Music. The starter project contains SwiftJWTSample to generate the JSON Web Token.

After opening it, run the following command in terminal -

swift run generateToken   path_to_keys/AuthKey.p8

For example,

rudrankriyam@MacBook-Pro ~ % cd /Users/rudrankriyam/Desktop/TonesChat/SwiftJWTSample

rudrankriyam@MacBook-Pro SwiftJWTSample % swift run generateToken ZYXWDS9V6JD 12AB3C4D56 /Users/rudrankriyam/Desktop/AuthKey_12AB3C4D56.p8

If everything is done correctly, we’ll receive the output containing the JSON Web Token. Copy the token, and save it for the next step.

Configure Apple Music API

With the more challenging part of creating the token completed, working with the Apple Music API is relatively straightforward.

  • Create a new Swift file, and name it as AppleMusicManager. Next, we will create a singleton class AppleMusicManager to handle all the API requests.
    class AppleMusicManager {    
      static let shared = AppleMusicManager()

      // 1
      private let developerToken = "JWT GOES HERE"

      // 2
      func getSearchSongs(for term: String) -> AnyPublisher<SearchSongModel, Error> {
        execute(request: createURLRequest(with: .search(term: term)))
      }

      func getTopSongs() -> AnyPublisher<SongModel, Error> {
        execute(request: createURLRequest(with: .chart))
      }

      private func execute<T: Codable>(request: URLRequest) -> AnyPublisher<T, Error> {
        URLSession.shared
          .dataTaskPublisher(for: request)
          .map(\.data)
          .decode(type: T.self, decoder: JSONDecoder())
          .receive(on: RunLoop.main)
          .eraseToAnyPublisher()
      }

      private func createURLRequest(with endpoint: Endpoint) -> URLRequest {
        var request = URLRequest(url: endpoint.url)
        request.httpMethod = "GET"
        request.addValue("Bearer \(developerToken)", forHTTPHeaderField: "Authorization")
        return request
      }
    }

Here’s what this class is doing:

  1. Create a constant to save the JWT token. Ideally, you should securely save this in Keychain.
  2. Create a request to search songs for a particular term. Similarly, request for the top songs.

We’ll use these two methods later for the home and search screen. Before moving on to the code, let’s first set up the Stream SDK.

Stream Account Setup

The iOS SDK of Stream Chat helps us to build our own chat experience for the app. We want to implement chat functionality in TonesChat for sharing our exceptional music taste with the world.

First, go to https://getstream.io and sign up for an account.

We’ll be redirected to the dashboard screen.

Click on the Create App button. Fill the list as follows -

  • App Name: TonesChat.
  • Feeds Server Location: Based on where you’re located.
  • Chat Server Location: Based on where you’re located.
  • Clone Existing App: ---
  • Environment: Development.

Click on the Create App button.

On our dashboard screen, we can find the app created for us. Take note of the key, as it’ll be helpful later.

Now, click on TonesChat.

Next, from the navigation bar on the top, select Chat, and then select Overview.

Scroll down, and disable auth checks and permission checks for the purpose of this article.

Now, we’re ready to add the SDK to our app!

Stream Chat SDK in Xcode

In Xcode, select File, then ****Swift Packages, and finally, choose **Add Package Dependency. In the search field, add the following package location -

https://github.com/GetStream/stream-chat-swift.git

Click Next. The current version at the time of writing is 3.1.10. Click Next again and then checkmark both the products to add to the TonesChat target. Finally, click Finish to add the dependencies to the app.

The next step is to configure the SDK for our project. To initialise Stream, open the project and select TonesChatApp.swift file. Import StreamChat at the top of the file.

import StreamChat

Create an extension on ChatClient to have a singleton object for our app. It is the root object representing a Stream Chat.

extension ChatClient {
    static var shared: ChatClient!
}

Create an extension on TonesChatApp and add setupStream() method. Here, we configure the ChatClient with the key that we created on the dashboard.

extension TonesChatApp {
  private func setupStream() {
    let config = ChatClientConfig(apiKeyString: "KEY")
    ChatClient.shared = ChatClient(config: config, tokenProvider: .anonymous)
  }
}

Inside TonesChatApp, we create an initializer to set the chat client. The whole struct looks like this -

@main@main
struct TonesChatApp: App {
  init() {
    setupStream()
  }

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

And that’s it to get started with the next step of designing the user interface!

Home Screen

The home screen will contain a grid of songs that we can play/pause, and copy the name of the song. For illustration, we’ll fetch the top 20 popular songs in the US.

Create a new file named SongsViewModel.swift and copy the class -

class SongsViewModel: ObservableObject {
  var cancellable: AnyCancellable?
  @Published var songs: [SongData] = []

  func requestAuthorization(completion: @escaping (Bool) -> ()) {
    SKCloudServiceController.requestAuthorization { (status) in
      completion(status == .authorized ? true : false)
    }
  }
}

We’ve a variable to store the array of songs to be displayed. The requestAuthorization(completion:) method asks the user for permission to access the music library on the device.

class TopSongsViewModel: SongsViewModel {
  func updateTopSongs() {
    requestAuthorization { status in
      if status {
        self.cancellable = AppleMusicManager.shared.getTopSongs()
          .sink(receiveCompletion: { _ in
          }, receiveValue: { model in
            if let songs = model.results.songs.first {
              self.songs = songs.data
            }
          })
      }
    }
  }
}

Inheriting from SongsViewModel, we create another class, TopSongsViewModel, that updates the top songs. We get the user token and then fetch the required data using AppleMusicManager.

With the core logic done, we move on to creating the grid layout to display the album art, song and artist name.

Create MusicCardView.swift and add the following content to it -

struct MusicGridView: View {
    // 1
    @ObservedObject var viewModel: SongsViewModel
    // 2
    var player = MPMusicPlayerController.applicationQueuePlayer
    var items: [GridItem] = Array(repeating: .init(.flexible()), count: 2)

    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            LazyVGrid(columns: items, spacing: 4) {
                ForEach(viewModel.songs, id: \.id) { song in
                    MusicCardView(song: song)
                        // 3
                        .onTapGesture(count: 2) {
                            setSong(with: song.id)
                        }
                        // 4
                        .onLongPressGesture {
                            copyToClipboard(string: song.attributes.name)
                        }
                }
            }
            .padding()
        }
    }

    // 5
    private func setSong(with id: String) {
        switch player.playbackState {
        case .stopped, .paused:
            player.setQueue(with: [id])
            player.play()
        default:
            player.stop()
        }
        successNotification()
    }

    private func copyToClipboard(string: String) {
        UIPasteboard.general.string = string
        successNotification()
    }

    private func successNotification() {
        let generator = UINotificationFeedbackGenerator()
        generator.notificationOccurred(.success)
    }
}

Figuring out the code, we -

  1. Create an @ObservedObject variable of the type SongsViewModel. This helps us to reuse this view in the home screen as well as the search screen.
  2. Create an instance of the application queue player that helps us to play music locally within our app.
  3. Add a double-tap gesture to play/pause the song.
  4. Add a long gesture on the card. This method copies the name of the song to the phone’s clipboard. This helps us to directly share our music with the world.
  5. Use the music player to set the song based on its id and play/pause it accordingly.

Next, we piece all of them together to create the HomeView -

struct HomeView: View {
  // 1
  @StateObject private var viewModel = TopSongsViewModel()

  var body: some View {
    NavigationView {
      // 2
      MusicGridView(viewModel: viewModel)
        .navigationTitle("TonesChat")
    }
    .navigationViewStyle(StackNavigationViewStyle())
    // 3
    .onAppear {
      viewModel.updateTopSongs()
    }
  }
}

Here's what's happening -

  1. Create a @StateObject for TopSongsViewModel() to be alive for the whole lifecycle, becoming the ultimate source of truth.
  2. We pass the instance of TopSongsViewModel as the parameter to MusicGridView.
  3. As soon as the view appears, we fetch the top songs firing the method updateTopSongs().

Run the app to see a beautiful home screen.

You can play/pause some of the popular songs out there. With the home screen being completed, and working, it’s time for some searching!

Search Screen

Whenever anyone shares a song with us, we want to search that song in the whole catalogue of Apple Music, and if found, play it.

First, create SearchSongsViewModel.swift with the following class -

class SearchSongsViewModel: SongsViewModel {
  func updateSearchSongs(for term: String) {
    requestAuthorization { status in
      if status {
        self.cancellable = AppleMusicManager.shared.getSearchSongs(for: term)
          .sink(receiveCompletion: { _ in
          }, receiveValue: { model in
            self.songs = model.results.songs.data
          })
      }
    }
  }
}

We use the method updateSearchSongs(for:) to get the search term from the user. Then, after successful authorisation, we receive the list of the songs that matched.

To create the UI for the search, add SearchView to SearchView.swift -

struct SearchView: View {

  // 1
  @State private var searchText = ""
  @State private var showCancelButton: Bool = false

  // 2
  @StateObject private var viewModel = SearchSongsViewModel()

  var body: some View {
    NavigationView {
      VStack {
        // 3
        SearchBar(searchText: $searchText, showCancelButton: $showCancelButton) {
          UIApplication.shared.resignFirstResponder()
          if self.searchText.isEmpty {
            viewModel.songs = []
          } else {
            viewModel.updateSearchSongs(for: searchText)
          }
        }

        // 4
        MusicGridView(viewModel: viewModel)
      }
      .navigationTitle("Search")
      .navigationBarHidden(showCancelButton)
    }
    .navigationViewStyle(StackNavigationViewStyle())
  }
}

Here, the code:

  1. Creates private @State variables for mutating the search text and to show/hide the cancel button.
  2. Create a @StateObject for SearchSongsViewModel() to be alive for the whole lifecycle even when the view recreates.
  3. Adds the SearchBar accepts the search text, and on commit, calls the updateSearchSongs(for:) method from the view model. When the songs are fetched, it updates the @Published variable songs, resulting in recreating the view.
  4. Add the MusicGridView() that shows the search results in a grid.

Run the app and search for your treasure tune!

Now we can search for whatever songs our good friends recommend!

Login Screen

To enter the music paradise, we need to log in with our username first. So, for that, we create a login screen.

Create a new SwiftUI file named LoginHeaderView.swift and add the following struct to it:

struct LoginHeaderView: View {
  var body: some View {
    VStack {

      // 1
      Image("login_header_image")
        .resizable()
        .aspectRatio(contentMode: .fit)

      Text("Welcome to Music Paradise!")
        .fontWeight(.black)
        .foregroundColor(Color(.systemIndigo))
        .font(.largeTitle)
        .multilineTextAlignment(.center)

      Text("Share your exceptional music taste with the world.")
        .fontWeight(.light)
        .multilineTextAlignment(.center)
        .padding()
    }
  }
}

You can customise the LoginHeaderView however you want. Create another file with the name LoginView.swift and add the following code to it -

import SwiftUI
import StreamChat

struct LoginView: View {

  // 1
  @State private var username: String = ""
  @State private var success: Bool?

  var body: some View {
    NavigationView {
      VStack {
        Spacer()

        // 2
        LoginHeaderView()

        VStack(alignment: .leading) {
          Text("Username")
            .font(.headline)

          TextField("Enter username", text: $username)
            .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()

        Spacer()

        // 3
        NavigationLink(destination: ChatView(), tag: true, selection: $success) {
          EmptyView()
        }

        Button("Log in".uppercased(), action: login)
          .buttonStyle(AuthenticationButtonStyle())
      }
    }
  }

  private func login() {
    // 4
    ChatClient.shared.tokenProvider = .development(userId: username)

    // 5
    ChatClient.shared.currentUserController().reloadUserIfNeeded { error in
      switch error {
      case .none:
        success = true
      case .some:
        success = false
      }
    }
  }
}

// 5
struct AuthenticationButtonStyle: ButtonStyle {
  func makeBody(configuration: Self.Configuration) -> some View {
    configuration.label
      .foregroundColor(.white)
      .padding()
      .frame(maxWidth: .infinity)
      .background(Color(.systemIndigo))
      .cornerRadius(12)
      .padding()
  }
}

Here's the breakdown:

  1. We use the @State variables for mutating the username as well as successful authentication.
  2. It is the code for the UI of the login screen. You can customise it however you want.
  3. NavigationLink to go to the ChatView() after successful authentication.
  4. A Login button that calls the login() method to login the user. For the purpose of this article, we use the development provider since it doesn't require a token.
  5. We fetch the token from tokenProvider and prepare the shared ChatClient variable for the new user. If there’s no error, we update the value of success to true. This results in the navigation to the ChatView().
  6. Custom ButtonStyle for the login button.

To get the project running, create a new SwiftUI file, and name it as ChatView.swift.

With the login screen done and the pathway to the music paradise cleared, let’s create the Chat screen!

Chat Screen

Stream’s Chat SDK makes it easier to implement chat functionality in our app in few tens of lines of code.

We start off by building our own custom MessageView. It contains the username of other users and the message in a design similar to iMessage.

import SwiftUI
import StreamChat

struct MessageView: View {

  // 1
  var message: ChatMessage

  var body: some View {
    VStack(alignment: .leading) {
      // 2
      if !message.isSentByCurrentUser {
        Text(message.author.id)
          .font(.footnote)
          .bold()
      }

      Text(message.text)
        .foregroundColor(isSentByCurrentUser ? .white : .primary)
        .padding()
        .background(background)
        .clipShape(ChatBubbleShape(isSentByCurrentUser: isSentByCurrentUser))
        // 3
        .onLongPressGesture(perform: copyToClipboard)
    }
    .padding(isSentByCurrentUser ? .trailing : .leading)
    .frame(maxWidth: .infinity, alignment: isSentByCurrentUser ? .trailing: .leading)
  }

  private var background: Color {
    isSentByCurrentUser ? .brand : Color(.systemGray5)
  }

  private var isSentByCurrentUser: Bool {
    message.isSentByCurrentUser
  }

  private func copyToClipboard() {
    UIPasteboard.general.string = message.text

    let generator = UINotificationFeedbackGenerator()
    generator.notificationOccurred(.success)
  }
}

Breaking down the code of MessageView into essential parts -

  1. ChatMessage is a type representing a chat message. It is an immutable snapshot of a chat message entity at the given time.
  2. If the message is not sent by the user, we display the other user’s name above the text message.
  3. On long press on a message, we copy to the phone’s clipboard. This helps us to directly search for the song shared with us.

Now open ChatView.swift, and add the following to it:

import SwiftUI
import StreamChat

struct ChatView: View {
  // 1
  @StateObject private var channel = ChatClient.shared.channelController(
    for: ChannelId(type: .messaging, id: "general"))
    .observableObject

  @State private var text: String = ""

  var body: some View {
    VStack {
      // 2
      List(channel.messages, id: \.self) { message in
        MessageView(message: message)
          .scaleEffect(x: 1, y: -1, anchor: .center)
      }
      .scaleEffect(x: 1, y: -1, anchor: .center)
      .offset(x: 0, y: 2)

      HStack {
        TextField("Type a message", text: $text)

        Button(action: sendMessage) {
          Image(systemName: "paperplane.circle.fill")
            .accessibility(label: Text("Send"))
            .font(.system(size: 24))
            .foregroundColor(Color(.systemIndigo))
        }
      }
      .padding()
    }
    .navigationBarTitle("Music Paradise", displayMode: .inline)
    .onAppear {
      // 3
      channel.controller.synchronize()
    }
  }

  // 4
  private func sendMessage() {
    if !text.isEmpty {
      channel.controller.createNewMessage(text: text) { result in
        switch result {
        case .success(let response):
          print(response)
        case .failure(let error):
          print(error)
        }
      }
      text = ""
    }
  }
}

The chat screen forms the core of the app, so here's the detailed explanation:

  1. We create @StateObject for managing the channel. For this post, we'll create a single channel for messaging and name it paradise. To keep the article within limits, we'll have a single channel that the user joins as soon as they log in. We assign the channel variable to a channel controller. The controller is used for continuous data change observations (like getting new messages in the channel) and for quick channel mutations (like adding a member to a channel).
  2. We create a List to list all the messages from the channel. When we receive new messages, the body of the view automatically recreates as channel is an observable object.
  3. synchronize() asynchronously fetches the latest version of the data from the servers.
  4. The sendMessage() method creates a new message locally and schedules it for sending.

This completes the chat screen. Create few different usernames and test the app out with your friends and family!

Main Screen

Wrapping up the design process, we add the three main views to ContentView. It contains a TabView with the three contains with their respective Label.

struct ContentView: View {
  var body: some View {
    TabView {
      HomeView().tabItem { TabViewItem(type: .home) }

      SearchView().tabItem { TabViewItem(type: .search) }

      LoginView().tabItem { TabViewItem(type: .chat) }
    }
    .accentColor(.brand)
  }
}

Completing our app, run the app to enter your new world!

Conclusion

You can download the final project from the GitHub repository of TonesChat.

We finished building a glimpse of the opportunities Apple Music SDK and Stream Chat SDK provide. For example, adding a whole chat system into your app is now much more accessible, thanks to the open-source framework of Stream.

We only worked with one channel for this starter tutorial. You can experiment with adding more channels, each for different genres of music. You can also add reactions, as well as send attachments using the SDK.

I hope you enjoyed this tutorial! Also, if you found this article helpful, let us know!