Project 8 · Milestone 1 — audio playbackswift-6.4/ios-26
Lesson 2 / 6
Unit 28 · Trackcast

Project 8 · Milestone 1 — audio playback

Note

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.

Note

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.

Coming from older Swift

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.

Tip

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()
    }
}
Tip

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 .playback audio session before playing.
  • Load a remote episode URL into an AVPlayer and 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 self weakly and is removed in deinit.
  • 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 setting rate also starts playback, so keep isPlaying in sync.
  • Add skip-forward-30 and skip-back-15 by seeking relative to currentTime, clamped to 0...duration.
  • Observe player.currentItem?.status with an async for await over 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.