Add CarPlay support to Swift Radio

5 minute read

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

Setting up the project

First let’s start by cloning the project, or downloading 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, 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 two following entry:

com.apple.developer.playable-content as a Boolean/YES value.

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

target > capabilities

The SwiftRadio.entitlements file should look like this:

target > capabilities

When we run the app again, we’ll be able to see our CarPlay app:

target > capabilities

Add playableContentManager

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
import FRadioPlayer

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    
    // CarPlay
    var playableContentManager: MPPlayableContentManager?
    
    // ...   
}

Add delegates and StationsManager observer

To separate CarPlay logic, we can create a new file extension for the AppDelegate class, and name it AppDelegate+CarPlay.swift.

First, let’s add a setup function to initiate our playableContentManager property, set both the delegate and dataSource properties to self (AppDelegate), and also add it as a StationsManager observer:

// AppDelegate+CarPlay.swift

import Foundation
import MediaPlayer

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

Next, we add an extension for the delegate protocol:

// AppDelegate+CarPlay.swift

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) {
        completionHandler(nil)
    }
}

Then, add the MPPlayableContentDataSource required function into another extension:

// AppDelegate+CarPlay.swift

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

Finally, we add an extension for StationsManagerObserver:

// AppDelegate+CarPlay.swift

extension AppDelegate: StationsManagerObserver {
    
    func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) {
        // code here
    }
    
    func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) {
        // code here
    }
}

Implement playableContentManager delegate

Let’s start by implementing initiatePlaybackOfContentItemAt delegate method, this function will be triggered when the user selects a station from the CarPlay list:

// MPPlayableContentDelegate

func playableContentManager(_ contentManager: MPPlayableContentManager, initiatePlaybackOfContentItemAt indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
        
    DispatchQueue.main.async {
        // Check if the user tapped the second section (first section will be reserved for the list tab name)         
        if indexPath.count == 2 {
            // Getting the selected station
            let station = StationsManager.shared.stations[indexPath[1]]
						
            // Setting the station, this will trigger the player playback
            StationsManager.shared.set(station: station)
						
            // Tell the `contentManager` the playing identifier, we are using the station name as an ID here.
            contentManager.nowPlayingIdentifiers = [station.name]
        }
        completionHandler(nil)
    }
}

The next function in the delegate section is beginLoadingChildItems:

// MPPlayableContentDelegate

func beginLoadingChildItems(at indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) {
    // We call the stations' manager fetch function to get the station list
    StationsManager.shared.fetch {
        completionHandler(nil)
    }
}

Implement playableContentManager datasource:

For the datasource we can 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 datasource, for numberOfChildItems, the number of tabs is 1, and the number of items is the StationsManager.shared.stations count:

// MPPlayableContentDataSource

func numberOfChildItems(at indexPath: IndexPath) -> Int {
    if indexPath.indices.count == 0 {
        return 1
    }
        
    return StationsManager.shared.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:

// MPPlayableContentDataSource

func contentItem(at indexPath: IndexPath) -> MPContentItem? {
        
    if indexPath.count == 1 {
        // Tab section
        let item = MPContentItem(identifier: "Stations")
        item.title = "Stations"
        item.isContainer = true
        item.isPlayable = false
        item.artwork = MPMediaItemArtwork(boundsSize: #imageLiteral(resourceName: "carPlayTab").size, requestHandler: { _ -> UIImage in
            return #imageLiteral(resourceName: "carPlayTab")
        })
        return item
    }
    
    return nil
}

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

For the stations’ list:

// MPPlayableContentDataSource

func contentItem(at indexPath: IndexPath) -> MPContentItem? {
        
    if indexPath.count == 1 {
        // Tab section
        //  ... Code added in the previous section
    } else if indexPath.count == 2, indexPath.item < StationsManager.shared.stations.count {
            
        // Stations section
        let station = StationsManager.shared.stations[indexPath.item]
            
        let item = MPContentItem(identifier: "\(station.name)")
        item.title = station.name
        item.subtitle = station.desc
        item.isPlayable = true
        item.isStreamingContent = true
        station.getImage { image in
            item.artwork = MPMediaItemArtwork(boundsSize: image.size) { _ -> UIImage in
                return image
            }
        }
            
        return item
    } else {
        return nil
    }
}

Implement StaionsManager observers

For the StationsManagerObserver we need to implement both functions:

// StationsManagerObserver

func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) {
    // Reload playableContentManager when there is a stations update
    playableContentManager?.reloadData()
}
// StationsManagerObserver

func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) {
    // Check if the new station is not nil and update the `nowPlayingIdentifiers`
    guard let station = station else {
        playableContentManager?.nowPlayingIdentifiers = []
        return
		}
        
    playableContentManager?.nowPlayingIdentifiers = [station.name]
}

Run using the iOS simulator

Finally, we need to call our setup function in the AppDelegate:

// AppDelegate.swift

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

And run our app again, and we’ll be able to see the list in our CarPlay display:

CarPlay List

CarPlay Now Playing

There is a small bug on the simulator causing the pause / play button to show out of sync in the first launch.

Testing on a device with the CarPlay Simulator

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 to @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.

Once this is done, you can add it to the project, and run the app on your device:

CarPlay provisioning

Now, we need to get the CarPlay simulator from Apple’s developer website by downloading the Additional Tools for Xcode.

In the Hardware folder, we can launch the CarPlay Simulator app, and connect our iPhone, we’ll get a full CarPlay experience on our Mac:

CarPlay simulator

CarPlay simulator now playing

@end

All this code has been added to the SwiftRadio project in Xcode SwiftRadio-CarPlay target.

More infos about CarPlay: