Project 8 · Milestone 1 — audio playback
Unit 28 · Project 8 · Milestone 1. You build the player core: an @Observable model that owns
an AVPlayer, configures the audio session, and drives play/pause/seek. By the end you can
play a remote episode and scrub it.
Everything visual in Trackcast is a thin layer over one object: a player model that owns an
AVPlayer and publishes UI-friendly state. Get this model right — its isolation, its
observation, its cleanup — and the rest of the project is wiring. Get it wrong and you will
chase phantom bugs for the whole unit.
Step 1 — configure the audio session
Before any audio plays you tell the OS what kind of audio this is. For a podcast player that
should keep playing in the background and interrupt other audio, use the .playback category:
import AVFoundation enum AudioSession { static func activatePlayback() throws { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .spokenAudio) try session.setActive(true) } }
.playback is what permits background audio (paired with the capability you add in Milestone
2). .spokenAudio is the mode tuned for talk content — it cooperates with the system's
speech-oriented behaviors. Activating the session is a throwing call; surface the error, do
not try! it.
Do not configure the session from inside a SwiftUI body. Session setup is a side effect that
should happen once, at app launch or when the player model is created — not on every re-render.
A body must be a pure function of state.
Step 2 — the @Observable player model
State in modern Swift is @Observable, and this model is @MainActor because it feeds the UI
and mutates AVPlayer (a main-thread-affine object):
import AVFoundation import Observation @MainActor @Observable final class PlayerModel { private(set) var isPlaying = false private(set) var currentTime: Double = 0 private(set) var duration: Double = 0 private let player = AVPlayer() private var timeObserver: Any? func load(url: URL) { let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) observeTime() } func play() { try? AudioSession.activatePlayback() player.play() isPlaying = true } func pause() { player.pause() isPlaying = false } }
The model is final (no subclassing) and @MainActor (all its state is touched from the
main actor). @Observable means SwiftUI views that read isPlaying or currentTime
re-render automatically when those change — no @Published, no objectWillChange.
The old shape was class PlayerModel: ObservableObject with @Published var isPlaying. The
@Observable macro replaces the whole ObservableObject/@Published dance: you mark the
class and write plain stored properties, and observation is synthesized. Views use it with
@State private var player = PlayerModel() instead of @StateObject.
Step 3 — observe time without leaking
AVPlayer reports playback position through a periodic time observer. The trap: the
closure it holds can capture self strongly and outlive the model, and the observer must be
removed or it leaks. Capture self weakly and store the token so you can remove it:
private func observeTime() { let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver( forInterval: interval, queue: .main ) { [weak self] time in guard let self else { return } self.currentTime = time.seconds if let dur = self.player.currentItem?.duration.seconds, dur.isFinite { self.duration = dur } } } isolated deinit { if let timeObserver { player.removeTimeObserver(timeObserver) } }
queue: .main means the closure runs on the main queue, consistent with the @MainActor
model. [weak self] breaks the retain cycle between the player (which holds the closure) and
the model (which holds the player). Removing the observer in deinit is not optional — a
leaked periodic observer keeps firing.
Note the isolated deinit. player and timeObserver are @MainActor-isolated
stored properties, and a plain deinit is nonisolated — so under Swift 6 touching them
there is a data-race error. isolated deinit runs the deinitializer on the actor (here the
main actor), which is exactly what you want: it removes the observer on the same actor that
added it.
CMTime(seconds:preferredTimescale:) with a timescale of 600 is the conventional value for
media time — it divides evenly into common frame rates. You are asking for an update roughly
every half second; that is smooth enough for a scrubber without waking the CPU too often.
Step 4 — seek
Scrubbing is a seek. Expose a method the scrubber calls on release:
func seek(to seconds: Double) { let target = CMTime(seconds: seconds, preferredTimescale: 600) player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) }
The zero tolerances make the seek land exactly where the user dropped the thumb. Without
them, AVPlayer may snap to a nearby keyframe — fine for video preview, wrong for "resume at
12:03." A Slider bound to currentTime with an onEditingChanged that calls seek on
release is the whole scrubber.
The library, chapters, the queue, and a sleep timer
With the engine driving playback, the app's surfaces are mostly views over state you already own.
The library is a grid of subscribed Podcasts; each opens a show page — the show's info
plus its episodes, where a tap loads the episode and plays. Discover is the same grid over a
directory, filtered by a .searchable query. None of it touches the engine beyond load + play.
Chapters are the most engine-adjacent, and even they're mostly derived. An episode carries
[Chapter] (each a title + a start time); the current chapter is the last one you've reached,
and tapping one is a seek:
var currentChapterIndex: Int? { guard let chapters = player.episode?.chapters, !chapters.isEmpty else { return nil } return chapters.lastIndex { $0.start <= player.currentTime } // derived from the play head } // tapping a chapter row: Button { player.seek(to: chapter.start) } label: { … }
The queue is an ordered [Episode] the player advances through: when an item ends (an
AVPlayerItemDidPlayToEndTime notification), pop the front of the queue and load it. Up-next in
the Now Playing screen is just the first few of that list.
The sleep timer is the one feature that reaches back into playback — a timer that pauses when it fires:
func startSleepTimer(minutes: Int) { sleepTask?.cancel() sleepTask = Task { try? await Task.sleep(for: .seconds(minutes * 60)) guard !Task.isCancelled else { return } pause() } }
Notice the split: the library, show page, discover, chapters list, and up-next are views over
state (subscribed shows, an episode's chapters, a queue array). Only the queue's auto-advance and
the sleep timer's pause() actually call the engine. Get the @Observable player model right and
the "features" are cheap — the same lesson every tier keeps teaching, here in a media app.
Checkpoint
Your app should now:
- Configure and activate a
.playbackaudio session before playing. - Load a remote episode URL into an
AVPlayerand play/pause it. - Update a progress label and scrubber roughly twice a second while playing.
- Seek to an exact position when you scrub and release.
- Have no retain cycle: the periodic observer captures
selfweakly and is removed indeinit. - Browse subscribed podcasts and a discover directory, open a show page, and play an episode.
- Show the current chapter and a chapters list (seek on tap), an up-next queue, and a sleep timer.
Play a real episode URL, scrub to the middle, and confirm the time label and player agree.
Stretch goals
- Add variable speed with
player.rate(0.8 to 2.0) and a control that sets it; note that settingratealso starts playback, so keepisPlayingin sync. - Add skip-forward-30 and skip-back-15 by seeking relative to
currentTime, clamped to0...duration. - Observe
player.currentItem?.statuswith an asyncfor awaitover the item's status to disable the transport controls until the item is.readyToPlay.
Knowledge check
Q: Why is PlayerModel marked @MainActor?
Its state feeds SwiftUI and it drives AVPlayer, which expects main-thread use. Isolating the
whole model to the main actor means every property mutation and every player call happens on
the main actor by construction, so there is no cross-actor data race to reason about.
Q: What breaks if the periodic time observer captures self strongly?
A retain cycle: the player holds the closure, the closure holds the model, the model holds the
player. Nothing deallocates, the observer keeps firing, and currentTime updates for a screen
that is gone. [weak self] plus removing the observer in deinit prevents both.