Project 8 · Milestone 2 — background audio and downloads
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.
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.
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.
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
@Modelper 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.