Add CarPlay support to SwiftRadio [archived]

Deprecated: For the scene-based CarPlay setup, read Adding CarPlay support to Swift Radio (iOS 14+).

CarPlay turns SwiftRadio into a truly in-car experience. In this guide, we’ll add the right entitlements, hook into MPPlayableContentManager, and exercise our setup using the Simulator so you can ship the feature with confidence.

Setting up the project

  1. Clone the SwiftRadio fork that includes the CarPlay target:

    git clone https://github.com/analogcode/Swift-Radio-Pro
    
  2. Run the app in the iOS Simulator and confirm Hardware → External Displays → CarPlay appears.

    The iOS Simulator hardware menu with the CarPlay option highlighted

    If the menu is missing, enable it manually:

    defaults write com.apple.iphonesimulator CarPlay -bool YES
    
  3. Add the CarPlay entitlement so iOS will expose playable content.

    Xcode capabilities pane showing the entitlement toggle

    Toggling Push Notifications briefly nudges Xcode to create the SwiftRadio.entitlements file with com.apple.developer.playable-content = YES.

    The generated entitlements file containing the playable content key
  4. Relaunch the app—you should now see the CarPlay shell.

    Empty CarPlay template showing SwiftRadio without content

Add playableContentManager

Start by declaring a shared MPPlayableContentManager on the app delegate:

// AppDelegate.swift
import UIKit
import MediaPlayer
import FRadioPlayer

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  // CarPlay
  var playableContentManager: MPPlayableContentManager?

  // ...
}

Wire delegates and the StationsManager observer

Keep CarPlay logic in an extension so it stays out of the main app bootstrap:

// AppDelegate+CarPlay.swift
import Foundation
import MediaPlayer

extension AppDelegate {
  func setupCarPlay() {
    playableContentManager = MPPlayableContentManager.shared()

    playableContentManager?.delegate = self
    playableContentManager?.dataSource = self

    StationsManager.shared.addObserver(self)
  }
}

Hook up the delegate protocols:

// AppDelegate+CarPlay.swift
extension AppDelegate: MPPlayableContentDelegate {
  func playableContentManager(
    _ contentManager: MPPlayableContentManager,
    initiatePlaybackOfContentItemAt indexPath: IndexPath,
    completionHandler: @escaping (Error?) -> Void
  ) {
    DispatchQueue.main.async {
      if indexPath.count == 2 {
        let station = StationsManager.shared.stations[indexPath[1]]
        StationsManager.shared.set(station: station)
        contentManager.nowPlayingIdentifiers = [station.name]
      }

      completionHandler(nil)
    }
  }

  func beginLoadingChildItems(
    at indexPath: IndexPath,
    completionHandler: @escaping (Error?) -> Void
  ) {
    StationsManager.shared.fetch {
      completionHandler(nil)
    }
  }
}
// AppDelegate+CarPlay.swift
extension AppDelegate: MPPlayableContentDataSource {
  func numberOfChildItems(at indexPath: IndexPath) -> Int {
    if indexPath.count == 0 {
      return 1
    }

    return StationsManager.shared.stations.count
  }

  func contentItem(at indexPath: IndexPath) -> MPContentItem? {
    if indexPath.count == 1 {
      let item = MPContentItem(identifier: "Stations")
      item.title = "Stations"
      item.isContainer = true
      item.isPlayable = false
      return item
    }

    guard
      indexPath.count == 2,
      indexPath.item < StationsManager.shared.stations.count
    else {
      return nil
    }

    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) { _ in image }
    }

    return item
  }
}
// AppDelegate+CarPlay.swift
extension AppDelegate: StationsManagerObserver {
  func stationsManager(
    _ manager: StationsManager,
    stationsDidUpdate stations: [RadioStation]
  ) {
    playableContentManager?.reloadData()
  }

  func stationsManager(
    _ manager: StationsManager,
    stationDidChange station: RadioStation?
  ) {
    guard let station else {
      playableContentManager?.nowPlayingIdentifiers = []
      return
    }

    playableContentManager?.nowPlayingIdentifiers = [station.name]
  }
}

Add the UIBrowsableContentSupportsSectionedBrowsing = YES key to Info.plist so CarPlay treats your playlists as sections.

Info.plist showing the CarPlay browsing entitlement

Run it in the Simulator

Register CarPlay when the app launches:

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

The CarPlay template now lists stations and highlights the active stream.

CarPlay showing the SwiftRadio stations list CarPlay now playing screen for SwiftRadio

The first launch in the Simulator may display the play/pause button out of sync—toggle the transport once to reset it.

Test on real hardware with CarPlay Simulator

  1. Request the CarPlay entitlement from Apple using the CarPlay contact form. Once approved, generate a provisioning profile that includes it.

    Xcode provisioning profile with the CarPlay entitlement
  2. Download the Additional Tools for Xcode package from Apple’s developer downloads and launch the CarPlay Simulator app found in the Hardware folder.

    The CarPlay Simulator dashboard on macOS CarPlay Simulator showing SwiftRadio now playing controls
  3. Connect your iPhone and run the SwiftRadio target to experience the full CarPlay workflow right on your Mac.

Resources