Adding CarPlay support to Swift Radio (iOS 14+)
A CarPlay station picker needs to use the same playback state as the phone. Pick a station in the car, and the phone should show it when you open the app. Pick another on the phone, and the car should follow.
In Swift Radio, I use a CarPlay scene delegate for the car's interface and keep the station selection and audio player shared. This guide covers the scene-based approach available to audio apps on iOS 14 and later. The older MPPlayableContentManager setup is in the archived guide.
Swift Radio already includes a CarPlay target. If you're working from a recent fork, use this guide to understand or adapt that setup. The examples rely on the project's existing StationsManager, RadioStation, and audio setup.
1. Add CarPlay scenes to Info.plist
The CarPlay target uses SwiftRadio/Info-CarPlay.plist. Its UIApplicationSceneManifest declares a scene for the phone and another for CarPlay, each with its own delegate:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
Check that the CarPlay target's Info.plist File build setting points to this file, and that CarPlaySceneDelegate.swift belongs to that target. A scene declaration in the wrong plist won't be used.
2. Configure the audio entitlement
For an audio app using CarPlay templates, the entitlement is com.apple.developer.carplay-audio. In the entitlements file used by your CarPlay target, it looks like this:
<dict>
<key>com.apple.developer.carplay-audio</key>
<true/>
</dict>
Request access through Apple's CarPlay page, then follow the entitlement setup guide to configure the app's provisioning profile. Adding the key to a local file alone doesn't grant access on a device.
Swift Radio's entitlements file also contains com.apple.developer.playable-content, which belongs to the older Media Player integration. Include that when supporting that path too. The CarPlay Developer Guide covers which entitlement matches each integration.
3. Build the CarPlay scene delegate
When CarPlay connects, create a CPListTemplate and fill it with stations from the shared manager. Each row selects a station through that manager and starts the shared player:
import CarPlay
import FRadioPlayer
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var interfaceController: CPInterfaceController?
func templateApplicationScene(
_ scene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
self.interfaceController = interfaceController
let template = CPListTemplate(title: "Radio Stations", sections: [])
interfaceController.setRootTemplate(template, animated: false, completion: nil)
if StationsManager.shared.stations.isEmpty {
StationsManager.shared.fetch { [weak self] _ in
self?.reloadStations(on: template)
}
} else {
reloadStations(on: template)
}
StationsManager.shared.addObserver(self)
}
func templateApplicationScene(
_ scene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
StationsManager.shared.removeObserver(self)
self.interfaceController = nil
}
private func reloadStations(on template: CPListTemplate) {
let items = StationsManager.shared.stations.map { station -> CPListItem in
let item = CPListItem(text: station.name, detailText: station.desc)
item.isPlaying = station == StationsManager.shared.currentStation
station.getImage { image in item.setImage(image) }
item.handler = { _, completion in
StationsManager.shared.set(station: station)
FRadioPlayer.shared.play()
completion()
}
return item
}
template.updateSections([CPListSection(items: items)])
}
}
extension CarPlaySceneDelegate: StationsManagerObserver {
func stationsManager(
_ manager: StationsManager,
stationsDidUpdate stations: [RadioStation]
) {
guard let template = interfaceController?.rootTemplate as? CPListTemplate else { return }
DispatchQueue.main.async { self.reloadStations(on: template) }
}
func stationsManager(
_ manager: StationsManager,
stationDidChange station: RadioStation?
) {
guard let template = interfaceController?.rootTemplate as? CPListTemplate else { return }
let items = template.sections.flatMap { $0.items }.compactMap { $0 as? CPListItem }
for (index, item) in items.enumerated() where index < manager.stations.count {
item.isPlaying = manager.stations[index] == station
}
}
}
Call the row handler's completion after handling the selection; CarPlay uses it to dismiss the loading indicator. Remove the station observer when CarPlay disconnects, since that interface no longer needs updates.
This is the station-list portion of the integration. The full scene delegate also initializes the shared audio setup service, which handles audio configuration independently of the phone's views.
4. Handle a launch from the car
CarPlay can launch the app before any phone screen exists. Playback setup therefore needs to work without StationsViewController.viewDidLoad running first. Apple calls out this lifecycle in Accelerate your app with CarPlay.
The reverse matters too. When the phone's station list eventually opens, a station may already be playing. Read the current state when creating the UI, then observe later changes. In the v3 station list, the initial setup includes:
// StationsViewController.viewDidLoad
updateNowPlayingBarButton(station: manager.currentStation)
updateHandoffUserActivity(userActivity, station: manager.currentStation)
That lets the phone show the station selected in the car as soon as its interface appears. Both interfaces continue using the same StationsManager and FRadioPlayer.
Try both launch orders
Test with the CarPlay display in the iOS Simulator, then with a signed build on an iPhone. Apple's CarPlay tools page links to the separate CarPlay Simulator for Mac.
- Car first: launch from CarPlay, pick a station, then open the phone app. Check playback and the now playing bar.
- Phone first: start a station on the phone, connect CarPlay, and check the station list and playback state.
- Switch stations: select a different station from each interface and check that the other follows.
- Reconnect: disconnect and reconnect CarPlay, then select another station to check that its list still updates.
This integration is included in Swift Radio v3. If your fork behaves differently depending on where it launches, open an issue with that launch order. It's a useful place to start looking.