Add CarPlay support to Swift Radio

6 minute read

In this tutorial we will see how to add CarPlay support to the open source radio app SwiftRadio, and test it using the Simulator.

Setting up the project

First let’s start by cloning the project, or download it directly from GitHub:

git clone https://github.com/analogcode/Swift-Radio-Pro

After running the project using the Simulator, we want to make sure that we have the CarPlay menu available under: Hardware > External Displays > CarPlay:

Hardware > External Displays > CarPlay

If you can’t find the CarPlay menu, just open the Terminal and run the following command:

defaults write com.apple.iphonesimulator CarPlay -bool YES

Now we need to add an entitlement file to the app and add the following entry to it: com.apple.developer.playable-content to support CarPlay as a Boolean/YES value.

To generate the file automatically we can just toggle the PushNotification in our app target > capabilities and turn off later:

target > capabilities

The SwiftRadio.entitlements file should look like this:

target > capabilities

When we run the app again, we should see our CarPlay app:

target > capabilities

Next, let’s jump to the project and start adding some code.

First, we need to import the MediaPlayer framework in our AppDelegate class, and add a new property playableContentManager:

// AppDelegate.swift

import UIKit
import MediaPlayer

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    weak var stationsViewController: StationsViewController?
    var playableContentManager: MPPlayableContentManager?
    
    // ...

We create a new file extension for the AppDelegate class (just to separate the CarPlay logic), we name it AppDelegate+CarPlay.swift.

First, we add a setup function to initiate our playableContentManager property, and we set both the delagate and dataSource to self (AppDelagate):

// AppDelegate+CarPlay.swift

import Foundation
import MediaPlayer

extension AppDelegate {
    
    func setupCarPlay() {
        playableContentManager = MPPlayableContentManager.shared()
        
        playableContentManager?.delegate = self
        playableContentManager?.dataSource = self
    }
}

Next we implement the delegate and datasource protocols with extensions and add the needed implementations:

// AppDelegate+CarPlay.swift

import Foundation
import MediaPlayer

extension AppDelegate {
    
    func setupCarPlay() {
        playableContentManager = MPPlayableContentManager.shared()
        
        playableContentManager?.delegate = self
        playableContentManager?.dataSource = self
    }
}

extension AppDelegate: MPPlayableContentDelegate {
    
    func playableContentManager(_ contentManager: MPPlayableContentManager, initiatePlaybackOfContentItemAt indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
        completionHandler(nil)
    }
    
    func beginLoadingChildItems(at indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
    }
}

extension AppDelegate: MPPlayableContentDataSource {
    
    func numberOfChildItems(at indexPath: IndexPath) -> Int {
        return 0
    }
    
    func contentItem(at indexPath: IndexPath) -> MPContentItem? {
        return nil
    }
}

In order to get the data needed (list of stations in our case) for the data source, we add another class, and name it CarPlayPlaylist, in this class we add a property to hold the stations array and a method to load the data from our DataManager class:

// CarPlayPlaylist.swift

import Foundation

class CarPlayPlaylist {
    
    var stations = [RadioStation]()
    
    func load(_ completion: @escaping (Error?) -> Void) {
        
        DataManager.getStationDataWithSuccess() { (data) in
            
            guard let data = data else {
                completion(nil)
                return
            }
            
            do {
                let jsonDictionary = try JSONDecoder().decode([String: [RadioStation]].self, from: data)
                if let stationsArray = jsonDictionary["station"] {
                    self.stations = stationsArray
                }
            } catch (let error) {
                completion(error)
                return
            }
            
            completion(nil)
        }
    }    
}

And now in our AppDelegate we add the the carPlayPlaylist property:

// AppDelegate.swift

import UIKit
import MediaPlayer

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    weak var stationsViewController: StationsViewController?
    var playableContentManager: MPPlayableContentManager?
    let carplayPlaylist = CarPlayPlaylist()
  
// ...

For the datasource we will create a single tab to represent the stations list, to do so we need to add the following UIBrowsableContentSupportsSectionedBrowsing Boolean / YES value to our app’s info.plist:

target > capabilities

Now let’s add the data needed in the adatasource, for numberOfChildItems, the number of tabs is 1, and the number of items is the CarPlayPlayList stations count:

// AppDelegate+CarPlay.swift

func numberOfChildItems(at indexPath: IndexPath) -> Int {
    if indexPath.indices.count == 0 {
        return 1
    }
    return carplayPlaylist.stations.count
}

