Project 8 · Milestone 2 — background audio and downloadsswift-6.4/ios-26
Lesson 3 / 6
Unit 28 · Trackcast

Project 8 · Milestone 2 — background audio and downloads

Note

Unit 28 · Project 8 · Milestone 2. You make Trackcast keep playing in the background, appear on the lock screen, and download episodes for offline listening. This is where the OS becomes a participant and correctness under interruption matters.

A podcast player that stops when you lock the phone is not a podcast player. This milestone connects your PlayerModel to the system: the background audio capability, the now-playing info center, the remote command center, and background downloads. Each is a small API with a sharp contract.

Step 1 — enable background audio

Two things make audio continue in the background. First, the .playback audio session category from Milestone 1. Second, the Background Modes capability with Audio, AirPlay, and Picture in Picture checked (Xcode target → Signing & Capabilities → Background Modes). That checkbox writes UIBackgroundModes with audio into your Info.plist. With both in place, an actively-playing AVPlayer keeps running when the app leaves the foreground.

Note

The capability alone does nothing if the session category is wrong, and the category alone does nothing if the capability is missing. Both are required, and neither is verified at compile time — so it is exactly the kind of "works in the foreground, dies on lock" bug you test by actually locking the device.

Step 2 — publish now-playing info

The lock screen and Control Center read from MPNowPlayingInfoCenter. You push a dictionary whenever the track or playback state changes:

import MediaPlayer

extension PlayerModel {
    func updateNowPlaying(title: String, artwork: UIImage?) {
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: title,
            MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
            MPMediaItemPropertyPlaybackDuration: duration,
            MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
        ]
        if let artwork {
            info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork }
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }
}

The PlaybackRate field is what makes the lock-screen scrubber move on its own: at rate 1.0 the OS advances the elapsed time between your updates, so you do not have to push every second. Set it to 0.0 on pause or the scrubber drifts.

Step 3 — handle remote commands

The lock-screen and headphone buttons talk to MPRemoteCommandCenter. Wire each command to a model method, and return a status so the system knows it was handled:

func configureRemoteCommands() {
    let center = MPRemoteCommandCenter.shared()
    center.playCommand.addTarget { [weak self] _ in
        self?.play()
        return .success
    }
    center.pauseCommand.addTarget { [weak self] _ in
        self?.pause()
        return .success
    }
    center.skipForwardCommand.preferredIntervals = [30]
    center.skipForwardCommand.addTarget { [weak self] _ in
        self?.skip(by: 30)
        return .success
    }
}

Again [weak self] — the command center is a global singleton that will outlive any one model, so a strong capture leaks. Call configureRemoteCommands() once when the model is created, not per track.

Tip

Handle audio-session interruptions too: subscribe to AVAudioSession.interruptionNotification. On .began you are already paused by the system; on .ended with the .shouldResume option, resume. This is what makes a phone call pause you and playback pick back up afterward — a detail reviewers notice immediately.

Step 4 — background downloads

Streaming needs a network; offline listening needs the file on disk. Downloads that continue when the app is suspended use a URLSession with a background configuration and a delegate (background sessions do not support the async/await data(from:) API — they are delegate-driven by design):

final class DownloadManager: NSObject, URLSessionDownloadDelegate {
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: "com.trackcast.downloads")
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    func start(url: URL) {
        session.downloadTask(with: url).resume()
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // `location` is a temp file that is deleted when this returns —
        // move it synchronously into your container now.
        let dest = URL.documentsDirectory.appending(path: downloadTask.taskDescription ?? "episode.mp3")
        try? FileManager.default.moveItem(at: location, to: dest)
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        // report `fraction` for downloadTask — SAFELY (see the checkpoint)
    }
}

Two contracts that bite if you miss them. The temp file in didFinishDownloadingTo is deleted the instant the delegate method returns, so you must move it synchronously inside that call. And the progress callback fires concurrently for multiple downloads — how you record that progress is exactly the trap the next lesson's AI checkpoint is built on.

Note

Progress is reported from the session's delegate queue, not the main actor, and multiple downloads report at once. A shared mutable [URL: Double] written from these callbacks without isolation is a data race. Hold that thought — the checkpoint makes you find and fix exactly this bug.

Checkpoint

Your app should now:

  • Keep playing when you background the app and lock the screen.
  • Show title, artwork, elapsed time, and a self-advancing scrubber on the lock screen.
  • Respond to lock-screen play/pause/skip and to headphone controls.
  • Pause on an incoming call and resume afterward.
  • Download an episode with a background session, move the file into the app container, and play the local file with the network disabled.

Test the real thing: start a download, force-quit the app, reopen — the background session resumes and finishes.

Stretch goals

  • Persist download state (queued / downloading / done) so the library reflects it across launches; a small SwiftData @Model per episode is the natural home.
  • Implement cancel with downloadTask.cancel(byProducingResumeData:) and resume from the resume data, so a cancelled or dropped download can pick up where it left off.
  • Show aggregate progress ("3 of 5 downloaded, 62%") — which requires reading many concurrent progress values, again pointing at the isolation problem the checkpoint solves.

Knowledge check

Q: Why must you move the downloaded file inside didFinishDownloadingTo rather than later? The location URL points at a temporary file the system deletes as soon as the delegate method returns. Moving it synchronously inside the callback is the only safe window; defer it to an async hop and the file is gone.

Q: Why does the now-playing info set MPNowPlayingInfoPropertyPlaybackRate? It tells the system whether time is advancing. At rate 1.0 the OS interpolates the elapsed time between your updates so the lock-screen scrubber moves smoothly; setting it to 0.0 on pause stops that interpolation so the scrubber does not drift past a paused track.