In the contentItem(at indexPath: IndexPath) -> MPContentItem? function we create an item of type MPContentItem for each section (tab and list), for the tab we add:

if indexPath.count == 1 {
    // Tab section
    let item = MPContentItem(identifier: "Stations")
    item.title = "Stations"
    item.isContainer = true
    item.isPlayable = false
    if let tabImage = UIImage(named: "carPlayTab") {
        item.artwork = MPMediaItemArtwork(boundsSize: tabImage.size, requestHandler: { _ -> UIImage in
            return tabImage
        })
    }
    return item
}

You can download the carPlayTab icon images from here and add them to the project’s Images.xcassets.

For the stations list:

if indexPath.count == 1 {
    // Tab section
    // ...
} if indexPath.count == 2, indexPath.item < carplayPlaylist.stations.count {
            
	// Stations section
    let station = carplayPlaylist.stations[indexPath.item]
    let item = MPContentItem(identifier: "\(station.name)")
    item.title = station.name
    item.subtitle = station.desc
    item.isPlayable = true
    item.isStreamingContent = true
    
    // Get the station image from http or local
    if station.imageURL.contains("http") {
        ImageLoader.sharedLoader.imageForUrl(urlString: station.imageURL) { image, _ in
            DispatchQueue.main.async {
                guard let image = image else { return }
                item.artwork = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ -> UIImage in
                    return image
                })
            }
        }
    } else {
        if let image = UIImage(named: station.imageURL) ?? UIImage(named: "stationImage") {
            item.artwork = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ -> UIImage in
                return image
            })
        }
    }
    return item
} else {
	return nil
}

In the beginLoadingChildItems delegate method we add the carPlayPlaylist load function:

func beginLoadingChildItems(at indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
    carplayPlaylist.load { error in
        completionHandler(error)
    }
}

And finally we should not forget to call our setup function in the AppDelegate:

// AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
    // ...
        
    setupCarPlay()
        
    return true
}

Let’s run our app again, we should see the list in our CarPlay display:

target > capabilities

Handle Playback

Let’s start by creating a new file and add another extension helper method to StationsViewController, with the name selectFromCarPlay:

// StationsViewController+CarPlay.swift

import UIKit

extension StationsViewController {
    func selectFromCarPlay(_ station: RadioStation) {
        radioPlayer.station = station
        handleRemoteStationChange()
    }
}

In the delegate method playableContentManager, we get the selected item using indexPath and send the station to the stationViewController:

// AppDelegate+CarPlay.swift

func playableContentManager(_ contentManager: MPPlayableContentManager, initiatePlaybackOfContentItemAt indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
        
    DispatchQueue.main.async {
        if indexPath.count == 2 {
            let station = self.carplayPlaylist.stations[indexPath[1]]
            self.stationsViewController?.selectFromCarPlay(station)
        }
        
        completionHandler(nil)
    }
}

If we run our project again, we will be able to play the selected station directly from CarPlay.

If you get an out of range error when selecting a station, just restart the CarPlay simulator app, this will reload the playlist and resolve the issue.

Workaround to show the Now Playing screen in the simulator

This is a workaround to be able to see the NowPlaying interface on the simulator (this is not required on an actual device), source.

Let’s update our delegate method to look like this:

func playableContentManager(_ contentManager: MPPlayableContentManager, initiatePlaybackOfContentItemAt indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
        
    DispatchQueue.main.async {
            
        if indexPath.count == 2 {
            let station = self.carplayPlaylist.stations[indexPath[1]]
            self.stationsViewController?.selectFromCarPlay(station)
        }
        completionHandler(nil)
        
        // Workaround to make the Now Playing working on the simulator:
        #if targetEnvironment(simulator)
        	UIApplication.shared.endReceivingRemoteControlEvents()
        	UIApplication.shared.beginReceivingRemoteControlEvents()
        #endif
    }
}

If we run our app again we will get the NowPlaying screen on the CarPlay:

target > capabilities

You will notice that the play button is not in sync with the playing state, and it will show as paused in the first launch, since we have disabled the initial remote events with this code.

Testing on an actual device

In order to run the app on an actual device or publish it to the App Store, you need to ask for a CarPlay entitlement from Apple using this form.

Thanks for @urayoanm for sharing his experience on this Github issue.

Once your request gets approved you will receive an email saying that the CarPlay entitlement has been added to your account, and you will be able to generate an explicit provisioning file with the CarPlay entitlement for your app.

@end

All this code has been pushed to the CarPlay branch on the SwiftRadio repo.

More infos about CarPlay: